// lib/tool-register.mjs — 第三方命令/API 清单:抽取、比对、时效 // // 用途:领域知识比工具操作层稳定——技能里的**命令/API 会随上游版本漂移**, // 而 validate.mjs 只校验结构,不执行示例、也不检查命令是否还存在。 // 本模块提供「登记表 + 候选发现 + 时效报告」的最小骨架(与增量审查机制共用状态模型)。 import { readFileSync, readdirSync, existsSync } from 'node:fs'; import { join } from 'node:path'; import { execFileSync } from 'node:child_process'; export const REGISTER_REL = 'docs/audit/tool-register.json'; // shell 关键字 / 控制结构 / heredoc 定界符——不是第三方工具 const SHELL_WORDS = new Set([ 'for', 'if', 'while', 'until', 'do', 'done', 'then', 'else', 'elif', 'fi', 'case', 'esac', 'function', 'export', 'set', 'unset', 'source', 'local', 'return', 'exit', 'break', 'continue', 'cd', 'echo', 'printf', 'true', 'false', 'test', 'read', 'eval', 'exec', 'shift', 'trap', 'wait', 'PY', 'EOF', 'SH', 'BASH', 'END', 'JSON', 'PYTHON', ]); // 常见 shell 内建/基础工具:登记表不收录(噪音大、无生命周期风险) export const BASE_UTILS = new Set([ 'ls', 'cat', 'cp', 'mv', 'rm', 'mkdir', 'rmdir', 'touch', 'ln', 'chmod', 'chown', 'pwd', 'tee', 'head', 'tail', 'wc', 'sort', 'uniq', 'cut', 'tr', 'sed', 'awk', 'grep', 'egrep', 'find', 'xargs', 'which', 'whereis', 'env', 'date', 'sleep', 'kill', 'ps', 'top', 'df', 'du', 'mount', 'umount', 'sha256sum', 'md5sum', 'sha1sum', 'base64', 'xxd', 'od', 'stat', 'file', 'strings', 'diff', 'patch', 'dd', 'hexdump', 'gzip', 'bzip2', 'xz', 'zstd', 'jq', // jq 属基础数据处理,不作为分析工具登记 'tar', 'gzip', 'gunzip', 'zcat', 'unzip', 'zip', 'curl', 'wget', 'ssh', 'scp', 'rsync', 'nc', 'netcat', 'sudo', 'su', 'git', 'make', 'cmake', 'gcc', 'clang', 'python', 'python3', 'pip', 'pip3', 'node', 'npm', 'apt', 'apt-get', 'dnf', 'yum', 'pacman', 'brew', 'choco', 'pipx', 'podman', ]); // 只抽显式标注为 shell 的代码块——未标注块里混着 python / C / 伪代码, // 会把 print、import、struct 字段名等当成"命令",噪音量级大一个数量级。 const SHELL_TAGS = new Set(['sh', 'bash', 'console', 'shell', 'zsh']); /** 抽取代码块中出现的「命令首 token」 */ export function extractCommands(markdown) { const out = []; const fence = /^```([A-Za-z]*)\s*$/; let inBlock = false; let tag = ''; let skipUntil = null; // heredoc 定界符 for (const raw of markdown.split('\n')) { const trimmed = raw.trim(); const m = fence.exec(trimmed); if (m) { if (!inBlock) { inBlock = true; tag = m[1].toLowerCase(); skipUntil = null; } else { inBlock = false; tag = ''; skipUntil = null; } continue; } if (!inBlock) continue; if (!SHELL_TAGS.has(tag)) continue; if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('//')) continue; if (skipUntil) { if (trimmed === skipUntil) skipUntil = null; continue; } const hd = /<<-?\s*'?"?([A-Za-z_][A-Za-z0-9_]*)'?"?/.exec(trimmed); if (hd) { skipUntil = hd[1]; continue; } // 去提示符 / 环境变量赋值前缀 / sudo,取首 token const stripped = trimmed .replace(/^(\$|>|#)\s*/, '') .replace(/^(\w+=\S*\s+)+/, '') .replace(/^(sudo|doas)\s+/, ''); const tok = /^([A-Za-z][A-Za-z0-9_.+-]*)/.exec(stripped); if (!tok) continue; const cmd = tok[1]; if (SHELL_WORDS.has(cmd) || BASE_UTILS.has(cmd) || cmd.length < 2) continue; out.push(cmd); } return out; } /** 扫描技能目录,返回 cmd → 技能名集合 */ export function collectFromSkills(skillsDir) { const map = new Map(); const walk = (dir, owner) => { for (const e of readdirSync(dir, { withFileTypes: true })) { const p = join(dir, e.name); if (e.isDirectory()) walk(p, owner ?? e.name); else if (e.name.endsWith('.md')) { const skill = owner ?? e.name.replace(/\.md$/, ''); for (const cmd of extractCommands(readFileSync(p, 'utf8'))) { if (!map.has(cmd)) map.set(cmd, new Set()); map.get(cmd).add(skill); } } } }; for (const e of readdirSync(skillsDir, { withFileTypes: true })) { if (e.isDirectory() && e.name.startsWith('re-')) walk(join(skillsDir, e.name), e.name); } return map; } export function loadRegister(repoRoot) { const p = join(repoRoot, REGISTER_REL); if (!existsSync(p)) return null; return JSON.parse(readFileSync(p, 'utf8')); } export function todayISO() { return new Date().toISOString().slice(0, 10); } export function daysSince(iso) { const t = Date.parse(`${iso}T00:00:00Z`); if (Number.isNaN(t)) return Infinity; return Math.floor((Date.now() - t) / 86400000); } /** 一致性检查:返回 {errors, warnings, stale, candidates} */ export function checkRegister(repoRoot, skillsDir, { staleDays = 180 } = {}) { const errors = []; const warnings = []; const register = loadRegister(repoRoot); if (!register) { return { errors: [`找不到登记表 ${REGISTER_REL}`], warnings: [], stale: [], candidates: [], register: null }; } if (!Array.isArray(register.tools)) { return { errors: [`${REGISTER_REL} 缺少 tools 数组`], warnings: [], stale: [], candidates: [], register }; } const seen = new Set(); for (const t of register.tools) { if (!t.name) errors.push('登记项缺少 name'); if (seen.has(t.name)) errors.push(`登记项重复:${t.name}`); seen.add(t.name); // last_verified 为 null = 已登记但尚未核验当前 CLI/API 形态(允许,且会被计入待核验) if (t.last_verified !== null && !/^\d{4}-\d{2}-\d{2}$/.test(t.last_verified ?? '')) { errors.push(`登记项 ${t.name} 的 last_verified 须为 YYYY-MM-DD 或 null`); } if (t.last_verified && !t.source) warnings.push(`登记项 ${t.name} 已标核验日期但未记录来源(source)`); } const used = collectFromSkills(skillsDir); // 死条目:登记了却已无技能引用 for (const t of register.tools) { if (!used.has(t.name)) errors.push(`死条目:登记为在用,但技能里已找不到 ${t.name}`); } // 候选:技能里出现但未登记(且不在忽略名单) const ignore = new Set(register.ignore ?? []); const candidates = [...used.keys()].filter((c) => !seen.has(c) && !ignore.has(c)).sort(); // 时效:已核验的超期 → stale;从未核验的 → pending(待核验积压,与 stale 分开统计) const stale = []; const pending = []; for (const t of register.tools) { if (!t.last_verified) { pending.push(t.name); continue; } const days = daysSince(t.last_verified); if (days >= staleDays) stale.push({ name: t.name, last_verified: t.last_verified, days }); } stale.sort((a, b) => b.days - a.days); pending.sort(); return { errors, warnings, stale, pending, candidates, register, used }; } // 风险判据:技能对某工具的**断言越具体,越容易随上游版本漂移** // (只点名工具名 → 低风险;写死子命令/插件路径/版本 → 高风险) const RISK_FLAG = /(^|\s)--?[A-Za-z][\w-]*/; // 命令行开关 const RISK_PATH = /\b[a-z_]+(\.[a-z_]+){2,}\b/; // 点分子命令/插件路径 windows.registry.hashdump const RISK_VER = /\bv?\d+\.\d+(\.\d+)?\b/; // 版本号 /** * 从技能正文推导每个工具的风险分层(不落盘,随内容实时派生) * 返回 name → { level: 'high'|'low', score, lines: [示例行] } */ export function deriveRisk(skillsDir, names) { const want = new Set(names); const hits = new Map(); // name → [{file, line}] for (const skill of readdirSync(skillsDir, { withFileTypes: true })) { if (!skill.isDirectory() || !skill.name.startsWith('re-')) continue; walkLines(join(skillsDir, skill.name), (file, line, text) => { for (const n of want) { if (new RegExp(`(^|[\\s\`(\\[/])${n.replace(/[.+]/g, '\\$&')}([\\s\`)\\]/:]|$)`).test(text)) { if (!hits.has(n)) hits.set(n, []); hits.get(n).push({ file, line, text: text.trim().slice(0, 120) }); } } }); } const out = {}; for (const n of want) { const hs = hits.get(n) ?? []; // 断言类型(而不是出现次数)决定风险:写死了层级路径或版本号的,最容易随上游漂移 const hasFlag = hs.some((h) => RISK_FLAG.test(h.text)); const hasPath = hs.some((h) => RISK_PATH.test(h.text)); const hasVer = hs.some((h) => RISK_VER.test(h.text)); const specificity = (hasFlag ? 1 : 0) + (hasPath ? 2 : 0) + (hasVer ? 2 : 0); out[n] = { level: specificity >= 2 ? 'high' : 'low', specificity, spread: hs.length, // 出现面:错了会影响多少条示例(严重度) sample: hs.slice(0, 3), }; } return out; } function walkLines(dir, cb) { for (const e of readdirSync(dir, { withFileTypes: true })) { const p = join(dir, e.name); if (e.isDirectory()) { walkLines(p, cb); continue; } if (!e.name.endsWith('.md')) continue; const lines = readFileSync(p, 'utf8').split('\n'); for (let i = 0; i < lines.length; i++) cb(p, i + 1, lines[i]); } } /** 本机探测:仅报告,不参与 CI 判定(CI 机器未必装这些工具) */ export function smokeProbe(names) { return names.map((n) => { try { execFileSync('sh', ['-c', `command -v ${n}`], { stdio: 'ignore' }); return { name: n, present: true }; } catch { return { name: n, present: false }; } }); }