diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e4a888b..985430a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ All notable changes to this project will be documented in this file. - Project Hub Overview now opens with a compact Context card above the atlas instead of a header button. It links to Context when the Wiki index is fresh and to Health otherwise, so a stale or unavailable index no longer leads to a page that cannot load. ### Fixed +- `mex impact` no longer drops grounding records first when the output budget runs out. They are admitted right after the target's definitions, ahead of transitive callers and source ranges, so a target with many callers still returns its knowledge links at the default budget; the response is still marked `truncated` when callers are cut. The accepted cost: on a target with many groundings, a few caller or source records that used to fit now give way to them (#225). - `mex graph status` no longer prints placeholder zeros as measurements when immutable inspection is skipped. While a stranded `graph.db-wal`, an unreadable sidecar, a containment failure or a failed invariant audit blocks the read, the text output now says `Last successful index: not inspected`, `Sources: not inspected` and `Parse health: not inspected` instead of `never` and `0 ok`, and `--json` carries an additive `inspected: false` so a consumer can tell an uninspected store from one that genuinely parsed nothing. The same applies when a `GRAPH_SNAPSHOT_CONTENT_MISMATCH` stops the source comparison before the index timestamps are read, and to the `mex check` fallback status when the loader itself throws. `parseHealth` and `changes` keep their shape (#204). - Agent population failures retain the real copyable manual prompt for retry or manual continuation. Integration pointer notes are visible as non-blocking guidance. - Setup and Overview share the computer's contact preference so completing or skipping the invitation does not immediately trigger another request. diff --git a/src/graph/cli-agent.ts b/src/graph/cli-agent.ts index 40f6051b..c56d4005 100644 --- a/src/graph/cli-agent.ts +++ b/src/graph/cli-agent.ts @@ -132,6 +132,20 @@ export function runImpact( } } const ordered = [...impacted.values()].sort((a, b) => a.depth - b.depth || a.node.id.localeCompare(b.node.id)); + + // Grounding is keyed by node, so an excluded node must not reappear + // through it. Nodes omitted only by the returned-node cap still count. + const affectedIds = [...new Set([...roots.map((node) => node.id), ...impacted.keys()])] + .filter((id) => !isDriftedFile(session, session.graph.getNode(id)?.filePath)); + // Knowledge links are admitted before callers and source (#225): they are + // tiny and no other command returns them, so budget pressure cuts the + // callers other commands can reproduce instead. Output order is unchanged. + const groundingRecords: Rec[] = []; + for (const grounding of groundedFiles(session.db, affectedIds)) { + const record: Rec = { type: "grounding", node: grounding.node_id, file: grounding.scaffold_file }; + if (ledger.tryAdd(record)) groundingRecords.push(record); else truncated = true; + } + for (const entry of ordered) { if (emittedNodes.length >= opts.maxNodes) { truncated = true; break; } const fact = factFor(session, entry.node.id, opts.detail, opts.fingerprint); @@ -147,16 +161,6 @@ export function runImpact( const sourceRecords = planSource(session, ledger, emittedNodes, rootDir, opts); - // Grounding is keyed by node, so an excluded node must not reappear - // through it. Nodes omitted only by the returned-node cap still count. - const affectedIds = [...new Set([...roots.map((node) => node.id), ...impacted.keys()])] - .filter((id) => !isDriftedFile(session, session.graph.getNode(id)?.filePath)); - const groundingRecords: Rec[] = []; - for (const grounding of groundedFiles(session.db, affectedIds)) { - const record: Rec = { type: "grounding", node: grounding.node_id, file: grounding.scaffold_file }; - if (ledger.tryAdd(record)) groundingRecords.push(record); else truncated = true; - } - emitAll(write, meta, [ ...configDriftRecords(session), ...headRecords, ...factRecords, ...sourceRecords, ...groundingRecords, diff --git a/test/graph-cli-agent.test.ts b/test/graph-cli-agent.test.ts index 2a6918f4..9a26e95b 100644 --- a/test/graph-cli-agent.test.ts +++ b/test/graph-cli-agent.test.ts @@ -78,6 +78,57 @@ describe("agent graph commands", () => { expect(fixture.close).toHaveBeenCalledOnce(); }); + it("impact keeps grounding records when callers exhaust the default budget", () => { + const leaf = node("function:leaf", "leaf"); + const callers = Array.from({ length: 60 }, (_, i) => node(`function:caller${i}`, `caller${i}`, `src/caller${i}.ts`, 4)); + const nodes = [leaf, ...callers]; + const graph: GraphEngine = { + build: vi.fn(), sync: vi.fn(), close: vi.fn(), + getNode: (id) => nodes.find((entry) => entry.id === id) ?? null, + searchNodes: (query) => nodes.filter((entry) => entry.name.toLowerCase() === query.toLowerCase()), + getCallers: (id) => id === leaf.id ? callers : [], + getCallees: () => [], + getIncoming: () => [], + getOutgoing: () => [], + getIndexedFiles: () => [], + }; + const groundings = Array.from({ length: 6 }, (_, i) => ({ + scaffold_file: `.mex/context/architecture-${i}.md`, node_id: i < 3 ? leaf.id : callers[i].id, + })); + const db = { + prepare: (sql: string) => ({ + run: vi.fn(), get: vi.fn(), iterate: vi.fn(), + all: () => sql.includes("FROM nodes") ? [] : groundings, + }), + exec: vi.fn(), pragma: vi.fn(), transaction: (fn: () => T) => fn(), close: vi.fn(), open: true, + }; + const output: string[] = []; + runImpact("leaf", "/repo", { open: () => ({ graph, db, close: vi.fn() }), write: (line) => output.push(line) }, { maxNodes: 100 }); + const rows = output.map((line) => JSON.parse(line)); + expect(rows.at(-1)).toMatchObject({ type: "summary", truncated: true }); + expect(rows.filter((row) => row.type === "caller").length).toBeLessThan(callers.length); + expect(rows.filter((row) => row.type === "grounding")).toEqual( + groundings.map((g) => ({ type: "grounding", node: g.node_id, file: g.scaffold_file })), + ); + expect(rows.findIndex((row) => row.type === "grounding")).toBeLessThan(rows.findIndex((row) => row.type === "summary")); + }); + + it("impact stays well-formed and truncated when even grounding cannot fit", () => { + // The smallest accepted budget holds the protocol framing and nothing else. + const probe = deps(); + runImpact("leaf", "/repo", probe.deps, { maxOutputTokens: 1 }); + const floor = Number(/use at least (\d+)/.exec(JSON.parse(probe.output[0]!).message)?.[1]); + expect(floor).toBeGreaterThan(1); + + const fixture = deps(); + runImpact("leaf", "/repo", fixture.deps, { maxOutputTokens: floor }); + const rows = fixture.output.map((line) => JSON.parse(line)); + expect(rows[0]).toMatchObject({ type: "meta" }); + expect(rows.at(-1)).toMatchObject({ type: "summary", truncated: true }); + expect(rows.at(-1).estimatedOutputTokens).toBeLessThanOrEqual(floor); + expect(rows.filter((row) => row.type === "grounding")).toEqual([]); + }); + it("impact accepts a file and reports each node it defines", () => { const fixture = deps(); runImpact("src/a.ts", "/repo", fixture.deps);