Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions source/subagents/markdown-parser.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 7 additions & 10 deletions source/subagents/markdown-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -149,14 +148,12 @@ function extractRawFrontmatter(content: string): Record<string, unknown> {
throw new Error('No YAML frontmatter found in file');
}

let frontmatter: Record<string, unknown>;
try {
frontmatter = parseYaml(raw) as Record<string, unknown>;
} 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');
}

Expand Down
95 changes: 95 additions & 0 deletions source/utils/frontmatter.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
10 changes: 7 additions & 3 deletions source/utils/frontmatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down