From 4be9d112cb50cac4a71e161d633a7ec025c37f07 Mon Sep 17 00:00:00 2001 From: Masa <98894508+Masashi-Ono0611@users.noreply.github.com> Date: Fri, 24 Jul 2026 06:21:39 +0900 Subject: [PATCH] fix(frontmatter): stop treating YAML comments inside the fence as markdown headings (#3225) (#3247) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(frontmatter): stop treating YAML comments inside the fence as markdown headings autoFixFrontmatter's MISSING_CLOSE repair walked lines from the opening `---` and broke out of the scan on the first `#`-prefixed line, treating it as a markdown heading before it ever reached the real closing fence. A `#` line inside a closed YAML block is a comment, not a heading — but the scan never got that far, so it inserted a spurious `---` right before the comment and split valid frontmatter in two, pushing the real keys (title, pubDate, ...) into the document body. This is the same bug PR #2153 fixed in the parseMarkdown validator, but autoFixFrontmatter in brain-writer.ts is a separate reimplementation of the same MISSING_CLOSE logic that PR never touched. Because parseMarkdown's validator now parses this shape cleanly, autoFixFrontmatter is only reachable when some other fixable error (SLUG_MISMATCH, NULL_BYTES, etc.) also fires on the same file — a common real-world case (e.g. a renamed file with a stale slug: field) that still corrupts otherwise-valid frontmatter today. Fix: scan the full zone for the closing `---` first; only fall back to the heading-shaped-line heuristic when no closer is found at all. Addresses the report in #3225. Thanks to @WilliamCourterWelch for the clear repro and for catching this via git diff before it reached a live site. Tests: 4 new regression cases in test/brain-writer.test.ts covering a YAML comment before the close, comment-only frontmatter, a `#` inside a quoted string value, and a comment co-occurring with an unrelated real fix (SLUG_MISMATCH) — confirmed all 3 corruption-covering cases fail against the pre-fix code (stash/red/restore) and pass after the fix. The pre-existing genuinely-missing-closer case is unchanged. bun test test/brain-writer.test.ts test/markdown-validation.test.ts test/markdown.test.ts test/lint-frontmatter.test.ts test/doctor-frontmatter-partial.test.ts test/frontmatter-cli.test.ts -> 122 pass / 0 fail. bun run typecheck -> clean. Full suite intentionally not run locally (targeted scope per contribution norms); CI covers it. Co-Authored-By: Claude Fable 5 * test(frontmatter): swap non-exercising regression case per codex review The quoted-string test (title: "Chapter #1 recap") never exercised the fixed branch — the heading regex is line-anchored on the trimmed line, so a `#` mid-string never matched before or after the fix. Replace it with an indented `#` line inside a YAML block scalar, which does hit the same closer-first-scan code path as the other regression cases with a different real-world shape. bun test test/brain-writer.test.ts -> 27 pass / 0 fail. bun run typecheck -> clean. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- src/core/brain-writer.ts | 52 +++++++++++++++++++++++++-------------- test/brain-writer.test.ts | 37 ++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 18 deletions(-) diff --git a/src/core/brain-writer.ts b/src/core/brain-writer.ts index d7ff74082..1d963dbbc 100644 --- a/src/core/brain-writer.ts +++ b/src/core/brain-writer.ts @@ -139,8 +139,21 @@ export function autoFixFrontmatter( fixes.push({ code: 'NULL_BYTES', description: 'Stripped null bytes' }); } - // 2. MISSING_CLOSE — if there's an opener but no closer before a heading, - // insert `---` immediately before the heading. Walk lines once. + // 2. MISSING_CLOSE — if there's an opener but no closer at all, insert + // `---` immediately before the first heading-shaped line (best-effort + // guess at where the frontmatter was meant to end). + // + // Find the closer FIRST, scanning the full zone — do not stop at the + // first `#`-prefixed line. A `#` line between the opening and closing + // `---` is a YAML comment (comments are valid anywhere in a YAML + // document), not a markdown heading; only the genuine absence of a + // closing `---` counts as MISSING_CLOSE. Mirrors the fix applied to + // the parseMarkdown validator in #2153 — this is the sibling + // reimplementation in the auto-fixer and had the same bug (it broke + // out of the scan on the first heading-shaped line, so a `#` comment + // appearing before a real closing fence was misdetected as + // MISSING_CLOSE and the fix inserted a spurious `---` that split + // valid frontmatter in two, pushing the real keys into the body). { const lines = working.split('\n'); let firstNonEmpty = -1; @@ -149,24 +162,27 @@ export function autoFixFrontmatter( } if (firstNonEmpty >= 0 && lines[firstNonEmpty].trim() === '---') { let closeIdx = -1; - let headingIdx = -1; for (let i = firstNonEmpty + 1; i < lines.length; i++) { - const t = lines[i].trim(); - if (t === '---') { closeIdx = i; break; } - if (/^#{1,6}\s/.test(t)) { headingIdx = i; break; } + if (lines[i].trim() === '---') { closeIdx = i; break; } } - if (closeIdx === -1 && headingIdx >= 0) { - const fixed = [ - ...lines.slice(0, headingIdx), - '---', - '', - ...lines.slice(headingIdx), - ]; - working = fixed.join('\n'); - fixes.push({ - code: 'MISSING_CLOSE', - description: `Inserted closing --- before heading at line ${headingIdx + 1}`, - }); + if (closeIdx === -1) { + let headingIdx = -1; + for (let i = firstNonEmpty + 1; i < lines.length; i++) { + if (/^#{1,6}\s/.test(lines[i].trim())) { headingIdx = i; break; } + } + if (headingIdx >= 0) { + const fixed = [ + ...lines.slice(0, headingIdx), + '---', + '', + ...lines.slice(headingIdx), + ]; + working = fixed.join('\n'); + fixes.push({ + code: 'MISSING_CLOSE', + description: `Inserted closing --- before heading at line ${headingIdx + 1}`, + }); + } } } } diff --git a/test/brain-writer.test.ts b/test/brain-writer.test.ts index 611d69fff..733100f63 100644 --- a/test/brain-writer.test.ts +++ b/test/brain-writer.test.ts @@ -32,6 +32,43 @@ describe('autoFixFrontmatter', () => { expect(idxClose).toBeLessThan(idxHeading); }); + // Regression for #3225: a `#`-prefixed line inside an already-closed + // frontmatter fence is a YAML comment, not a markdown heading. The old + // MISSING_CLOSE scan broke out on the first heading-shaped line without + // continuing to look for the real closer, so it inserted a spurious + // `---` before the comment and split valid frontmatter in two — pushing + // the real keys (title, pubDate, ...) into the document body. + test('does not corrupt closed frontmatter containing a YAML comment line', () => { + const input = `${fence}\n# a YAML comment inside the frontmatter block\ntitle: "Real Title"\npubDate: 2026-06-29\n${fence}\nBody...`; + const { content, fixes } = autoFixFrontmatter(input); + expect(content).toBe(input); + expect(fixes).toEqual([]); + }); + + test('does not corrupt closed frontmatter that is comment-only', () => { + const input = `${fence}\n# just a comment\n# another comment\n${fence}\nBody`; + const { content, fixes } = autoFixFrontmatter(input); + expect(content).toBe(input); + expect(fixes).toEqual([]); + }); + + test('does not corrupt closed frontmatter with an indented `#` line inside a YAML block scalar', () => { + const input = `${fence}\ndescription: |\n # not a heading, just literal block-scalar text\ntitle: ok\n${fence}\nBody`; + const { content, fixes } = autoFixFrontmatter(input); + expect(content).toBe(input); + expect(fixes).toEqual([]); + }); + + test('YAML comment before close does not suppress an unrelated real fix (SLUG_MISMATCH)', () => { + const input = `${fence}\n# a YAML comment\ntitle: hi\nslug: wrong-slug\n${fence}\nBody`; + const { content, fixes } = autoFixFrontmatter(input, { filePath: 'people/jane-doe.md' }); + expect(fixes.some(f => f.code === 'MISSING_CLOSE')).toBe(false); + expect(fixes.some(f => f.code === 'SLUG_MISMATCH')).toBe(true); + // The frontmatter fence itself must stay intact — only the slug line + // is removed, the comment/title/close survive unchanged. + expect(content).toBe(`${fence}\n# a YAML comment\ntitle: hi\n\n${fence}\nBody`); + }); + test('rewrites nested-quote title to single-quoted', () => { const input = `${fence}\ntype: concept\ntitle: "Phil "Nick" Last"\n${fence}\n\nbody`; const { content, fixes } = autoFixFrontmatter(input);