/** * Ticket file persistence: slug generation, filename conventions, * read/write/list round trips, rename-with-cleanup, ID allocation, and the * escaped/edge parsing of ticket files. */ import { test } from "node:test"; import assert from "node:assert/strict"; import { existsSync } from "node:fs"; import { join } from "node:path"; import { slugifyTitle, ticketFileName, writeTicket, readTicket, listTickets, nextTicketId, } from "../src/utils.ts"; import { makeTempRepo, cleanupTemp, writeFiles, readRepoFile, sampleTicket, } from "./helpers.ts"; // ── slugifyTitle ────────────────────────────────────────────────────── test("slugifyTitle kebab-cases lowercase alphanumeric words", () => { assert.equal(slugifyTitle("Add dark mode support"), "add-dark-mode-support"); assert.equal(slugifyTitle(" Fix the Bug "), "fix-the-bug"); assert.equal(slugifyTitle("API v2 rollout"), "api-v2-rollout"); }); test("slugifyTitle drops non-alphanumeric characters", () => { // Note: the apostrophe collapses into a hyphen, exactly like any other // non-alphanumeric run — "don't" becomes "don-t". assert.equal(slugifyTitle("Don't break (the) build!"), "don-t-break-the-build"); assert.equal(slugifyTitle("Café au Lait"), "caf-au-lait"); }); test("slugifyTitle returns empty string when nothing usable remains", () => { assert.equal(slugifyTitle("!!!"), ""); assert.equal(slugifyTitle(" "), ""); assert.equal(slugifyTitle("日本語のみ"), ""); }); test("slugifyTitle caps the slug at 60 characters", () => { const long = "x".repeat(100) + " abcd"; const slug = slugifyTitle(long); assert.ok(slug.length <= 60); assert.equal(slug, "x".repeat(60)); }); // ── ticketFileName ──────────────────────────────────────────────────── test("ticketFileName appends the descriptive slug", () => { assert.equal( ticketFileName({ id: "001", title: "Add dark mode" }), "T-001-add-dark-mode.md", ); }); test("ticketFileName falls back to the plain ID file when the slug is empty", () => { assert.equal(ticketFileName({ id: "042", title: "!!!" }), "T-042.md"); }); // ── readTicket / writeTicket ────────────────────────────────────────── test("writeTicket then readTicket round-trips the full ticket payload", () => { const cwd = makeTempRepo(); try { const ticket = sampleTicket({ title: "Auth flow", status: "ready", priority: "P0", estimate: "2d", dependencies: ["001"], context: "Users need OAuth.", acceptanceCriteria: [ { text: "Login works", checked: false, comment: "" }, { text: "Logout works", checked: false, comment: "" }, ], definitionOfDone: [ { text: "Tests pass", checked: true, comment: "VERIFIED: suite green", }, ], implementationNotes: ["Note one"], relatedDocs: ["docs/API.md"], relatedFiles: ["src/auth.ts"], }); assert.equal(writeTicket(ticket, cwd), true); // The checked box and its inline comment survive re-parsing. assert.match(readRepoFile(cwd, "docs/tickets/T-001-auth-flow.md")!, /- \[x\] Tests pass /); const loaded = readTicket("001", cwd); assert.ok(loaded); assert.equal(loaded!.title, "Auth flow"); assert.equal(loaded!.status, "ready"); assert.equal(loaded!.priority, "P0"); assert.equal(loaded!.estimate, "2d"); assert.deepEqual(loaded!.dependencies, ["001"]); assert.equal(loaded!.context, "Users need OAuth."); assert.deepEqual(loaded!.acceptanceCriteria, [ { text: "Login works", checked: false, comment: "" }, { text: "Logout works", checked: false, comment: "" }, ]); assert.deepEqual(loaded!.definitionOfDone, [ { text: "Tests pass", checked: true, comment: "VERIFIED: suite green", }, ]); assert.deepEqual(loaded!.implementationNotes, ["Note one"]); assert.deepEqual(loaded!.relatedFiles, ["src/auth.ts"]); assert.equal(loaded!.createdAt, ticket.createdAt); assert.equal(loaded!.updatedAt, ticket.updatedAt); } finally { cleanupTemp(cwd); } }); test("readTicket preserves checkbox state and inline verification comments", () => { const cwd = makeTempRepo(); try { writeFiles(cwd, { "docs/tickets/T-001-foo.md": `# T-001 **ID:** 001 **Title:** Foo **Status:** in-progress **Priority:** P2 **Created:** 2026-01-01T00:00:00.000Z **Updated:** 2026-01-01T00:00:00.000Z ## Context Do the thing. ## Acceptance Criteria - [x] pnpm dev serves a page at localhost:3000 - [ ] pnpm typecheck passes with strict TypeScript ## Definition of Done - [x] Test written first — automated test encoded the expected behavior before any production code - [ ] Test confirmed failing for the expected reason (Red) ## Implementation Notes None yet ## Related Documentation - [ARCHITECTURE](../ARCHITECTURE.md) ## Related Files None yet `, }); const t = readTicket("001", cwd)!; assert.deepEqual(t.acceptanceCriteria, [ { text: "pnpm dev serves a page at localhost:3000", checked: true, comment: "VERIFIED: dev server started with `pnpm dev` and served HTTP 200.", }, { text: "pnpm typecheck passes with strict TypeScript", checked: false, comment: "", }, ]); assert.deepEqual(t.definitionOfDone, [ { text: "Test written first — automated test encoded the expected behavior before any production code", checked: true, comment: "", }, { text: "Test confirmed failing for the expected reason (Red)", checked: false, comment: "", }, ]); } finally { cleanupTemp(cwd); } }); test("writeTicket does not clobber checked boxes or inline comments on re-serialization", () => { const cwd = makeTempRepo(); try { writeFiles(cwd, { "docs/tickets/T-001-foo.md": `# T-001 **ID:** 001 **Title:** Foo **Status:** in-progress **Priority:** P2 **Created:** 2026-01-01T00:00:00.000Z **Updated:** 2026-01-01T00:00:00.000Z ## Context ## Acceptance Criteria - [x] pnpm dev serves a page at localhost:3000 - [ ] pnpm typecheck passes with strict TypeScript ## Definition of Done - [x] Test written first - [ ] Test confirmed failing for the expected reason (Red) ## Implementation Notes None yet ## Related Documentation None yet ## Related Files None yet `, }); const t = readTicket("001", cwd)!; assert.equal(writeTicket(t, cwd), true); const file = readRepoFile(cwd, "docs/tickets/T-001-foo.md")!; // Checked state survived. assert.match(file, /- \[x\] pnpm dev serves a page at localhost:3000/); assert.match(file, /- \[x\] Test written first/); // Unchecked items stay unchecked. assert.match(file, /- \[ \] pnpm typecheck passes with strict TypeScript/); assert.match(file, /- \[ \] Test confirmed failing for the expected reason \(Red\)/); // The inline comment stayed on the same line as its criterion. assert.match(file, /- \[x\] pnpm dev serves a page at localhost:3000 /); } finally { cleanupTemp(cwd); } }); test("comment-only checkbox lines fold into the previous criterion as an inline comment", () => { const cwd = makeTempRepo(); try { writeFiles(cwd, { "docs/tickets/T-001-foo.md": `# T-001 **ID:** 001 **Title:** Foo **Status:** in-progress **Priority:** P2 **Created:** 2026-01-01T00:00:00.000Z **Updated:** 2026-01-01T00:00:00.000Z ## Context ## Acceptance Criteria - [ ] pnpm dev serves a page at localhost:3000 - [ ] ## Definition of Done None yet ## Implementation Notes None yet ## Related Documentation None yet ## Related Files None yet `, }); const t = readTicket("001", cwd)!; assert.deepEqual(t.acceptanceCriteria, [ { text: "pnpm dev serves a page at localhost:3000", checked: false, comment: "VERIFIED: dev server started with `pnpm dev` and served HTTP 200.", }, ]); // Re-serializing moves the comment inline instead of re-creating a phantom item. assert.equal(writeTicket(t, cwd), true); const file = readRepoFile(cwd, "docs/tickets/T-001-foo.md")!; assert.match(file, /- \[ \] pnpm dev serves a page at localhost:3000 ## Definition of Done None yet ## Implementation Notes None yet ## Related Documentation None yet ## Related Files None yet `, }); const t = readTicket("001", cwd)!; assert.deepEqual(t.acceptanceCriteria, [ { text: "pnpm dev serves a page at localhost:3000", checked: false, comment: "HUMAN VERIFICATION REQUIRED: Start the dev server, open localhost:3000 in a browser, and verify the page renders correctly.", }, ]); } finally { cleanupTemp(cwd); } }); test("related documentation links round-trip without path drift", () => { const cwd = makeTempRepo(); try { writeFiles(cwd, { "docs/tickets/T-001-foo.md": `# T-001 **ID:** 001 **Title:** Foo **Status:** in-progress **Priority:** P2 **Created:** 2026-01-01T00:00:00.000Z **Updated:** 2026-01-01T00:00:00.000Z ## Context ## Acceptance Criteria None yet ## Definition of Done None yet ## Implementation Notes None yet ## Related Documentation - [ARCHITECTURE](../ARCHITECTURE.md) - [API](../API.md) ## Related Files None yet `, }); const t = readTicket("001", cwd)!; // Hrefs are stored canonically (repo-root-relative), not as raw hrefs. assert.deepEqual(t.relatedDocs, ["docs/ARCHITECTURE.md", "docs/API.md"]); // Re-serialization produces the same hrefs the author wrote (no ../../../ drift). assert.equal(writeTicket(t, cwd), true); const file = readRepoFile(cwd, "docs/tickets/T-001-foo.md")!; assert.match(file, /- \[ARCHITECTURE\]\(\.\.\/ARCHITECTURE\.md\)/); assert.match(file, /- \[API\]\(\.\.\/API\.md\)/); assert.doesNotMatch(file, /\.\.\/\.\.\/\.\.\//); } finally { cleanupTemp(cwd); } }); test("related documentation ignores external URLs, anchors, and absolute paths", () => { const cwd = makeTempRepo(); try { writeFiles(cwd, { "docs/tickets/T-001-foo.md": `# T-001 **ID:** 001 **Title:** Foo **Status:** backlog **Priority:** P2 **Created:** 2026-01-01T00:00:00.000Z **Updated:** 2026-01-01T00:00:00.000Z ## Context ## Acceptance Criteria None yet ## Definition of Done None yet ## Implementation Notes None yet ## Related Documentation - [Docs](https://example.com/ARCHITECTURE.md) - [Anchor](#top) - [API](../API.md) ## Related Files None yet `, }); assert.deepEqual(readTicket("001", cwd)!.relatedDocs, ["docs/API.md"]); } finally { cleanupTemp(cwd); } }); test("already-mangled related-doc hrefs stay inside the repo (no further drift)", () => { const cwd = makeTempRepo(); try { writeFiles(cwd, { "docs/tickets/T-001-foo.md": `# T-001 **ID:** 001 **Title:** Foo **Status:** backlog **Priority:** P2 **Created:** 2026-01-01T00:00:00.000Z **Updated:** 2026-01-01T00:00:00.000Z ## Context ## Acceptance Criteria None yet ## Definition of Done None yet ## Implementation Notes None yet ## Related Documentation - [ARCHITECTURE](../../../ARCHITECTURE.md) ## Related Files None yet `, }); const t = readTicket("001", cwd)!; // The mangled href (which escaped the repo root) is clamped back inside. assert.ok(t.relatedDocs.every((p) => !p.startsWith(".."))); assert.equal(writeTicket(t, cwd), true); const file = readRepoFile(cwd, "docs/tickets/T-001-foo.md")!; assert.doesNotMatch(file, /\.\.\/\.\.\/\.\.\//); } finally { cleanupTemp(cwd); } }); test("readTicket finds tickets with or without the T- prefix", () => { const cwd = makeTempRepo(); try { assert.equal(writeTicket(sampleTicket({ id: "007" }), cwd), true); assert.notEqual(readTicket("007", cwd), null); assert.notEqual(readTicket("T-007", cwd), null); } finally { cleanupTemp(cwd); } }); test("readTicket finds descriptively-named ticket files", () => { const cwd = makeTempRepo(); try { assert.equal(writeTicket(sampleTicket({ id: "012", title: "Wire up API" }), cwd), true); assert.ok(existsSync(join(cwd, "docs/tickets/T-012-wire-up-api.md"))); const loaded = readTicket("012", cwd); assert.equal(loaded!.title, "Wire up API"); } finally { cleanupTemp(cwd); } }); test("readTicket returns null for unknown IDs and missing directories", () => { const empty = makeTempRepo(); try { assert.equal(readTicket("001", empty), null); } finally { cleanupTemp(empty); } const cwd = makeTempRepo(); try { writeFiles(cwd, { "docs/tickets/T-001-foo.md": "# T-001" }); // no **ID:** line assert.equal(readTicket("001", cwd), null); } finally { cleanupTemp(cwd); } }); test("writeTicket renames the file when the title changes and removes the stale file", () => { const cwd = makeTempRepo(); try { const ticket = sampleTicket({ id: "003", title: "Old name" }); assert.equal(writeTicket(ticket, cwd), true); assert.ok(existsSync(join(cwd, "docs/tickets/T-003-old-name.md"))); const renamed = writeTicket( { ...ticket, title: "New name with spaces", updatedAt: "2026-02-01T00:00:00.000Z" }, cwd, ); assert.equal(renamed, true); assert.ok(existsSync(join(cwd, "docs/tickets/T-003-new-name-with-spaces.md"))); assert.ok(!existsSync(join(cwd, "docs/tickets/T-003-old-name.md"))); // No orphan duplicates after rename. assert.ok(listTickets(cwd).filter((t) => t.id === "003").length === 1); } finally { cleanupTemp(cwd); } }); test("writeTicket keeps estimate undefined when not provided", () => { const cwd = makeTempRepo(); try { assert.equal(writeTicket(sampleTicket({ estimate: undefined }), cwd), true); assert.equal(readTicket("001", cwd)!.estimate, undefined); } finally { cleanupTemp(cwd); } }); test("empty implementation notes round-trip as the 'None yet' sentinel (documented behavior)", () => { const cwd = makeTempRepo(); try { assert.equal(writeTicket(sampleTicket({ implementationNotes: [] }), cwd), true); assert.deepEqual(readTicket("001", cwd)!.implementationNotes, ["None yet"]); } finally { cleanupTemp(cwd); } }); // ── listTickets ─────────────────────────────────────────────────────── test("listTickets returns [] when the tickets directory is missing", () => { const cwd = makeTempRepo(); try { assert.deepEqual(listTickets(cwd), []); } finally { cleanupTemp(cwd); } }); test("listTickets returns every parseable ticket file", () => { const cwd = makeTempRepo(); try { writeTicket(sampleTicket({ id: "001" }), cwd); writeTicket(sampleTicket({ id: "002", title: "Second", priority: "P1" }), cwd); // A non-ticket .md file in the dir is skipped when it has no **ID:** writeFiles(cwd, { "docs/tickets/scratchpad.md": "# Notes\n\nno id here" }); const tickets = listTickets(cwd); assert.equal(tickets.length, 2); assert.deepEqual( tickets.map((t) => t.id).sort(), ["001", "002"], ); } finally { cleanupTemp(cwd); } }); test("listTickets deduplicates on ticket ID, preferring the plain T-.md file on ties", () => { const cwd = makeTempRepo(); try { writeFiles(cwd, { "docs/tickets/T-001.md": "# T-001\n\n**ID:** 001\n**Title:** Plain\n**Status:** backlog\n**Priority:** P2\n\n## Context\n", "docs/tickets/T-001-foo.md": "# T-001\n\n**ID:** 001\n**Title:** Descriptive\n**Status:** review\n**Priority:** P2\n\n## Context\n", }); const tickets = listTickets(cwd); // Stale duplicate parses to the same ID as the canonical file, so it must // not surface twice in docs/BACKLOG.md. assert.equal(tickets.length, 1); // No **Updated:** stamps to disambiguate: the plain T-.md file wins. assert.equal(tickets[0]!.title, "Plain"); } finally { cleanupTemp(cwd); } }); test("listTickets keeps the newest file when a rename leaves a stale duplicate", () => { const cwd = makeTempRepo(); try { writeFiles(cwd, { "docs/tickets/T-001-old-name.md": "# T-001\n\n**ID:** 001\n**Title:** Old name\n**Status:** backlog\n**Priority:** P2\n**Updated:** 2026-01-01T00:00:00.000Z\n\n## Context\n", "docs/tickets/T-001-new-name.md": "# T-001\n\n**ID:** 001\n**Title:** New name\n**Status:** review\n**Priority:** P2\n**Updated:** 2026-02-01T00:00:00.000Z\n\n## Context\n", }); const tickets = listTickets(cwd); assert.equal(tickets.length, 1); // The stale file left behind by a failed rename has an older **Updated:** // stamp, so the file that replaced it is the one surfaced. assert.equal(tickets[0]!.title, "New name"); assert.equal(tickets[0]!.status, "review"); } finally { cleanupTemp(cwd); } }); // ── nextTicketId ────────────────────────────────────────────────────── test("nextTicketId starts at 001 for an empty repo", () => { const cwd = makeTempRepo(); try { assert.equal(nextTicketId(cwd), "001"); } finally { cleanupTemp(cwd); } }); test("nextTicketId continues sequentially across clean ids", () => { const cwd = makeTempRepo(); try { writeTicket(sampleTicket({ id: "001" }), cwd); writeTicket(sampleTicket({ id: "002" }), cwd); assert.equal(nextTicketId(cwd), "003"); } finally { cleanupTemp(cwd); } }); test("nextTicketId is highest-plus-one, ignoring gaps and non-numeric ids", () => { const cwd = makeTempRepo(); try { writeTicket(sampleTicket({ id: "001" }), cwd); writeTicket(sampleTicket({ id: "002" }), cwd); // Gap: tickets with ids 003 and 004 never existed. writeTicket(sampleTicket({ id: "005" }), cwd); // Non-numeric ids are ignored. writeFiles(cwd, { "docs/tickets/T-abc.md": "# T-abc\n\n**ID:** abc\n**Title:** X\n\n## Context\n" }); assert.equal(nextTicketId(cwd), "006"); } finally { cleanupTemp(cwd); } }); test("nextTicketId handles ids beyond three digits without losing information", () => { const cwd = makeTempRepo(); try { writeTicket(sampleTicket({ id: "999" }), cwd); writeTicket(sampleTicket({ id: "1000" }), cwd); assert.equal(nextTicketId(cwd), "1001"); } finally { cleanupTemp(cwd); } });