Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ jobs:
- name: Typecheck
run: npm run typecheck

- name: Lint
run: npm run lint

- name: Test
run: npm test

Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/daily-rig-task-generator.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions .github/workflows/daily-rig-task-generator.md
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,12 @@ Emit a `create-issue` safe output with:
Identify API patterns that were awkward, frequently confused, or required extra
boilerplate that a helper could eliminate.

### Candidate lint rules
For repeated code patterns that confused the model, propose a focused lint rule.
Include the proposed rule name, invalid and valid examples, why the pattern is
model-confusing, and whether a safe autofix is possible. Do not suggest a rule
for a one-off mistake or an issue already caught by the current linter.

### Documentation gaps
Note anything in SKILL.md or the references that was underdocumented, missing an
example, or frequently led to wrong usage.
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"exports": {
".": "./skills/rig/rig.ts",
"./addons": "./skills/rig/addons.ts",
"./eslint": "./skills/rig/eslint/index.js",
"./engines/anthropic": "./skills/rig/engines/anthropic.ts",
"./engines/codex": "./skills/rig/engines/codex.ts",
"./engines/gemini": "./skills/rig/engines/gemini.ts",
Expand All @@ -15,6 +16,7 @@
"test:integration": "vitest run scripts/haiku.integration.test.ts",
"sample": "vitest run scripts/run-sample.test.ts",
"sample:run": "node skills/rig/rig.ts",
"lint": "node skills/rig/eslint/lint.js .",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
Expand Down
8 changes: 8 additions & 0 deletions skills/rig/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,13 +117,20 @@ Typecheck without executing:
cat program.ts | node skills/rig/rig.ts --typecheck
```

Lint a generated program before running it:

```bash
node skills/rig/eslint/lint.js program.ts
```

Run inline input or a program file with `node skills/rig/rig.ts`; add `--server` to start the Copilot server. Assume Node.js 24, prefer native APIs, and use `google/zx` for shell-style TypeScript automation.

## Final checks

- Known context uses `p.*`; true runtime data uses `input`.
- Important outputs are explicitly typed and constrained.
- Every helper and import uses the current `rig` or `rig/addons` API.
- Generated TypeScript passes `node skills/rig/eslint/lint.js <program.ts>` and typechecking.
- Every subagent is named, reachable, and narrowly scoped.
- Snippets have one default export and no `console.log`.
- No deprecated hooks or compatibility layers were introduced.
Expand All @@ -135,4 +142,5 @@ Read only the reference needed for the current task:
- [Agent API and schemas](references/agent-api.md) — spec fields, schema overloads, tools, and call-time options.
- [Prompt intents](references/prompt-intents.md) — helper semantics, writes, dynamic paths, and failures.
- [Composition and addons](references/composition.md) — subagents, coordinator patterns, repair, and addon lifecycle.
- [Linting](references/linting.md) — custom Rig ESLint rules, fixes, and rule scaffolding.
- [Running and engines](references/runtime.md) — inline/file launch modes, typechecking, stdin coercion, and SDK adapters.
10 changes: 10 additions & 0 deletions skills/rig/eslint/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import noObjectLiteralRecord from "./rules/no-object-literal-record.js";

export default {
meta: {
name: "rig",
},
rules: {
"no-object-literal-record": noObjectLiteralRecord,
},
};
166 changes: 166 additions & 0 deletions skills/rig/eslint/lint.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
#!/usr/bin/env node

import { readFile, readdir, writeFile } from "node:fs/promises";
import { extname, resolve } from "node:path";
import { pathToFileURL } from "node:url";

const methods = new Set(["record", "nonEmptyObject"]);
const ignoredDirectories = new Set([".git", "node_modules"]);

function tokenize(source) {
const tokens = [];
let index = 0;

while (index < source.length) {
const start = index;
const char = source[index];
const next = source[index + 1];

if (/\s/.test(char)) {
index += 1;
} else if (char === "/" && next === "/") {
index = source.indexOf("\n", index + 2);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] Line 22: if a file ends with a // comment and no trailing newline, source.indexOf(' ', index + 2) returns -1, the loop breaks, and tokens after the comment are silently skipped.

This means a file ending in // comment (no newline) is tokenised differently from the same file with a trailing newline — a silent correctness bug with no test coverage.

💡 Suggested fix + test
// In tokenize, for single-line comments:
const newline = source.indexOf('
', index + 2);
index = newline === -1 ? source.length : newline;
// (no break — continue the loop)

Test:

it('handles trailing single-line comment without newline', () => {
  expect(lintSource('s.record({ a: s.string }) // trailing')).toHaveLength(1);
});

if (index === -1) break;
} else if (char === "/" && next === "*") {
index = source.indexOf("*/", index + 2);
index = index === -1 ? source.length : index + 2;
} else if (char === "'" || char === "\"" || char === "`") {
const quote = char;
index += 1;
while (index < source.length) {
if (source[index] === "\\") {
index += 2;
} else if (source[index] === quote) {
index += 1;
break;
} else {
index += 1;
}
}
} else if (/[A-Za-z_$]/.test(char)) {
index += 1;
while (index < source.length && /[\w$]/.test(source[index])) index += 1;
tokens.push({ value: source.slice(start, index), start, end: index });
} else {
index += 1;
tokens.push({ value: char, start, end: index });
}
}

