fix(research): expand skill-hub mode list and parse block-scalar descriptions

Frontmatter validation in the research skill-hub starter now mirrors the
shapes already used by `skills/*/SKILL.md`:

- `ALLOWED_OD_MODES` adds `image`, `video`, `audio`, and `utility` so
  media and utility skills (e.g. `image-poster`, `video-shortform`,
  `audio-jingle`, `pptx-html-fidelity-audit`) are no longer rejected by a
  hub mirroring the catalog.
- The minimal YAML parser now reads `description: |`, `description: |-`,
  `description: >`, and `description: >-` block scalars instead of
  storing the literal `|` / `>`. Continuation lines are joined with `\n`
  and right-trimmed, matching `apps/daemon/src/frontmatter.ts`.
- Adds `apps/daemon/tests/research-skill-hub-frontmatter.test.ts` with
  fixture coverage against representative live skills for every
  currently-used `od.mode` value plus inline cases for `>` / `|-`.

Generated-By: looper 0.4.0 (runner=fixer, agent=claude-code)
This commit is contained in:
pftom
2026-05-06 13:17:12 +08:00
parent c706866068
commit 02cd4c61e7
2 changed files with 224 additions and 18 deletions
+109 -18
View File
@@ -246,13 +246,34 @@ async function fetchRawCapped(url: string, token?: string): Promise<string | nul
const FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
const NAME_PATTERN = /^[a-z0-9][a-z0-9._-]{0,127}$/i;
const SLUG_PATTERN = /^[a-z0-9][a-z0-9-]{0,127}$/;
const ALLOWED_OD_MODES = new Set(['prototype', 'deck', 'template', 'design-system']);
// Values observed across the 65+ skills currently shipped in `skills/*/SKILL.md`
// plus the four documented in `docs/skills-protocol.md`. Keep this list aligned
// with reality so a hub mirroring the catalog does not reject media or utility
// skills (PR #617 review). The integration PR should replace this with a
// shared schema export from `apps/daemon/src/skills.ts`.
const ALLOWED_OD_MODES = new Set([
'prototype',
'deck',
'template',
'design-system',
'image',
'video',
'audio',
'utility',
]);
/**
* Minimal frontmatter parser, intentionally a subset of
* `apps/daemon/src/frontmatter.ts`. Supports the fields we actually validate
* here (top-level scalars and the `od.mode` scalar). Anything more complex is
* left as raw text for the integration PR to swap in the daemon parser.
* `apps/daemon/src/frontmatter.ts`. Supports top-level scalars, the `od:`
* nested-scalar block, and YAML block scalars (`|`, `|-`, `>`, `>-`) for
* `description` — which most existing `SKILL.md` files use. Anything more
* complex is left as raw text for the integration PR to swap in the daemon
* parser.
*
* Block scalars are joined with `\n` and right-trimmed, matching what
* `apps/daemon/src/frontmatter.ts` does today. This sacrifices strict YAML
* folding behavior for `>` in exchange for matching how the daemon already
* reads the same files; consistency with the live catalog is the point.
*/
export function parseFrontmatter(raw: string): Record<string, unknown> {
const text = raw.replace(/^/, '');
@@ -261,42 +282,112 @@ export function parseFrontmatter(raw: string): Record<string, unknown> {
const yaml = match[1];
if (!yaml) return {};
const out: Record<string, unknown> = {};
let inOdBlock = false;
const od: Record<string, unknown> = {};
for (const rawLine of yaml.split(/\r?\n/)) {
if (!rawLine.trim() || rawLine.trim().startsWith('#')) continue;
const lines = yaml.split(/\r?\n/);
let i = 0;
let inOdBlock = false;
while (i < lines.length) {
const rawLine = lines[i] ?? '';
if (!rawLine.trim() || rawLine.trim().startsWith('#')) {
i++;
continue;
}
const indent = rawLine.match(/^\s*/)?.[0].length ?? 0;
const line = rawLine.slice(indent);
if (indent === 0) {
if (line === 'od:' || line.startsWith('od:')) {
inOdBlock = true;
const inline = line.slice(3).trim();
if (inline) {
inOdBlock = false;
// od: <scalar> is not a documented shape; ignore.
}
inOdBlock = line === 'od:';
i++;
continue;
}
inOdBlock = false;
const kv = /^([A-Za-z_][A-Za-z0-9_-]*)\s*:\s*(.*)$/.exec(line);
if (!kv) continue;
if (!kv) {
i++;
continue;
}
const key = kv[1];
const val = (kv[2] ?? '').trim();
if (!key) continue;
if (!key) {
i++;
continue;
}
if (isBlockScalar(val)) {
const block = readBlockScalar(lines, i + 1, indent);
out[key] = block.value;
i = block.next;
continue;
}
out[key] = stripQuotes(val);
} else if (inOdBlock && indent === 2) {
i++;
continue;
}
if (inOdBlock && indent === 2) {
const kv = /^([A-Za-z_][A-Za-z0-9_-]*)\s*:\s*(.*)$/.exec(line);
if (!kv) continue;
if (!kv) {
i++;
continue;
}
const key = kv[1];
const val = (kv[2] ?? '').trim();
if (!key) continue;
if (!key) {
i++;
continue;
}
if (isBlockScalar(val)) {
const block = readBlockScalar(lines, i + 1, indent);
od[key] = block.value;
i = block.next;
continue;
}
od[key] = stripQuotes(val);
i++;
continue;
}
i++;
}
if (Object.keys(od).length > 0) out['od'] = od;
return out;
}
function isBlockScalar(val: string): boolean {
return (
val === '|'
|| val === '|-'
|| val === '|+'
|| val === '>'
|| val === '>-'
|| val === '>+'
);
}
function readBlockScalar(
lines: string[],
start: number,
parentIndent: number,
): { value: string; next: number } {
const collected: string[] = [];
const childIndent = parentIndent + 2;
let i = start;
while (i < lines.length) {
const next = lines[i] ?? '';
if (next.trim() === '') {
collected.push('');
i++;
continue;
}
const nIndent = next.match(/^\s*/)?.[0].length ?? 0;
if (nIndent < childIndent) break;
collected.push(next.slice(childIndent));
i++;
}
return { value: collected.join('\n').trimEnd(), next: i };
}
function stripQuotes(s: string): string {
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
return s.slice(1, -1);
@@ -0,0 +1,115 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import {
parseFrontmatter,
resolveManifest,
validateFrontmatter,
} from '../src/research/skill-hub.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const repoRoot = path.resolve(__dirname, '../../..');
function readSkill(slug: string): string {
return fs.readFileSync(path.join(repoRoot, 'skills', slug, 'SKILL.md'), 'utf8');
}
describe('research/skill-hub frontmatter parser', () => {
it('reads `description: |` block scalars instead of storing the literal "|"', () => {
const fm = parseFrontmatter(readSkill('image-poster'));
expect(typeof fm['description']).toBe('string');
expect(fm['description']).not.toBe('|');
expect(fm['description']).not.toBe('');
expect(String(fm['description'])).toContain('Single-image generation');
expect(String(fm['description'])).toContain('PNG/JPEG');
});
it('reads `description: |` block scalars on the audio-jingle fixture', () => {
const fm = parseFrontmatter(readSkill('audio-jingle'));
expect(String(fm['description'])).toContain('Audio generation skill');
expect(String(fm['description'])).toContain('MP3/WAV');
});
it('reads `description: |` block scalars on the video-shortform fixture', () => {
const fm = parseFrontmatter(readSkill('video-shortform'));
expect(String(fm['description'])).toContain('Short-form video generation');
});
it('reads `description: >` folded scalars without storing the literal ">"', () => {
const raw = [
'---',
'name: demo',
'description: >',
' First line.',
' Second line.',
'---',
'',
'# body',
].join('\n');
const fm = parseFrontmatter(raw);
expect(fm['description']).not.toBe('>');
expect(String(fm['description'])).toContain('First line.');
expect(String(fm['description'])).toContain('Second line.');
});
it('reads `description: |-` chomping marker as a non-literal value', () => {
const raw = [
'---',
'name: demo',
'description: |-',
' First line.',
' Second line.',
'---',
'',
].join('\n');
const fm = parseFrontmatter(raw);
expect(fm['description']).not.toBe('|-');
expect(String(fm['description'])).toBe('First line.\nSecond line.');
});
it('parses `od.mode` from the nested `od:` block', () => {
const fm = parseFrontmatter(readSkill('image-poster'));
expect(fm['od']).toBeTypeOf('object');
expect((fm['od'] as Record<string, unknown>)['mode']).toBe('image');
});
});
describe('research/skill-hub validateFrontmatter mode coverage', () => {
// Canonical modes plus media/utility modes already in `skills/*/SKILL.md`.
const cases: Array<{ slug: string; mode: string }> = [
{ slug: 'image-poster', mode: 'image' },
{ slug: 'video-shortform', mode: 'video' },
{ slug: 'audio-jingle', mode: 'audio' },
{ slug: 'pptx-html-fidelity-audit', mode: 'utility' },
{ slug: 'live-artifact', mode: 'prototype' },
{ slug: 'simple-deck', mode: 'deck' },
{ slug: 'design-brief', mode: 'design-system' },
];
for (const { slug, mode } of cases) {
it(`accepts od.mode=${mode} from skills/${slug}`, () => {
const fm = parseFrontmatter(readSkill(slug));
expect((fm['od'] as Record<string, unknown> | undefined)?.['mode']).toBe(mode);
expect(validateFrontmatter('skills', fm, slug)).toBe(true);
expect(resolveManifest('skills', readSkill(slug), slug)).toBeTruthy();
});
}
it('still rejects an unknown od.mode value', () => {
const fm = parseFrontmatter(
[
'---',
'name: bogus-skill',
'description: A skill with a made-up mode.',
'od:',
' mode: not-a-real-mode',
'---',
].join('\n'),
);
expect(validateFrontmatter('skills', fm, 'bogus-skill')).toBe(false);
});
});