From 85c9156d942720d436eede8237644aa01d18b917 Mon Sep 17 00:00:00 2001 From: Fadhlan Date: Sun, 21 Jun 2026 20:25:43 +0700 Subject: [PATCH 1/2] fix(frontmatter): recognise empty frontmatter blocks An empty frontmatter block (`---\n---\n` with no content between the delimiters) was not matched by the splitFrontmatter regex. This caused the literal `---` markers to leak into the body and hasFrontmatter to be reported as false. For subagent markdown files this even raised "No YAML frontmatter found in file". Make the inner frontmatter capture group optional so an empty block is recognised, returning an empty frontmatter string and a clean body. The only behavioural change is for the previously-unhandled empty-block case; all other inputs split identically. Add frontmatter.spec.ts covering splitFrontmatter (standard, multi-line, CRLF, no-trailing-newline, empty block, blank-line block, embedded ---, no-frontmatter, lone-delimiter) and parseYamlObject. --- source/utils/frontmatter.spec.ts | 95 ++++++++++++++++++++++++++++++++ source/utils/frontmatter.ts | 10 +++- 2 files changed, 102 insertions(+), 3 deletions(-) create mode 100644 source/utils/frontmatter.spec.ts diff --git a/source/utils/frontmatter.spec.ts b/source/utils/frontmatter.spec.ts new file mode 100644 index 00000000..3fad94dc --- /dev/null +++ b/source/utils/frontmatter.spec.ts @@ -0,0 +1,95 @@ +import test from 'ava'; +import {parseYamlObject, splitFrontmatter} from './frontmatter.js'; + +test('splitFrontmatter extracts a standard frontmatter block', t => { + const result = splitFrontmatter('---\ntitle: Hello\n---\nBody content'); + t.true(result.hasFrontmatter); + t.is(result.frontmatter, 'title: Hello'); + t.is(result.body, 'Body content'); +}); + +test('splitFrontmatter handles multi-line frontmatter', t => { + const result = splitFrontmatter('---\na: 1\nb: 2\n---\nline one\nline two'); + t.true(result.hasFrontmatter); + t.is(result.frontmatter, 'a: 1\nb: 2'); + t.is(result.body, 'line one\nline two'); +}); + +test('splitFrontmatter handles CRLF line endings', t => { + const result = splitFrontmatter('---\r\ntitle: Hello\r\n---\r\nBody'); + t.true(result.hasFrontmatter); + t.is(result.frontmatter, 'title: Hello'); + t.is(result.body, 'Body'); +}); + +test('splitFrontmatter handles a closing delimiter with no trailing newline', t => { + const result = splitFrontmatter('---\ntitle: Hello\n---'); + t.true(result.hasFrontmatter); + t.is(result.frontmatter, 'title: Hello'); + t.is(result.body, ''); +}); + +test('splitFrontmatter recognises an empty frontmatter block', t => { + // Regression: `---\n---\n` used to be unrecognised, leaking the literal + // `---` delimiters into the body and reporting hasFrontmatter: false. + const result = splitFrontmatter('---\n---\nBody content'); + t.true(result.hasFrontmatter); + t.is(result.frontmatter, ''); + t.is(result.body, 'Body content'); +}); + +test('splitFrontmatter recognises a blank-line frontmatter block', t => { + const result = splitFrontmatter('---\n\n---\nBody'); + t.true(result.hasFrontmatter); + t.is(result.frontmatter, ''); + t.is(result.body, 'Body'); +}); + +test('splitFrontmatter leaves --- markers inside the body untouched', t => { + const result = splitFrontmatter('---\ntitle: Hello\n---\nbefore\n---\nafter'); + t.true(result.hasFrontmatter); + t.is(result.frontmatter, 'title: Hello'); + t.is(result.body, 'before\n---\nafter'); +}); + +test('splitFrontmatter returns the whole file as body when no frontmatter present', t => { + const content = '# Heading\n\nSome body text'; + const result = splitFrontmatter(content); + t.false(result.hasFrontmatter); + t.is(result.frontmatter, ''); + t.is(result.body, content); +}); + +test('splitFrontmatter does not treat a lone delimiter as frontmatter', t => { + const content = 'just body\n---\nnot frontmatter'; + const result = splitFrontmatter(content); + t.false(result.hasFrontmatter); + t.is(result.body, content); +}); + +test('parseYamlObject parses a valid YAML object', t => { + const parsed = parseYamlObject('title: Hello\ncount: 3'); + t.deepEqual(parsed, {title: 'Hello', count: 3}); +}); + +test('parseYamlObject parses nested objects and arrays', t => { + const parsed = parseYamlObject('tags:\n - a\n - b\nmeta:\n nested: true'); + t.deepEqual(parsed, {tags: ['a', 'b'], meta: {nested: true}}); +}); + +test('parseYamlObject returns an empty object for blank input', t => { + t.deepEqual(parseYamlObject(''), {}); + t.deepEqual(parseYamlObject(' \n '), {}); +}); + +test('parseYamlObject returns null for a scalar value', t => { + t.is(parseYamlObject('just a string'), null); +}); + +test('parseYamlObject returns null for a top-level array', t => { + t.is(parseYamlObject('- one\n- two'), null); +}); + +test('parseYamlObject returns null on invalid YAML', t => { + t.is(parseYamlObject('key: "unterminated'), null); +}); diff --git a/source/utils/frontmatter.ts b/source/utils/frontmatter.ts index b2f55a63..2da13911 100644 --- a/source/utils/frontmatter.ts +++ b/source/utils/frontmatter.ts @@ -20,11 +20,15 @@ export interface FrontmatterSplit { * `hasFrontmatter` is false. */ export function splitFrontmatter(fileContent: string): FrontmatterSplit { - const frontmatterRegex = /^---\s*\r?\n([\s\S]*?)\r?\n---\s*\r?\n?([\s\S]*)$/; + // The frontmatter group is optional (`(?:…)?`) so an empty block — + // `---\n---\n` with no lines between the delimiters — is still recognised + // rather than leaking the literal `---` markers into the body. + const frontmatterRegex = + /^---\s*\r?\n(?:([\s\S]*?)\r?\n)?---\s*\r?\n?([\s\S]*)$/; const match = fileContent.match(frontmatterRegex); - if (match && match[1] !== undefined && match[2] !== undefined) { + if (match && match[2] !== undefined) { return { - frontmatter: match[1], + frontmatter: match[1] ?? '', body: match[2].trim(), hasFrontmatter: true, }; From 17d9e2c410c21ae3b560e9746488afe1ae7665b5 Mon Sep 17 00:00:00 2001 From: Fadhlan Date: Mon, 22 Jun 2026 20:14:45 +0700 Subject: [PATCH 2/2] fix(subagents): use parseYamlObject for empty frontmatter blocks extractRawFrontmatter called parseYaml directly, so an empty frontmatter block produced null and threw 'YAML frontmatter must be an object' before reaching schema validation. Switch to the shared parseYamlObject helper, which returns {} for empty input, so empty frontmatter now flows through to validation and surfaces the clear 'name is required' error instead. Addresses review feedback on PR #592. --- source/subagents/markdown-parser.spec.ts | 37 ++++++++++++++++++++++++ source/subagents/markdown-parser.ts | 17 +++++------ 2 files changed, 44 insertions(+), 10 deletions(-) diff --git a/source/subagents/markdown-parser.spec.ts b/source/subagents/markdown-parser.spec.ts index 0154ee85..9ea0ed8b 100644 --- a/source/subagents/markdown-parser.spec.ts +++ b/source/subagents/markdown-parser.spec.ts @@ -37,6 +37,43 @@ test.serial('extractFrontmatter - throws on missing frontmatter', t => { ); }); +test.serial( + 'extractFrontmatter - empty frontmatter block surfaces a validation error', + t => { + // An empty `---\n---` block should be treated as an empty object so it + // flows through to schema validation, producing a clear "name is + // required" error rather than a misleading "must be an object" parse + // failure. + const content = `--- +--- +You are a subagent without metadata.`; + + t.throws(() => extractFrontmatter(content), { + message: /name is required/, + }); + }, +); + +test.serial( + 'parseSubagentMarkdown - empty frontmatter block surfaces a validation error', + async t => { + const tmpPath = join(tmpdir(), 'empty-frontmatter-agent.md'); + const content = `--- +--- +You are a subagent without metadata.`; + + await writeFile(tmpPath, content, 'utf-8'); + + try { + await t.throwsAsync(() => parseSubagentMarkdown(tmpPath), { + message: /name is required/, + }); + } finally { + await unlink(tmpPath); + } + }, +); + test.serial('extractBody - extracts body content', t => { const content = `--- name: test diff --git a/source/subagents/markdown-parser.ts b/source/subagents/markdown-parser.ts index 9ce6e141..201997d5 100644 --- a/source/subagents/markdown-parser.ts +++ b/source/subagents/markdown-parser.ts @@ -18,10 +18,9 @@ */ import * as fs from 'node:fs/promises'; -import {parse as parseYaml} from 'yaml'; import {parseSubscribeBlock} from '@/skills/parse-subscribe'; import type {SkillTrigger} from '@/types/skills'; -import {splitFrontmatter} from '@/utils/frontmatter'; +import {parseYamlObject, splitFrontmatter} from '@/utils/frontmatter'; import type { ParsedSubagentFile, SubagentConfig, @@ -149,14 +148,12 @@ function extractRawFrontmatter(content: string): Record { throw new Error('No YAML frontmatter found in file'); } - let frontmatter: Record; - try { - frontmatter = parseYaml(raw) as Record; - } catch (error) { - throw new Error(`Failed to parse YAML frontmatter: ${error}`); - } - - if (!frontmatter || typeof frontmatter !== 'object') { + // `parseYamlObject` returns `{}` for an empty frontmatter block, which lets + // empty frontmatter fall through to schema validation and produce a clear + // "name is required" error rather than a misleading parse failure. It + // returns `null` only for genuinely invalid YAML or non-object values. + const frontmatter = parseYamlObject(raw); + if (!frontmatter) { throw new Error('YAML frontmatter must be an object'); }