From 86ff85e0028cb5a85146a2987cece048d7401bc3 Mon Sep 17 00:00:00 2001 From: RealDiligent Date: Thu, 30 Jul 2026 03:20:15 +0800 Subject: [PATCH] fix(content-lane): parse zero-indent YAML block sequences in frontmatter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parseSimpleFrontmatter's sequence branch only consumed lines beginning with whitespace, so a ZERO-INDENT block sequence — valid YAML that js-yaml/gray-matter (the site build's own parser) accept — author: - Alice was dropped, leaving the field an empty string. A contributor could therefore hide a protected-field edit (the value parses identically before/after) or evade the duplicate/source-evidence gates, the exact bypass the parser's own contract says it exists to prevent. Extend the consume loop to also accept a zero-indent line starting with `-`, stopping at a blank line or the next top-level key. Applied identically to the byte-equivalent copy in source-evidence.ts and to findDuplicateFrontmatterKeys's mirroring skip loop, so a zero-indent sequence value is never mistaken for a duplicate top-level key. Indented sequences, block scalars, flow sequences, and inline scalars are unchanged. Closes #9664 --- src/review/content-lane/duplicates.ts | 14 ++++++---- src/review/content-lane/source-evidence.ts | 11 ++++---- test/unit/content-lane-duplicates.test.ts | 27 +++++++++++++++++++ .../unit/content-lane-source-evidence.test.ts | 8 ++++++ 4 files changed, 50 insertions(+), 10 deletions(-) diff --git a/src/review/content-lane/duplicates.ts b/src/review/content-lane/duplicates.ts index e1f2e2cdf5..b7636be56e 100644 --- a/src/review/content-lane/duplicates.ts +++ b/src/review/content-lane/duplicates.ts @@ -99,10 +99,12 @@ export function parseSimpleFrontmatter(source: string): Record { } fields[key] = block.join(inline.startsWith(">") ? " " : "\n").trim(); } else if (inline === "") { - // Block/flow sequence or nested map on the following indented lines. + // Block/flow sequence or nested map on the following lines: an INDENTED line, or a ZERO-INDENT + // block-sequence item (`- x`), which js-yaml/gray-matter accept and the old indent-only loop dropped + // (#9664). A blank line or the next top-level `key:` (neither indented nor `-`) ends the value. const items: string[] = []; - /* v8 ignore next -- noUncheckedIndexedAccess fallback: the second lines[i] reuses the same in-bounds index already validated by /^\s/.test above */ - while (i < lines.length && /^\s/.test(lines[i] ?? "") && (lines[i] ?? "").trim() !== "") { + /* v8 ignore next -- noUncheckedIndexedAccess fallback: the second/third lines[i] reuse the same in-bounds index already validated by the guard above */ + while (i < lines.length && (lines[i] ?? "").trim() !== "" && (/^\s/.test(lines[i] ?? "") || /^-/.test(lines[i] ?? ""))) { /* v8 ignore next -- noUncheckedIndexedAccess fallback: loop guard keeps i in bounds; split() elements are always strings */ items.push((lines[i] ?? "").replace(/^\s*-\s*/, "").trim()); i += 1; @@ -147,8 +149,10 @@ export function findDuplicateFrontmatterKeys(source: string): string[] { /* v8 ignore next -- noUncheckedIndexedAccess fallback: i < lines.length guards the index; split() elements are always strings */ while (i < lines.length && ((lines[i] ?? "").trim() === "" || /^\s/.test(lines[i] ?? ""))) i += 1; } else if (inline === "") { - /* v8 ignore next -- noUncheckedIndexedAccess fallback: the second lines[i] reuses the same in-bounds index already validated by /^\s/.test */ - while (i < lines.length && /^\s/.test(lines[i] ?? "") && (lines[i] ?? "").trim() !== "") i += 1; + // Skip exactly what the parser's sequence branch consumes (indented lines OR a zero-indent `- item`), + // so a zero-indent sequence value is never mistaken for a duplicate top-level key (#9664). + /* v8 ignore next -- noUncheckedIndexedAccess fallback: the second/third lines[i] reuse the same in-bounds index already validated by the guard */ + while (i < lines.length && (lines[i] ?? "").trim() !== "" && (/^\s/.test(lines[i] ?? "") || /^-/.test(lines[i] ?? ""))) i += 1; } } return [...dupes]; diff --git a/src/review/content-lane/source-evidence.ts b/src/review/content-lane/source-evidence.ts index 2f2aaf0b32..3b6aecc61e 100644 --- a/src/review/content-lane/source-evidence.ts +++ b/src/review/content-lane/source-evidence.ts @@ -163,15 +163,16 @@ function parseSimpleFrontmatter(source: string): Record { } fields[key] = block.join(inline.startsWith(">") ? " " : "\n").trim(); } else if (inline === "") { - // Block/flow sequence (or nested map) on the following indented lines: gather each `- item` so a - // scalar-only field authored as a YAML sequence is still captured, matching duplicates.ts's parser - // (#8016; this branch existed only there, so such a field was invisible to the source-evidence gate). + // Block/flow sequence (or nested map) on the following lines: an INDENTED line, or a ZERO-INDENT + // block-sequence item (`- x`), which js-yaml/gray-matter accept and the old indent-only loop dropped + // (#9664). A blank line or the next top-level `key:` (neither indented nor `-`) ends the value. Kept + // byte-equivalent to duplicates.ts's parser (#8016; this branch existed only there originally). const items: string[] = []; // `lines[i]` is bounded by the `i < lines.length` loop guard; the `?? ""` is an unreachable // noUncheckedIndexedAccess fallback (same guard as the block-scalar loop above). /* v8 ignore next */ - while (i < lines.length && /^\s/.test(lines[i] ?? "") && (lines[i] ?? "").trim() !== "") { - // `lines[i]` reuses the same in-bounds index already validated by `/^\s/.test` above; `?? ""` + while (i < lines.length && (lines[i] ?? "").trim() !== "" && (/^\s/.test(lines[i] ?? "") || /^-/.test(lines[i] ?? ""))) { + // `lines[i]` reuses the same in-bounds index already validated by the guard above; `?? ""` // cannot fire (unreachable noUncheckedIndexedAccess fallback). /* v8 ignore next */ items.push((lines[i] ?? "").replace(/^\s*-\s*/, "").trim()); diff --git a/test/unit/content-lane-duplicates.test.ts b/test/unit/content-lane-duplicates.test.ts index 1d6bbb8946..fdb70b7c15 100644 --- a/test/unit/content-lane-duplicates.test.ts +++ b/test/unit/content-lane-duplicates.test.ts @@ -79,6 +79,20 @@ describe("parseSimpleFrontmatter", () => { expect(f.description).toBe("word one word two"); expect(f.title).toBe("T"); }); + + it("regression (#9664): captures a ZERO-INDENT block sequence, which js-yaml/gray-matter accept", () => { + const src = ["---", "author:", "- Alice", "downloadUrl:", "- https://good.example/x.zip", "---", "", "body"].join("\n"); + expect(parseSimpleFrontmatter(src)).toEqual({ author: "Alice", downloadUrl: "https://good.example/x.zip" }); + }); + + it("regression (#9664): a zero-indent sequence terminates at the next top-level key or a blank line, never swallowing them", () => { + const atKey = parseSimpleFrontmatter(["---", "tags:", "- one", "- two", "title: Real", "---", "", "b"].join("\n")); + expect(atKey.tags).toBe("one, two"); + expect(atKey.title).toBe("Real"); // the following key is parsed, not absorbed into the sequence + const atBlank = parseSimpleFrontmatter(["---", "tags:", "- one", "", "title: Real2", "---", "", "b"].join("\n")); + expect(atBlank.tags).toBe("one"); // a blank line ends the sequence + expect(atBlank.title).toBe("Real2"); + }); }); describe("findDuplicateFrontmatterKeys", () => { @@ -97,6 +111,11 @@ describe("findDuplicateFrontmatterKeys", () => { expect(findDuplicateFrontmatterKeys(undefined as unknown as string)).toEqual([]); expect(findDuplicateFrontmatterKeys(0 as unknown as string)).toEqual([]); }); + + it("regression (#9664): a zero-indent sequence value is not mistaken for a duplicate top-level key", () => { + const src = ["---", "author:", "- Alice", "downloadUrl:", "- https://good.example/x.zip", "---", "", "body"].join("\n"); + expect(findDuplicateFrontmatterKeys(src)).toEqual([]); + }); }); describe("protectedFrontmatterChanges", () => { @@ -112,6 +131,14 @@ describe("protectedFrontmatterChanges", () => { expect(protectedFrontmatterChanges(before, after)).toEqual([]); }); + it("regression (#9664): flags a protected field whose value is authored as a zero-indent sequence", () => { + // Before the fix both sides parsed author to "" (the zero-indent item was dropped), so a real edit to a + // protected field written as a block sequence slipped past the protected-edit gate unnoticed. + const before = ["---", "title: T", "slug: a", "author:", "- Alice", "---", "", "b"].join("\n"); + const after = ["---", "title: T", "slug: a", "author:", "- Eve", "---", "", "b"].join("\n"); + expect(protectedFrontmatterChanges(before, after)).toEqual(["author"]); + }); + it("is scalar-style insensitive (quoted vs unquoted same value → no change)", () => { const before = mdx({ title: "T", slug: "a", author: '"Alice"' }); const after = mdx({ title: "T", slug: "a", author: "Alice" }); diff --git a/test/unit/content-lane-source-evidence.test.ts b/test/unit/content-lane-source-evidence.test.ts index 05305d91cf..280541d461 100644 --- a/test/unit/content-lane-source-evidence.test.ts +++ b/test/unit/content-lane-source-evidence.test.ts @@ -335,6 +335,14 @@ describe("extractSubmittedSourceUrls — frontmatter parsing edge cases", () => ); }); + it("captures a scalar-only source field authored as a ZERO-INDENT block sequence (#9664)", () => { + // The ported sequence branch dropped a zero-indent `- item`, so a URL written this way — valid YAML that + // js-yaml/gray-matter accept — never reached the fetch-reachability gate. Mirrors the indented #8016 case. + const src = ["---", "documentationUrl:", "- https://docs.acme.example/guide", "---", "", "body"].join("\n"); + const urls = extractSubmittedSourceUrls(src); + expect(urls.map((u) => `${u.field}:${u.url}`)).toContain("documentationUrl:https://docs.acme.example/guide"); + }); + it("does not surface any block-scalar header on a list field as a bogus URL (both indicator orders)", () => { // `retrievalSources` is a list field; a block-scalar header is not a URL. The old guard only skipped the bare // `|`/`>`, so `|-` leaked as the literal url "|-". YAML allows chomping and the indentation digit in EITHER