Skip to content
Open
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
43 changes: 35 additions & 8 deletions src/docs/refresh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
*
* Flow per stale doc:
* 1. Re-anchor: recompute each anchor's hash against the current code
* (dropping anchors whose symbol vanished).
* (dropping anchors whose symbol vanished from the graph; a symbol still
* in the graph whose source can't be read skips the doc untouched).
* 2. Gather the changed symbols' CURRENT source as context.
* 3. Ask the host LLM (injected `generate`) to produce a bounded rewrite.
* 4. Gate it (objective invariants — see ./gate.ts).
Expand Down Expand Up @@ -102,18 +103,27 @@ export function buildRefreshPrompt(ctx: RefreshContext): string {

/**
* Recompute anchors against the current snapshot + working tree. Anchors whose
* symbol no longer resolves are dropped (the doc loses that anchor rather than
* carrying a dangling one).
* symbol is absent from the graph are dropped (the doc loses that anchor rather
* than carrying a dangling one). Anchors whose symbol IS in the graph but whose
* source slice can't be read (stale/out-of-range location, file unreadable) are
* reported in `unreadable` — that symbol was not removed, so the caller must
* not treat it as gone.
*/
function reanchor(doc: DocRow, nodeById: Map<string, GraphNode>, repoRoot: string): DocAnchor[] {
const out: DocAnchor[] = [];
function reanchor(
doc: DocRow,
nodeById: Map<string, GraphNode>,
repoRoot: string,
): { anchors: DocAnchor[]; unreadable: string[] } {
const anchors: DocAnchor[] = [];
const unreadable: string[] = [];
for (const a of doc.anchors) {
const node = nodeById.get(a.symbol_id);
if (!node) continue;
const fresh = buildAnchor(node, repoRoot);
if (fresh) out.push(fresh);
if (fresh) anchors.push(fresh);
else unreadable.push(a.symbol_id);
}
return out;
return { anchors, unreadable };
}

/** Collect the changed symbols' current source for the prompt context. */
Expand Down Expand Up @@ -165,7 +175,24 @@ export async function refreshDocs(args: RefreshArgs): Promise<RefreshReport> {
return;
}

const newAnchors = reanchor(doc, nodeById, args.repoRoot);
const { anchors: newAnchors, unreadable } = reanchor(doc, nodeById, args.repoRoot);

// A symbol still in the graph whose source can't be read means the graph
// is out of date with the working tree (or the file can't be read) — NOT
// that the symbol was removed. Archiving would destroy a valid doc, and
// regenerating from only the readable subset would silently drop anchors
// and content. Leave the doc untouched and say how to recover.
if (unreadable.length > 0) {
outcomes.push({
doc_id: imp.doc_id,
status: "skipped",
reasons: [
`anchored symbol source unreadable at its graph location (stale graph or source read failure): ${unreadable.join(", ")}; ` +
"rebuild the graph (`hivemind graph build`) and check the source file is readable, then retry",
],
});
return;
}

// Fully-orphaned doc: it HAD anchors, and every one of them vanished from
// the graph (the documented file was deleted or renamed). Re-authoring it
Expand Down
107 changes: 106 additions & 1 deletion tests/shared/docs-refresh.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { describe, expect, it, beforeEach, afterEach, vi } from "vitest";
vi.mock("../../src/docs/stable-read.js", () => ({
stableUnionRows: (q: (sql: string) => unknown, sql: string) => q(sql),
}));
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import {
Expand Down Expand Up @@ -271,4 +271,109 @@ describe("refreshDocs", () => {
expect(calls[1]).toContain(foo.id);
expect(calls[1]).not.toContain("a.ts:gone:function");
});

// ── present-in-graph but unreadable source: skip, never archive/regenerate ──

const UNREADABLE_REASON =
/^anchored symbol source unreadable at its graph location \(stale graph or source read failure\): .+; rebuild the graph \(`hivemind graph build`\)/;

it("skips (does not archive) a doc whose only symbol has a stale out-of-range location", async () => {
// a.ts has 3 lines (+ trailing newline); the graph still says L10-L20.
const staleFoo = node(foo.id, "a.ts", "L10-L20");
const d = doc({ anchors: [{ symbol_id: staleFoo.id, content_hash: "x" }] });
const { calls, query } = mockQuery([]);
const generate = vi.fn(async () => "should never be called");
const report = await refreshDocs({
query, tableName: "hivemind_docs", snap: snap([staleFoo]), repoRoot: dir,
impacted: [{ doc_id: "a.ts", reasons: [{ kind: "symbol_missing", symbol_id: staleFoo.id }] }],
docsById: new Map([["a.ts", d]]), generate,
});
expect(report).toMatchObject({ skipped: 1, archived: 0, refreshed: 0, rejected: 0 });
expect(report.outcomes[0].status).toBe("skipped");
expect(report.outcomes[0].reasons).toEqual([expect.stringMatching(UNREADABLE_REASON)]);
expect(report.outcomes[0].reasons![0]).toContain(staleFoo.id);
expect(generate).not.toHaveBeenCalled();
expect(calls).toHaveLength(0); // doc untouched
});

it("skips a doc with mixed readable/unreadable anchors instead of regenerating from the readable subset", async () => {
writeFileSync(join(dir, "b.ts"), "export function bar() {\n return 2;\n}\n");
const bar = node("b.ts:bar:function", "b.ts", "L40-L45"); // stale: b.ts is 4 lines
const d = doc({ anchors: [{ symbol_id: foo.id, content_hash: "x" }, { symbol_id: bar.id, content_hash: "y" }] });
const { calls, query } = mockQuery([]);
const generate = vi.fn(async () => "small");
const report = await refreshDocs({
query, tableName: "hivemind_docs", snap: snap([foo, bar]), repoRoot: dir,
impacted: [{ doc_id: "a.ts", reasons: [{ kind: "code_changed", symbol_id: foo.id }, { kind: "symbol_missing", symbol_id: bar.id }] }],
docsById: new Map([["a.ts", d]]), generate,
});
expect(report).toMatchObject({ skipped: 1, archived: 0, refreshed: 0 });
expect(report.outcomes[0].reasons).toEqual([expect.stringMatching(UNREADABLE_REASON)]);
expect(report.outcomes[0].reasons![0]).toContain(bar.id);
expect(report.outcomes[0].reasons![0]).not.toContain(foo.id);
expect(generate).not.toHaveBeenCalled();
expect(calls).toHaveLength(0);
});

it("skips (does not archive) when the symbol's source file cannot be read", async () => {
// source_file resolves to a directory → readFileSync throws (EISDIR).
mkdirSync(join(dir, "c.ts"));
const baz = node("c.ts:baz:function", "c.ts", "L1-L3");
const d = doc({ doc_id: "c.ts", anchors: [{ symbol_id: baz.id, content_hash: "x" }] });
const { calls, query } = mockQuery([]);
const generate = vi.fn(async () => "small");
const report = await refreshDocs({
query, tableName: "hivemind_docs", snap: snap([baz]), repoRoot: dir,
impacted: [{ doc_id: "c.ts", reasons: [{ kind: "symbol_missing", symbol_id: baz.id }] }],
docsById: new Map([["c.ts", d]]), generate,
});
expect(report).toMatchObject({ skipped: 1, archived: 0, refreshed: 0 });
expect(report.outcomes[0]).toMatchObject({ doc_id: "c.ts", status: "skipped" });
expect(report.outcomes[0].reasons).toEqual([expect.stringMatching(UNREADABLE_REASON)]);
expect(generate).not.toHaveBeenCalled();
expect(calls).toHaveLength(0);
});

it("still archives when the gone symbol is absent from the graph even if the file remains on disk", async () => {
// Control: a.ts still exists, but the graph no longer has the node → removed.
const d = doc({ anchors: [{ symbol_id: foo.id, content_hash: "x" }] });
const { calls, query } = mockQuery([
() => [{ id: "r", doc_id: "a.ts", version: 3, content: "old", anchors: "[]", tier: "fast", status: "active", project: "p", created_at: "t", updated_at: "t" }],
() => [],
]);
const generate = vi.fn(async () => "should never be called");
const report = await refreshDocs({
query, tableName: "hivemind_docs", snap: snap([]), repoRoot: dir,
impacted: [{ doc_id: "a.ts", reasons: [{ kind: "symbol_missing", symbol_id: foo.id }] }],
docsById: new Map([["a.ts", d]]), generate,
});
expect(report).toMatchObject({ archived: 1, skipped: 0 });
expect(report.outcomes[0].reasons).toEqual(["all anchored symbols gone (file deleted/renamed)"]);
expect(generate).not.toHaveBeenCalled();
expect(calls[1]).toContain("status = 'archived'");
});

it("refreshes normally when every present anchor reads, dropping only graph-absent ones", async () => {
writeFileSync(join(dir, "b.ts"), "export function bar() {\n return 2;\n}\n");
const bar = node("b.ts:bar:function", "b.ts", "L1-L3");
const d = doc({ anchors: [
{ symbol_id: foo.id, content_hash: "x" },
{ symbol_id: bar.id, content_hash: "y" },
{ symbol_id: "a.ts:gone:function", content_hash: "z" },
] });
const { calls, query } = mockQuery([
() => [{ id: "r", doc_id: "a.ts", version: 3, content: "old doc", anchors: "[]", tier: "fast", status: "active", project: "p", created_at: "t", updated_at: "t" }],
() => [],
]);
const generate = vi.fn(async () => "new doc body");
const report = await refreshDocs({
query, tableName: "hivemind_docs", snap: snap([foo, bar]), repoRoot: dir,
impacted: impacted(), docsById: new Map([["a.ts", d]]), generate,
});
expect(report).toMatchObject({ refreshed: 1, skipped: 0, archived: 0 });
expect(generate).toHaveBeenCalledOnce();
expect(calls[1]).toContain(buildAnchor(foo, dir)!.content_hash);
expect(calls[1]).toContain(buildAnchor(bar, dir)!.content_hash);
expect(calls[1]).not.toContain("a.ts:gone:function");
});
});