From 0877b693ced341cec699ea959dc39f8bd78f33ef Mon Sep 17 00:00:00 2001 From: Trevin Chow Date: Thu, 26 Mar 2026 14:07:28 -0700 Subject: [PATCH] fix: add strict YAML validation for plugin frontmatter (#399) --- plugins/compound-engineering/AGENTS.md | 1 + src/parsers/claude-home.ts | 4 +- src/parsers/claude.ts | 6 +-- src/utils/frontmatter.ts | 14 +++++-- tests/frontmatter.test.ts | 57 ++++++++++++++++++++++++++ 5 files changed, 73 insertions(+), 9 deletions(-) diff --git a/plugins/compound-engineering/AGENTS.md b/plugins/compound-engineering/AGENTS.md index 96c24f6..fe6d804 100644 --- a/plugins/compound-engineering/AGENTS.md +++ b/plugins/compound-engineering/AGENTS.md @@ -67,6 +67,7 @@ When adding or modifying skills, verify compliance with the skill spec: - [ ] `name:` present and matches directory name (lowercase-with-hyphens) - [ ] `description:` present and describes **what it does and when to use it** (per official spec: "Explains code with diagrams. Use when exploring how code works.") +- [ ] `description:` value is quoted (single or double) if it contains colons -- unquoted colons break `js-yaml` strict parsing and crash `install --to opencode/codex`. Run `bun test tests/frontmatter.test.ts` to verify. ### Reference File Inclusion (Required if references/ exists) diff --git a/src/parsers/claude-home.ts b/src/parsers/claude-home.ts index 5731875..3433a15 100644 --- a/src/parsers/claude-home.ts +++ b/src/parsers/claude-home.ts @@ -44,7 +44,7 @@ async function loadPersonalSkills(skillsDir: string): Promise { let data: Record = {} try { const raw = await fs.readFile(skillPath, "utf8") - data = parseFrontmatter(raw).data + data = parseFrontmatter(raw, skillPath).data } catch { // Keep syncing the skill even if frontmatter is malformed. } @@ -87,7 +87,7 @@ async function loadPersonalCommands(commandsDir: string): Promise { const agents: ClaudeAgent[] = [] for (const file of files) { const raw = await readText(file) - const { data, body } = parseFrontmatter(raw) + const { data, body } = parseFrontmatter(raw, file) const name = (data.name as string) ?? path.basename(file, ".md") agents.push({ name, @@ -80,7 +80,7 @@ async function loadCommands(commandsDirs: string[]): Promise { const commands: ClaudeCommand[] = [] for (const file of files) { const raw = await readText(file) - const { data, body } = parseFrontmatter(raw) + const { data, body } = parseFrontmatter(raw, file) const name = (data.name as string) ?? path.basename(file, ".md") const allowedTools = parseAllowedTools(data["allowed-tools"]) const disableModelInvocation = data["disable-model-invocation"] === true ? true : undefined @@ -104,7 +104,7 @@ async function loadSkills(skillsDirs: string[]): Promise { const skills: ClaudeSkill[] = [] for (const file of skillFiles) { const raw = await readText(file) - const { data } = parseFrontmatter(raw) + const { data } = parseFrontmatter(raw, file) const name = (data.name as string) ?? path.basename(path.dirname(file)) const disableModelInvocation = data["disable-model-invocation"] === true ? true : undefined skills.push({ diff --git a/src/utils/frontmatter.ts b/src/utils/frontmatter.ts index dfe85bf..b824631 100644 --- a/src/utils/frontmatter.ts +++ b/src/utils/frontmatter.ts @@ -5,7 +5,7 @@ export type FrontmatterResult = { body: string } -export function parseFrontmatter(raw: string): FrontmatterResult { +export function parseFrontmatter(raw: string, sourcePath?: string): FrontmatterResult { const lines = raw.split(/\r?\n/) if (lines.length === 0 || lines[0].trim() !== "---") { return { data: {}, body: raw } @@ -25,9 +25,15 @@ export function parseFrontmatter(raw: string): FrontmatterResult { const yamlText = lines.slice(1, endIndex).join("\n") const body = lines.slice(endIndex + 1).join("\n") - const parsed = load(yamlText) - const data = (parsed && typeof parsed === "object") ? (parsed as Record) : {} - return { data, body } + try { + const parsed = load(yamlText) + const data = (parsed && typeof parsed === "object") ? (parsed as Record) : {} + return { data, body } + } catch (err) { + const location = sourcePath ? ` in ${sourcePath}` : "" + const hint = "Tip: quote frontmatter values containing colons (e.g. description: 'Use for X: Y')" + throw new Error(`Invalid YAML frontmatter${location}: ${err instanceof Error ? err.message : err}\n${hint}`) + } } export function formatFrontmatter(data: Record, body: string): string { diff --git a/tests/frontmatter.test.ts b/tests/frontmatter.test.ts index 33b9f0d..854ca39 100644 --- a/tests/frontmatter.test.ts +++ b/tests/frontmatter.test.ts @@ -1,4 +1,7 @@ +import { readdirSync, readFileSync, statSync } from "fs" +import path from "path" import { describe, expect, test } from "bun:test" +import { load } from "js-yaml" import { formatFrontmatter, parseFrontmatter } from "../src/utils/frontmatter" describe("frontmatter", () => { @@ -17,4 +20,58 @@ describe("frontmatter", () => { expect(parsed.data.description).toBe("Test") expect(parsed.body.trim()).toBe(body) }) + +}) + +/** + * Collect all markdown files with YAML frontmatter from a plugin directory. + * Returns [relativePath, yamlText] pairs for each file with a frontmatter block. + */ +function collectFrontmatterFiles(pluginRoot: string): [string, string][] { + const results: [string, string][] = [] + + function walk(dir: string) { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name) + if (entry.isDirectory()) { + if (entry.name === "node_modules" || entry.name === ".git") continue + walk(full) + continue + } + if (!entry.name.endsWith(".md")) continue + const raw = readFileSync(full, "utf8") + const lines = raw.split(/\r?\n/) + if (lines[0]?.trim() !== "---") continue + let end = -1 + for (let i = 1; i < lines.length; i++) { + if (lines[i].trim() === "---") { end = i; break } + } + if (end === -1) continue + const yaml = lines.slice(1, end).join("\n") + const rel = path.relative(pluginRoot, full) + results.push([rel, yaml]) + } + } + + walk(pluginRoot) + return results +} + +describe("frontmatter YAML validity", () => { + const pluginRoots = [ + "plugins/compound-engineering", + "plugins/coding-tutor", + ] + + for (const pluginRoot of pluginRoots) { + const root = path.join(process.cwd(), pluginRoot) + try { statSync(root) } catch { continue } + const files = collectFrontmatterFiles(root) + + for (const [rel, yaml] of files) { + test(`${pluginRoot}/${rel} has valid strict YAML frontmatter`, () => { + expect(() => load(yaml)).not.toThrow() + }) + } + } })