voyage/lib/skills/skill-lint.mjs
Kjell Tore Guttormsen bef9c5b3ac
fix: the findings of the release review of 6.3.0
The review of v6.2.0..f669b87 (BLOCK: 1 BLOCKER, 12 MAJOR, 1 MINOR), each
fixed behind a test that failed first, or, for the two missing tests, a test
shown to fail on a stub of the branch it covers.

- Review steps appended by /trekexecute Phase 7.2 are held to the step rules
  on any plan version: plan-validator --steps-from <N> (on a plan below 1.8 a
  template review step validated and was executed).
- The guard refuses where no one can be asked also when its rules will not
  load; the wave merge path names that refusal instead of MERGE_REFUSED.
  Tests for the classifier-error refusal and post-approval-log's own check.
- release-check: the version files may change only in their version after
  the review (JSON compared without it), README diff lines are read inside
  hunks only, an unreadable diff is exit 2, and npm test runs without
  NODE_TEST_CONTEXT.
- What counts as a step of only tests: a note in parentheses is not a path,
  and a data file under specs/ is not a test. End-to-end: rides on the last
  step that builds behaviour (validator hint, trekplan, template).
- A removed line is cited as <rev>:<path>:<line> (findings-schema hint,
  trekreview re-ask).
- skill-lint counts a trailing newline as no line; regression-check takes
  commandEnv from lib/util/command-env.mjs; CLAUDE.md names four mode-read
  exemptions.
- /security-review, older than v6.2.0: the criteria runner refuses git
  --output, which writes a file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 22:12:12 +02:00

282 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, realpathSync, statSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { readFrontmatterFields, splitFile } from './skill-frontmatter.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 ----
function isMain() {
if (!process.argv[1]) return false;
try { return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(process.argv[1]); } catch { return false; }
}
if (isMain()) {
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;
}