Round 7 (516dffa..45aa9e1, WARN: 12 MAJOR, 0 BLOCKER) and the fence defect
found while writing its review.md. Each fix began with a test that failed on
0f400d3 (v6.3.0); suite 2208 (2206/0/2), 14 tests more.
- Tests-only (operator 2026-10-11): the plan's Files: alone decides, and the
text says so (red-proof, trekexecute Sub-step B, step-implementer,
HANDOVER-CONTRACTS). red-proof no longer reads the step's diff, so a change
already in the tree when the step began no longer counts against it, and a
plan placed outside .claude/ reads the same. The round-6 test that had the
diff overrule the plan is rewritten to the new rule.
- The Files: reader keeps a plain file beside a backticked path
(", Dockerfile", "the CLI entry src/cli.mjs", "and Makefile"). Measured on
the 520 Files: fields in 182 plans: no field reads differently.
- Red proof reports a command its timeout killed as killed after the timeout,
not "could not start"; the release check tells a timeout from output past
its buffer and from another signal. One reading of a stop
(criteria-runner stoppedBy) for red proof, the criteria runner and the
release check.
- A trailing `# comment` is dropped in a backticked check too, and in a
command passed as written, so the words screened are the words run.
- The release check reads a relative --review in --repo.
- The 15-minute timeout is defined once (criteria-runner.mjs).
- The findings check reads each cited file once per validation.
- One isMain helper (lib/util/is-main.mjs) for every CLI: the 31 copies of
the realpath guard and the 20 modules still on the file:// guard, which
exited 0 having done nothing under a symlink or a path with a space.
- The Bash guard writes its no-one-to-ask refusal once, on a direct condition.
- The findings check ends a ```json block only at a fence on its own line, so
three backticks inside a JSON string no longer end it (also in 6.2.0).
- Brief-conformance notes: a test for test-lock green on exit 127 (it
passed at once; it fails when 127 is read as could-not-run); the prose now
names the git revision `@~1` as the one leading-@ exception.
Not changed: the example plan's steps 1-2 keep their tests-only warning
(operator decision 2026-10-09; the r5 test records it). Brief note 4 (a
Regression: whose runner is not installed goes to the fix rounds) is a
routing question for the operator.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
278 lines
14 KiB
JavaScript
278 lines
14 KiB
JavaScript
#!/usr/bin/env node
|
|
// lib/skills/skill-lint.mjs
|
|
// The skill form check (contract S1). Model-free; `npm test` runs it over
|
|
// every skills/<name>/SKILL.md through tests/lib/skill-lint.test.mjs.
|
|
//
|
|
// It follows Claude Code's own skill fields (code.claude.com/docs/en/skills.md,
|
|
// "Frontmatter reference"), not the narrower portable spec: Voyage needs
|
|
// fields such as `user-invocable` that the portable spec rejects.
|
|
//
|
|
// Rules (error codes):
|
|
// SKILL_NO_FRONTMATTER no leading --- block
|
|
// SKILL_YAML_HAZARD a line real YAML would misread (unquoted ": ",
|
|
// " #", tab, unclosed quote, leading indicator)
|
|
// SKILL_UNKNOWN_FIELD a field Claude Code does not know (it would be
|
|
// ignored silently)
|
|
// SKILL_NAME_MISMATCH / _FORM name = directory name; kebab-case, <= 64,
|
|
// no "anthropic" / "claude"
|
|
// SKILL_DESC_MISSING / _TOO_LONG description present, <= 1 024 characters
|
|
// SKILL_DESC_NO_USE_WHEN the description says "Use when ..."
|
|
// SKILL_DESC_NOT_THIRD_PERSON no I / we / you in the description
|
|
// SKILL_MODEL_INVOCATION_DISABLED disable-model-invocation set true: the skill
|
|
// could not be preloaded into a helper nor
|
|
// called by a command ("commands are the trunk")
|
|
// SKILL_TOO_LONG SKILL.md is 500 lines or more
|
|
// SKILL_MISSING_HEADING / _ORDER the fixed headings, in order
|
|
// SKILL_ENFORCED_BY_VAGUE each "Enforced by" bullet names a check
|
|
// (a backticked path) or says "prose only"
|
|
// SKILL_REF_MISSING a linked or backticked file does not exist
|
|
// SKILL_SHELL_INJECTION !`cmd` or a ```! block: Voyage advises users
|
|
// to switch skill shell execution off
|
|
// SKILL_SELF_EXEMPTION a skill that exempts itself
|
|
//
|
|
// Exemptions live in EXEMPTIONS below, with a reason, never in a skill.
|
|
//
|
|
// CLI: node lib/skills/skill-lint.mjs [--root <plugin root>] [--json]
|
|
// exit 0 = every skill passes · 1 = findings · 2 = could not run
|
|
//
|
|
// Adapted from addyosmani/agent-skills @9d0c60d, scripts/lib/skill-lint.js
|
|
// (rules, held-in-the-linter exemptions, fenced-code stripping) and
|
|
// docs/skill-anatomy.md - MIT, Copyright (c) 2025 Addy Osmani. Reimplemented,
|
|
// no code copied.
|
|
|
|
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
import { dirname, join, resolve } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { readFrontmatterFields, splitFile } from './skill-frontmatter.mjs';
|
|
import { isMain } from '../util/is-main.mjs';
|
|
|
|
/** Claude Code's skill frontmatter fields (skills.md, Frontmatter reference). */
|
|
export const KNOWN_FIELDS = new Set([
|
|
'name', 'description', 'when_to_use', 'argument-hint', 'arguments',
|
|
'disable-model-invocation', 'user-invocable', 'allowed-tools', 'disallowed-tools',
|
|
'model', 'effort', 'context', 'agent', 'background', 'hooks', 'paths', 'shell',
|
|
'metadata', 'license', 'compatibility',
|
|
]);
|
|
|
|
/** The fixed headings of a Voyage skill (S1), in this order. */
|
|
export const REQUIRED_HEADINGS = ['Overview', 'When to use', 'Process', 'Red flags', 'Enforced by', 'Credits'];
|
|
|
|
export const MAX_DESCRIPTION = 1024;
|
|
export const MAX_LINES = 500;
|
|
|
|
/**
|
|
* Exemptions, held here and only here: skill name -> { codes, reason }.
|
|
* Empty by design; an entry needs a reason a reviewer can judge.
|
|
* @type {Map<string, {codes: string[], reason: string}>}
|
|
*/
|
|
export const EXEMPTIONS = new Map();
|
|
|
|
const TRUE_WORDS = /^(true|yes|on|1)$/i;
|
|
const FIRST_SECOND_PERSON = /\b(you|your|yours|you're|we|we're|our|ours|us|me|my|mine)\b/i;
|
|
const FIRST_PERSON_I = /(^|[^A-Za-z.])(I|I'm|I'll|I've)(?![A-Za-z.])/;
|
|
const PATH_PREFIXES = ['lib/', 'hooks/', 'agents/', 'commands/', 'templates/', 'docs/', 'skills/', 'scripts/'];
|
|
|
|
function err(code, message, line) { return { code, message, ...(line ? { line } : {}) }; }
|
|
|
|
/** Body lines with fenced code blocks blanked (their headings and links do not count). */
|
|
function unfenced(body) {
|
|
let inFence = false;
|
|
return body.split('\n').map((l) => {
|
|
if (/^\s*(```|~~~)/.test(l)) { inFence = !inFence; return ''; }
|
|
return inFence ? '' : l;
|
|
});
|
|
}
|
|
|
|
/** The lines under one `## heading`, up to the next `## `. */
|
|
function section(lines, heading) {
|
|
const start = lines.findIndex((l) => l.trim() === `## ${heading}`);
|
|
if (start < 0) return null;
|
|
const out = [];
|
|
for (let i = start + 1; i < lines.length && !/^##\s/.test(lines[i]); i++) out.push(lines[i]);
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Lint one skill directory.
|
|
* @param {string} skillDir path to skills/<name>
|
|
* @param {{pluginRoot?: string}} [opts] root that backticked plugin paths resolve against
|
|
* @returns {{ name: string, errors: object[], warnings: object[] }}
|
|
*/
|
|
export function lintSkill(skillDir, opts = {}) {
|
|
const dirName = skillDir.split(/[\\/]/).filter(Boolean).pop();
|
|
const pluginRoot = opts.pluginRoot || resolve(skillDir, '..', '..');
|
|
const file = join(skillDir, 'SKILL.md');
|
|
const errors = [];
|
|
const warnings = [];
|
|
if (!existsSync(file)) return { name: dirName, errors: [err('SKILL_NO_FRONTMATTER', 'SKILL.md is missing')], warnings };
|
|
const text = readFileSync(file, 'utf8');
|
|
|
|
// Frontmatter
|
|
const fm = readFrontmatterFields(text);
|
|
if (fm.error) errors.push(err('SKILL_NO_FRONTMATTER', fm.error, 1));
|
|
for (const h of fm.hazards) errors.push(err('SKILL_YAML_HAZARD', h.message, h.line));
|
|
const f = fm.fields;
|
|
for (const [key, v] of Object.entries(f)) {
|
|
if (!KNOWN_FIELDS.has(key)) errors.push(err('SKILL_UNKNOWN_FIELD', `"${key}" is not a Claude Code skill field; it would be ignored`, v.line));
|
|
}
|
|
|
|
// Name
|
|
const name = f.name ? String(f.name.value) : dirName;
|
|
if (f.name && name !== dirName) errors.push(err('SKILL_NAME_MISMATCH', `name "${name}" must equal the directory name "${dirName}"`, f.name.line));
|
|
if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(name) || name.length > 64 || /anthropic|claude/.test(name)) {
|
|
errors.push(err('SKILL_NAME_FORM', `name "${name}": kebab-case, at most 64 characters, without "anthropic" or "claude"`, f.name && f.name.line));
|
|
}
|
|
|
|
// Description
|
|
const d = f.description;
|
|
const desc = d ? String(Array.isArray(d.value) ? d.value.join(' ') : d.value).trim() : '';
|
|
if (!desc) errors.push(err('SKILL_DESC_MISSING', 'a description is required: it is how Claude decides to use the skill'));
|
|
else {
|
|
if (desc.length > MAX_DESCRIPTION) errors.push(err('SKILL_DESC_TOO_LONG', `description is ${desc.length} characters (max ${MAX_DESCRIPTION})`, d.line));
|
|
if (!/\bUse when\b/.test(desc)) errors.push(err('SKILL_DESC_NO_USE_WHEN', 'the description must say when to use the skill: "Use when ..."', d.line));
|
|
if (FIRST_SECOND_PERSON.test(desc) || FIRST_PERSON_I.test(desc)) {
|
|
errors.push(err('SKILL_DESC_NOT_THIRD_PERSON', 'write the description in the third person (no I / we / you)', d.line));
|
|
}
|
|
}
|
|
|
|
// Invocation: no Voyage skill is closed to the model.
|
|
const dmi = f['disable-model-invocation'];
|
|
if (dmi && TRUE_WORDS.test(String(dmi.value).trim())) {
|
|
errors.push(err('SKILL_MODEL_INVOCATION_DISABLED',
|
|
'disable-model-invocation is true: no helper could preload the skill and no command could call it', dmi.line));
|
|
}
|
|
|
|
// Self-declared exemption (frontmatter or body)
|
|
if (/lint-exempt/i.test(text)) errors.push(err('SKILL_SELF_EXEMPTION', 'a skill may not exempt itself; exemptions live in EXEMPTIONS in lib/skills/skill-lint.mjs'));
|
|
|
|
// Size
|
|
// A file that ends in a newline has no line after it (findings-schema counts the same way).
|
|
const lineCount = text.endsWith('\n') ? text.split('\n').length - 1 : text.split('\n').length;
|
|
if (lineCount >= MAX_LINES) errors.push(err('SKILL_TOO_LONG', `SKILL.md has ${lineCount} lines (must stay under ${MAX_LINES}); move detail to a linked file`));
|
|
|
|
// Body: headings, enforcement, references, shell injection
|
|
const split = splitFile(text);
|
|
const bodyLines = unfenced(split.ok ? split.body : text);
|
|
const headings = bodyLines.filter((l) => /^##\s/.test(l)).map((l) => l.replace(/^##\s+/, '').trim());
|
|
const positions = [];
|
|
for (const h of REQUIRED_HEADINGS) {
|
|
const at = headings.indexOf(h);
|
|
if (at < 0) errors.push(err('SKILL_MISSING_HEADING', `missing "## ${h}"`));
|
|
else positions.push(at);
|
|
}
|
|
if (positions.some((p, i) => i > 0 && p < positions[i - 1])) {
|
|
errors.push(err('SKILL_HEADING_ORDER', `the headings must come in this order: ${REQUIRED_HEADINGS.join(', ')}`));
|
|
}
|
|
|
|
const enforced = section(bodyLines, 'Enforced by');
|
|
if (enforced) {
|
|
const bullets = enforced.filter((l) => /^\s*[-*]\s/.test(l));
|
|
if (bullets.length === 0) errors.push(err('SKILL_ENFORCED_BY_VAGUE', '"Enforced by" lists no check: name one, or say "prose only"'));
|
|
for (const b of bullets) {
|
|
if (!/`[^`]+`/.test(b) && !/prose only/i.test(b)) {
|
|
errors.push(err('SKILL_ENFORCED_BY_VAGUE', `"${b.trim().slice(0, 70)}": name the check (a backticked path) or say "prose only"`));
|
|
}
|
|
}
|
|
}
|
|
|
|
const fullBody = split.ok ? split.body : text;
|
|
if (/!`[^`]+`/.test(fullBody) || /^\s*```!/m.test(fullBody)) {
|
|
errors.push(err('SKILL_SHELL_INJECTION', 'shell injection (!`cmd` or a ```! block) runs on load; Voyage advises users to turn it off, so a skill may not depend on it'));
|
|
}
|
|
|
|
// A credit bullet that names another repository at a commit ("owner/repo
|
|
// @abc1234, `skills/tdd/SKILL.md`") cites a path in THAT repository; its
|
|
// backticked paths are not looked up here. Every other line is.
|
|
const external = new Set();
|
|
for (let i = 0; i < bodyLines.length; i++) {
|
|
if (!/^\s*[-*]\s/.test(bodyLines[i])) continue;
|
|
let j = i + 1;
|
|
while (j < bodyLines.length && /^\s{2,}\S/.test(bodyLines[j]) && !/^\s*[-*]\s/.test(bodyLines[j])) j++;
|
|
if (/[\w.-]+\/[\w.-]+ @[0-9a-f]{7,40}\b/.test(bodyLines.slice(i, j).join(' '))) for (let k = i; k < j; k++) external.add(k);
|
|
}
|
|
const refs = new Set();
|
|
for (const [i, l] of bodyLines.entries()) {
|
|
for (const m of l.matchAll(/\]\(([^)\s#]+)(#[^)]*)?\)/g)) {
|
|
if (!/^[a-z]+:/.test(m[1])) refs.add(JSON.stringify(['skill', m[1]]));
|
|
}
|
|
if (external.has(i)) continue;
|
|
for (const m of l.matchAll(/`([^`\s]+)`/g)) {
|
|
const p = m[1].replace(/^\$\{CLAUDE_PLUGIN_ROOT\}\//, '');
|
|
if (PATH_PREFIXES.some((x) => p.startsWith(x)) && /\.[a-z]+$/.test(p) && !/[{}*<>]/.test(p)) refs.add(JSON.stringify(['plugin', p]));
|
|
}
|
|
}
|
|
for (const r of refs) {
|
|
const [base, p] = JSON.parse(r);
|
|
const abs = base === 'skill' ? join(skillDir, p) : join(pluginRoot, p);
|
|
if (!existsSync(abs)) errors.push(err('SKILL_REF_MISSING', `${p} does not exist (${base === 'skill' ? 'relative to the skill' : 'relative to the plugin root'})`));
|
|
}
|
|
|
|
// Exemptions held in the check
|
|
const ex = EXEMPTIONS.get(name);
|
|
const kept = ex ? errors.filter((e) => !ex.codes.includes(e.code)) : errors;
|
|
return { name, errors: kept, warnings };
|
|
}
|
|
|
|
/** Lint every skills/<name>/ under a plugin root. */
|
|
export function lintSkillsRoot(pluginRoot) {
|
|
const dir = join(pluginRoot, 'skills');
|
|
const skills = {};
|
|
if (!existsSync(dir)) return { skills };
|
|
for (const name of readdirSync(dir).sort()) {
|
|
const p = join(dir, name);
|
|
if (!statSync(p).isDirectory()) continue;
|
|
skills[name] = lintSkill(p, { pluginRoot });
|
|
}
|
|
return { skills };
|
|
}
|
|
|
|
// ---- CLI ----
|
|
|
|
if (isMain(import.meta.url)) {
|
|
const args = process.argv.slice(2);
|
|
const at = args.indexOf('--root');
|
|
const root = resolve(at >= 0 ? args[at + 1] || '' : join(dirname(fileURLToPath(import.meta.url)), '..', '..'));
|
|
const json = args.includes('--json');
|
|
if (!existsSync(root) || !existsSync(join(root, 'skills'))) {
|
|
process.stderr.write(`skill-lint: not measured: no skills/ directory under ${root}\n`);
|
|
process.exit(2);
|
|
}
|
|
const r = lintSkillsRoot(root);
|
|
const bad = Object.values(r.skills).filter((s) => s.errors.length);
|
|
if (json) process.stdout.write(`${JSON.stringify(r, null, 2)}\n`);
|
|
else {
|
|
for (const s of Object.values(r.skills)) {
|
|
process.stdout.write(`${s.errors.length ? 'FAIL' : 'ok '} ${s.name}\n`);
|
|
for (const e of s.errors) process.stdout.write(` ${e.code}${e.line ? ` (line ${e.line})` : ''}: ${e.message}\n`);
|
|
}
|
|
process.stdout.write(`${Object.keys(r.skills).length} skill(s), ${bad.length} with findings\n`);
|
|
}
|
|
process.exit(bad.length ? 1 : 0);
|
|
}
|
|
|
|
|
|
/**
|
|
* Which commands and agents ask for each skill ("the commands are the trunk").
|
|
* Asked for = an agent's `skills:` list holds `voyage:<name>` or `<name>`, or a
|
|
* command or agent file names `voyage:<name>` in its text. A bare word in prose
|
|
* is not a request. Returns { <skill>: ['agents/x.md', 'commands/y.md', ...] }.
|
|
*/
|
|
export function findSkillUsers(pluginRoot) {
|
|
const dir = join(pluginRoot, 'skills');
|
|
const names = existsSync(dir) ? readdirSync(dir).filter((n) => existsSync(join(dir, n, 'SKILL.md'))).sort() : [];
|
|
const users = Object.fromEntries(names.map((n) => [n, []]));
|
|
for (const sub of ['agents', 'commands']) {
|
|
const d = join(pluginRoot, sub);
|
|
if (!existsSync(d)) continue;
|
|
for (const f of readdirSync(d).filter((x) => x.endsWith('.md')).sort()) {
|
|
const text = readFileSync(join(d, f), 'utf8');
|
|
const listed = readFrontmatterFields(text).fields.skills;
|
|
const preload = listed && Array.isArray(listed.value) ? listed.value.map((v) => String(v).replace(/^voyage:/, '')) : [];
|
|
for (const n of names) {
|
|
const named = new RegExp(`(^|[^A-Za-z0-9_-])voyage:${n}(?![A-Za-z0-9_-])`).test(text);
|
|
if (preload.includes(n) || named) users[n].push(`${sub}/${f}`);
|
|
}
|
|
}
|
|
}
|
|
return users;
|
|
}
|