return tokens;
}

function closingBrace(tokens, openingIndex) {
let depth = 0;
for (let index = openingIndex; index < tokens.length; index += 1) {
if (tokens[index].value === "{") depth += 1;
if (tokens[index].value === "}") depth -= 1;
if (depth === 0) return index;
}
return undefined;
}

export function lintSource(source) {
const tokens = tokenize(source);
const problems = [];

for (let index = 0; index <= tokens.length - 5; index += 1) {
const [schema, dot, method, openCall] = tokens.slice(index, index + 4);
if (
tokens[index - 1]?.value === "."
|| schema.value !== "s"
|| dot.value !== "."
|| !methods.has(method.value)
|| openCall.value !== "("
) {
continue;
}

let objectIndex = index + 4;
while (tokens[objectIndex]?.value === "(") objectIndex += 1;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] The parenthesis-skip loop (while tokens[objectIndex]?.value === '(') advances past any number of open parens but the closing-parens check on line 86-89 uses a fixed offset from closingIndex. If the object is nested (({...})) and there is a comment token between the closing brace and the closing parens, wrappersClose will read the wrong tokens and either suppress a real problem or false-positive.

💡 Missing test case
it('handles nested parens with comment between braces', () => {
  const src = 's.record(/* x */({ count: s.number }))';
  expect(lintSource(src)).toHaveLength(1);
});

Adding this test will surface whether the wrapper-check logic is robust.

const object = tokens[objectIndex];
if (object?.value !== "{") continue;

const closingIndex = closingBrace(tokens, objectIndex);
const wrapperCount = objectIndex - (index + 4);
const wrappersClose = Array.from(
{ length: wrapperCount },
(_, offset) => tokens[(closingIndex ?? tokens.length) + offset + 1]?.value,
).every((value) => value === ")");
if (closingIndex !== undefined && wrappersClose) {
problems.push({
start: object.start,
end: tokens[closingIndex].end,
message: `Wrap object-valued record fields with s.object(...).`,
});
}
}

return problems;
}

export function fixSource(source, problems = lintSource(source)) {
let fixed = source;
const edits = problems
.flatMap(({ start, end }) => [
{ index: start, text: "s.object(" },
{ index: end, text: ")" },
])
.sort((left, right) => right.index - left.index);
for (const edit of edits) {
fixed = `${fixed.slice(0, edit.index)}${edit.text}${fixed.slice(edit.index)}`;
}
return fixed;
}

async function sourceFiles(paths) {
const files = [];
for (const path of paths) {
const entries = await readdir(path, { withFileTypes: true }).catch(() => undefined);
if (!entries) {
if (extname(path) === ".ts") files.push(path);
continue;
}
for (const entry of entries) {
if (entry.isDirectory() && ignoredDirectories.has(entry.name)) continue;
const child = resolve(path, entry.name);
if (entry.isDirectory()) files.push(...await sourceFiles([child]));
else if (extname(entry.name) === ".ts") files.push(child);
}
}
return files;
}

async function main(argv) {
const fix = argv.includes("--fix");
const paths = argv.filter((arg) => arg !== "--fix").map((path) => resolve(path));
if (paths.length === 0) {
throw new Error("Usage: node skills/rig/eslint/lint.js [--fix] <file-or-directory> [...]");
}

let failures = 0;
for (const file of await sourceFiles(paths)) {
const source = await readFile(file, "utf8");
const problems = lintSource(source);
if (problems.length === 0) continue;
if (fix) {
await writeFile(file, fixSource(source, problems));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] --fix silently suppresses errors even when a file fails to write. If writeFile rejects (e.g. permission error), the await rejects, the loop aborts with an unhandled rejection, but failures stays 0 and exit code is 0.

💡 Suggestion

Either let the error propagate naturally (already handled by main's .catch), or collect write errors and set process.exitCode = 1:

try {
  await writeFile(file, fixSource(source, problems));
} catch (err) {
  console.error(`Failed to write ${file}: ${err.message}`);
  failures += 1;
}

Also add a test for the fixSource round-trip so that a broken fix doesn't silently produce malformed TypeScript.

continue;
}
failures += problems.length;
for (const problem of problems) {
const line = source.slice(0, problem.start).split("\n").length;
console.error(`${file}:${line}: ${problem.message}`);
}
}

if (failures > 0) process.exitCode = 1;
}

const isMain = process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url;
if (isMain) {
main(process.argv.slice(2)).catch((error) => {
console.error(error.message);
process.exitCode = 1;
});
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/codebase-design] lint.js and no-object-literal-record.js are two parallel implementations of the same rule — both must be kept in sync whenever the rule evolves.

The test at eslint-rules.test.js:41 only spot-checks one call path and doesn't cross-validate that both implementations accept/reject the same inputs.

