diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8892443..1021183 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,6 +22,9 @@ jobs: - name: Typecheck run: npm run typecheck + - name: Lint + run: npm run lint + - name: Test run: npm test diff --git a/.github/workflows/daily-rig-task-generator.lock.yml b/.github/workflows/daily-rig-task-generator.lock.yml index 79521d1..d3a5012 100644 --- a/.github/workflows/daily-rig-task-generator.lock.yml +++ b/.github/workflows/daily-rig-task-generator.lock.yml @@ -1,4 +1,4 @@ -# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"4491a9f8fc088dfc25b10aa3262320d47bbed70d15fcc1e671d746fc6985a866","body_hash":"1fed8dd518d1fa56f36c3f1df247cf79b1f5f77f3c6af7448ceda9d50f4e4fa6","compiler_version":"v0.83.1","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.73"}} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"4491a9f8fc088dfc25b10aa3262320d47bbed70d15fcc1e671d746fc6985a866","body_hash":"aa746324cc7980c6de25b8b4c23a4fddd71a03673d0d5f886ad32a112e1629cf","compiler_version":"v0.83.1","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.73"}} # gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_AGENT_TOKEN","GH_AW_CI_TRIGGER_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/cache/restore","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/cache/save","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/checkout","sha":"9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0","version":"v7.0.0"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"820762786026740c76f36085b0efc47a31fe5020","version":"v7.0.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"v0.83.1","version":"v0.83.1"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.38","digest":"sha256:cb928eb62d9139a013c2d278dab19af232d35a2d83dca71a3d98eb431f786243","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.38@sha256:cb928eb62d9139a013c2d278dab19af232d35a2d83dca71a3d98eb431f786243"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.38","digest":"sha256:cd6145620d96acee46e1ede25180a13aa36002467e663db0caa453a8bc8eb60c","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.38@sha256:cd6145620d96acee46e1ede25180a13aa36002467e663db0caa453a8bc8eb60c"},{"image":"ghcr.io/github/gh-aw-firewall/cli-proxy:0.27.38","digest":"sha256:c30c5319da37505d42f95cb3faa2cfa55e794ccb5cc805dbd9201410d1ac2a3e","pinned_image":"ghcr.io/github/gh-aw-firewall/cli-proxy:0.27.38@sha256:c30c5319da37505d42f95cb3faa2cfa55e794ccb5cc805dbd9201410d1ac2a3e"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.38","digest":"sha256:6c19094d95aad5f9f128ad5e583f0f2b894b158aa66c3b86dd9bcc90970a2917","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.38@sha256:6c19094d95aad5f9f128ad5e583f0f2b894b158aa66c3b86dd9bcc90970a2917"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.4.3","digest":"sha256:3c744710ea275cd5ee65db92a1099e0d980754bd9fafda9ce67704c67004dc83","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.4.3@sha256:3c744710ea275cd5ee65db92a1099e0d980754bd9fafda9ce67704c67004dc83"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b","pinned_image":"ghcr.io/github/gh-aw-node@sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b"},{"image":"ghcr.io/github/github-mcp-server:v1.6.0","digest":"sha256:2b0c48b070f61e9d3969269ead600f62d00fb237b60ac849ef3d166ee7de9ad3","pinned_image":"ghcr.io/github/github-mcp-server:v1.6.0@sha256:2b0c48b070f61e9d3969269ead600f62d00fb237b60ac849ef3d166ee7de9ad3"}]} # This file was automatically generated by gh-aw (v0.83.1). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md # diff --git a/.github/workflows/daily-rig-task-generator.md b/.github/workflows/daily-rig-task-generator.md index 42ce984..80a4d54 100644 --- a/.github/workflows/daily-rig-task-generator.md +++ b/.github/workflows/daily-rig-task-generator.md @@ -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. diff --git a/package.json b/package.json index 684d451..c42e956 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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": { diff --git a/skills/rig/SKILL.md b/skills/rig/SKILL.md index a5726f0..d26b30e 100644 --- a/skills/rig/SKILL.md +++ b/skills/rig/SKILL.md @@ -117,6 +117,12 @@ 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 @@ -124,6 +130,7 @@ Run inline input or a program file with `node skills/rig/rig.ts`; add `--server` - 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 ` 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. @@ -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. diff --git a/skills/rig/eslint/index.js b/skills/rig/eslint/index.js new file mode 100644 index 0000000..597725b --- /dev/null +++ b/skills/rig/eslint/index.js @@ -0,0 +1,10 @@ +import noObjectLiteralRecord from "./rules/no-object-literal-record.js"; + +export default { + meta: { + name: "rig", + }, + rules: { + "no-object-literal-record": noObjectLiteralRecord, + }, +}; diff --git a/skills/rig/eslint/lint.js b/skills/rig/eslint/lint.js new file mode 100644 index 0000000..a2c099f --- /dev/null +++ b/skills/rig/eslint/lint.js @@ -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); + 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; + 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] [...]"); + } + + 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)); + 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; + }); +} diff --git a/skills/rig/eslint/rules/no-object-literal-record.js b/skills/rig/eslint/rules/no-object-literal-record.js new file mode 100644 index 0000000..04022fc --- /dev/null +++ b/skills/rig/eslint/rules/no-object-literal-record.js @@ -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)})`); + }, + }); + }, + }; + }, +}; diff --git a/skills/rig/references/linting.md b/skills/rig/references/linting.md new file mode 100644 index 0000000..03f799e --- /dev/null +++ b/skills/rig/references/linting.md @@ -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 + +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`. diff --git a/src/eslint-rules.test.js b/src/eslint-rules.test.js new file mode 100644 index 0000000..55cf3f1 --- /dev/null +++ b/src/eslint-rules.test.js @@ -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 })"); + }); +});