From cbbe10ff8a794f4d97c32af9ef25f334b22bb5f5 Mon Sep 17 00:00:00 2001 From: 0xfandom <50949929+0xfandom@users.noreply.github.com> Date: Mon, 21 Sep 2026 06:35:11 +0530 Subject: [PATCH] fix(frontmatter): anchor the closing --- to the start of a line (#2027) * fix(frontmatter): anchor the closing --- to the start of a line The opening delimiter was anchored but the closing one was not, and [\s\S]*? is lazy, so parsing stopped at the first `---` appearing anywhere -- including inside a value. A description reading "Reviews code --- thoroughly" ended the block early: the value was truncated and the remaining frontmatter lines plus the real delimiter leaked into the body that is sent to the model. A `---` line inside a block scalar did the same. Requiring the captured block to be empty or to end at a line break pins the close to a line start. The m flag would also work for the close but would un-anchor the open, letting a horizontal rule partway down a body be read as frontmatter. Tightened the trailing run to spaces and tabs as well, since \s* matched a newline and accepted a bare `---` with no line terminator after it. Affects every .md frontmatter consumer: agents, skills, slash commands, output styles and memory files. * test(frontmatter): fold the delimiter cases into the existing suite A separate test file adds a slot to bun's sequential file ordering, which shifted which suite runs before which and surfaced an unrelated mock leak in CI (taskReport git metadata and the /ads command failed on both Node versions, neither touching frontmatter). The cases belong next to the existing parser coverage anyway. * test(frontmatter): cover tabs after both delimiters * fix(frontmatter): accept CRLF delimiter lines Windows-authored Markdown terminates the `---` delimiters with `\r\n`. The `[ \t]*\n` matcher rejected the carriage return, so such files were treated as having no frontmatter. Accept an optional `\r` on the open, captured body, and close line endings. * fix(frontmatter): keep consuming blank separator lines after the close Anchoring the closing delimiter narrowed its trailing match from `\s*` to just the delimiter's own line ending, so a conventional blank line between the frontmatter and body survived as a leading newline in the parsed content. Skill and plugin-command bodies are fed into prompts untrimmed, so that became a stray newline in the model instructions. Consume trailing whitespace-only lines again after the (still strictly validated, line-terminated) close, which cannot revive the mid-document false close since it only runs once a valid terminator matched. * fix(frontmatter): close empty frontmatter at the first delimiter line The anchored close made the captured block's outer quantifier greedy, so empty frontmatter followed by a body horizontal rule (`---\n---\n---`) read the second delimiter as YAML and closed on the third, swallowing the rule. Make the outer capture lazy so an empty block closes at the first `---` line as it did before anchoring; real frontmatter still matches on backtrack. Fixes the same shape hard-failing `claude plugin validate`. --- src/utils/frontmatterParser.test.ts | 137 +++++++++++++++++++++++++++- src/utils/frontmatterParser.ts | 35 ++++++- 2 files changed, 170 insertions(+), 2 deletions(-) diff --git a/src/utils/frontmatterParser.test.ts b/src/utils/frontmatterParser.test.ts index 0fea95418..ac6b42314 100644 --- a/src/utils/frontmatterParser.test.ts +++ b/src/utils/frontmatterParser.test.ts @@ -1,5 +1,8 @@ import { expect, test } from 'bun:test' -import { splitPathInFrontmatter } from './frontmatterParser.ts' +import { + parseFrontmatter, + splitPathInFrontmatter, +} from './frontmatterParser.ts' // splitPathInFrontmatter is the public entry that flatMaps each comma-separated // part through the (private) expandBraces helper, so it exercises the brace @@ -53,3 +56,135 @@ test('keeps an empty brace group literal instead of yielding an empty path', () test('keeps a literal empty group while still expanding later groups', () => { expect(splitPathInFrontmatter('{}/{a,b}')).toEqual(['{}/a', '{}/b']) }) + +// The opening `---` was anchored but the closing one was not, and `[\s\S]*?` is +// lazy, so parsing stopped at the first `---` appearing anywhere -- including +// inside a value. Every .md frontmatter consumer is affected: agents, skills, +// slash commands, output styles and memory files all go through here. + +test('does not end the block at a --- inside a quoted value', () => { + const { frontmatter, content } = parseFrontmatter( + '---\nname: r\ndescription: "Reviews code --- thoroughly"\n---\n\nBody.\n', + ) + + // Previously the block ended at the `---` inside the quotes: the description + // was truncated, and the rest of the frontmatter plus the real delimiter + // leaked into the body that is sent to the model. + expect(frontmatter.description).toBe('Reviews code --- thoroughly') + expect(frontmatter.name).toBe('r') + // The blank separator line after the closing delimiter is consumed, so the + // body starts at the first real line (not with a leading newline). + expect(content).toBe('Body.\n') + expect(content).not.toContain('---') +}) + +test('does not end the block at a --- line inside a block scalar', () => { + const { frontmatter, content } = parseFrontmatter( + '---\ndescription: |\n step one\n ---\n step two\n---\nBody\n', + ) + + expect(frontmatter.description).toBe('step one\n---\nstep two\n') + expect(content).toBe('Body\n') +}) + +test('does not leak the frontmatter tail into the body', () => { + // Whether the YAML layer can make sense of an unquoted value containing + // `---` is its own question; what must not happen is the delimiter being + // found mid-value, which spilled the remaining frontmatter lines and a stray + // `---` into the body. + const { content } = parseFrontmatter( + '---\nname: a\nsummary: uses --- as a separator\n---\nBody\n', + ) + + expect(content).toBe('Body\n') +}) + +test('parses the ordinary shapes exactly as before', () => { + const simple = parseFrontmatter('---\nname: a\n---\nBody\n') + expect(simple.frontmatter).toEqual({ name: 'a' }) + expect(simple.content).toBe('Body\n') + + const empty = parseFrontmatter('---\n---\nBody\n') + expect(empty.frontmatter).toEqual({}) + expect(empty.content).toBe('Body\n') + + // Trailing spaces are allowed on either delimiter. + const padded = parseFrontmatter('--- \nname: a\n--- \nBody\n') + expect(padded.frontmatter).toEqual({ name: 'a' }) + expect(padded.content).toBe('Body\n') + + // Tabs too: the trailing run is [ \t]* on both delimiters. + const tabbed = parseFrontmatter('---\t\nname: a\n---\t\nBody\n') + expect(tabbed.frontmatter).toEqual({ name: 'a' }) + expect(tabbed.content).toBe('Body\n') + + // Frontmatter that ends at EOF with no trailing newline. + const atEof = parseFrontmatter('---\nname: a\n---') + expect(atEof.frontmatter).toEqual({ name: 'a' }) + expect(atEof.content).toBe('') +}) + +test('parses CRLF-delimited frontmatter (Windows-authored files)', () => { + // `---\r\n` must be recognized: the delimiter matcher accepts an optional + // carriage return on the open, the captured body lines, and the close. + const crlf = parseFrontmatter('---\r\nname: a\r\n---\r\nBody\r\n') + expect(crlf.frontmatter).toEqual({ name: 'a' }) + expect(crlf.content).toBe('Body\r\n') + + // Empty CRLF frontmatter. + const empty = parseFrontmatter('---\r\n---\r\nBody\r\n') + expect(empty.frontmatter).toEqual({}) + expect(empty.content).toBe('Body\r\n') +}) + +test('consumes blank and whitespace-only separator lines after the close', () => { + // Bodies are passed into prompts untrimmed, so a conventional blank line + // between frontmatter and body must not survive as a leading newline. The + // pre-anchor regex consumed it via `\s*`; the anchored one has to as well. + const oneBlank = parseFrontmatter('---\nname: a\n---\n\nBody\n') + expect(oneBlank.content).toBe('Body\n') + + const manyBlank = parseFrontmatter('---\nname: a\n---\n\n\n\nBody\n') + expect(manyBlank.content).toBe('Body\n') + + // Whitespace-only separator lines (spaces/tabs) are consumed too. + const wsOnly = parseFrontmatter('---\nname: a\n---\n \n\t\nBody\n') + expect(wsOnly.content).toBe('Body\n') + + // CRLF blank separators. + const crlfBlank = parseFrontmatter('---\r\nname: a\r\n---\r\n\r\nBody\r\n') + expect(crlfBlank.content).toBe('Body\r\n') +}) + +test('keeps a body horizontal rule out of empty frontmatter', () => { + // Empty frontmatter closes at the first `---` line, so a `---` rule that + // starts the body must survive as body content -- not be read as YAML. + const rule = parseFrontmatter('---\n---\n---\nBody\n') + expect(rule.frontmatter).toEqual({}) + expect(rule.content).toBe('---\nBody\n') + + // The same holds with blank lines between the close and the body rule. + const blankThenRule = parseFrontmatter('---\n---\n\n---\nBody\n') + expect(blankThenRule.frontmatter).toEqual({}) + expect(blankThenRule.content).toBe('---\nBody\n') + + // Real frontmatter followed by a body rule is unaffected. + const realThenRule = parseFrontmatter('---\nname: a\n---\n---\nBody\n') + expect(realThenRule.frontmatter).toEqual({ name: 'a' }) + expect(realThenRule.content).toBe('---\nBody\n') +}) + +test('leaves a file without frontmatter untouched', () => { + const markdown = 'Just a body.\n\nWith a --- rule in it.\n' + const { frontmatter, content } = parseFrontmatter(markdown) + + expect(frontmatter).toEqual({}) + expect(content).toBe(markdown) +}) + +test('does not treat a body horizontal rule as frontmatter', () => { + // The document does not open with a delimiter, so nothing is consumed even + // though `---` lines appear later. + const markdown = '# Title\n\n---\n\nSection\n\n---\n' + expect(parseFrontmatter(markdown).content).toBe(markdown) +}) diff --git a/src/utils/frontmatterParser.ts b/src/utils/frontmatterParser.ts index c8bbf7bd6..a2ff6c996 100644 --- a/src/utils/frontmatterParser.ts +++ b/src/utils/frontmatterParser.ts @@ -120,7 +120,40 @@ function quoteProblematicValues(frontmatterText: string): string { return result.join('\n') } -export const FRONTMATTER_REGEX = /^---\s*\n([\s\S]*?)---\s*\n?/ +/** + * The opening delimiter is anchored but the closing one must be too. + * + * `[\s\S]*?` is lazy, so an unanchored close stops at the first `---` appearing + * anywhere -- including in the middle of a value. `description: Reviews code + * --- thoroughly` ends the block early: the description is truncated to + * "Reviews code", and the rest of the frontmatter plus the real delimiter leak + * into the body that is sent to the model. A `---` line inside a block scalar + * has the same effect. + * + * Requiring the captured block to be empty or to end at a line break pins the + * close to the start of a line. The `m` flag would do the same for the close + * but would also un-anchor the open, letting a horizontal rule partway down a + * body be read as frontmatter. Only spaces and tabs may sit between the close + * `---` and its line terminator -- letting `\s*` match the terminator itself + * accepted a bare `---` with no line ending mid-document. + * + * Once that terminator (or EOF) is confirmed, the trailing `\s*` consumes the + * conventional blank separator line(s) between frontmatter and body, matching + * the pre-anchor behavior. Skill and plugin-command bodies are passed into + * prompts untrimmed, so leaving that blank line in would prepend a stray + * newline to the model instructions. This consumption runs only after a valid + * line-terminated close, so it cannot revive the mid-document false close. + * + * The captured block is lazy at the outer level too (`)??`). A greedy `)?` + * consumes one YAML line before trying empty, so empty frontmatter followed by + * a body horizontal rule (`---\n---\n---\nBody`) reads the second `---` as YAML + * and closes on the third, swallowing the rule into the frontmatter. Preferring + * the empty capture closes at the first `---` line, as it did before the block + * was anchored, while real frontmatter still matches by taking the non-empty + * branch on backtrack. + */ +export const FRONTMATTER_REGEX = + /^---[ \t]*\r?\n((?:[\s\S]*?\r?\n)??)---[ \t]*(?:\r?\n\s*|$)/ /** * Parses markdown content to extract frontmatter and content