/** * End-to-end lifecycle across all seven tools against one real repo: * * init → ticket_create ×2 → ticket_list → ticket_update (status + notes) * → read → update (metadata) → sync (single + all) → re-init (idempotent) * * Exercises cross-tool contracts such as filesystem-based initialization * detection and the derived docs/BACKLOG.md. */ import { test } from "node:test"; import assert from "node:assert/strict"; import { registerDocgraphInit } from "../src/tools/doc-init.ts"; import { registerDocgraphRead } from "../src/tools/doc-read.ts"; import { registerDocgraphSync } from "../src/tools/doc-sync.ts"; import { registerDocgraphUpdate } from "../src/tools/doc-update.ts"; import { registerDocgraphTicketCreate } from "../src/tools/ticket-create.ts"; import { registerDocgraphTicketUpdate } from "../src/tools/ticket-update.ts"; import { registerDocgraphTicketList } from "../src/tools/ticket-list.ts"; import { readTicket, listTickets, parseMetadata } from "../src/utils.ts"; import { mockPi, makeTempRepo, cleanupTemp, readRepoFile, makeCtx, runTool, toolText } from "./helpers.ts"; test("full docgraph lifecycle: scaffold → tickets → edit → sync → re-init", async () => { const cwd = makeTempRepo(); try { const { pi, tools } = mockPi(); registerDocgraphInit(pi); registerDocgraphRead(pi); registerDocgraphSync(pi); registerDocgraphUpdate(pi); registerDocgraphTicketCreate(pi); registerDocgraphTicketUpdate(pi); registerDocgraphTicketList(pi); const byName = new Map(tools.map((t) => [t.name, t])); const init = byName.get("docgraph_init")!; const read = byName.get("docgraph_read")!; const sync = byName.get("docgraph_sync")!; const update = byName.get("docgraph_update")!; const create = byName.get("docgraph_ticket_create")!; const tupdate = byName.get("docgraph_ticket_update")!; const tlist = byName.get("docgraph_ticket_list")!; // A single ctx is enough — after init, isInitialized resolves via the // filesystem for every downstream tool. const { ctx } = makeCtx({ cwd }); // 1. Init scaffolds the graph. const initRes = await runTool(init, {}, ctx); assert.equal((initRes.details as { created: string[] }).created.length, 8); assert.match(toolText(initRes), /^Created:/); // 2. Two tickets are created and indexed in BACKLOG.md. const t1 = await runTool(create, { title: "Add dark mode", priority: "P2" }, ctx); const t2 = await runTool( create, { title: "Auth flow", priority: "P0", estimate: "1d", definitionOfDone: ["E2e test with the UI"], }, ctx, ); assert.equal((t1.details as { ticket: { id: string } }).ticket.id, "001"); assert.equal((t2.details as { ticket: { id: string } }).ticket.id, "002"); assert.match(readRepoFile(cwd, "docs/BACKLOG.md")!, /T-001-add-dark-mode\.md/); assert.match(readRepoFile(cwd, "docs/BACKLOG.md")!, /T-002-auth-flow\.md/); // 3. Listing shows both. const listed = await runTool(tlist, {}, ctx); assert.match(toolText(listed), /2 ticket\(s\)/); assert.equal((listed.details as { total: number }).total, 2); // 4. Move the first ticket in-progress with an implementation note. const moved = await runTool( tupdate, { id: "001", status: "in-progress", implementationNote: "Wired the theme toggle." }, ctx, ); assert.match(toolText(moved), /status: backlog → in-progress/); const afterMove = readTicket("001", cwd)!; assert.equal(afterMove.status, "in-progress"); // An empty notes list round-trips as the "None yet" sentinel, so the // appended note sits after it (documented behavior). assert.deepEqual(afterMove.implementationNotes, ["None yet", "Wired the theme toggle."]); const backlog = readRepoFile(cwd, "docs/BACKLOG.md")!; assert.match(backlog.split("## In Progress")[1]!.split("## Review")[0]!, /T-001-add-dark-mode\.md/); // 5. Read a scaffolded doc: metadata plus body. const doc = await runTool(read, { path: "docs/SPEC.md" }, ctx); const details = doc.details as { metadata: { purpose: string }; bodyLength: number }; assert.match(details.metadata.purpose, /system does/i); assert.ok(details.bodyLength > 0); assert.match(toolText(doc), /## Product Overview/); // 6. Update the purpose; the change survives re-init. await runTool(update, { path: "docs/SPEC.md", field: "purpose", value: "Updated spec purpose" }, ctx); assert.equal( parseMetadata(readRepoFile(cwd, "docs/SPEC.md")!, "docs/SPEC.md").purpose, "Updated spec purpose", ); // 7. Sync a single doc and then sweep everything. const synced = await runTool(sync, { path: "docs/SPEC.md" }, ctx); assert.match(toolText(synced), /Synced 1 document\(s\)\./); const all = await runTool(sync, { path: "all" }, ctx); assert.equal( (all.details as { results: unknown[] }).results.length, 8, "all must validate the full canonical document set", ); // 8. Re-running init is idempotent and preserves edits. const reinit = await runTool(init, {}, ctx); assert.equal((reinit.details as { created: string[] }).created.length, 0); assert.equal( parseMetadata(readRepoFile(cwd, "docs/SPEC.md")!, "docs/SPEC.md").purpose, "Updated spec purpose", "re-init must not overwrite edited metadata", ); // 9. Tickets survive the whole cycle. assert.equal(listTickets(cwd).length, 2); } finally { cleanupTemp(cwd); } });