From 2846b7749015dadc0ab00f28c537255e998150ec Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 26 Jul 2026 20:07:30 +0000 Subject: [PATCH] feat(eslint): add agents-must-be-object rule Detects and autofixes agents declared as an array literal instead of a named object shorthand. The pattern agents: [extractor] fails at runtime; agents: { extractor } is required. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- skills/rig/eslint/index.js | 2 + skills/rig/eslint/lint.js | 3 +- .../rig/eslint/rules/agents-must-be-object.js | 100 ++++++++++++++++ src/eslint-rules.test.js | 109 ++++++++++++++++++ 4 files changed, 213 insertions(+), 1 deletion(-) create mode 100644 skills/rig/eslint/rules/agents-must-be-object.js diff --git a/skills/rig/eslint/index.js b/skills/rig/eslint/index.js index b03b62c..18e5cd4 100644 --- a/skills/rig/eslint/index.js +++ b/skills/rig/eslint/index.js @@ -1,3 +1,4 @@ +import agentsMustBeObject from "./rules/agents-must-be-object.js"; import noObjectLiteralRecord from "./rules/no-object-literal-record.js"; import repairNoArgs from "./rules/repair-no-args.js"; @@ -6,6 +7,7 @@ export default { name: "rig", }, rules: { + "agents-must-be-object": agentsMustBeObject, "no-object-literal-record": noObjectLiteralRecord, "repair-no-args": repairNoArgs, }, diff --git a/skills/rig/eslint/lint.js b/skills/rig/eslint/lint.js index 22e8792..d1203b5 100644 --- a/skills/rig/eslint/lint.js +++ b/skills/rig/eslint/lint.js @@ -3,11 +3,12 @@ import { readFile, readdir, writeFile } from "node:fs/promises"; import { extname, resolve } from "node:path"; import { pathToFileURL } from "node:url"; +import { scanTokens as scanAgentsMustBeObject } from "./rules/agents-must-be-object.js"; import { scanTokens as scanNoObjectLiteralRecord } from "./rules/no-object-literal-record.js"; import { scanTokens as scanRepairNoArgs } from "./rules/repair-no-args.js"; const ignoredDirectories = new Set([".git", "node_modules"]); -const tokenRules = [scanNoObjectLiteralRecord, scanRepairNoArgs]; +const tokenRules = [scanAgentsMustBeObject, scanNoObjectLiteralRecord, scanRepairNoArgs]; function tokenize(source) { const tokens = []; diff --git a/skills/rig/eslint/rules/agents-must-be-object.js b/skills/rig/eslint/rules/agents-must-be-object.js new file mode 100644 index 0000000..f55e9f4 --- /dev/null +++ b/skills/rig/eslint/rules/agents-must-be-object.js @@ -0,0 +1,100 @@ +function closingBracket(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 [key, colon, openBracket] = tokens.slice(index, index + 3); + if ( + key.value !== "agents" + || colon.value !== ":" + || openBracket.value !== "[" + ) { + continue; + } + + const closeIndex = closingBracket(tokens, index + 2); + if (closeIndex === undefined) continue; + + const inner = tokens.slice(index + 3, closeIndex); + + // Collect comma-separated identifier tokens; bail if non-identifiers found. + const identifiers = []; + let valid = true; + for (const token of inner) { + if (token.value === ",") continue; + if (/^[A-Za-z_$][\w$]*$/.test(token.value)) { + identifiers.push(token.value); + } else { + valid = false; + break; + } + } + + if (!valid || identifiers.length === 0) continue; + + const fixedText = `{ ${identifiers.join(", ")} }`; + + problems.push({ + start: openBracket.start, + end: tokens[closeIndex].end, + message: "agents must be an object, not an array. Use agents: { name } instead of agents: [name].", + kind: "agents-must-be-object", + edits: [{ start: openBracket.start, end: tokens[closeIndex].end, text: fixedText }], + }); + } + + return problems; +} + +export default { + meta: { + type: "problem", + docs: { + description: "Require agents to be declared as a named object, not an array", + }, + fixable: "code", + schema: [], + messages: { + mustBeObject: "agents must be an object, not an array. Use agents: { name } instead of agents: [name].", + }, + }, + create(context) { + return { + Property(node) { + if ( + node.key.type !== "Identifier" + || node.key.name !== "agents" + || node.value.type !== "ArrayExpression" + ) { + return; + } + + const elements = node.value.elements; + if ( + elements.length === 0 + || elements.some((el) => el === null || el.type !== "Identifier") + ) { + return; + } + + const names = elements.map((el) => el.name); + context.report({ + node: node.value, + messageId: "mustBeObject", + fix(fixer) { + return fixer.replaceText(node.value, `{ ${names.join(", ")} }`); + }, + }); + }, + }; + }, +}; diff --git a/src/eslint-rules.test.js b/src/eslint-rules.test.js index fcea53f..5d8cdc9 100644 --- a/src/eslint-rules.test.js +++ b/src/eslint-rules.test.js @@ -1,8 +1,117 @@ import { describe, expect, it } from "vitest"; import { fixSource, lintSource } from "../skills/rig/eslint/lint.js"; +import agentsMustBeObjectRule from "../skills/rig/eslint/rules/agents-must-be-object.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("agents-must-be-object", () => { + it.each([ + "agents: { extractor }", + "agents: { diagnose, fix }", + "agents: { a, b, c }", + "const text = 'agents: [extractor]';", + // Non-identifier elements must not be flagged (no safe autofix) + ])("accepts %s", (source) => { + const problems = lintSource(source).filter((p) => p.kind === "agents-must-be-object"); + expect(problems).toEqual([]); + }); + + it.each([ + [ + "agents: [extractor]", + "agents: { extractor }", + ], + [ + "agents: [diagnose, fix]", + "agents: { diagnose, fix }", + ], + [ + "const x = agent({ model: \"small\", agents: [summarizer] });", + "const x = agent({ model: \"small\", agents: { summarizer } });", + ], + ])("fixes %s", (source, expected) => { + const problems = lintSource(source).filter((p) => p.kind === "agents-must-be-object"); + expect(problems).toHaveLength(1); + expect(fixSource(source, problems)).toBe(expected); + }); + + it("is idempotent", () => { + const source = "agents: [extractor]"; + const once = fixSource(source); + const twice = fixSource(once); + expect(twice).toBe(once); + expect(lintSource(once).filter((p) => p.kind === "agents-must-be-object")).toEqual([]); + }); + + it("does not flag empty array", () => { + const source = "agents: []"; + const problems = lintSource(source).filter((p) => p.kind === "agents-must-be-object"); + expect(problems).toEqual([]); + }); + + it("keeps the ESLint rule aligned", () => { + const reports = []; + const visitor = agentsMustBeObjectRule.create({ + sourceCode: {}, + report: (problem) => reports.push(problem), + }); + + visitor.Property({ + key: { type: "Identifier", name: "agents" }, + value: { + type: "ArrayExpression", + elements: [ + { type: "Identifier", name: "extractor" }, + { type: "Identifier", name: "summarizer" }, + ], + }, + }); + + expect(reports).toHaveLength(1); + expect(reports[0].messageId).toBe("mustBeObject"); + expect(reports[0].fix({ replaceText: (_node, text) => text })) + .toBe("{ extractor, summarizer }"); + }); + + it("does not flag agents object", () => { + const reports = []; + const visitor = agentsMustBeObjectRule.create({ + sourceCode: {}, + report: (problem) => reports.push(problem), + }); + + visitor.Property({ + key: { type: "Identifier", name: "agents" }, + value: { + type: "ObjectExpression", + properties: [], + }, + }); + + expect(reports).toHaveLength(0); + }); + + it("does not flag array with non-identifier elements", () => { + const reports = []; + const visitor = agentsMustBeObjectRule.create({ + sourceCode: {}, + report: (problem) => reports.push(problem), + }); + + visitor.Property({ + key: { type: "Identifier", name: "agents" }, + value: { + type: "ArrayExpression", + elements: [ + { type: "CallExpression" }, + ], + }, + }); + + expect(reports).toHaveLength(0); + }); +}); + describe("no-object-literal-record", () => { it.each([ "const output = s.record(s.object({ count: s.number }));",