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'); } 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, };