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,10 +1,12 @@
import noObjectLiteralRecord from "./rules/no-object-literal-record.js";
import repairNoArgs from "./rules/repair-no-args.js";

export default {
meta: {
name: "rig",
},
rules: {
"no-object-literal-record": noObjectLiteralRecord,
"repair-no-args": repairNoArgs,
},
};
64 changes: 11 additions & 53 deletions skills/rig/eslint/lint.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [];
Expand Down Expand Up @@ -50,65 +52,21 @@ 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(...).`,
});
}
}

return problems;
return tokenRules.flatMap((scan) => scan(tokens));
}

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 = problems.flatMap((problem) => problem.edits ?? []);
edits.sort((left, right) => right.start - left.start);
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.start)}${edit.text}${fixed.slice(edit.end)}`;
} else {
fixed = `${fixed.slice(0, edit.start)}${edit.text}${fixed.slice(edit.start)}`;
}
}
return fixed;
}
Expand Down
54 changes: 54 additions & 0 deletions skills/rig/eslint/rules/no-object-literal-record.js
Original file line number Diff line number Diff line change
@@ -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: ")" },

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.

[/codebase-design] no-object-literal-record problems lack a kind field while repair-no-args problems include kind: "repair-no-args". This asymmetry means callers have no consistent way to filter problems by rule.

💡 Suggested fix

Add kind to the problem shape in no-object-literal-record.js:

problems.push({
  kind: "no-object-literal-record",
  start: object.start,
  end: tokens[closingIndex].end,
  message: "Wrap object-valued record fields with s.object(...).",
  edits: [...],
});

Tests can then filter symmetrically, just like the new repair-no-args tests do.

],
});
}

return problems;
}

export default {
meta: {
type: "problem",
Expand Down
77 changes: 77 additions & 0 deletions skills/rig/eslint/rules/repair-no-args.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
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",
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()");
},
});
},
};
},
};
76 changes: 76 additions & 0 deletions src/eslint-rules.test.js
Original file line number Diff line number Diff line change
@@ -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([
Expand Down Expand Up @@ -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);
});
});
Loading