From 2d04cf42126a14c54191c6bd3a1c4ade5c0625d7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 03:27:01 +0000 Subject: [PATCH] S07 part 1: plan grounding check (observe-only, flag off) Pure module plus git-backed adapter that checks whether the files, symbols, package scripts and make targets a plan names exist in the committed tree at a stated revision. Three-valued (grounded / ungrounded / unchecked, plus proposed for things the plan says it creates). Advisory only; gated by SHIP_PLAN_GROUNDING (default off); not wired into any run or route. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01VqsBNqvaWezf1DwQrAnVgX --- src/plan-grounding.test.ts | 229 +++++++++++++++++++++++++ src/plan-grounding.ts | 331 +++++++++++++++++++++++++++++++++++++ 2 files changed, 560 insertions(+) create mode 100644 src/plan-grounding.test.ts create mode 100644 src/plan-grounding.ts diff --git a/src/plan-grounding.test.ts b/src/plan-grounding.test.ts new file mode 100644 index 0000000..ed5ed93 --- /dev/null +++ b/src/plan-grounding.test.ts @@ -0,0 +1,229 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { test } from "node:test"; + +import { LocalExecutor } from "@neutron-build/agents"; + +import { extractRefs, groundPlan, groundPlanFromExecutor, planGroundingEnabled, readGroundingFacts, renderGrounding } from "./plan-grounding.js"; +import type { GroundingReport } from "./plan-grounding.js"; + +/** + * Real temp git repositories read through a real LocalExecutor, the same path + * a run's checkout takes. There is no mock of git. + */ + +const GIT = ["-c", "user.email=t@example.com", "-c", "user.name=t", "-c", "commit.gpgsign=false"]; +function git(cwd: string, ...args: string[]): string { + return execFileSync("git", [...GIT, ...args], { cwd, encoding: "utf8" }).trim(); +} + +async function repo(tree: Record): Promise<{ root: string; sha: string; executor: LocalExecutor; done: () => Promise }> { + const root = await mkdtemp(join(tmpdir(), "plan-grounding-")); + git(root, "init", "-q", "-b", "main"); + for (const [p, text] of Object.entries(tree)) { + await mkdir(dirname(join(root, p)), { recursive: true }); + await writeFile(join(root, p), text); + } + git(root, "add", "-A"); + git(root, "commit", "-q", "-m", "init"); + return { root, sha: git(root, "rev-parse", "HEAD"), executor: new LocalExecutor({ root }), done: () => rm(root, { recursive: true, force: true }) }; +} + +const TREE = { + "package.json": JSON.stringify({ name: "x", scripts: { build: "tsc", test: "node --test", lint: "eslint ." } }), + "Makefile": "all: build\nbuild:\n\techo hi\n", + "src/billing/invoice.ts": "export function computeProration(a: number) { return a; }\nexport class InvoiceStore { save() {} }\n", + "src/index.ts": "export const x = 1;\n", + "docs/notes.md": "hello\n", +}; + +function byName(r: GroundingReport, name: string) { + const x = r.refs.find((y) => y.name === name); + assert.ok(x, `no ref ${name}; have ${r.refs.map((y) => y.name).join(", ")}`); + return x; +} + +test("flag: off by default, on only for explicit values", () => { + assert.equal(planGroundingEnabled({}), false); + assert.equal(planGroundingEnabled({ SHIP_PLAN_GROUNDING: "off" }), false); + assert.equal(planGroundingEnabled({ SHIP_PLAN_GROUNDING: "" }), false); + assert.equal(planGroundingEnabled({ SHIP_PLAN_GROUNDING: "on" }), true); + assert.equal(planGroundingEnabled({ SHIP_PLAN_GROUNDING: "1" }), true); +}); + +test("real repo: existing files, symbols and scripts are grounded; invented ones are ungrounded", async () => { + const r = await repo(TREE); + try { + const plan = [ + "1. Edit `src/billing/invoice.ts:1` so `computeProration()` rounds.", + "2. Update `InvoiceStore.save` and `src/billing/ledger.ts`.", + "3. Call `reconcileLedgerEntries()` from `src/index.ts`.", + "4. Run `pnpm run build` then `pnpm run migrate` and `make build`, `make deploy`.", + ].join("\n"); + const rep = await groundPlanFromExecutor(r.executor, plan, { expectedRevision: r.sha.slice(0, 10) }); + assert.equal(rep.revision, r.sha); + assert.equal(rep.revisionMismatch, undefined); + assert.equal(byName(rep, "src/billing/invoice.ts").status, "grounded"); + assert.equal(byName(rep, "src/billing/invoice.ts").line, 1); + assert.equal(byName(rep, "computeProration").status, "grounded"); + assert.equal(byName(rep, "InvoiceStore.save").status, "grounded"); + assert.equal(byName(rep, "src/index.ts").status, "grounded"); + assert.equal(byName(rep, "src/billing/ledger.ts").status, "ungrounded"); + assert.equal(byName(rep, "reconcileLedgerEntries").status, "ungrounded"); + assert.equal(byName(rep, "pnpm run build").status, "grounded"); + assert.equal(byName(rep, "pnpm run migrate").status, "ungrounded"); + assert.equal(byName(rep, "make build").status, "grounded"); + assert.equal(byName(rep, "make deploy").status, "ungrounded"); + assert.equal(rep.allCheckedGrounded, false); + const text = renderGrounding(rep); + assert.ok(text.indexOf("[ungrounded]") < text.indexOf("[grounded]"), "ungrounded listed first"); + assert.match(text, /advisory/); + } finally { await r.done(); } +}); + +test("a fully grounded plan reports allCheckedGrounded; an empty plan does not claim grounding", async () => { + const r = await repo(TREE); + try { + const ok = await groundPlanFromExecutor(r.executor, "Edit `src/index.ts` and run `pnpm test`."); + assert.equal(ok.allCheckedGrounded, true); + const none = await groundPlanFromExecutor(r.executor, "Make it better and faster, somehow."); + assert.equal(none.refs.length, 0); + assert.equal(none.allCheckedGrounded, false); + assert.match(renderGrounding(none), /nothing to ground/); + } finally { await r.done(); } +}); + +test("negative control: an untracked or uncommitted file does not ground a claim (revision-bound, not working-tree)", async () => { + const r = await repo(TREE); + try { + await writeFile(join(r.root, "src/untracked.ts"), "export function onlyOnDisk() {}\n"); + await writeFile(join(r.root, "src/index.ts"), "export const x = 1;\nexport function editedUncommitted() {}\n"); + const rep = await groundPlanFromExecutor(r.executor, "Touch `src/untracked.ts` and `onlyOnDisk()` and `editedUncommitted()`."); + assert.equal(byName(rep, "src/untracked.ts").status, "ungrounded"); + assert.equal(byName(rep, "onlyOnDisk").status, "ungrounded"); + assert.equal(byName(rep, "editedUncommitted").status, "ungrounded"); + } finally { await r.done(); } +}); + +test("a later commit is not visible when grounding an earlier revision", async () => { + const r = await repo(TREE); + try { + await writeFile(join(r.root, "src/later.ts"), "export function laterFn() {}\n"); + git(r.root, "add", "-A"); git(r.root, "commit", "-q", "-m", "later"); + const old = await groundPlanFromExecutor(r.executor, "Use `src/later.ts` and `laterFn()`.", { rev: r.sha }); + assert.equal(old.revision, r.sha); + assert.equal(byName(old, "src/later.ts").status, "ungrounded"); + assert.equal(byName(old, "laterFn").status, "ungrounded"); + const head = await groundPlanFromExecutor(r.executor, "Use `src/later.ts` and `laterFn()`."); + assert.equal(byName(head, "src/later.ts").status, "grounded"); + assert.equal(byName(head, "laterFn").status, "grounded"); + } finally { await r.done(); } +}); + +test("revision mismatch asserts nothing: every ref is unchecked, none grounded or ungrounded", async () => { + const r = await repo(TREE); + try { + const rep = await groundPlanFromExecutor(r.executor, "Edit `src/index.ts` and `src/nope.ts`.", { expectedRevision: "deadbeef" }); + assert.ok(rep.revisionMismatch); + assert.deepEqual(rep.counts, { grounded: 0, ungrounded: 0, proposed: 0, unchecked: 2 }); + assert.equal(rep.allCheckedGrounded, false); + assert.match(renderGrounding(rep), /REVISION MISMATCH/); + } finally { await r.done(); } +}); + +test("a file the plan says it creates is proposed, not ungrounded; creating something that exists is called out", async () => { + const r = await repo(TREE); + try { + const rep = await groundPlanFromExecutor(r.executor, ["Create `src/billing/ledger.ts` with the new store.", "Add `src/index.ts` as the entry."].join("\n")); + const fresh = byName(rep, "src/billing/ledger.ts"); + assert.equal(fresh.status, "proposed"); + assert.match(fresh.detail, /not present/); + const dup = byName(rep, "src/index.ts"); + assert.equal(dup.status, "proposed"); + assert.match(dup.detail, /already exists/); + assert.equal(rep.counts.ungrounded, 0); + } finally { await r.done(); } +}); + +test("unknown is not pass: unparsed commands, out-of-repo paths and a missing package.json are unchecked", async () => { + const r = await repo({ "src/a.ts": "export const a = 1;\n" }); + try { + const plan = "Run `cargo test`, `pnpm --filter web build`, `pnpm run build`; read `../secrets/x.ts` and `/etc/passwd.txt`."; + const rep = await groundPlanFromExecutor(r.executor, plan); + assert.equal(byName(rep, "cargo test").status, "unchecked"); + assert.equal(byName(rep, "pnpm --filter web build").status, "unchecked"); + const nopkg = byName(rep, "pnpm run build"); + assert.equal(nopkg.status, "unchecked"); + assert.match(nopkg.detail, /no package\.json/); + assert.equal(byName(rep, "../secrets/x.ts").status, "unchecked"); + assert.equal(byName(rep, "/etc/passwd.txt").status, "unchecked"); + assert.equal(rep.counts.grounded, 0); + assert.equal(rep.counts.ungrounded, 0); + } finally { await r.done(); } +}); + +test("prose is not a claim: plain words and URLs in backticks are not extracted; fenced commands are", () => { + const refs = extractRefs([ + "Set `true` for `config` and see `https://example.com/a/b.ts` and fractions like `1/2`.", + "```sh", + "$ pnpm run lint", + "echo not a command we know", + "```", + "Use `parseThing()` and `snake_case_name`.", + ].join("\n")); + const names = refs.map((x) => `${x.kind}:${x.name}`); + assert.deepEqual(names, ["command:pnpm run lint", "symbol:parseThing", "symbol:snake_case_name"]); + assert.equal(refs[0]!.line, 3); +}); + +test("hostile plan text never reaches a shell: a symbol with metacharacters is not extracted or searched", async () => { + const r = await repo(TREE); + try { + const marker = join(r.root, "pwned"); + const plan = `Look at \`foo();touch ${marker}\` and \`$(touch ${marker})\` and \`x\`; also \`computeProration()\`.`; + const rep = await groundPlanFromExecutor(r.executor, plan); + await assert.rejects(() => r.executor.getFile("pwned")); + assert.equal(byName(rep, "computeProration").status, "grounded"); + assert.ok(rep.refs.every((x) => !x.name.includes(";") && !x.name.includes("$("))); + } finally { await r.done(); } +}); + +test("adapter guard: a hand-built symbol ref with shell metacharacters is never interpolated", async () => { + const r = await repo(TREE); + try { + const facts = await readGroundingFacts(r.executor, [{ kind: "symbol", name: "a;touch pwned", line: 1, creates: false }]); + await assert.rejects(() => r.executor.getFile("pwned")); + assert.equal(facts.symbols["a;touch pwned"], undefined); + assert.match(facts.symbolNotes["a;touch pwned"] ?? "", /not a plain identifier/); + } finally { await r.done(); } +}); + +test("not a git repository throws rather than reporting everything ungrounded", async () => { + const root = await mkdtemp(join(tmpdir(), "plan-grounding-nogit-")); + try { + await writeFile(join(root, "a.ts"), "export {};\n"); + await assert.rejects(() => groundPlanFromExecutor(new LocalExecutor({ root }), "Edit `a.ts`."), /not a git repository|unknown revision/); + } finally { await rm(root, { recursive: true, force: true }); } +}); + +test("unsafe revision strings are refused before any command runs", async () => { + const r = await repo(TREE); + try { + await assert.rejects(() => groundPlanFromExecutor(r.executor, "Edit `src/index.ts`.", { rev: "HEAD; touch pwned" }), /unsafe revision/); + await assert.rejects(() => r.executor.getFile("pwned")); + } finally { await r.done(); } +}); + +test("pure half: a truncated listing makes absence unassertable (unchecked), never ungrounded", () => { + const rep = groundPlan("Edit `src/gone.ts`.", { revision: "a".repeat(40), filesNote: "file listing truncated", symbols: {}, symbolNotes: {} }); + assert.equal(byName(rep, "src/gone.ts").status, "unchecked"); + assert.equal(rep.counts.ungrounded, 0); +}); + +test("pure half: a symbol that could not be searched is unchecked", () => { + const rep = groundPlan("Call `doTheThing()`.", { revision: "a".repeat(40), symbols: {}, symbolNotes: { doTheThing: "git grep failed (exit 128)" } }); + assert.equal(byName(rep, "doTheThing").status, "unchecked"); +}); diff --git a/src/plan-grounding.ts b/src/plan-grounding.ts new file mode 100644 index 0000000..ddb4fd6 --- /dev/null +++ b/src/plan-grounding.ts @@ -0,0 +1,331 @@ +/** + * Plan grounding (S07, first part). A plan that names `src/billing/invoice.ts`, + * `computeProration()` or `pnpm run migrate` is making claims about the + * repository. This module answers one narrow, model-free question: do those + * named files, symbols and package/make commands exist in the tree at the + * stated revision? + * + * What it is NOT: it does not judge whether the plan is good, does not read + * code semantically, and "grounded" only means "the name exists". A plan can be + * fully grounded and wrong. The advisory is for the reviewer, never a gate. + * + * Rules that shape the code: + * - Three-valued, never two. A reference is `grounded`, `ungrounded` (we + * looked and it is absent) or `unchecked` (we could not look: truncated + * listing, path outside the repo, command we do not understand, git error). + * `unchecked` is shown as such and never counted as a pass. + * - A reference on a line that says it will CREATE it is `proposed`, not + * ungrounded; a plan is allowed to introduce new files. If a "create" target + * already exists we say so (`proposed` + note) because that is a plan/repo + * disagreement a reviewer wants to see. + * - Revision-bound. Facts come from the git object database at `revision` + * (`git ls-tree`, `git show`, `git grep` against that commit), not from the + * working tree, so an uncommitted or untracked file cannot make a claim look + * grounded. If the caller states an expected revision and HEAD differs, no + * reference is asserted at all (`revisionMismatch`). + * - Plan text is untrusted. It never reaches a shell: symbols are matched + * against a strict identifier grammar before being interpolated, paths are + * never interpolated (they are compared to the listing), and command text is + * only parsed, never run. + * + * Observe-only: nothing here writes a run, a step or a record. Gate callers + * with `planGroundingEnabled` (SHIP_PLAN_GROUNDING, default off). + */ +import type { AgentExecutor } from "@neutron-build/agents"; + +/** Gate for every caller. Default OFF. */ +export function planGroundingEnabled(env: NodeJS.ProcessEnv = process.env): boolean { + const raw = (env.SHIP_PLAN_GROUNDING ?? "").trim().toLowerCase(); + return raw === "on" || raw === "1" || raw === "true"; +} + +export type RefKind = "file" | "symbol" | "command"; +export type RefStatus = "grounded" | "ungrounded" | "proposed" | "unchecked"; + +export interface PlanRef { + kind: RefKind; + /** Normalised name: path without `./` and `:line`, symbol without `()`, command text. */ + name: string; + /** 1-based line of the plan text the reference came from. */ + line: number; + /** True when the plan line says it creates/adds this thing. */ + creates: boolean; +} + +export interface RefResult extends PlanRef { + status: RefStatus; + /** Why: what was looked up, or why it could not be. Always set. */ + detail: string; +} + +/** Facts about the tree at `revision`. Built by `readGroundingFacts` or by hand in tests. */ +export interface GroundingFacts { + revision: string; + /** Every path at the revision, or undefined when the listing could not be trusted (error/truncated). */ + files?: Set; + filesNote?: string; + /** Root package.json scripts, undefined when absent/unreadable. */ + scripts?: Record; + scriptsNote?: string; + /** Makefile target names, undefined when no Makefile or unreadable. */ + makeTargets?: Set; + makeNote?: string; + /** symbol -> found. Absent key = could not be looked up (see symbolNotes). */ + symbols: Record; + symbolNotes: Record; +} + +export interface GroundingReport { + /** Constant: nothing was executed against the repo beyond read-only git queries. */ + mode: "advisory"; + revision: string; + /** Set when the caller's expected revision does not match; then every ref is `unchecked`. */ + revisionMismatch?: { expected: string; actual: string }; + refs: RefResult[]; + counts: Record; + /** True only when at least one reference was found and none is ungrounded; says nothing about `unchecked`. */ + allCheckedGrounded: boolean; +} + +const MAX_REFS = 200; +const FILE_EXT = "(?:ts|tsx|js|jsx|mjs|cjs|json|ya?ml|toml|md|py|go|rs|java|rb|php|sh|sql|css|html|lock|txt|cfg|ini|env|mk)"; +const IDENT = /^[A-Za-z_$][A-Za-z0-9_$]*$/; +const CREATE_WORDS = /\b(create|creates|creating|add|adds|adding|introduce|introduces|new file|new module|scaffold|write a new|generate)\b/i; +const PKG_BUILTIN = new Set([ + "install", "i", "add", "remove", "rm", "update", "up", "upgrade", "uninstall", "ci", "init", "publish", "pack", "link", "unlink", + "audit", "outdated", "list", "ls", "exec", "dlx", "x", "create", "config", "cache", "store", "prune", "dedupe", "why", "rebuild", + "version", "login", "logout", "whoami", "help", "import", "patch", "fetch", "env", "self-update", "setup", "root", "bin", "run", + "start", "test", "stop", "restart", "workspaces", "workspace", "info", "run-script", "install-test", +]); +/** `npm start`/`npm test`-style aliases that run a script of that name. */ +const RUN_ALIASES = new Set(["start", "test", "stop", "restart"]); + +function normPath(raw: string): string | undefined { + let p = raw.trim().replace(/^\.\//, "").replace(/:\d+(?::\d+)?$/, "").replace(/#L\d+.*$/, ""); + p = p.replace(/[.,;)]+$/, ""); + if (p === "" || p.startsWith("/") || p.startsWith("~") || p.split("/").includes("..")) return undefined; + return p; +} + +function looksLikePath(s: string): boolean { + if (/\s/.test(s) || /^[a-z]+:\/\//i.test(s) || s.startsWith("@") && !s.includes(".")) return false; + const bare = s.replace(/:\d+(?::\d+)?$/, ""); + if (/[*?{}<>|$`"'=]/.test(bare)) return false; + if (bare.includes("/")) return /[A-Za-z0-9]/.test(bare) && /\/[^/]+$/.test(bare) && !/^\d+\/\d+$/.test(bare) && !bare.endsWith("/"); + return new RegExp(`^[A-Za-z0-9_.-]+\\.${FILE_EXT}$`).test(bare); +} + +function looksLikeSymbol(s: string): string | undefined { + const t = s.replace(/\(\s*\)$/, "").replace(/\(.*\)$/, ""); + const hadCall = t !== s; + const segs = t.split("."); + if (!segs.every((x) => IDENT.test(x))) return undefined; + // A bare lowercase word (`true`, `config`, `user`) is prose, not a code claim. + const distinctive = (x: string) => /[a-z][A-Z]/.test(x) || /^[A-Z][a-z]+[A-Z]/.test(x) || x.includes("_") && x.length > 3; + if (segs.length === 1 && !hadCall && !distinctive(t)) return undefined; + if (segs.length === 1 && hadCall && t.length < 3) return undefined; + if (segs.length > 1 && !hadCall && !segs.some(distinctive) && !/^[A-Z]/.test(segs[0]!)) return undefined; + return t; +} + +const COMMAND_START = /^(?:\$\s*)?(?:pnpm|npm|yarn|make|node|npx|cargo|go|pytest|python3?|git|docker|tsc|bun)\b/; + +function commandOf(s: string): string | undefined { + const t = s.trim().replace(/^\$\s*/, ""); + return COMMAND_START.test(s.trim()) ? t : undefined; +} + +/** Pull file, symbol and command references out of plan text. Pure; order is source order, de-duplicated by kind+name. */ +export function extractRefs(plan: string): PlanRef[] { + const out: PlanRef[] = []; + const seen = new Set(); + const push = (kind: RefKind, name: string, line: number, creates: boolean) => { + const key = `${kind}\0${name}`; + if (seen.has(key) || out.length >= MAX_REFS) return; + seen.add(key); + out.push({ kind, name, line, creates }); + }; + const lines = plan.split(/\r?\n/); + let fenced = false; + lines.forEach((text, i) => { + const line = i + 1; + if (/^\s*```/.test(text)) { fenced = !fenced; return; } + const creates = CREATE_WORDS.test(text); + if (fenced) { + const cmd = commandOf(text); + if (cmd !== undefined) push("command", cmd, line, false); + return; + } + for (const m of text.matchAll(/`([^`\n]+)`/g)) { + const span = m[1]!.trim(); + const cmd = commandOf(span); + if (cmd !== undefined) { push("command", cmd, line, false); continue; } + if (looksLikePath(span)) { + const p = normPath(span); + // Keep an out-of-repo path as a ref so it is reported `unchecked`, not silently dropped. + push("file", p ?? span, line, creates); + continue; + } + const sym = looksLikeSymbol(span); + if (sym !== undefined) push("symbol", sym, line, creates); + } + }); + return out; +} + +/** What a package-manager command line means for script checking; undefined when we do not understand it. */ +function scriptOf(command: string): { tool: string; script: string } | undefined | "unparsed" { + const parts = command.trim().split(/\s+/); + const tool = parts[0]!; + if (!["pnpm", "npm", "yarn", "bun"].includes(tool)) return undefined; + if (/(^|\s)(&&|\|\||;|\|)(\s|$)/.test(command) || parts.some((p) => /^(--filter|-F|-C|--prefix|-w|--workspace|--cwd|--dir|-r|--recursive)(=|$)/.test(p))) return "unparsed"; + const args = parts.slice(1).filter((p) => !p.startsWith("-")); + const first = args[0]; + if (first === undefined) return "unparsed"; + if (first === "run" || first === "run-script") return args[1] !== undefined ? { tool, script: args[1] } : "unparsed"; + if (RUN_ALIASES.has(first)) return { tool, script: first }; + if (PKG_BUILTIN.has(first)) return "unparsed"; + if (tool === "npm") return "unparsed"; // `npm foo` is not a script shortcut + return { tool, script: first }; +} + +/** Pure judgement of `refs` against `facts`. */ +export function groundRefs(refs: PlanRef[], facts: GroundingFacts): RefResult[] { + return refs.map((ref): RefResult => { + const r = (status: RefStatus, detail: string): RefResult => ({ ...ref, status, detail }); + if (ref.kind === "file") { + const p = normPath(ref.name); + if (p === undefined || p !== ref.name) return r("unchecked", "path is absolute or escapes the repository; not looked up"); + if (facts.files === undefined) return r("unchecked", facts.filesNote ?? "file listing unavailable"); + const isDir = [...facts.files].some((f) => f.startsWith(`${p}/`)); + const exists = facts.files.has(p) || isDir; + if (ref.creates) return exists ? r("proposed", `plan says it creates ${p}, but it already exists at ${facts.revision.slice(0, 12)}`) : r("proposed", `new at ${facts.revision.slice(0, 12)} (not present, plan creates it)`); + return exists ? r("grounded", `present at ${facts.revision.slice(0, 12)}`) : r("ungrounded", `no such path at ${facts.revision.slice(0, 12)}`); + } + if (ref.kind === "symbol") { + const segs = ref.name.split("."); + const missing: string[] = []; + for (const s of segs) { + const v = facts.symbols[s]; + if (v === undefined) return r("unchecked", facts.symbolNotes[s] ?? `${s} was not searched`); + if (!v) missing.push(s); + } + if (missing.length === 0) return r("grounded", `identifier text found at ${facts.revision.slice(0, 12)} (name match only, not a semantic check)`); + if (ref.creates) return r("proposed", `${missing.join(", ")} not present; plan introduces it`); + return r("ungrounded", `${missing.join(", ")} not found in any tracked file at ${facts.revision.slice(0, 12)}`); + } + // command + const sc = scriptOf(ref.name); + if (sc !== undefined && sc !== "unparsed") { + if (facts.scripts === undefined) return r("unchecked", facts.scriptsNote ?? "root package.json unavailable"); + return sc.script in facts.scripts ? r("grounded", `package.json script "${sc.script}" exists`) : r("ungrounded", `no package.json script "${sc.script}" at the repository root`); + } + const mk = /^make\s+([A-Za-z0-9_.-]+)\s*$/.exec(ref.name); + if (mk) { + if (facts.makeTargets === undefined) return r("unchecked", facts.makeNote ?? "no Makefile read"); + return facts.makeTargets.has(mk[1]!) ? r("grounded", `Makefile target "${mk[1]}" exists`) : r("ungrounded", `no Makefile target "${mk[1]}"`); + } + return r("unchecked", "command form not verified (only package scripts and make targets are checked; nothing was run)"); + }); +} + +export interface GroundOptions { + /** Revision the plan claims to be about; mismatch with facts.revision asserts nothing. */ + expectedRevision?: string; +} + +/** Pure end to end: extract, check, summarise. */ +export function groundPlan(plan: string, facts: GroundingFacts, opts: GroundOptions = {}): GroundingReport { + const refs = extractRefs(plan); + const exp = opts.expectedRevision?.trim().toLowerCase(); + const act = facts.revision.toLowerCase(); + const mismatch = exp !== undefined && exp !== "" && !(act.startsWith(exp) || exp.startsWith(act)); + const results: RefResult[] = mismatch + ? refs.map((x) => ({ ...x, status: "unchecked" as const, detail: `plan is about ${exp}, tree is at ${act.slice(0, 12)}; nothing asserted` })) + : groundRefs(refs, facts); + const counts: Record = { grounded: 0, ungrounded: 0, proposed: 0, unchecked: 0 }; + for (const x of results) counts[x.status]++; + return { + mode: "advisory", + revision: facts.revision, + ...(mismatch ? { revisionMismatch: { expected: exp!, actual: act } } : {}), + refs: results, + counts, + allCheckedGrounded: counts.grounded > 0 && counts.ungrounded === 0, + }; +} + +/** Plain-text advisory for a reviewer. Ungrounded first; unchecked is never folded into "ok". */ +export function renderGrounding(report: GroundingReport): string { + const out: string[] = []; + const c = report.counts; + out.push(`Plan grounding at ${report.revision.slice(0, 12)} (advisory, name-existence only): ${c.grounded} grounded, ${c.ungrounded} ungrounded, ${c.proposed} proposed, ${c.unchecked} unchecked`); + if (report.revisionMismatch) out.push(`REVISION MISMATCH: plan is about ${report.revisionMismatch.expected}, tree is at ${report.revisionMismatch.actual.slice(0, 12)}; no reference was checked`); + if (report.refs.length === 0) out.push("The plan names no files, symbols or commands, so there was nothing to ground."); + const order: RefStatus[] = ["ungrounded", "unchecked", "proposed", "grounded"]; + for (const s of order) for (const x of report.refs.filter((y) => y.status === s)) out.push(` [${s}] ${x.kind} ${JSON.stringify(x.name)} (plan line ${x.line}): ${x.detail}`); + return out.join("\n"); +} + +const SAFE_SYMBOL = /^[A-Za-z_$][A-Za-z0-9_$]*$/; +const MAX_SYMBOLS = 60; +const MAX_LIST_BYTES = 8 * 1024 * 1024; + +/** + * Read the facts for `refs` through `executor` with read-only git queries + * against `rev` (default HEAD). Throws only when the directory is not a git + * repository at all; every other failure degrades to `unchecked`. + */ +export async function readGroundingFacts(executor: AgentExecutor, refs: PlanRef[], rev = "HEAD"): Promise { + if (!/^[A-Za-z0-9._\/-]+$/.test(rev) || rev.startsWith("-")) throw new Error(`unsafe revision ${JSON.stringify(rev)}`); + const head = await executor.exec(`git rev-parse --verify --quiet ${rev}^{commit}`, { timeoutMs: 30_000 }); + const revision = head.stdout.trim(); + if (head.exitCode !== 0 || !/^[0-9a-f]{40,64}$/.test(revision)) throw new Error(`not a git repository or unknown revision ${rev}`); + const facts: GroundingFacts = { revision, symbols: {}, symbolNotes: {} }; + + const wantFiles = refs.some((x) => x.kind === "file"); + if (wantFiles) { + const ls = await executor.exec(`git ls-tree -r -z --name-only ${revision}`, { timeoutMs: 60_000, maxOutputBytes: MAX_LIST_BYTES }); + if (ls.exitCode !== 0 || ls.timedOut) facts.filesNote = `git ls-tree failed (exit ${ls.exitCode}${ls.timedOut ? ", timed out" : ""})`; + else if (ls.truncated) facts.filesNote = `file listing truncated at ${MAX_LIST_BYTES} bytes; absence cannot be asserted`; + else facts.files = new Set(ls.stdout.split("\0").filter((f) => f !== "")); + } + + const wantScripts = refs.some((x) => x.kind === "command" && scriptOf(x.name) !== undefined && scriptOf(x.name) !== "unparsed"); + if (wantScripts) { + const pj = await executor.exec(`git show ${revision}:package.json`, { timeoutMs: 30_000 }); + if (pj.exitCode !== 0) facts.scriptsNote = "no package.json at the repository root at this revision"; + else { + try { + const parsed = JSON.parse(pj.stdout) as { scripts?: unknown }; + const s = parsed.scripts; + facts.scripts = s && typeof s === "object" && !Array.isArray(s) ? (s as Record) : {}; + } catch { facts.scriptsNote = "root package.json is not valid JSON"; } + } + } + + if (refs.some((x) => x.kind === "command" && /^make\s/.test(x.name))) { + const mf = await executor.exec(`git show ${revision}:Makefile`, { timeoutMs: 30_000 }); + if (mf.exitCode !== 0) facts.makeNote = "no Makefile at the repository root at this revision"; + else facts.makeTargets = new Set([...mf.stdout.matchAll(/^([A-Za-z0-9_.-]+)\s*:(?!=)/gm)].map((m) => m[1]!)); + } + + const symbols = [...new Set(refs.filter((x) => x.kind === "symbol").flatMap((x) => x.name.split(".")))]; + for (const [i, s] of symbols.entries()) { + if (i >= MAX_SYMBOLS) { facts.symbolNotes[s] = `more than ${MAX_SYMBOLS} distinct identifiers; not searched`; continue; } + if (!SAFE_SYMBOL.test(s)) { facts.symbolNotes[s] = "not a plain identifier; not searched"; continue; } + // -q: exit 0 found, 1 absent, >1 error. -I skips binaries. Only the committed tree at `revision` is searched. + const g = await executor.exec(`git grep -q -I -w -F -e ${s} ${revision} --`, { timeoutMs: 60_000, maxOutputBytes: 4096 }); + if (g.exitCode === 0) facts.symbols[s] = true; + else if (g.exitCode === 1 && !g.timedOut) facts.symbols[s] = false; + else facts.symbolNotes[s] = `git grep failed (exit ${g.exitCode}${g.timedOut ? ", timed out" : ""})`; + } + return facts; +} + +/** Read the tree through `executor` and ground `plan` in it. */ +export async function groundPlanFromExecutor(executor: AgentExecutor, plan: string, opts: GroundOptions & { rev?: string } = {}): Promise { + const refs = extractRefs(plan); + const facts = await readGroundingFacts(executor, refs, opts.rev ?? "HEAD"); + return groundPlan(plan, facts, opts); +}