mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-11-06 12:50:54 -04:00
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:
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user