/** * docgraph_sync tool: registration, initialization guard, missing document * reporting, Last-Updated stamp refreshing, dry-run mode, broken * cross-reference detection, and the "all documents" sweep. */ import { test } from "node:test"; import assert from "node:assert/strict"; import { Text } from "@earendil-works/pi-tui"; import { registerDocgraphSync } from "../src/tools/doc-sync.ts"; import { DOC_NAMES } from "../src/types.ts"; import { captureTool, makeTempRepo, cleanupTemp, writeFiles, readRepoFile, makeCtx, runTool, toolText, componentText, renderCall, renderResult, } from "./helpers.ts"; function tool() { return captureTool(registerDocgraphSync); } const OLD_DATE = "2000-01-01"; function specWithDateAndLinks(date: string, extra = "") { return [ "> **Purpose:** Spec", "> **Audience:** Both", `> **Last Updated:** ${date}`, ">", "> **Depends On:** [ARCHITECTURE](ARCHITECTURE.md)", "", "## Body", "", "text", extra, ].join("\n"); } test("docgraph_sync registers descriptive metadata and accepts dryRun", () => { const t = tool(); assert.equal(t.name, "docgraph_sync"); assert.equal(t.label, "Docgraph Sync"); const props = (t.parameters as unknown as { properties: Record }).properties; assert.ok(props.path, "path parameter required"); assert.ok(props.dryRun, "dryRun parameter expected"); }); test("docgraph_sync refuses to run before initialization", async () => { const cwd = makeTempRepo(); try { const { ctx } = makeCtx({ cwd }); const res = await runTool(tool(), { path: "docs/SPEC.md" }, ctx); assert.match(toolText(res), /Documentation not initialized\. Run `docgraph_init` first\./); assert.equal((res.details as { error: string }).error, "not_initialized"); } finally { cleanupTemp(cwd); } }); test("docgraph_sync reports documents that do not exist", async () => { const cwd = makeTempRepo(); try { const { ctx } = makeCtx({ cwd, state: { initialized: true, schemaVersion: 1 } }); const res = await runTool(tool(), { path: "docs/GONE.md" }, ctx); const text = toolText(res); assert.match(text, /Synced 1 document\(s\)\./); assert.match(text, /Missing documents:/); assert.match(text, /docs\/GONE\.md/); const results = (res.details as { results: Array<{ found: boolean }> }).results; assert.equal(results.length, 1); assert.equal(results[0]!.found, false); } finally { cleanupTemp(cwd); } }); test("docgraph_sync refreshes a stale Last Updated stamp", async () => { const cwd = makeTempRepo(); try { writeFiles(cwd, { "docs/SPEC.md": specWithDateAndLinks(OLD_DATE), "docs/ARCHITECTURE.md": "> **Purpose:** arch", // dependsOn target }); const { ctx } = makeCtx({ cwd, state: { initialized: true, schemaVersion: 1 } }); const res = await runTool(tool(), { path: "docs/SPEC.md" }, ctx); const text = toolText(res); assert.match(text, /Updated Last Updated on 1 file\(s\)\./); assert.doesNotMatch(text, /Broken cross-references/); const details = res.details as { results: Array<{ found: boolean; updated: boolean; linksBroken: string[] }>; }; assert.equal(details.results.length, 1); assert.equal(details.results[0]!.updated, true); assert.deepEqual(details.results[0]!.linksBroken, []); // The file on disk now carries today's date. const file = readRepoFile(cwd, "docs/SPEC.md")!; const today = new Date().toISOString().slice(0, 10); assert.match(file, new RegExp(`> \\*\\*Last Updated:\\*\\* ${today}`)); } finally { cleanupTemp(cwd); } }); test("docgraph_sync leaves an already-fresh document untouched", async () => { const cwd = makeTempRepo(); try { const today = new Date().toISOString().slice(0, 10); const original = specWithDateAndLinks(today); writeFiles(cwd, { "docs/SPEC.md": original }); const { ctx } = makeCtx({ cwd, state: { initialized: true, schemaVersion: 1 } }); const res = await runTool(tool(), { path: "docs/SPEC.md" }, ctx); assert.match(toolText(res), /Synced 1 document\(s\)\./); const results = (res.details as { results: Array<{ updated: boolean }> }).results; assert.equal(results[0]!.updated, false); assert.equal(readRepoFile(cwd, "docs/SPEC.md"), original, "file must be untouched"); } finally { cleanupTemp(cwd); } }); test("docgraph_sync dry-run validates without writing", async () => { const cwd = makeTempRepo(); try { const original = specWithDateAndLinks(OLD_DATE); writeFiles(cwd, { "docs/SPEC.md": original }); const { ctx } = makeCtx({ cwd, state: { initialized: true, schemaVersion: 1 } }); const res = await runTool(tool(), { path: "docs/SPEC.md", dryRun: true }, ctx); const text = toolText(res); assert.match(text, /\(Dry run — no files were modified\)/); const results = (res.details as { results: Array<{ updated: boolean }> }).results; assert.equal(results[0]!.updated, false); assert.equal(readRepoFile(cwd, "docs/SPEC.md"), original, "dry run must not write"); } finally { cleanupTemp(cwd); } }); test("docgraph_sync flags broken cross-references in content and details", async () => { const cwd = makeTempRepo(); try { writeFiles(cwd, { "docs/SPEC.md": specWithDateAndLinks(OLD_DATE, "[Nope](missing.md)\n"), "docs/ARCHITECTURE.md": "> **Purpose:** arch", }); const { ctx } = makeCtx({ cwd, state: { initialized: true, schemaVersion: 1 } }); const res = await runTool(tool(), { path: "docs/SPEC.md" }, ctx); const text = toolText(res); assert.match(text, /Broken cross-references found:/); assert.match(text, /docs\/SPEC\.md: missing\.md/); const results = (res.details as { results: Array<{ linksBroken: string[] }> }).results; assert.deepEqual(results[0]!.linksBroken, ["missing.md"]); } finally { cleanupTemp(cwd); } }); test("docgraph_sync 'all' sweeps every managed document", async () => { const cwd = makeTempRepo(); try { // Full scaffolding minus one document, plus a stale-dated, broken-linked doc. for (const name of DOC_NAMES) { if (name === "docs/SPEC.md") continue; writeFiles(cwd, { [name]: specWithDateAndLinks(OLD_DATE) }); } writeFiles(cwd, { "docs/SPEC.md": specWithDateAndLinks(OLD_DATE, "[Broken](gone.md)\n"), }); // The sweep also validates links to real targets, so create the dir docs link. const { mkdirSync } = await import("node:fs"); const { join } = await import("node:path"); mkdirSync(join(cwd, "docs/tickets"), { recursive: true }); const { ctx } = makeCtx({ cwd, state: { initialized: true, schemaVersion: 1 } }); const res = await runTool(tool(), { path: "all" }, ctx); const details = res.details as { results: Array<{ path: string; found: boolean; updated: boolean; linksBroken: string[] }>; }; assert.equal(details.results.length, DOC_NAMES.length); for (const r of details.results) assert.equal(r.found, true); assert.ok( details.results.filter((r) => r.updated).length >= DOC_NAMES.length - 1, "stale docs should be re-stamped", ); const spec = details.results.find((r) => r.path === "docs/SPEC.md")!; assert.deepEqual(spec.linksBroken, ["gone.md"]); } finally { cleanupTemp(cwd); } }); test("docgraph_sync calls validateLinks relative to each document's directory", async () => { const cwd = makeTempRepo(); try { // A root-relative href from within docs/ is genuinely broken... writeFiles(cwd, { "docs/SPEC.md": specWithDateAndLinks(OLD_DATE, "[Root broken](docs/API.md)\n"), "docs/ARCHITECTURE.md": "> **Purpose:** arch", "docs/API.md": "> **Purpose:** api\n", }); const { ctx } = makeCtx({ cwd, state: { initialized: true, schemaVersion: 1 } }); const res = await runTool(tool(), { path: "docs/SPEC.md" }, ctx); assert.deepEqual( (res.details as { results: Array<{ linksBroken: string[] }> }).results[0]!.linksBroken, ["docs/API.md"], ); } finally { cleanupTemp(cwd); } }); test("docgraph_sync renderers output the summary line", () => { const t = tool(); const call = componentText(renderCall(t, { path: "all", dryRun: false })); assert.match(call, /docgraph-sync/); assert.match(call, /all/); const result = { content: [{ type: "text", text: "Synced 3 document(s)." }] }; const rendered = componentText(renderResult(t, result as never)); assert.match(rendered, /Synced 3 document\(s\)\./); assert.ok(renderCall(t, { path: "docs/API.md" }) instanceof Text); });