Skip to content

Commit fff2deb

Browse files
authored
Add custom ESLint rule scaffolding for Rig (#125)
1 parent c03b90a commit fff2deb

10 files changed

Lines changed: 339 additions & 1 deletion

File tree

.github/workflows/ci.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ jobs:
2222
- name: Typecheck
2323
run: npm run typecheck
2424

25+
- name: Lint
26+
run: npm run lint
27+
2528
- name: Test
2629
run: npm test
2730

.github/workflows/daily-rig-task-generator.lock.yml

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

.github/workflows/daily-rig-task-generator.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,12 @@ Emit a `create-issue` safe output with:
229229
Identify API patterns that were awkward, frequently confused, or required extra
230230
boilerplate that a helper could eliminate.
231231

232+
### Candidate lint rules
233+
For repeated code patterns that confused the model, propose a focused lint rule.
234+
Include the proposed rule name, invalid and valid examples, why the pattern is
235+
model-confusing, and whether a safe autofix is possible. Do not suggest a rule
236+
for a one-off mistake or an issue already caught by the current linter.
237+
232238
### Documentation gaps
233239
Note anything in SKILL.md or the references that was underdocumented, missing an
234240
example, or frequently led to wrong usage.

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
"exports": {
66
".": "./skills/rig/rig.ts",
77
"./addons": "./skills/rig/addons.ts",
8+
"./eslint": "./skills/rig/eslint/index.js",
89
"./engines/anthropic": "./skills/rig/engines/anthropic.ts",
910
"./engines/codex": "./skills/rig/engines/codex.ts",
1011
"./engines/gemini": "./skills/rig/engines/gemini.ts",
@@ -15,6 +16,7 @@
1516
"test:integration": "vitest run scripts/haiku.integration.test.ts",
1617
"sample": "vitest run scripts/run-sample.test.ts",
1718
"sample:run": "node skills/rig/rig.ts",
19+
"lint": "node skills/rig/eslint/lint.js .",
1820
"typecheck": "tsc --noEmit"
1921
},
2022
"devDependencies": {

skills/rig/SKILL.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,13 +117,20 @@ Typecheck without executing:
117117
cat program.ts | node skills/rig/rig.ts --typecheck
118118
```
119119

120+
Lint a generated program before running it:
121+
122+
```bash
123+
node skills/rig/eslint/lint.js program.ts
124+
```
125+
120126
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.
121127

122128
## Final checks
123129

124130
- Known context uses `p.*`; true runtime data uses `input`.
125131
- Important outputs are explicitly typed and constrained.
126132
- Every helper and import uses the current `rig` or `rig/addons` API.
133+
- Generated TypeScript passes `node skills/rig/eslint/lint.js <program.ts>` and typechecking.
127134
- Every subagent is named, reachable, and narrowly scoped.
128135
- Snippets have one default export and no `console.log`.
129136
- No deprecated hooks or compatibility layers were introduced.
@@ -136,4 +143,5 @@ Read only the reference needed for the current task:
136143
- [Agent API and schemas](references/agent-api.md) — spec fields, schema overloads, tools, and call-time options.
137144
- [Prompt intents](references/prompt-intents.md) — helper semantics, writes, dynamic paths, and failures.
138145
- [Composition and addons](references/composition.md) — subagents, coordinator patterns, repair, and addon lifecycle.
146+
- [Linting](references/linting.md) — custom Rig ESLint rules, fixes, and rule scaffolding.
139147
- [Running and engines](references/runtime.md) — inline/file launch modes, typechecking, stdin coercion, and SDK adapters.

skills/rig/eslint/index.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import noObjectLiteralRecord from "./rules/no-object-literal-record.js";
2+
3+
export default {
4+
meta: {
5+
name: "rig",
6+
},
7+
rules: {
8+
"no-object-literal-record": noObjectLiteralRecord,
9+
},
10+
};

skills/rig/eslint/lint.js

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
#!/usr/bin/env node
2+
3+
import { readFile, readdir, writeFile } from "node:fs/promises";
4+
import { extname, resolve } from "node:path";
5+
import { pathToFileURL } from "node:url";
6+
7+
const methods = new Set(["record", "nonEmptyObject"]);
8+
const ignoredDirectories = new Set([".git", "node_modules"]);
9+
10+
function tokenize(source) {
11+
const tokens = [];
12+
let index = 0;
13+
14+
while (index < source.length) {
15+
const start = index;
16+
const char = source[index];
17+
const next = source[index + 1];
18+
19+
if (/\s/.test(char)) {
20+
index += 1;
21+
} else if (char === "/" && next === "/") {
22+
index = source.indexOf("\n", index + 2);
23+
if (index === -1) break;
24+
} else if (char === "/" && next === "*") {
25+
index = source.indexOf("*/", index + 2);
26+
index = index === -1 ? source.length : index + 2;
27+
} else if (char === "'" || char === "\"" || char === "`") {
28+
const quote = char;
29+
index += 1;
30+
while (index < source.length) {
31+
if (source[index] === "\\") {
32+
index += 2;
33+
} else if (source[index] === quote) {
34+
index += 1;
35+
break;
36+
} else {
37+
index += 1;
38+
}
39+
}
40+
} else if (/[A-Za-z_$]/.test(char)) {
41+
index += 1;
42+
while (index < source.length && /[\w$]/.test(source[index])) index += 1;
43+
tokens.push({ value: source.slice(start, index), start, end: index });
44+
} else {
45+
index += 1;
46+
tokens.push({ value: char, start, end: index });
47+
}
48+
}
49+
50+
return tokens;
51+
}
52+
53+
function closingBrace(tokens, openingIndex) {
54+
let depth = 0;
55+
for (let index = openingIndex; index < tokens.length; index += 1) {
56+
if (tokens[index].value === "{") depth += 1;
57+
if (tokens[index].value === "}") depth -= 1;
58+
if (depth === 0) return index;
59+
}
60+
return undefined;
61+
}
62+
63+
export function lintSource(source) {
64+
const tokens = tokenize(source);
65+
const problems = [];
66+
67+
for (let index = 0; index <= tokens.length - 5; index += 1) {
68+
const [schema, dot, method, openCall] = tokens.slice(index, index + 4);
69+
if (
70+
tokens[index - 1]?.value === "."
71+
|| schema.value !== "s"
72+
|| dot.value !== "."
73+
|| !methods.has(method.value)
74+
|| openCall.value !== "("
75+
) {
76+
continue;
77+
}
78+
79+
let objectIndex = index + 4;
80+
while (tokens[objectIndex]?.value === "(") objectIndex += 1;
81+
const object = tokens[objectIndex];
82+
if (object?.value !== "{") continue;
83+
84+
const closingIndex = closingBrace(tokens, objectIndex);
85+
const wrapperCount = objectIndex - (index + 4);
86+
const wrappersClose = Array.from(
87+
{ length: wrapperCount },
88+
(_, offset) => tokens[(closingIndex ?? tokens.length) + offset + 1]?.value,
89+
).every((value) => value === ")");
90+
if (closingIndex !== undefined && wrappersClose) {
91+
problems.push({
92+
start: object.start,
93+
end: tokens[closingIndex].end,
94+
message: `Wrap object-valued record fields with s.object(...).`,
95+
});
96+
}
97+
}
98+
99+
return problems;
100+
}
101+
102+
export function fixSource(source, problems = lintSource(source)) {
103+
let fixed = source;
104+
const edits = problems
105+
.flatMap(({ start, end }) => [
106+
{ index: start, text: "s.object(" },
107+
{ index: end, text: ")" },
108+
])
109+
.sort((left, right) => right.index - left.index);
110+
for (const edit of edits) {
111+
fixed = `${fixed.slice(0, edit.index)}${edit.text}${fixed.slice(edit.index)}`;
112+
}
113+
return fixed;
114+
}
115+
116+
async function sourceFiles(paths) {
117+
const files = [];
118+
for (const path of paths) {
119+
const entries = await readdir(path, { withFileTypes: true }).catch(() => undefined);
120+
if (!entries) {
121+
if (extname(path) === ".ts") files.push(path);
122+
continue;
123+
}
124+
for (const entry of entries) {
125+
if (entry.isDirectory() && ignoredDirectories.has(entry.name)) continue;
126+
const child = resolve(path, entry.name);
127+
if (entry.isDirectory()) files.push(...await sourceFiles([child]));
128+
else if (extname(entry.name) === ".ts") files.push(child);
129+
}
130+
}
131+
return files;
132+
}
133+
134+
async function main(argv) {
135+
const fix = argv.includes("--fix");
136+
const paths = argv.filter((arg) => arg !== "--fix").map((path) => resolve(path));
137+
if (paths.length === 0) {
138+
throw new Error("Usage: node skills/rig/eslint/lint.js [--fix] <file-or-directory> [...]");
139+
}
140+
141+
let failures = 0;
142+
for (const file of await sourceFiles(paths)) {
143+
const source = await readFile(file, "utf8");
144+
const problems = lintSource(source);
145+
if (problems.length === 0) continue;
146+
if (fix) {
147+
await writeFile(file, fixSource(source, problems));
148+
continue;
149+
}
150+
failures += problems.length;
151+
for (const problem of problems) {
152+
const line = source.slice(0, problem.start).split("\n").length;
153+
console.error(`${file}:${line}: ${problem.message}`);
154+
}
155+
}
156+
157+
if (failures > 0) process.exitCode = 1;
158+
}
159+
160+
const isMain = process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url;
161+
if (isMain) {
162+
main(process.argv.slice(2)).catch((error) => {
163+
console.error(error.message);
164+
process.exitCode = 1;
165+
});
166+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
export default {
2+
meta: {
3+
type: "problem",
4+
docs: {
5+
description: "Require object-valued record schemas to use s.object",
6+
},
7+
fixable: "code",
8+
schema: [],
9+
messages: {
10+
wrapObject: "Wrap object-valued record fields with {{schema}}.object(...).",
11+
},
12+
},
13+
create(context) {
14+
const sourceCode = context.sourceCode;
15+
16+
return {
17+
CallExpression(node) {
18+
const { callee } = node;
19+
if (
20+
callee.type !== "MemberExpression"
21+
|| callee.computed
22+
|| callee.object.type !== "Identifier"
23+
|| callee.object.name !== "s"
24+
|| callee.property.type !== "Identifier"
25+
|| !["record", "nonEmptyObject"].includes(callee.property.name)
26+
) {
27+
return;
28+
}
29+
30+
const value = node.arguments[0];
31+
if (!value || value.type !== "ObjectExpression") {
32+
return;
33+
}
34+
35+
context.report({
36+
node: value,
37+
messageId: "wrapObject",
38+
data: {
39+
schema: callee.object.name,
40+
},
41+
fix(fixer) {
42+
return fixer.replaceText(value, `${callee.object.name}.object(${sourceCode.getText(value)})`);
43+
},
44+
});
45+
},
46+
};
47+
},
48+
};

skills/rig/references/linting.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# Linting Rig programs
2+
3+
Rig includes a dependency-free linter in the skill folder and exports an ESLint plugin from `rig/eslint`.
4+
5+
Run the linter after generating or changing a Rig program:
6+
7+
```bash
8+
node skills/rig/eslint/lint.js path/to/program.ts
9+
```
10+
11+
Use `--fix` to apply safe fixes:
12+
13+
```bash
14+
node skills/rig/eslint/lint.js --fix path/to/program.ts
15+
```
16+
17+
## Rules
18+
19+
### `rig/no-object-literal-record`
20+
21+
An `s.record` or `s.nonEmptyObject` value must be a schema. Wrap object fields with `s.object`:
22+
23+
```ts
24+
// Invalid
25+
s.record({ status: s.string })
26+
27+
// Valid
28+
s.record(s.object({ status: s.string }))
29+
```
30+
31+
The rule fixes the invalid form automatically.
32+
33+
## Adding rules
34+
35+
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`.

src/eslint-rules.test.js

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { describe, expect, it } from "vitest";
2+
import { fixSource, lintSource } from "../skills/rig/eslint/lint.js";
3+
import rule from "../skills/rig/eslint/rules/no-object-literal-record.js";
4+
5+
describe("no-object-literal-record", () => {
6+
it.each([
7+
"const output = s.record(s.object({ count: s.number }));",
8+
"const output = s.nonEmptyObject(s.object({ count: s.number }));",
9+
"const output = s.record(s.string);",
10+
"const output = other.record({ count: s.number });",
11+
"const output = config.s.record({ count: s.number });",
12+
"const text = 's.record({ count: s.number })';",
13+
])("accepts %s", (source) => {
14+
expect(lintSource(source)).toEqual([]);
15+
});
16+
17+
it.each([
18+
[
19+
"const output = s.record({ count: s.number });",
20+
"const output = s.record(s.object({ count: s.number }));",
21+
],
22+
[
23+
"const output = s.nonEmptyObject(/* value */ { count: s.number });",
24+
"const output = s.nonEmptyObject(/* value */ s.object({ count: s.number }));",
25+
],
26+
[
27+
"const output = s.record(({ count: s.number }));",
28+
"const output = s.record((s.object({ count: s.number })));",
29+
],
30+
])("fixes %s", (source, expected) => {
31+
const problems = lintSource(source);
32+
expect(problems).toHaveLength(1);
33+
expect(fixSource(source, problems)).toBe(expected);
34+
});
35+
36+
it("keeps the ESLint rule aligned", () => {
37+
const reports = [];
38+
const object = { type: "ObjectExpression" };
39+
const visitor = rule.create({
40+
sourceCode: { getText: () => "{ count: s.number }" },
41+
report: (problem) => reports.push(problem),
42+
});
43+
44+
visitor.CallExpression({
45+
type: "CallExpression",
46+
callee: {
47+
type: "MemberExpression",
48+
computed: false,
49+
object: { type: "Identifier", name: "s" },
50+
property: { type: "Identifier", name: "record" },
51+
},
52+
arguments: [object],
53+
});
54+
55+
expect(reports).toHaveLength(1);
56+
expect(reports[0].messageId).toBe("wrapObject");
57+
expect(reports[0].fix({ replaceText: (_node, text) => text }))
58+
.toBe("s.object({ count: s.number })");
59+
});
60+
});

0 commit comments

Comments
 (0)