mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user