Skip to content
Merged
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
4 changes: 3 additions & 1 deletion .github/workflows/test-cross-plugin.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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'
Expand All @@ -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/
2 changes: 2 additions & 0 deletions .github/workflows/test-dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@ on:
branches: [main]
paths:
- 'plugins/claude-code-dev-hermit/**'
- 'tests/lib/**'
- '.github/workflows/test-dev.yml'
- 'package.json'
- 'bun.lock'
- 'tsconfig.json'
pull_request:
paths:
- 'plugins/claude-code-dev-hermit/**'
- 'tests/lib/**'
- '.github/workflows/test-dev.yml'
- 'package.json'
- 'bun.lock'
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/test-feed.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@ on:
branches: [main]
paths:
- 'plugins/feed-hermit/**'
- 'tests/lib/**'
- '.github/workflows/test-feed.yml'
- 'package.json'
- 'bun.lock'
- 'tsconfig.json'
pull_request:
paths:
- 'plugins/feed-hermit/**'
- 'tests/lib/**'
- '.github/workflows/test-feed.yml'
- 'package.json'
- 'bun.lock'
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/test-fitness.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@ on:
branches: [main]
paths:
- 'plugins/claude-code-fitness-hermit/**'
- 'tests/lib/**'
- '.github/workflows/test-fitness.yml'
- 'package.json'
- 'bun.lock'
- 'tsconfig.json'
pull_request:
paths:
- 'plugins/claude-code-fitness-hermit/**'
- 'tests/lib/**'
- '.github/workflows/test-fitness.yml'
- 'package.json'
- 'bun.lock'
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/test-forge.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@ on:
branches: [main]
paths:
- 'plugins/laravel-forge-hermit/**'
- 'tests/lib/**'
- '.github/workflows/test-forge.yml'
- 'package.json'
- 'bun.lock'
- 'tsconfig.json'
pull_request:
paths:
- 'plugins/laravel-forge-hermit/**'
- 'tests/lib/**'
- '.github/workflows/test-forge.yml'
- 'package.json'
- 'bun.lock'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
2 changes: 1 addition & 1 deletion plugins/claude-code-dev-hermit/tests/hatch-mode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, '..');
Expand Down
65 changes: 8 additions & 57 deletions plugins/claude-code-dev-hermit/tests/skill-structure.test.ts
Original file line number Diff line number Diff line change
@@ -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).
Expand All @@ -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/<file> reference must resolve to a script
// this plugin actually ships. Installed plugins cannot reach outside their own
Expand Down
31 changes: 0 additions & 31 deletions plugins/claude-code-dev-hermit/tests/test-utils.ts

This file was deleted.

64 changes: 7 additions & 57 deletions plugins/claude-code-fitness-hermit/tests/skill-structure.test.ts
Original file line number Diff line number Diff line change
@@ -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).
Expand All @@ -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
Expand Down
31 changes: 0 additions & 31 deletions plugins/claude-code-fitness-hermit/tests/test-utils.ts

This file was deleted.

37 changes: 7 additions & 30 deletions plugins/feed-hermit/tests/skill-structure.test.ts
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -14,39 +15,15 @@ const SKILLS = [
"source-health",
"story-arcs",
"deep-dive",
];
].map((name) => ({ name, gates: 0 }));

function frontmatter(md: string): Record<string, string> {
const m = md.match(/^---\n([\s\S]*?)\n---/);
if (!m) return {};
const out: Record<string, string> = {};
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");
Expand Down
Loading
Loading