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`.
This commit is contained in:
0xfandom
2026-09-21 09:05:11 +08:00
committed by GitHub
parent c3930ca5ad
commit cbbe10ff8a
2 changed files with 170 additions and 2 deletions
+136 -1
View File
@@ -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)
})
+34 -1
View File
@@ -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