fix(context): preserve Markdown block boundaries in addresses (#962)

Context addresses respect Markdown block boundaries, and recap writes keep working with blank headings.
This commit is contained in:
Rudy Mizrahi Celekli authored and GitHub committed 2026-10-07 17:43:43 -07:00
1 parent 33f80efeaa
commit 69acd45b58
4 files changed
+81 -6

No files matched your search

+20 -6
View File
@@ -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<string, number[]>();
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;
@@ -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);
@@ -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");
});
});
@@ -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 });