Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions src/review/content-lane/duplicates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,10 +99,12 @@ export function parseSimpleFrontmatter(source: string): Record<string, string> {
}
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;
Expand Down Expand Up @@ -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];
Expand Down
11 changes: 6 additions & 5 deletions src/review/content-lane/source-evidence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,15 +163,16 @@ function parseSimpleFrontmatter(source: string): Record<string, string> {
}
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());
Expand Down
27 changes: 27 additions & 0 deletions test/unit/content-lane-duplicates.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand All @@ -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", () => {
Expand All @@ -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" });
Expand Down
8 changes: 8 additions & 0 deletions test/unit/content-lane-source-evidence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading