/** * docgraph_update tool: registration, guards (initialization/field/file), * metadata field updates with audience normalization, comma-separated link * lists, body replacement, and preservation of untouched content. */ import { test } from "node:test"; import assert from "node:assert/strict"; import { Text } from "@earendil-works/pi-tui"; import { registerDocgraphUpdate } from "../src/tools/doc-update.ts"; import { parseMetadata } from "../src/utils.ts"; import { captureTool, makeTempRepo, cleanupTemp, writeFiles, readRepoFile, makeCtx, runTool, toolText, componentText, renderCall, renderResult, } from "./helpers.ts"; function tool() { return captureTool(registerDocgraphUpdate); } const SPEC = [ "> **Purpose:** Original purpose", ">", "> **Audience:** Both", ">", "> **Source of Truth:** Codebase (implementation is authoritative)", ">", "> **Last Updated:** 2026-01-01", ">", "> **Depends On:** None", ">", "> **Referenced By:** Unknown", "", "## Product Overview", "", "Original body text.", ].join("\n"); function initializedCtx(cwd: ReturnType) { return makeCtx({ cwd, state: { initialized: true, schemaVersion: 1 } }).ctx; } test("docgraph_update registers descriptive metadata and validates the field parameter", () => { const t = tool(); assert.equal(t.name, "docgraph_update"); assert.equal(t.label, "Docgraph Update"); assert.match(t.description, /purpose, audience/); const props = (t.parameters as unknown as { properties: Record }).properties; assert.ok(props.path && props.field && props.value, "path/field/value required"); }); test("docgraph_update refuses to run before initialization", async () => { const cwd = makeTempRepo(); try { const { ctx } = makeCtx({ cwd }); const res = await runTool(tool(), { path: "docs/SPEC.md", field: "purpose", value: "x" }, ctx); assert.match(toolText(res), /not initialized/i); assert.equal((res.details as { error: string }).error, "not_initialized"); } finally { cleanupTemp(cwd); } }); test("docgraph_update rejects unknown fields with guidance", async () => { const cwd = makeTempRepo(); try { const ctx = initializedCtx(cwd); const res = await runTool(tool(), { path: "docs/SPEC.md", field: "bogus", value: "x" }, ctx); assert.match(toolText(res), /Invalid field: bogus/); assert.match(toolText(res), /purpose/); assert.equal((res.details as { error: string }).error, "invalid_field"); } finally { cleanupTemp(cwd); } }); test("docgraph_update reports missing documents", async () => { const cwd = makeTempRepo(); try { const ctx = initializedCtx(cwd); const res = await runTool(tool(), { path: "docs/NOPE.md", field: "purpose", value: "x" }, ctx); assert.match(toolText(res), /Document not found: docs\/NOPE\.md/); assert.equal((res.details as { error: string }).error, "not_found"); } finally { cleanupTemp(cwd); } }); test("docgraph_update rewrites purpose and refreshes Last Updated", async () => { const cwd = makeTempRepo(); try { writeFiles(cwd, { "docs/SPEC.md": SPEC }); const ctx = initializedCtx(cwd); const res = await runTool( tool(), { path: "docs/SPEC.md", field: "purpose", value: "New purpose" }, ctx, ); assert.match(toolText(res), /Updated `purpose` in `docs\/SPEC\.md`/); assert.equal((res.details as { success: boolean }).success, true); const meta = parseMetadata(readRepoFile(cwd, "docs/SPEC.md")!, "docs/SPEC.md"); assert.equal(meta.purpose, "New purpose"); assert.equal(meta.lastUpdated, new Date().toISOString().slice(0, 10)); // Untouched parts of the document survive. assert.match(readRepoFile(cwd, "docs/SPEC.md")!, /Original body text\./); } finally { cleanupTemp(cwd); } }); test("docgraph_update normalizes audience values", async () => { const cwd = makeTempRepo(); try { writeFiles(cwd, { "docs/SPEC.md": SPEC }); const ctx = initializedCtx(cwd); for (const [input, expected] of [ ["Human", "Human"], ["human", "Human"], ["ai", "AI"], ["Both", "Both"], ["both", "Both"], ] as const) { await runTool(tool(), { path: "docs/SPEC.md", field: "audience", value: input }, ctx); const meta = parseMetadata(readRepoFile(cwd, "docs/SPEC.md")!, "docs/SPEC.md"); assert.equal(meta.audience, expected, `value ${input} should map to ${expected}`); } } finally { cleanupTemp(cwd); } }); test("docgraph_update sets dependsOn and renders links relative to the document", async () => { const cwd = makeTempRepo(); try { writeFiles(cwd, { "docs/SPEC.md": SPEC }); const ctx = initializedCtx(cwd); await runTool( tool(), { path: "docs/SPEC.md", field: "dependsOn", value: "docs/API.md, docs/DESIGN.md" }, ctx, ); const file = readRepoFile(cwd, "docs/SPEC.md")!; const meta = parseMetadata(file, "docs/SPEC.md"); assert.deepEqual(meta.dependsOn, ["docs/API.md", "docs/DESIGN.md"]); // Sibling hrefs — no docs/ prefix from within docs/. assert.match(file, /\*\*Depends On:\*\* \[API\]\(API\.md\), \[DESIGN\]\(DESIGN\.md\)/); } finally { cleanupTemp(cwd); } }); test("docgraph_update replaces the body field while keeping title and metadata", async () => { const cwd = makeTempRepo(); try { writeFiles(cwd, { "docs/SPEC.md": SPEC }); const ctx = initializedCtx(cwd); const res = await runTool( tool(), { path: "docs/SPEC.md", field: "body", value: "## New Body\n\nFresh content." }, ctx, ); assert.match(toolText(res), /Updated body of `docs\/SPEC\.md`/); const file = readRepoFile(cwd, "docs/SPEC.md")!; // The implementation re-emits the document's first line before the new // metadata block, so the original purpose still appears on line one. assert.ok(file.startsWith("> **Purpose:** Original purpose")); assert.match(file, /> \*\*Purpose:\*\* Original purpose/); assert.match(file, /## New Body/); assert.match(file, /Fresh content\./); assert.doesNotMatch(file, /Original body text\./); assert.equal((res.details as { field: string }).field, "body"); } finally { cleanupTemp(cwd); } }); test("docgraph_update keeps metadata-only updates free of body churn", async () => { const cwd = makeTempRepo(); try { writeFiles(cwd, { "docs/SPEC.md": SPEC }); const ctx = initializedCtx(cwd); await runTool(tool(), { path: "docs/SPEC.md", field: "referencedBy", value: "AGENTS.md" }, ctx); const file = readRepoFile(cwd, "docs/SPEC.md")!; // The AGENTS link from docs/ climbs one level; body text remains. assert.match(file, /\*\*Referenced By:\*\* \[AGENTS\]\(\.\.\/AGENTS\.md\)/); assert.match(file, /Original body text\./); } finally { cleanupTemp(cwd); } }); test("docgraph_update renderers show path and field", () => { const t = tool(); const call = componentText( renderCall(t, { path: "docs/SPEC.md", field: "purpose", value: "x" }), ); assert.match(call, /docgraph-update/); assert.match(call, /docs\/SPEC\.md:purpose/); const result = { content: [{ type: "text", text: "Updated `purpose` in `docs/SPEC.md`" }] }; const rendered = componentText(renderResult(t, result as never)); assert.match(rendered, /Updated `purpose`/); assert.ok( renderCall(t, { path: "a.md", field: "body", value: "" }) instanceof Text, ); });