Files
reverse-skills-max/vendor-new/rev-skills/validate.mjs

253 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env node
// validate.mjs — 技能库结构校验器。npm test 与 CI 入口。
import { readdirSync, readFileSync, existsSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { parseFrontmatterDocument } from './lib/frontmatter.mjs';
import { collectDeclaredCapabilities, collectRegistryTags, checkCapabilityIndex } from './lib/capability-index.mjs';
export const SKILL_PREFIX = 're-';
const HERE = dirname(fileURLToPath(import.meta.url));
// 触发词双语约定(AGENTS.md):description 须同时含 CJK 与拉丁字母,中英环境都能命中
const CJK_RE = /[㐀-䶿一-鿿぀-ヿ가-힯]/;
const LATIN_RE = /[A-Za-z]/;
export function parseFrontmatter(md) {
const { fm, body } = parseFrontmatterDocument(md);
if (!fm.name) throw new Error('frontmatter missing name');
if (!fm.description) throw new Error('frontmatter missing description');
// capabilities: [a, b] YAML list → 数组;无字段 → undefined
if (fm.capabilities !== undefined) {
const m2 = fm.capabilities.match(/^\[\s*([a-z0-9-]+(?:\s*,\s*[a-z0-9-]+)*)\s*\]$/);
fm.capabilities = m2 ? m2[1].split(',').map(s => s.trim()) : null; // null = 非法写法
}
// guard: {"require_authorization": true, "forbidden": ["tag1", "tag2"]} — 机器可读安全前置声明(JSON 格式)
if (fm.guard !== undefined) {
try { fm.guard = JSON.parse(fm.guard); }
catch { fm.guard = null; } // null = 非法 JSON
}
return { name: fm.name, description: fm.description, type: fm.type, capabilities: fm.capabilities, guard: fm.guard, body };
}
// 从能力注册表(re-analyze/references/capabilities.md)解析合法标签清单
export function collectCapabilities(root) {
return new Set(collectRegistryTags(root).keys());
}
// 路由一致性:triage/rerouting 的能力列必须在注册表内,且该行提到的技能里至少有一个真的声明了它
// (防路由与声明漂移——改了技能的 capabilities 却忘了改路由表,或反之)
export function checkRoutingCapabilities(root) {
const errors = [];
const registry = collectCapabilities(root);
const declared = collectDeclaredCapabilities(root); // tag → [技能名]
for (const rel of ['re-analyze/references/triage.md', 're-analyze/references/rerouting.md']) {
const p = join(root, rel);
if (!existsSync(p)) continue;
let capCol = -1;
const lines = readFileSync(p, 'utf8').split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (!line.trim().startsWith('|')) { capCol = -1; continue; }
const cells = line.split('|').map(s => s.trim());
if (capCol === -1) {
// 表头:定位「需要能力」列;分隔行不参与
const idx = cells.findIndex(c => c.includes('需要能力'));
if (idx >= 0) capCol = idx;
continue;
}
const tags = [...(cells[capCol] ?? '').matchAll(/`([a-z0-9-]+)`/g)].map(m => m[1]);
if (!tags.length) continue;
const skills = [...line.matchAll(/\bre-[a-z0-9-]+\b/g)].map(m => m[0]);
for (const t of tags) {
if (!registry.has(t)) {
errors.push(`${rel}:${i + 1} 路由引用了未注册的能力 '${t}'`);
} else if (!skills.some(s => (declared.get(t) ?? []).includes(s))) {
errors.push(`${rel}:${i + 1} 路由标称能力 '${t}',但本行技能(${skills.join(' / ') || '未列技能'})均未声明该能力`);
}
}
}
}
return errors;
}
// 能力标注一致性(网关选择树/工作流):① 选择树内的技能链接必须带能力标注
// ② 标注的能力必须在注册表内,且被被标注的技能真的声明(防"标注说会 X、技能其实不会 X")
export function checkCapabilityAnnotations(root) {
const errors = [];
const registry = collectCapabilities(root);
const skillCaps = new Map();
for (const dir of readdirSync(root, { withFileTypes: true })) {
if (!dir.isDirectory() || !dir.name.startsWith(SKILL_PREFIX)) continue;
const p = join(root, dir.name, 'SKILL.md');
if (!existsSync(p)) continue;
try {
const fm = parseFrontmatter(readFileSync(p, 'utf8'));
skillCaps.set(dir.name, { caps: fm.capabilities ?? [], body: fm.body });
} catch { /* 单技能错误由 checkSkillDir 报出 */ }
}
for (const [name, { body }] of skillCaps) {
// ① 标注内容:显式前缀「能力:」+ 连续标签段——括号内可再嵌 [[子技能]](能力:`tag`),不归属外层;
// 无前缀的括号是普通说明(如 [[re-x64dbg]](`minidump` 命令)),不参与能力校验
for (const m of body.matchAll(/\[\[([a-z0-9-]+)\]\](能力:((?:`[a-z0-9-]+`[、,\s]*)+)/g)) {
const tags = [...m[2].matchAll(/`([a-z0-9-]+)`/g)].map(x => x[1]);
if (!tags.length) continue;
const target = m[1];
if (!skillCaps.has(target)) {
errors.push(`${name}: 标注 [[${target}]] 的能力,但该技能不存在`);
continue;
}
for (const t of tags) {
if (!registry.has(t)) errors.push(`${name}: 标注了未注册的能力 '${t}'([[${target}]])`);
else if (!skillCaps.get(target).caps.includes(t)) {
errors.push(`${name}: 标注 [[${target}]] 提供 '${t}',但该技能未声明该能力`);
}
}
}
// ② 选择树段内的技能链接必须紧跟能力标注(`[[技能]](`tag`)`)
let inTree = false;
for (const line of body.split('\n')) {
if (/^## 何时用哪个原子技能(选择树)/.test(line)) { inTree = true; continue; }
if (inTree && /^## /.test(line)) inTree = false;
if (!inTree) continue;
for (const m of line.matchAll(/\[\[([a-z0-9-]+)\]\]/g)) {
if (!skillCaps.has(m[1])) continue; // 非技能链接(references 文档如 platform-tips)不要求能力标注
if (!line.slice(m.index + m[0].length).startsWith('(能力:`')) {
errors.push(`${name}: 选择树内 [[${m[1]}]] 缺少能力标注(形如 [[${m[1]}]](能力:\`能力\`))`);
}
}
}
}
return errors;
}
// 注册表自洽:每个标签至少被一个技能声明(防悬空标签堆积——见 docs/audit)
export function checkCapabilityRegistry(root) {
const errors = [];
const declared = collectDeclaredCapabilities(root);
for (const tag of [...collectCapabilities(root)].sort()) {
if (!declared.has(tag)) errors.push(`capability registry: '${tag}' is declared by no skill (dangling tag)`);
}
return errors;
}
export function isLeafSkill(dir) {
return !readdirSync(dir, { withFileTypes: true })
.some(e => e.isDirectory() && !e.name.startsWith('.') && e.name !== 'references');
}
export function collectSkills(root) {
return readdirSync(root, { withFileTypes: true })
.filter(e => e.isDirectory())
.map(e => e.name);
}
// references/*.md 文件名(去扩展名)也作为合法 [[链接]] 目标,如 [[platform-tips]]
export function collectReferences(root) {
const refs = new Set();
for (const dir of readdirSync(root, { withFileTypes: true })) {
if (!dir.isDirectory() || !dir.name.startsWith(SKILL_PREFIX)) continue;
const refDir = join(root, dir.name, 'references');
if (!existsSync(refDir)) continue;
for (const f of readdirSync(refDir)) {
if (f.endsWith('.md')) refs.add(f.replace(/\.md$/, ''));
}
}
return refs;
}
export function checkSkillDir(dir, opts = {}) {
const errors = [];
const name = dir.split(/[\\/]/).pop();
const mdPath = join(dir, 'SKILL.md');
if (!existsSync(mdPath)) { errors.push(`SKILL.md missing in ${name}`); return { errors }; }
let fm;
try { fm = parseFrontmatter(readFileSync(mdPath, 'utf8')); }
catch (e) { errors.push(`${name}: ${e.message}`); return { errors }; }
if (fm.name !== name) errors.push(`${name}: frontmatter name '${fm.name}' != dir name`);
if (!fm.description.trim()) errors.push(`${name}: empty description`);
if (!CJK_RE.test(fm.description) || !LATIN_RE.test(fm.description)) {
errors.push(`${name}: description must contain both CJK and Latin trigger words`);
}
// 工具准备检查:仅原子技能(无 type 或 type: atomic)且为叶子。入口(type: entry)/网关(type: gateway)豁免
const type = fm.type ?? 'atomic';
if (type === 'atomic' && isLeafSkill(dir) && !fm.body.includes('## 工具准备')) {
errors.push(`${name}: atomic skill missing '## 工具准备' section`);
}
if (!['atomic', 'entry', 'gateway'].includes(type)) errors.push(`${name}: invalid type '${type}' (atomic/entry/gateway)`);
// 原子技能必须声明能力(能力层完整性:路由靠能力匹配,未声明即路由不可达)
if (type === 'atomic' && fm.capabilities === undefined) {
errors.push(`${name}: atomic skill must declare capabilities (see re-analyze/references/capabilities.md)`);
}
// capabilities 校验:有字段时必须是非空 list 且值都在注册表内
if (fm.capabilities !== undefined) {
const caps = opts.knownCapabilities ?? new Set();
if (fm.capabilities === null || fm.capabilities.length === 0) {
errors.push(`${name}: capabilities must be a non-empty list like [tag1, tag2]`);
} else {
for (const c of fm.capabilities) {
if (!caps.has(c)) errors.push(`${name}: unknown capability '${c}' (see re-analyze/references/capabilities.md)`);
}
}
}
// guard 校验:有字段时必须是 {"require_authorization": bool, "forbidden": [tags]} 结构
if (fm.guard !== undefined) {
if (fm.guard === null || typeof fm.guard !== 'object' || Array.isArray(fm.guard)
|| typeof fm.guard.require_authorization !== 'boolean'
|| !Array.isArray(fm.guard.forbidden) || fm.guard.forbidden.some(t => typeof t !== 'string' || !t)) {
errors.push(`${name}: guard must be {"require_authorization": bool, "forbidden": [tags]}`);
}
}
// 链接规则:技能 `[[re-xxx]]`;跨技能 references `[[re-xxx/name]]`;裸 `[[name]]` 必须落在本技能 references/ 内
// (references 同名文件普遍——gotchas 33 个、decision-tree 13 个——裸链会歧义,故跨技能须限定前缀)
const known = new Set(opts.knownSkills ?? []);
const checkLinks = (text, label) => {
for (const link of text.matchAll(/\[\[([a-z0-9-]+(?:\/[a-z0-9-]+)?)\]\]/g)) {
const target = link[1];
if (target.includes('/')) {
const [owner, ref] = target.split('/');
if (!known.has(owner) || !existsSync(join(dir, '..', owner, 'references', `${ref}.md`))) {
errors.push(`${label}: broken [[${target}]] link(跨技能 references 须解析到 ${owner}/references/${ref}.md)`);
}
continue;
}
if (known.has(target)) continue;
if (!existsSync(join(dir, 'references', `${target}.md`))) {
errors.push(`${label}: broken [[${target}]] link(裸 references 链接须在本技能 references/ 内;跨技能请写 [[re-xxx/${target}]])`);
}
}
};
checkLinks(fm.body, name);
// references/ 内的链接同规则校验(跨技能引用多在此处)
const refDir = join(dir, 'references');
if (existsSync(refDir)) {
for (const f of readdirSync(refDir)) {
if (!f.endsWith('.md')) continue;
checkLinks(readFileSync(join(refDir, f), 'utf8'), `${name}/references/${f}`);
}
}
return { errors, name };
}
function main() {
const root = join(HERE, '.claude', 'skills');
if (!existsSync(root)) { console.error(`skills dir not found: ${root}`); process.exit(1); }
const known = collectSkills(root);
const knownRefs = collectReferences(root);
const knownCapabilities = collectCapabilities(root);
let failed = 0;
for (const dir of readdirSync(root, { withFileTypes: true })) {
if (!dir.isDirectory() || !dir.name.startsWith(SKILL_PREFIX)) continue;
const { errors } = checkSkillDir(join(root, dir.name), { knownSkills: known, knownRefs, knownCapabilities });
for (const e of errors) { console.error(`FAIL: ${e}`); failed++; }
}
for (const e of checkCapabilityRegistry(root)) { console.error(`FAIL: ${e}`); failed++; }
for (const e of checkRoutingCapabilities(root)) { console.error(`FAIL: ${e}`); failed++; }
for (const e of checkCapabilityAnnotations(root)) { console.error(`FAIL: ${e}`); failed++; }
for (const e of checkCapabilityIndex(root)) { console.error(`FAIL: ${e}`); failed++; }
if (failed) { console.error(`${failed} problem(s) found`); process.exit(1); }
console.log(`OK: ${known.length} skills validated`);
}
if (process.argv[1] && process.argv[1].endsWith('validate.mjs')) main();