diff --git a/packages/daemon/src/domain/markdown-address.ts b/packages/daemon/src/domain/markdown-address.ts index 11658ed5f..77c2fd3bf 100644 --- a/packages/daemon/src/domain/markdown-address.ts +++ b/packages/daemon/src/domain/markdown-address.ts @@ -115,6 +115,9 @@ function scanHeaders(lines: string[]): HeaderScan { const marker = fenceMatch[1]![0]!; const length = fenceMatch[1]!.length; if (!fence) { + // Backticks in an opener's info string prevent a fenced block. + // Do not let inline code spans hide the following real headings. + if (marker === "`" && line.slice(fenceMatch[0].length).includes("`")) continue; fence = { marker, length, line: i }; } else if (fence.marker === marker && length >= fence.length && /^[ \t\r]*$/.test(line.slice(fenceMatch[0].length))) { @@ -123,8 +126,9 @@ function scanHeaders(lines: string[]): HeaderScan { continue; } if (fence) continue; - const header = line.match(/^ {0,3}(#{1,6})\s+(.*\S)\s*$/); - if (header) hits.push({ level: header[1]!.length, title: header[2]!, line: i }); + // Empty ATX headings still end spans and change the parent scope. + const header = line.match(/^ {0,3}(#{1,6})(?:\s+(.*\S))?\s*$/); + if (header) hits.push({ level: header[1]!.length, title: header[2] ?? "", line: i }); } return { hits, unterminatedFenceLine: fence?.line ?? null }; } @@ -142,7 +146,7 @@ export function parseMarkdownSections(text: string): MarkdownSection[] { for (let idx = 0; idx < headers.length; idx++) { const h = headers[idx]!; if (h.level < MIN_LEVEL || h.level > MAX_LEVEL) { - if (h.level < MIN_LEVEL) currentH2 = null; // an H1 resets the H2 scope + if (h.level < MIN_LEVEL) currentH2 = h.title.length === 0 ? "" : null; // a blank H1 keeps children unaddressable continue; } const slug = slugifyHeader(h.title); @@ -210,7 +214,7 @@ export type AddressabilityFinding = * the caller decides the gate. */ export function validateMarkdownAddressability(text: string): AddressabilityFinding[] { const lines = text.split("\n"); - const { unterminatedFenceLine } = scanHeaders(lines); + const { hits: headers, unterminatedFenceLine } = scanHeaders(lines); const sections = parseMarkdownSections(text); const findings: AddressabilityFinding[] = []; // r1 F1: an unclosed fence swallows every later header; resolution stays honest @@ -220,9 +224,19 @@ export function validateMarkdownAddressability(text: string): AddressabilityFind findings.push({ kind: "unterminated-fence", line: unterminatedFenceLine }); } const seen = new Map(); + let blankParentScope = false; + let headerIndex = 0; for (const s of sections) { - // r1 F2 family rule: ANY empty segment makes a section unreachable by a legal - // address (parseAddress rejects empty segments) — flag parent AND children. + while (headerIndex < headers.length && headers[headerIndex]!.line <= s.headerLine) { + const header = headers[headerIndex++]!; + if (header.level <= 2) blankParentScope = header.title.length === 0; + } + // Blank headings are scope boundaries, not names to validate. Preserve + // recap-write compatibility for their content without promoting children + // into a preceding section or the top-level address space. + if (s.title.length === 0 || (blankParentScope && s.headerPath.length === 2 && s.headerPath[1]!.length > 0)) continue; + // r1 F2 family rule: for remaining named headers, any empty segment makes + // the section unreachable by a legal address — flag parent AND children. if (s.headerPath.some((segment) => segment.length === 0)) { findings.push({ kind: "unaddressable-header", headerPath: s.headerPath.join("/"), line: s.headerLine, title: s.title }); continue; diff --git a/packages/daemon/test/context-pack-address-route.test.ts b/packages/daemon/test/context-pack-address-route.test.ts index 3d4dd2fbd..ca0422904 100644 --- a/packages/daemon/test/context-pack-address-route.test.ts +++ b/packages/daemon/test/context-pack-address-route.test.ts @@ -54,6 +54,23 @@ describe("GET /library/resolve-address — file-level span serving (Atom 4c)", ( const resolve = (address: string) => app.request(`/api/context-packs/library/resolve-address?address=${encodeURIComponent(address)}`); + it("serves a requested section after an inline triple-backtick span", async () => { + writeFileSync(join(tmp, "lib", "packs", "world", "walk.md"), + "## Welcome\n```literal ` backticks```\n## Reference\nreference instructions\n"); + const res = await resolve("packs/world/walk.md#reference"); + expect(res.status).toBe(200); + expect(((await res.json()) as { text: string }).text).toBe("## Reference\nreference instructions\n"); + }); + + it("does not serve unrelated instructions across an empty heading", async () => { + writeFileSync(join(tmp, "lib", "packs", "world", "walk.md"), + "## Welcome\nwelcome instructions\n##\nunrelated instructions\n### Child\nchild instructions\n"); + const res = await resolve("packs/world/walk.md#welcome"); + expect(res.status).toBe(200); + expect(((await res.json()) as { text: string }).text).toBe("## Welcome\nwelcome instructions"); + expect((await resolve("packs/world/walk.md#welcome/child")).status).toBe(422); + }); + it("resolves pack/file#H2 to the correct span (the mini-req 6 door shape)", async () => { const res = await resolve("packs/world/walk.md#welcome"); expect(res.status).toBe(200); diff --git a/packages/daemon/test/markdown-address.test.ts b/packages/daemon/test/markdown-address.test.ts index ff2647ee0..66a23ee04 100644 --- a/packages/daemon/test/markdown-address.test.ts +++ b/packages/daemon/test/markdown-address.test.ts @@ -206,3 +206,30 @@ describe("validateMarkdownAddressability — the compose gate", () => { expect(findings.filter((f) => f.kind === "unaddressable-header").length).toBe(2); }); }); + + +describe("Markdown block boundaries in context addresses", () => { + it("keeps inline backtick spans from swallowing later sections", () => { + const text = "## Setup\n```literal ` backticks```\n## Deploy\ndeploy instructions\n"; + expect(resolveAddress(text, ["deploy"]).text).toBe("## Deploy\ndeploy instructions\n"); + expect(validateMarkdownAddressability(text)).toEqual([]); + expect(resolveAddress(text, ["setup"]).text).toContain("```literal ` backticks```"); + }); + + it.each(["##", "## ", "##\t"])("keeps the empty heading %j as a span and scope boundary", (heading) => { + const text = `## Setup\nsetup instructions\n${heading}\nother section\n### Child\nchild instructions\n`; + expect(resolveAddress(text, ["setup"]).text).toBe("## Setup\nsetup instructions"); + expect(() => resolveAddress(text, ["setup", "child"])).toThrow(AddressResolutionError); + expect(parseMarkdownSections(text).find((section) => section.title === "Child")?.headerPath).toEqual(["", "child"]); + expect(validateMarkdownAddressability(text)).toEqual([]); + }); + + it("keeps children of an empty H1 unaddressable and terminates an H3 at an empty H4", () => { + const text = "## Setup\n### Child\nchild instructions\n####\nother subsection\n#\n### Independent\nindependent instructions"; + expect(resolveAddress(text, ["setup", "child"]).ownText).toBe("### Child\nchild instructions"); + expect(parseMarkdownSections(text).find((section) => section.title === "Independent")?.headerPath).toEqual(["", "independent"]); + expect(() => resolveAddress(text, ["independent"])).toThrow(AddressResolutionError); + expect(validateMarkdownAddressability(text)).toEqual([]); + expect(resolveAddress(text.replace("\n#\n", "\n# Named\n"), ["independent"]).text).toContain("independent instructions"); + }); +}); diff --git a/packages/daemon/test/seat-recap-store.test.ts b/packages/daemon/test/seat-recap-store.test.ts index b8b882ed4..8256adbf0 100644 --- a/packages/daemon/test/seat-recap-store.test.ts +++ b/packages/daemon/test/seat-recap-store.test.ts @@ -27,6 +27,23 @@ beforeEach(() => { seatDir = mkdtempSync(join(tmpdir(), "s05-recap-")); }); afterEach(() => rmSync(seatDir, { recursive: true, force: true })); describe("writeSeatRecap — superseded-chain retention (Q2-Amendment 1(b))", () => { + it.each(["##", "##\n### Child\nchild prose", "###", "#", "#\n### Next\n## Next"])("retains a recap containing the blank heading boundary %j", (heading) => { + writeSeatRecap({ seatDir, content: GOOD_RECAP, now: () => 1 }); + const content = `## Recent Decisions\nOwned decision\n${heading}\nUnaddressed prose\n`; + writeSeatRecap({ seatDir, content, now: () => 2 }); + expect(readFileSync(join(seatDir, "RECAP.md"), "utf-8")).toBe(content); + const chain = listRecapChain(seatDir); + expect(chain).toHaveLength(1); + expect(readFileSync(chain[0]!.path, "utf-8")).toBe(GOOD_RECAP); + }); + + it("still refuses a named unaddressable heading after a blank boundary", () => { + writeSeatRecap({ seatDir, content: GOOD_RECAP, now: () => 1 }); + expect(() => writeSeatRecap({ seatDir, content: "##\n### ???\nunaddressable title", now: () => 2 })).toThrow(/unaddressable-header/); + expect(readFileSync(join(seatDir, "RECAP.md"), "utf-8")).toBe(GOOD_RECAP); + expect(listRecapChain(seatDir)).toHaveLength(0); + }); + it("first write creates RECAP.md; a second write supersedes the first INTO the chain, byte-preserved", () => { let t = 1000; writeSeatRecap({ seatDir, content: "## Recent Decisions\nfirst era", now: () => t });