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);