💡 Suggestion: share fixtures across both implementations

Extract valid/invalid case arrays and run them against both lintSource and an ESLint RuleTester to detect drift automatically:

const INVALID = [
  'const x = s.record({ count: s.number });',
  'const x = s.nonEmptyObject({ count: s.number });',
];
it.each(INVALID)('lintSource flags %s', (src) => {
  expect(lintSource(src)).toHaveLength(1);
});
// same list fed to RuleTester

48 changes: 48 additions & 0 deletions skills/rig/eslint/rules/no-object-literal-record.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
export default {
meta: {
type: "problem",
docs: {
description: "Require object-valued record schemas to use s.object",
},
fixable: "code",
schema: [],
messages: {
wrapObject: "Wrap object-valued record fields with {{schema}}.object(...).",
},
},
create(context) {
const sourceCode = context.sourceCode;

return {
CallExpression(node) {
const { callee } = node;
if (
callee.type !== "MemberExpression"
|| callee.computed
|| callee.object.type !== "Identifier"
|| callee.object.name !== "s"
|| callee.property.type !== "Identifier"
|| !["record", "nonEmptyObject"].includes(callee.property.name)
) {
return;
}

const value = node.arguments[0];
if (!value || value.type !== "ObjectExpression") {
return;
}

context.report({
node: value,
messageId: "wrapObject",
data: {
schema: callee.object.name,
},
fix(fixer) {
return fixer.replaceText(value, `${callee.object.name}.object(${sourceCode.getText(value)})`);
},
});
},
};
},
};
35 changes: 35 additions & 0 deletions skills/rig/references/linting.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Linting Rig programs

Rig includes a dependency-free linter in the skill folder and exports an ESLint plugin from `rig/eslint`.

Run the linter after generating or changing a Rig program:

```bash
node skills/rig/eslint/lint.js path/to/program.ts
```

Use `--fix` to apply safe fixes:

```bash
node skills/rig/eslint/lint.js --fix path/to/program.ts
```

## Rules

### `rig/no-object-literal-record`

An `s.record` or `s.nonEmptyObject` value must be a schema. Wrap object fields with `s.object`:

```ts
// Invalid
s.record({ status: s.string })

// Valid
s.record(s.object({ status: s.string }))
```

The rule fixes the invalid form automatically.

## Adding rules

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/codebase-design] The scaffolding instructions say to add the equivalent check to lint.js alongside the ESLint rule, but there is no explicit note that both the lintSource path and the ESLint create path need a test. This risks future contributors adding only one of the two when they extend the ruleset.

💡 Suggestion

Strengthen the last bullet:

Add the rule to skills/rig/eslint/rules/, export it from index.js, add the equivalent tokenizer check to lint.js, and cover both code paths in src/eslint-rules.test.js using shared valid/invalid fixtures.

This makes the dual-implementation expectation explicit.


Put rule implementations in `skills/rig/eslint/rules/`, export them from `skills/rig/eslint/index.js`, add the equivalent skill-local check to `skills/rig/eslint/lint.js`, and cover both in `src/eslint-rules.test.js`.
60 changes: 60 additions & 0 deletions src/eslint-rules.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import { fixSource, lintSource } from "../skills/rig/eslint/lint.js";
import rule from "../skills/rig/eslint/rules/no-object-literal-record.js";

describe("no-object-literal-record", () => {
it.each([
"const output = s.record(s.object({ count: s.number }));",
"const output = s.nonEmptyObject(s.object({ count: s.number }));",
"const output = s.record(s.string);",
"const output = other.record({ count: s.number });",
"const output = config.s.record({ count: s.number });",
"const text = 's.record({ count: s.number })';",
])("accepts %s", (source) => {
expect(lintSource(source)).toEqual([]);
});

it.each([
[
"const output = s.record({ count: s.number });",
"const output = s.record(s.object({ count: s.number }));",
],
[
"const output = s.nonEmptyObject(/* value */ { count: s.number });",
"const output = s.nonEmptyObject(/* value */ s.object({ count: s.number }));",
],
[
"const output = s.record(({ count: s.number }));",
"const output = s.record((s.object({ count: s.number })));",
],
])("fixes %s", (source, expected) => {
const problems = lintSource(source);
expect(problems).toHaveLength(1);
expect(fixSource(source, problems)).toBe(expected);
});

it("keeps the ESLint rule aligned", () => {
const reports = [];
const object = { type: "ObjectExpression" };
const visitor = rule.create({
sourceCode: { getText: () => "{ count: s.number }" },
report: (problem) => reports.push(problem),
});

visitor.CallExpression({
type: "CallExpression",
callee: {
type: "MemberExpression",
computed: false,
object: { type: "Identifier", name: "s" },
property: { type: "Identifier", name: "record" },
},
arguments: [object],
});

expect(reports).toHaveLength(1);
expect(reports[0].messageId).toBe("wrapObject");
expect(reports[0].fix({ replaceText: (_node, text) => text }))
.toBe("s.object({ count: s.number })");
});
});