mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-07 05:56:21 -10:00
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:
1 parent
33f80efeaa
commit
69acd45b58
4 files changed
+81
-6
No files matched your search
@@ -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 });
|
||||
|
||||
Reference in new issue
Block a user