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
2 changes: 2 additions & 0 deletions skills/rig/eslint/index.js
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -6,6 +7,7 @@ export default {
name: "rig",
},
rules: {
"agents-must-be-object": agentsMustBeObject,
"no-object-literal-record": noObjectLiteralRecord,
"repair-no-args": repairNoArgs,
},
Expand Down
3 changes: 2 additions & 1 deletion skills/rig/eslint/lint.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [];
Expand Down
100 changes: 100 additions & 0 deletions skills/rig/eslint/rules/agents-must-be-object.js
Original file line number Diff line number Diff line change
@@ -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);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[/tdd] The identifier regex /^[A-Za-z_$][\w$]*$/ is redundant — the tokenizer in lint.js already guarantees that identifier-shaped tokens are the only word tokens emitted (lines 43–46). Only single-char punctuation tokens can ever reach this branch, and only , passes the skip check. Consider replacing the regex with a comment that names this invariant, making the guard's intent clearer.

💡 Clarified guard
// The tokenizer emits only identifier tokens (matched by /[A-Za-z_$][\w$]*/) or
// single-char punctuation tokens. Commas are separators; anything else is a
// non-identifier element (e.g. '[', '.') that can't be safely renamed.
if (token.value === ",") continue;
if (token.value.length === 1 && !/[A-Za-z_$]/.test(token.value[0])) {
  valid = false;
  break;
}
identifiers.push(token.value);

This avoids re-testing the regex on every identifier token.

} 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: {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[/grill-with-docs] The fixedText uses { a, b } (spaces inside braces), but the existing no-object-literal-record.js fix and the codebase style don't have a settled convention here. If the user's code uses {a, b} (no spaces), the autofix introduces a style difference. This is a minor inconsistency, but worth aligning with whatever convention prettier/eslint enforces in the repo.

💡 Check

Run grep -r 'agents: {' skills/rig/samples/ to see whether existing samples use spaces. If the project has Prettier configured, the fix output will be re-formatted anyway, but it's good to be consistent with the fix output of sibling rules.

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(", ")} }`);
},
});
},
};
},
};
109 changes: 109 additions & 0 deletions src/eslint-rules.test.js
Original file line number Diff line number Diff line change
@@ -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 }",

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[/tdd] The lintSource-level valid cases include "const text = 'agents: [extractor]';" in a comment but the test string doesn't actually contain a string literal — the outer double-quotes make it just a comment note in the it.each array. The test is correct (the regex 'agents: [extractor]' inside single-quoted string content is skipped by the tokenizer), but the comment-as-code makes it harder to verify intent. Consider a dedicated it that names what is being guarded.

💡 Suggested clarifying test
it("does not flag agents array inside a string literal", () => {
  const source = `const text = 'agents: [extractor]';`;
  expect(lintSource(source).filter((p) => p.kind === "agents-must-be-object")).toEqual([]);
});

This makes the string-context protection explicit and independently verifiable.

"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 }));",
Expand Down
Loading