diff --git a/.github/workflows/test-cross-plugin.yml b/.github/workflows/test-cross-plugin.yml index acd74694..3c6eff24 100644 --- a/.github/workflows/test-cross-plugin.yml +++ b/.github/workflows/test-cross-plugin.yml @@ -26,6 +26,7 @@ on: - '!plugins/claude-code-hermit/state-templates/CLAUDE-APPEND.md' - 'plugins/claude-code-hermit/scripts/**' - 'tests/cross-plugin/**' + - 'tests/lib/**' - '.github/workflows/test-cross-plugin.yml' - 'package.json' - 'bun.lock' @@ -41,6 +42,7 @@ on: - '!plugins/claude-code-hermit/state-templates/CLAUDE-APPEND.md' - 'plugins/claude-code-hermit/scripts/**' - 'tests/cross-plugin/**' + - 'tests/lib/**' - '.github/workflows/test-cross-plugin.yml' - 'package.json' - 'bun.lock' @@ -55,4 +57,4 @@ jobs: with: bun-version: '1.3.14' - run: bun install --frozen-lockfile - - run: bun test tests/cross-plugin/ + - run: bun test tests/cross-plugin/ tests/lib/ diff --git a/.github/workflows/test-dev.yml b/.github/workflows/test-dev.yml index e034ec22..3c057446 100644 --- a/.github/workflows/test-dev.yml +++ b/.github/workflows/test-dev.yml @@ -5,6 +5,7 @@ on: branches: [main] paths: - 'plugins/claude-code-dev-hermit/**' + - 'tests/lib/**' - '.github/workflows/test-dev.yml' - 'package.json' - 'bun.lock' @@ -12,6 +13,7 @@ on: pull_request: paths: - 'plugins/claude-code-dev-hermit/**' + - 'tests/lib/**' - '.github/workflows/test-dev.yml' - 'package.json' - 'bun.lock' diff --git a/.github/workflows/test-feed.yml b/.github/workflows/test-feed.yml index a3c859c6..fdf62809 100644 --- a/.github/workflows/test-feed.yml +++ b/.github/workflows/test-feed.yml @@ -5,6 +5,7 @@ on: branches: [main] paths: - 'plugins/feed-hermit/**' + - 'tests/lib/**' - '.github/workflows/test-feed.yml' - 'package.json' - 'bun.lock' @@ -12,6 +13,7 @@ on: pull_request: paths: - 'plugins/feed-hermit/**' + - 'tests/lib/**' - '.github/workflows/test-feed.yml' - 'package.json' - 'bun.lock' diff --git a/.github/workflows/test-fitness.yml b/.github/workflows/test-fitness.yml index a50bc5a4..86fedd18 100644 --- a/.github/workflows/test-fitness.yml +++ b/.github/workflows/test-fitness.yml @@ -5,6 +5,7 @@ on: branches: [main] paths: - 'plugins/claude-code-fitness-hermit/**' + - 'tests/lib/**' - '.github/workflows/test-fitness.yml' - 'package.json' - 'bun.lock' @@ -12,6 +13,7 @@ on: pull_request: paths: - 'plugins/claude-code-fitness-hermit/**' + - 'tests/lib/**' - '.github/workflows/test-fitness.yml' - 'package.json' - 'bun.lock' diff --git a/.github/workflows/test-forge.yml b/.github/workflows/test-forge.yml index e49bc155..a90f5190 100644 --- a/.github/workflows/test-forge.yml +++ b/.github/workflows/test-forge.yml @@ -5,6 +5,7 @@ on: branches: [main] paths: - 'plugins/laravel-forge-hermit/**' + - 'tests/lib/**' - '.github/workflows/test-forge.yml' - 'package.json' - 'bun.lock' @@ -12,6 +13,7 @@ on: pull_request: paths: - 'plugins/laravel-forge-hermit/**' + - 'tests/lib/**' - '.github/workflows/test-forge.yml' - 'package.json' - 'bun.lock' diff --git a/plugins/claude-code-dev-hermit/scripts/render-append.test.ts b/plugins/claude-code-dev-hermit/scripts/render-append.test.ts index ecae1ec1..e3c880b9 100644 --- a/plugins/claude-code-dev-hermit/scripts/render-append.test.ts +++ b/plugins/claude-code-dev-hermit/scripts/render-append.test.ts @@ -8,7 +8,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { render } from './render-append'; -import { makeReporter } from '../tests/test-utils'; +import { makeReporter } from '../../../tests/lib/skill-lint'; const PLUGIN_ROOT = path.join(import.meta.dir, '..'); const TEMPLATE = path.join(PLUGIN_ROOT, 'state-templates', 'CLAUDE-APPEND.md'); diff --git a/plugins/claude-code-dev-hermit/tests/forge-awareness.test.ts b/plugins/claude-code-dev-hermit/tests/forge-awareness.test.ts index f622394d..d7931274 100644 --- a/plugins/claude-code-dev-hermit/tests/forge-awareness.test.ts +++ b/plugins/claude-code-dev-hermit/tests/forge-awareness.test.ts @@ -4,7 +4,7 @@ import fs from 'node:fs'; import path from 'node:path'; -import { makeReporter } from './test-utils'; +import { makeReporter } from '../../../tests/lib/skill-lint'; const PLUGIN_ROOT = path.join(import.meta.dir, '..'); const HATCH_SKILL = path.join(PLUGIN_ROOT, 'skills', 'hatch', 'SKILL.md'); diff --git a/plugins/claude-code-dev-hermit/tests/hatch-mode.test.ts b/plugins/claude-code-dev-hermit/tests/hatch-mode.test.ts index fb11b672..e08f4572 100644 --- a/plugins/claude-code-dev-hermit/tests/hatch-mode.test.ts +++ b/plugins/claude-code-dev-hermit/tests/hatch-mode.test.ts @@ -4,7 +4,7 @@ import fs from 'node:fs'; import path from 'node:path'; -import { makeReporter } from './test-utils'; +import { makeReporter } from '../../../tests/lib/skill-lint'; import { render } from '../scripts/render-append'; const PLUGIN_ROOT = path.join(import.meta.dir, '..'); diff --git a/plugins/claude-code-dev-hermit/tests/skill-structure.test.ts b/plugins/claude-code-dev-hermit/tests/skill-structure.test.ts index 30bae295..c3c218f0 100644 --- a/plugins/claude-code-dev-hermit/tests/skill-structure.test.ts +++ b/plugins/claude-code-dev-hermit/tests/skill-structure.test.ts @@ -1,19 +1,14 @@ // Structural invariants for the new SKILL.md files. // Run with: bun tests/skill-structure.test.ts -// -// What this checks (and what it does NOT): -// ✓ Frontmatter present and parseable; `name` and `description` set. -// ✓ Frontmatter `name` matches the parent directory name. -// ✓ Expected gate count (and Gate 0/Gate N-1 markers visible). -// ✓ Internal markdown links resolve to existing on-disk files. -// We do NOT execute the skill or assert on its prose semantics — that's the -// LLM's job at runtime. This is structural lint only. +// The shared checks live in tests/lib/skill-lint.ts; this file holds the +// expectations and the dev-only script-reference check. import fs from 'node:fs'; import path from 'node:path'; -import { parseFrontmatter, makeReporter } from './test-utils'; +import { makeReporter, lintSkills } from '../../../tests/lib/skill-lint'; -const SKILL_DIR = path.join(import.meta.dir, '..', 'skills'); +const PLUGIN_ROOT = path.join(import.meta.dir, '..'); +const SKILL_DIR = path.join(PLUGIN_ROOT, 'skills'); // Per-skill expectations. Update if a skill's gate count changes. // gates: 0 → skill has no Gate N — section structure (e.g., read-only status skills). @@ -24,53 +19,9 @@ const SKILLS = [ const { ok, summary } = makeReporter(); -for (const { name, gates } of SKILLS) { - console.log(`\n${name}/SKILL.md:`); - const file = path.join(SKILL_DIR, name, 'SKILL.md'); - ok('file exists', fs.existsSync(file), file); - if (!fs.existsSync(file)) continue; - - const text = fs.readFileSync(file, 'utf-8'); - const fm = parseFrontmatter(text); - ok('frontmatter parseable', fm !== null); - if (!fm) continue; - - ok('frontmatter has name', !!fm.fields.name, JSON.stringify(fm.fields)); - ok('frontmatter name matches dir', fm.fields.name === name, `${fm.fields.name} vs ${name}`); - ok('frontmatter has description', !!fm.fields.description && fm.fields.description.length > 20); - - // Count Gate headers in the body. - const gateMatches = fm.body.match(/^### Gate \d+ —/gm) || []; - ok(`expected ${gates} Gate headers`, gateMatches.length === gates, `found ${gateMatches.length}`); - - // First and last gate present — only when the skill is gate-shaped. - if (gates > 0) { - ok('Gate 0 present', /^### Gate 0 —/m.test(fm.body)); - ok(`Gate ${gates - 1} present`, new RegExp(`^### Gate ${gates - 1} —`, 'm').test(fm.body)); - } - - // Internal links: resolve [text](relative/path) and verify the target exists. - // Skip absolute URLs (http://, mailto:) and same-document anchors (#section). - const linkRe = /\[[^\]]+\]\(([^)]+)\)/g; - const skillBaseDir = path.dirname(file); - let linkMatch: RegExpExecArray | null; - let linksChecked = 0; - let linksBad = 0; - while ((linkMatch = linkRe.exec(fm.body)) !== null) { - const target = linkMatch[1]; - if (/^(https?:|mailto:|#)/.test(target)) continue; - // Strip any anchor suffix. - const cleanTarget = target.split('#')[0]; - if (!cleanTarget) continue; - const resolved = path.resolve(skillBaseDir, cleanTarget); - linksChecked += 1; - if (!fs.existsSync(resolved)) { - linksBad += 1; - console.error(` bad link: ${target} → ${resolved}`); - } - } - ok(`internal links resolve (${linksChecked} checked)`, linksBad === 0, `${linksBad} bad`); -} +console.log('\nskill structure:'); +const lintFailures = lintSkills(PLUGIN_ROOT, SKILLS); +ok(`${SKILLS.length} skills pass structural lint`, lintFailures.length === 0, lintFailures.join('; ')); // Every ${CLAUDE_PLUGIN_ROOT}/scripts/ reference must resolve to a script // this plugin actually ships. Installed plugins cannot reach outside their own diff --git a/plugins/claude-code-dev-hermit/tests/test-utils.ts b/plugins/claude-code-dev-hermit/tests/test-utils.ts deleted file mode 100644 index 0d89bd26..00000000 --- a/plugins/claude-code-dev-hermit/tests/test-utils.ts +++ /dev/null @@ -1,31 +0,0 @@ -function parseFrontmatter(text: string) { - const m = text.match(/^---\n([\s\S]*?)\n---\n/); - if (!m) return null; - const fields: Record = {}; - for (const line of m[1].split('\n')) { - const kv = line.match(/^(\w+):\s*(.*)$/); - if (kv) fields[kv[1]] = kv[2].trim(); - } - return { raw: m[1], fields, body: text.slice(m[0].length) }; -} - -function makeReporter() { - let passed = 0; - let failed = 0; - function ok(name: string, cond: boolean, detail?: string) { - if (cond) { - console.log(` ✓ ${name}`); - passed += 1; - } else { - console.error(` ✗ ${name}${detail ? ' — ' + detail : ''}`); - failed += 1; - } - } - function summary(): number { - console.log(`\nResults: ${passed} passed, ${failed} failed`); - return failed; - } - return { ok, summary }; -} - -export { parseFrontmatter, makeReporter }; diff --git a/plugins/claude-code-fitness-hermit/tests/skill-structure.test.ts b/plugins/claude-code-fitness-hermit/tests/skill-structure.test.ts index 52dababe..2cf496d3 100644 --- a/plugins/claude-code-fitness-hermit/tests/skill-structure.test.ts +++ b/plugins/claude-code-fitness-hermit/tests/skill-structure.test.ts @@ -1,19 +1,13 @@ // Structural invariants for SKILL.md files in claude-code-fitness-hermit. // Run with: bun tests/skill-structure.test.ts -// -// What this checks (and what it does NOT): -// ✓ Frontmatter present and parseable; `name` and `description` set. -// ✓ Frontmatter `name` matches the parent directory name. -// ✓ Expected gate count (and Gate 0/Gate N-1 markers visible). -// ✓ Internal markdown links resolve to existing on-disk files. -// We do NOT execute the skill or assert on its prose semantics — that's the -// LLM's job at runtime. This is structural lint only. +// The shared checks live in tests/lib/skill-lint.ts; this file holds the +// expectations and the fitness-only CLAUDE-APPEND trim guard. import fs from 'node:fs'; import path from 'node:path'; -import { parseFrontmatter, makeReporter } from './test-utils'; +import { makeReporter, lintSkills } from '../../../tests/lib/skill-lint'; -const SKILL_DIR = path.join(import.meta.dir, '..', 'skills'); +const PLUGIN_ROOT = path.join(import.meta.dir, '..'); // Per-skill expectations. Update if a skill's gate count changes. // gates: 0 → skill has no Gate N — section structure (e.g., read-only status skills). @@ -29,53 +23,9 @@ const SKILLS = [ const { ok, summary } = makeReporter(); -for (const { name, gates } of SKILLS) { - console.log(`\n${name}/SKILL.md:`); - const file = path.join(SKILL_DIR, name, 'SKILL.md'); - ok('file exists', fs.existsSync(file), file); - if (!fs.existsSync(file)) continue; - - const text = fs.readFileSync(file, 'utf-8'); - const fm = parseFrontmatter(text); - ok('frontmatter parseable', fm !== null); - if (!fm) continue; - - ok('frontmatter has name', !!fm.fields.name, JSON.stringify(fm.fields)); - ok('frontmatter name matches dir', fm.fields.name === name, `${fm.fields.name} vs ${name}`); - ok('frontmatter has description', !!fm.fields.description && fm.fields.description.length > 20); - - // Count Gate headers in the body. - const gateMatches = fm.body.match(/^### Gate \d+ —/gm) || []; - ok(`expected ${gates} Gate headers`, gateMatches.length === gates, `found ${gateMatches.length}`); - - // First and last gate present — only when the skill is gate-shaped. - if (gates > 0) { - ok('Gate 0 present', /^### Gate 0 —/m.test(fm.body)); - ok(`Gate ${gates - 1} present`, new RegExp(`^### Gate ${gates - 1} —`, 'm').test(fm.body)); - } - - // Internal links: resolve [text](relative/path) and verify the target exists. - // Skip absolute URLs (http://, mailto:) and same-document anchors (#section). - const linkRe = /\[[^\]]+\]\(([^)]+)\)/g; - const skillBaseDir = path.dirname(file); - let linkMatch: RegExpExecArray | null; - let linksChecked = 0; - let linksBad = 0; - while ((linkMatch = linkRe.exec(fm.body)) !== null) { - const target = linkMatch[1]; - if (/^(https?:|mailto:|#)/.test(target)) continue; - // Strip any anchor suffix. - const cleanTarget = target.split('#')[0]; - if (!cleanTarget) continue; - const resolved = path.resolve(skillBaseDir, cleanTarget); - linksChecked += 1; - if (!fs.existsSync(resolved)) { - linksBad += 1; - console.error(` bad link: ${target} → ${resolved}`); - } - } - ok(`internal links resolve (${linksChecked} checked)`, linksBad === 0, `${linksBad} bad`); -} +console.log('\nskill structure:'); +const lintFailures = lintSkills(PLUGIN_ROOT, SKILLS); +ok(`${SKILLS.length} skills pass structural lint`, lintFailures.length === 0, lintFailures.join('; ')); // CLAUDE-APPEND token-efficiency trim guard. // The block is re-paid on every session load and subagent dispatch; the skills diff --git a/plugins/claude-code-fitness-hermit/tests/test-utils.ts b/plugins/claude-code-fitness-hermit/tests/test-utils.ts deleted file mode 100644 index 0d89bd26..00000000 --- a/plugins/claude-code-fitness-hermit/tests/test-utils.ts +++ /dev/null @@ -1,31 +0,0 @@ -function parseFrontmatter(text: string) { - const m = text.match(/^---\n([\s\S]*?)\n---\n/); - if (!m) return null; - const fields: Record = {}; - for (const line of m[1].split('\n')) { - const kv = line.match(/^(\w+):\s*(.*)$/); - if (kv) fields[kv[1]] = kv[2].trim(); - } - return { raw: m[1], fields, body: text.slice(m[0].length) }; -} - -function makeReporter() { - let passed = 0; - let failed = 0; - function ok(name: string, cond: boolean, detail?: string) { - if (cond) { - console.log(` ✓ ${name}`); - passed += 1; - } else { - console.error(` ✗ ${name}${detail ? ' — ' + detail : ''}`); - failed += 1; - } - } - function summary(): number { - console.log(`\nResults: ${passed} passed, ${failed} failed`); - return failed; - } - return { ok, summary }; -} - -export { parseFrontmatter, makeReporter }; diff --git a/plugins/feed-hermit/tests/skill-structure.test.ts b/plugins/feed-hermit/tests/skill-structure.test.ts index 9484a3d5..647d1592 100644 --- a/plugins/feed-hermit/tests/skill-structure.test.ts +++ b/plugins/feed-hermit/tests/skill-structure.test.ts @@ -1,10 +1,11 @@ import { test, expect } from "bun:test"; import { readFileSync, existsSync } from "node:fs"; import { join } from "node:path"; +import { parseFrontmatter, lintSkills } from "../../../tests/lib/skill-lint"; const ROOT = join(import.meta.dir, ".."); -// Every skill shipped by this plugin. `name:` frontmatter must equal the dir. +// Every skill shipped by this plugin. None is gate-shaped. const SKILLS = [ "hatch", "feed-brief", @@ -14,39 +15,15 @@ const SKILLS = [ "source-health", "story-arcs", "deep-dive", -]; +].map((name) => ({ name, gates: 0 })); function frontmatter(md: string): Record { - const m = md.match(/^---\n([\s\S]*?)\n---/); - if (!m) return {}; - const out: Record = {}; - const lines = m[1].split("\n"); - for (let i = 0; i < lines.length; i++) { - const kv = lines[i].match(/^([A-Za-z_]+):\s*(.*)$/); - if (!kv) continue; - let value = kv[2].trim(); - // Fold YAML block scalars (`>`, `>-`, `|`, `|-`): collect the following indented lines. - if (/^[|>][+-]?$/.test(value)) { - const folded: string[] = []; - while (i + 1 < lines.length && /^\s+\S/.test(lines[i + 1])) { - folded.push(lines[++i].trim()); - } - value = folded.join(" "); - } - out[kv[1]] = value.replace(/^["']|["']$/g, ""); - } - return out; + return parseFrontmatter(md)?.fields ?? {}; } -for (const name of SKILLS) { - test(`skill ${name} has valid frontmatter`, () => { - const path = join(ROOT, "skills", name, "SKILL.md"); - expect(existsSync(path)).toBe(true); - const fm = frontmatter(readFileSync(path, "utf8")); - expect(fm.name).toBe(name); - expect((fm.description ?? "").length).toBeGreaterThanOrEqual(10); - }); -} +test("every skill passes the shared structural lint", () => { + expect(lintSkills(ROOT, SKILLS)).toEqual([]); +}); test("source-fetcher agent has name/model frontmatter", () => { const path = join(ROOT, "agents", "source-fetcher.md"); diff --git a/plugins/laravel-forge-hermit/tests/skill-structure.test.ts b/plugins/laravel-forge-hermit/tests/skill-structure.test.ts index 6a214bbb..dec87c2f 100644 --- a/plugins/laravel-forge-hermit/tests/skill-structure.test.ts +++ b/plugins/laravel-forge-hermit/tests/skill-structure.test.ts @@ -3,9 +3,9 @@ import fs from 'node:fs'; import path from 'node:path'; -import { parseFrontmatter, makeReporter } from './test-utils'; +import { makeReporter, lintSkills } from '../../../tests/lib/skill-lint'; -const SKILL_DIR = path.join(import.meta.dir, '..', 'skills'); +const PLUGIN_ROOT = path.join(import.meta.dir, '..'); const SKILLS = [ { name: 'hatch', gates: 0 }, @@ -18,49 +18,9 @@ const SKILLS = [ const { ok, summary } = makeReporter(); -for (const { name, gates } of SKILLS) { - console.log(`\n${name}/SKILL.md:`); - const file = path.join(SKILL_DIR, name, 'SKILL.md'); - ok('file exists', fs.existsSync(file), file); - if (!fs.existsSync(file)) continue; - - const text = fs.readFileSync(file, 'utf-8'); - const fm = parseFrontmatter(text); - ok('frontmatter parseable', fm !== null); - if (!fm) continue; - - ok('frontmatter has name', !!fm.fields.name, JSON.stringify(fm.fields)); - ok('frontmatter name matches dir', fm.fields.name === name, `${fm.fields.name} vs ${name}`); - ok('frontmatter has description', !!fm.fields.description && fm.fields.description.length > 20); - - const gateMatches = fm.body.match(/^### Gate \d+ —/gm) || []; - ok(`expected ${gates} Gate headers`, gateMatches.length === gates, `found ${gateMatches.length}`); - - if (gates > 0) { - ok('Gate 0 present', /^### Gate 0 —/m.test(fm.body)); - ok(`Gate ${gates - 1} present`, new RegExp(`^### Gate ${gates - 1} —`, 'm').test(fm.body)); - } - - // Internal links: resolve [text](relative/path) and verify the target exists. - const linkRe = /\[[^\]]+\]\(([^)]+)\)/g; - const skillBaseDir = path.dirname(file); - let linkMatch: RegExpExecArray | null; - let linksChecked = 0; - let linksBad = 0; - while ((linkMatch = linkRe.exec(fm.body)) !== null) { - const target = linkMatch[1]; - if (/^(https?:|mailto:|#)/.test(target)) continue; - const cleanTarget = target.split('#')[0]; - if (!cleanTarget) continue; - const resolved = path.resolve(skillBaseDir, cleanTarget); - linksChecked += 1; - if (!fs.existsSync(resolved)) { - linksBad += 1; - console.error(` bad link: ${target} → ${resolved}`); - } - } - ok(`internal links resolve (${linksChecked} checked)`, linksBad === 0, `${linksBad} bad`); -} +console.log('\nskill structure:'); +const lintFailures = lintSkills(PLUGIN_ROOT, SKILLS); +ok(`${SKILLS.length} skills pass structural lint`, lintFailures.length === 0, lintFailures.join('; ')); // The shared domain-hatch protocol is asserted by the repo-root cross-plugin // contract test; the PHP floor is the one hatch invariant that stays @@ -71,7 +31,7 @@ for (const { name, gates } of SKILLS) { // existence is already asserted by the SKILLS loop above; a missing file here // should throw, not silently skip. console.log('\nhatch/SKILL.md forge-specific floor:'); -const hatchText = fs.readFileSync(path.join(SKILL_DIR, 'hatch', 'SKILL.md'), 'utf-8'); +const hatchText = fs.readFileSync(path.join(PLUGIN_ROOT, 'skills', 'hatch', 'SKILL.md'), 'utf-8'); const composerJson = JSON.parse( fs.readFileSync(path.join(import.meta.dir, '..', 'php', 'composer.json'), 'utf-8'), ); diff --git a/plugins/laravel-forge-hermit/tests/test-utils.ts b/plugins/laravel-forge-hermit/tests/test-utils.ts deleted file mode 100644 index 0d89bd26..00000000 --- a/plugins/laravel-forge-hermit/tests/test-utils.ts +++ /dev/null @@ -1,31 +0,0 @@ -function parseFrontmatter(text: string) { - const m = text.match(/^---\n([\s\S]*?)\n---\n/); - if (!m) return null; - const fields: Record = {}; - for (const line of m[1].split('\n')) { - const kv = line.match(/^(\w+):\s*(.*)$/); - if (kv) fields[kv[1]] = kv[2].trim(); - } - return { raw: m[1], fields, body: text.slice(m[0].length) }; -} - -function makeReporter() { - let passed = 0; - let failed = 0; - function ok(name: string, cond: boolean, detail?: string) { - if (cond) { - console.log(` ✓ ${name}`); - passed += 1; - } else { - console.error(` ✗ ${name}${detail ? ' — ' + detail : ''}`); - failed += 1; - } - } - function summary(): number { - console.log(`\nResults: ${passed} passed, ${failed} failed`); - return failed; - } - return { ok, summary }; -} - -export { parseFrontmatter, makeReporter }; diff --git a/tests/lib/skill-lint.test.ts b/tests/lib/skill-lint.test.ts new file mode 100644 index 00000000..1b7b21c9 --- /dev/null +++ b/tests/lib/skill-lint.test.ts @@ -0,0 +1,96 @@ +import { test, expect, afterAll } from 'bun:test'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { parseFrontmatter, lintSkills } from './skill-lint'; + +// ── parseFrontmatter ──────────────────────────────────────────────────────── + +test('returns null without a frontmatter block', () => { + expect(parseFrontmatter('# just a heading\n')).toBeNull(); +}); + +test('parses plain scalars and strips surrounding quotes', () => { + const fm = parseFrontmatter('---\nname: dev-pr\ntitle: "quoted"\n---\nbody text\n'); + expect(fm?.fields.name).toBe('dev-pr'); + expect(fm?.fields.title).toBe('quoted'); + expect(fm?.body).toBe('body text\n'); +}); + +test('folds YAML block scalars into a single-line value', () => { + // The stranded-parser bug this module exists to fix: three of the four plugin + // lints read `>-` literally and then reported the description as too short. + const md = [ + '---', + 'name: feed-brief', + 'description: >-', + ' Fetches every registered source, scores the items,', + ' and writes the brief.', + 'model: haiku', + '---', + 'body', + '', + ].join('\n'); + const fm = parseFrontmatter(md); + expect(fm?.fields.description).toBe( + 'Fetches every registered source, scores the items, and writes the brief.', + ); + expect(fm?.fields.model).toBe('haiku'); +}); + +test('keeps folding a block scalar across a paragraph break', () => { + const md = ['---', 'description: |', ' First para.', '', ' Second para.', 'model: haiku', '---', 'body', ''].join('\n'); + const fm = parseFrontmatter(md); + expect(fm?.fields.description).toBe('First para. Second para.'); + expect(fm?.fields.model).toBe('haiku'); +}); + +// ── lintSkills ────────────────────────────────────────────────────────────── + +const root = fs.mkdtempSync(path.join(os.tmpdir(), 'skill-lint-')); +afterAll(() => fs.rmSync(root, { recursive: true, force: true })); + +function writeSkill(name: string, body: string) { + const dir = path.join(root, 'skills', name); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'SKILL.md'), body); +} + +writeSkill( + 'good-gated', + '---\nname: good-gated\ndescription: A description comfortably longer than twenty characters.\n---\n' + + '### Gate 0 — first\n### Gate 1 — second\n[ref](SKILL.md)\n', +); +writeSkill( + 'good-flat', + '---\nname: good-flat\ndescription: >-\n A block-scalar description that is also long enough.\n---\nprose\n', +); +writeSkill( + 'broken', + '---\nname: mismatched\ndescription: short\n---\n### Gate 0 — only one\n[dead](nope.md)\n', +); + +test('passes skills that meet every structural expectation', () => { + expect( + lintSkills(root, [ + { name: 'good-gated', gates: 2 }, + { name: 'good-flat', gates: 0 }, + ]), + ).toEqual([]); +}); + +test('reports each structural violation on a broken skill', () => { + const failures = lintSkills(root, [{ name: 'broken', gates: 0 }]); + expect(failures).toEqual([ + 'broken: frontmatter name is mismatched', + 'broken: frontmatter description missing or too short', + 'broken: expected 0 Gate headers, found 1', + expect.stringContaining('broken: bad link nope.md'), + ]); +}); + +test('reports a missing SKILL.md without throwing', () => { + const failures = lintSkills(root, [{ name: 'absent', gates: 0 }]); + expect(failures).toHaveLength(1); + expect(failures[0]).toContain('absent: SKILL.md missing'); +}); diff --git a/tests/lib/skill-lint.ts b/tests/lib/skill-lint.ts new file mode 100644 index 00000000..a663f6de --- /dev/null +++ b/tests/lib/skill-lint.ts @@ -0,0 +1,136 @@ +// Shared structural lint for plugin SKILL.md files. Monorepo dev tooling only — +// never shipped to operators, which is why it may live at the repo root and be +// imported across plugin boundaries (same territory as tests/cross-plugin/). +// +// What lintSkills checks (and what it does NOT): +// ✓ Frontmatter present and parseable; `name` and `description` set. +// ✓ Frontmatter `name` matches the parent directory name. +// ✓ Expected gate count (and Gate 0/Gate N-1 markers visible). +// ✓ Internal markdown links resolve to existing on-disk files. +// We do NOT execute the skill or assert on its prose semantics — that's the +// LLM's job at runtime. This is structural lint only. +// +// lintSkills is pure: it returns failure messages and never prints or exits, so +// both the console-reporter suites (dev/fitness/forge, which run each lint as +// its own `bun` process) and feed's `bun test` suite can consume it. + +import fs from 'node:fs'; +import path from 'node:path'; + +type Frontmatter = { raw: string; fields: Record; body: string }; + +/** + * Parse YAML frontmatter from a markdown string. + * Returns { raw, fields, body }, or null when no frontmatter block is present. + * Block scalars (`>`, `>-`, `|`, `|-`) are folded into a single-line value — + * several shipped skills write their `description:` that way. + */ +function parseFrontmatter(text: string): Frontmatter | null { + const m = text.match(/^---\n([\s\S]*?)\n---\n/); + if (!m) return null; + const fields: Record = {}; + const lines = m[1].split('\n'); + for (let i = 0; i < lines.length; i++) { + const kv = lines[i].match(/^(\w+):\s*(.*)$/); + if (!kv) continue; + let value = kv[2].trim(); + if (/^[|>][+-]?$/.test(value)) { + const folded: string[] = []; + // A blank line is a paragraph break *inside* the block, not its end — stop + // only at the next line that is neither indented nor blank, or the value + // would silently truncate at the first empty line. + while (i + 1 < lines.length && (/^\s+\S/.test(lines[i + 1]) || lines[i + 1].trim() === '')) { + const next = lines[++i].trim(); + if (next) folded.push(next); + } + value = folded.join(' '); + } + fields[kv[1]] = value.replace(/^["']|["']$/g, ''); + } + return { raw: m[1], fields, body: text.slice(m[0].length) }; +} + +function makeReporter() { + let passed = 0; + let failed = 0; + function ok(name: string, cond: boolean, detail?: string) { + if (cond) { + console.log(` ✓ ${name}`); + passed += 1; + } else { + console.error(` ✗ ${name}${detail ? ' — ' + detail : ''}`); + failed += 1; + } + } + function summary(): number { + console.log(`\nResults: ${passed} passed, ${failed} failed`); + return failed; + } + return { ok, summary }; +} + +type SkillExpectation = { + name: string; + /** Number of `### Gate N —` headers. 0 → the skill is not gate-shaped. */ + gates: number; +}; + +/** + * Lint every expected SKILL.md under `/skills/`. + * Returns one message per failed check; an empty array means the lint passed. + */ +function lintSkills(pluginRoot: string, skills: SkillExpectation[]): string[] { + const failures: string[] = []; + const skillDir = path.join(pluginRoot, 'skills'); + + for (const { name, gates } of skills) { + const file = path.join(skillDir, name, 'SKILL.md'); + if (!fs.existsSync(file)) { + failures.push(`${name}: SKILL.md missing at ${file}`); + continue; + } + + const fm = parseFrontmatter(fs.readFileSync(file, 'utf-8')); + if (!fm) { + failures.push(`${name}: frontmatter not parseable`); + continue; + } + + if (!fm.fields.name) failures.push(`${name}: frontmatter has no name`); + else if (fm.fields.name !== name) failures.push(`${name}: frontmatter name is ${fm.fields.name}`); + if (!fm.fields.description || fm.fields.description.length <= 20) { + failures.push(`${name}: frontmatter description missing or too short`); + } + + const gateMatches = fm.body.match(/^### Gate \d+ —/gm) || []; + if (gateMatches.length !== gates) { + failures.push(`${name}: expected ${gates} Gate headers, found ${gateMatches.length}`); + } + if (gates > 0) { + if (!/^### Gate 0 —/m.test(fm.body)) failures.push(`${name}: Gate 0 missing`); + if (!new RegExp(`^### Gate ${gates - 1} —`, 'm').test(fm.body)) { + failures.push(`${name}: Gate ${gates - 1} missing`); + } + } + + // Internal links: resolve [text](relative/path) and verify the target exists. + // Skip absolute URLs (http://, mailto:) and same-document anchors (#section). + const linkRe = /\[[^\]]+\]\(([^)]+)\)/g; + const skillBaseDir = path.dirname(file); + let linkMatch: RegExpExecArray | null; + while ((linkMatch = linkRe.exec(fm.body)) !== null) { + const target = linkMatch[1]; + if (/^(https?:|mailto:|#)/.test(target)) continue; + // Strip any anchor suffix. + const cleanTarget = target.split('#')[0]; + if (!cleanTarget) continue; + const resolved = path.resolve(skillBaseDir, cleanTarget); + if (!fs.existsSync(resolved)) failures.push(`${name}: bad link ${target} → ${resolved}`); + } + } + + return failures; +} + +export { parseFrontmatter, makeReporter, lintSkills }; +export type { Frontmatter, SkillExpectation }; diff --git a/tsconfig.json b/tsconfig.json index 404cfb52..688af5b1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,5 +14,5 @@ "skipLibCheck": true, "forceConsistentCasingInFileNames": true }, - "include": ["plugins/**/*.ts"] + "include": ["plugins/**/*.ts", "tests/**/*.ts"] }