From 97c2cb05c9663ebfe0c91d1569c5614a7f520a73 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 25 Jul 2026 09:54:35 +0000 Subject: [PATCH 1/2] Add repair-no-args ESLint rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generators repeatedly write repair({ maxTurns: 3 }) which is not accepted by the API — repair() takes no arguments and maxTurns belongs on the agent spec. The rule detects this pattern and strips the arguments. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- skills/rig/eslint/index.js | 2 + skills/rig/eslint/lint.js | 57 ++++++++++++++--- skills/rig/eslint/rules/repair-no-args.js | 35 +++++++++++ src/eslint-rules.test.js | 76 +++++++++++++++++++++++ 4 files changed, 163 insertions(+), 7 deletions(-) create mode 100644 skills/rig/eslint/rules/repair-no-args.js diff --git a/skills/rig/eslint/index.js b/skills/rig/eslint/index.js index 597725b..b03b62c 100644 --- a/skills/rig/eslint/index.js +++ b/skills/rig/eslint/index.js @@ -1,4 +1,5 @@ import noObjectLiteralRecord from "./rules/no-object-literal-record.js"; +import repairNoArgs from "./rules/repair-no-args.js"; export default { meta: { @@ -6,5 +7,6 @@ export default { }, rules: { "no-object-literal-record": noObjectLiteralRecord, + "repair-no-args": repairNoArgs, }, }; diff --git a/skills/rig/eslint/lint.js b/skills/rig/eslint/lint.js index a2c099f..6dad765 100644 --- a/skills/rig/eslint/lint.js +++ b/skills/rig/eslint/lint.js @@ -96,19 +96,62 @@ export function lintSource(source) { } } + for (let index = 0; index <= tokens.length - 3; index += 1) { + const [fn, openParen] = tokens.slice(index, index + 2); + if ( + tokens[index - 1]?.value === "." + || fn.value !== "repair" + || openParen.value !== "(" + ) { + continue; + } + + const closeIndex = closingParen(tokens, index + 1); + if (closeIndex === undefined) continue; + const hasArgs = tokens + .slice(index + 2, closeIndex) + .some((t) => !/^\s*$/.test(t.value)); + if (hasArgs) { + problems.push({ + start: openParen.end, + end: tokens[closeIndex].start, + message: "repair() takes no arguments. Set maxTurns on the agent spec instead.", + kind: "repair-no-args", + }); + } + } + return problems; } +function closingParen(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 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); + const edits = []; + for (const problem of problems) { + if (problem.kind === "repair-no-args") { + edits.push({ index: problem.start, end: problem.end, text: "" }); + } else { + edits.push({ index: problem.start, text: "s.object(" }); + edits.push({ index: problem.end, text: ")" }); + } + } + edits.sort((left, right) => right.index - left.index); for (const edit of edits) { - fixed = `${fixed.slice(0, edit.index)}${edit.text}${fixed.slice(edit.index)}`; + if (edit.end !== undefined) { + fixed = `${fixed.slice(0, edit.index)}${edit.text}${fixed.slice(edit.end)}`; + } else { + fixed = `${fixed.slice(0, edit.index)}${edit.text}${fixed.slice(edit.index)}`; + } } return fixed; } diff --git a/skills/rig/eslint/rules/repair-no-args.js b/skills/rig/eslint/rules/repair-no-args.js new file mode 100644 index 0000000..3a33ead --- /dev/null +++ b/skills/rig/eslint/rules/repair-no-args.js @@ -0,0 +1,35 @@ +export default { + meta: { + type: "problem", + docs: { + description: "Disallow arguments to repair() — set maxTurns on the agent spec instead", + }, + fixable: "code", + schema: [], + messages: { + noArgs: "repair() takes no arguments. Set maxTurns on the agent spec instead.", + }, + }, + create(context) { + return { + CallExpression(node) { + const { callee, arguments: args } = node; + if ( + callee.type !== "Identifier" + || callee.name !== "repair" + || args.length === 0 + ) { + return; + } + + context.report({ + node, + messageId: "noArgs", + fix(fixer) { + return fixer.replaceText(node, "repair()"); + }, + }); + }, + }; + }, +}; diff --git a/src/eslint-rules.test.js b/src/eslint-rules.test.js index 55cf3f1..fcea53f 100644 --- a/src/eslint-rules.test.js +++ b/src/eslint-rules.test.js @@ -1,6 +1,7 @@ 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"; +import repairNoArgsRule from "../skills/rig/eslint/rules/repair-no-args.js"; describe("no-object-literal-record", () => { it.each([ @@ -58,3 +59,78 @@ describe("no-object-literal-record", () => { .toBe("s.object({ count: s.number })"); }); }); + +describe("repair-no-args", () => { + it.each([ + "addons: repair()", + "addons: [repair()]", + "addons: [steering(), repair()]", + "const text = 'repair({ maxTurns: 3 })';", + "foo.repair({ maxTurns: 3 })", + ])("accepts %s", (source) => { + const problems = lintSource(source).filter((p) => p.kind === "repair-no-args"); + expect(problems).toEqual([]); + }); + + it.each([ + [ + "addons: repair({ maxTurns: 3 })", + "addons: repair()", + ], + [ + "addons: [steering(), repair({ maxTurns: 2 })]", + "addons: [steering(), repair()]", + ], + [ + "addons: repair({ message: 'retry', maxTurns: 5 })", + "addons: repair()", + ], + ])("fixes %s", (source, expected) => { + const problems = lintSource(source).filter((p) => p.kind === "repair-no-args"); + expect(problems).toHaveLength(1); + expect(fixSource(source, problems)).toBe(expected); + }); + + it("is idempotent", () => { + const source = "addons: repair({ maxTurns: 3 })"; + const once = fixSource(source); + const twice = fixSource(once); + expect(twice).toBe(once); + expect(lintSource(once).filter((p) => p.kind === "repair-no-args")).toEqual([]); + }); + + it("keeps the ESLint rule aligned", () => { + const reports = []; + const visitor = repairNoArgsRule.create({ + sourceCode: {}, + report: (problem) => reports.push(problem), + }); + + visitor.CallExpression({ + type: "CallExpression", + callee: { type: "Identifier", name: "repair" }, + arguments: [{ type: "ObjectExpression" }], + }); + + expect(reports).toHaveLength(1); + expect(reports[0].messageId).toBe("noArgs"); + expect(reports[0].fix({ replaceText: (_node, text) => text })) + .toBe("repair()"); + }); + + it("does not flag repair() with no args", () => { + const reports = []; + const visitor = repairNoArgsRule.create({ + sourceCode: {}, + report: (problem) => reports.push(problem), + }); + + visitor.CallExpression({ + type: "CallExpression", + callee: { type: "Identifier", name: "repair" }, + arguments: [], + }); + + expect(reports).toHaveLength(0); + }); +}); From a8c197aef6eb67c3de5c4ccae87dc9803fbcc347 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 25 Jul 2026 10:06:19 +0000 Subject: [PATCH 2/2] Refactor lint scanner to use rule-provided token scans Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- skills/rig/eslint/lint.js | 101 ++---------------- .../eslint/rules/no-object-literal-record.js | 54 ++++++++++ skills/rig/eslint/rules/repair-no-args.js | 42 ++++++++ 3 files changed, 104 insertions(+), 93 deletions(-) diff --git a/skills/rig/eslint/lint.js b/skills/rig/eslint/lint.js index 6dad765..22e8792 100644 --- a/skills/rig/eslint/lint.js +++ b/skills/rig/eslint/lint.js @@ -3,9 +3,11 @@ import { readFile, readdir, writeFile } from "node:fs/promises"; import { extname, resolve } from "node:path"; import { pathToFileURL } from "node:url"; +import { scanTokens as scanNoObjectLiteralRecord } from "./rules/no-object-literal-record.js"; +import { scanTokens as scanRepairNoArgs } from "./rules/repair-no-args.js"; -const methods = new Set(["record", "nonEmptyObject"]); const ignoredDirectories = new Set([".git", "node_modules"]); +const tokenRules = [scanNoObjectLiteralRecord, scanRepairNoArgs]; function tokenize(source) { const tokens = []; @@ -50,107 +52,20 @@ function tokenize(source) { 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(...).`, - }); - } - } - - for (let index = 0; index <= tokens.length - 3; index += 1) { - const [fn, openParen] = tokens.slice(index, index + 2); - if ( - tokens[index - 1]?.value === "." - || fn.value !== "repair" - || openParen.value !== "(" - ) { - continue; - } - - const closeIndex = closingParen(tokens, index + 1); - if (closeIndex === undefined) continue; - const hasArgs = tokens - .slice(index + 2, closeIndex) - .some((t) => !/^\s*$/.test(t.value)); - if (hasArgs) { - problems.push({ - start: openParen.end, - end: tokens[closeIndex].start, - message: "repair() takes no arguments. Set maxTurns on the agent spec instead.", - kind: "repair-no-args", - }); - } - } - - return problems; -} - -function closingParen(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; + return tokenRules.flatMap((scan) => scan(tokens)); } export function fixSource(source, problems = lintSource(source)) { let fixed = source; - const edits = []; - for (const problem of problems) { - if (problem.kind === "repair-no-args") { - edits.push({ index: problem.start, end: problem.end, text: "" }); - } else { - edits.push({ index: problem.start, text: "s.object(" }); - edits.push({ index: problem.end, text: ")" }); - } - } - edits.sort((left, right) => right.index - left.index); + const edits = problems.flatMap((problem) => problem.edits ?? []); + edits.sort((left, right) => right.start - left.start); for (const edit of edits) { if (edit.end !== undefined) { - fixed = `${fixed.slice(0, edit.index)}${edit.text}${fixed.slice(edit.end)}`; + fixed = `${fixed.slice(0, edit.start)}${edit.text}${fixed.slice(edit.end)}`; } else { - fixed = `${fixed.slice(0, edit.index)}${edit.text}${fixed.slice(edit.index)}`; + fixed = `${fixed.slice(0, edit.start)}${edit.text}${fixed.slice(edit.start)}`; } } return fixed; diff --git a/skills/rig/eslint/rules/no-object-literal-record.js b/skills/rig/eslint/rules/no-object-literal-record.js index 04022fc..0e533c2 100644 --- a/skills/rig/eslint/rules/no-object-literal-record.js +++ b/skills/rig/eslint/rules/no-object-literal-record.js @@ -1,3 +1,57 @@ +const methods = new Set(["record", "nonEmptyObject"]); + +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 scanTokens(tokens) { + 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) continue; + + problems.push({ + start: object.start, + end: tokens[closingIndex].end, + message: "Wrap object-valued record fields with s.object(...).", + edits: [ + { start: object.start, text: "s.object(" }, + { start: tokens[closingIndex].end, text: ")" }, + ], + }); + } + + return problems; +} + export default { meta: { type: "problem", diff --git a/skills/rig/eslint/rules/repair-no-args.js b/skills/rig/eslint/rules/repair-no-args.js index 3a33ead..85c7e86 100644 --- a/skills/rig/eslint/rules/repair-no-args.js +++ b/skills/rig/eslint/rules/repair-no-args.js @@ -1,3 +1,45 @@ +function closingParen(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 scanTokens(tokens) { + const problems = []; + + for (let index = 0; index <= tokens.length - 3; index += 1) { + const [fn, openParen] = tokens.slice(index, index + 2); + if ( + tokens[index - 1]?.value === "." + || fn.value !== "repair" + || openParen.value !== "(" + ) { + continue; + } + + const closeIndex = closingParen(tokens, index + 1); + if (closeIndex === undefined) continue; + const hasArgs = tokens + .slice(index + 2, closeIndex) + .some((t) => !/^\s*$/.test(t.value)); + if (!hasArgs) continue; + + problems.push({ + start: openParen.end, + end: tokens[closeIndex].start, + message: "repair() takes no arguments. Set maxTurns on the agent spec instead.", + kind: "repair-no-args", + edits: [{ start: openParen.end, end: tokens[closeIndex].start, text: "" }], + }); + } + + return problems; +} + export default { meta: { type: "problem",