/** * 插件源码 ↔ 安装副本 一致性审计。 * * 背景:这些插件是"工作区源码 + ~/.zcode/plugins 安装副本"两份并存的手工流程, * 副本滞后不会被任何东西发现 —— 改了源码没重装,跑的还是旧代码,而且报错会指向 * 一个你刚改好的文件。这个脚本就是拿来看这件事的。 * * node audit-plugins.mjs # 审计工作区里每个插件(源码 vs 安装副本 + 文档/语法) * node audit-plugins.mjs --drift # 只报漂移,不跑语法检查 * node audit-plugins.mjs <名字>... # 只审计指定插件(不传=全部) * * 环境变量: * ZCODE_AUDIT_WORKSPACE 工作区路径(默认本脚本所在目录;测试用) * ZCODE_PLUGINS_DIR 安装目录(默认 ~/.zcode/plugins;测试用) * * 只读,不改任何东西。退出码:有问题为 1。 */ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { execFileSync } from "node:child_process"; const WORKSPACE = process.env.ZCODE_AUDIT_WORKSPACE || import.meta.dirname; const PLUGINS = process.env.ZCODE_PLUGINS_DIR || path.join(os.homedir(), ".zcode", "plugins"); const DRIFT_ONLY = process.argv.includes("--drift"); /** 位置参数 = 只审计这些插件;不传则全部 */ const WANTED = process.argv.slice(2).filter((a) => !a.startsWith("--")); /** 建议忽略的条目:本地产物,不属于"该同步的内容" */ const IGNORE = new Set(["node_modules", ".git", "package-lock.json"]); /** * 哪些路径算"源码管理的" —— 只有这些位置的文件从源码删掉才算残留。 * * 不能用扩展名一刀切:根目录的 local-state.json 是操作员放的本机状态, * 按扩展名判会把它报成"残留代码建议删除",把一个只读审计工具变成删数据的推手。 * 目录归属才是可靠信号:scripts/ 与 commands/ 完全是源码产物,根目录只认 .md 和 package.json。 */ function isSourceManaged(rel) { const r = rel.split(path.sep).join("/"); if (r.startsWith("scripts/") || r.startsWith("commands/") || r.startsWith(".zcode-plugin/")) return true; if (r.includes("/")) return false; // 别的子目录:来源不明,不当残留 return /\.(md)$/i.test(r) || r === "package.json"; // 根目录只认这两种 } const out = []; let problems = 0; let warns = 0; function walkFiles(root, base = root, acc = []) { for (const e of fs.readdirSync(base, { withFileTypes: true })) { if (IGNORE.has(e.name)) continue; const p = path.join(base, e.name); if (e.isDirectory()) walkFiles(root, p, acc); else if (e.isFile()) acc.push(path.relative(root, p).split(path.sep).join("/")); } return acc; } /** 源码有而副本没有 / 两边内容不同 / 副本有而源码没有 */ function diffTrees(srcRoot, dstRoot) { const a = new Set(walkFiles(srcRoot)), b = new Set(walkFiles(dstRoot)); const missing = [...a].filter((f) => !b.has(f)).sort(); // 没部署过去 const extra = [...b].filter((f) => !a.has(f)).sort(); // 副本里多出来的 const changed = [...a].filter((f) => b.has(f) && fs.readFileSync(path.join(srcRoot, f), "utf8") !== fs.readFileSync(path.join(dstRoot, f), "utf8")).sort(); return { missing, extra, changed }; } const sources = fs.readdirSync(WORKSPACE, { withFileTypes: true }) .filter((e) => e.isDirectory() && fs.existsSync(path.join(WORKSPACE, e.name, ".zcode-plugin", "plugin.json"))) .map((e) => e.name) .filter((n) => !WANTED.length || WANTED.includes(n)) .sort(); if (!sources.length) { console.log(WANTED.length ? `# 没找到插件:${WANTED.join(", ")}\n- 工作区:${WORKSPACE}` : `# 未在工作区找到插件(需要 .zcode-plugin/plugin.json)\n- 工作区:${WORKSPACE}`); process.exit(1); } out.push(`# 插件审计`); out.push(`- 工作区:${WORKSPACE}`); out.push(`- 安装目录:${PLUGINS}`); for (const name of sources) { const src = path.join(WORKSPACE, name); const dst = path.join(PLUGINS, name); const srcVer = JSON.parse(fs.readFileSync(path.join(src, ".zcode-plugin", "plugin.json"), "utf8")).version ?? "?"; out.push(`\n### ${name} v${srcVer}`); if (!fs.existsSync(dst)) { out.push(` ✗ 未安装 —— 装一下才能生效`); problems++; continue; } const dstVer = JSON.parse(fs.readFileSync(path.join(dst, ".zcode-plugin", "plugin.json"), "utf8")).version ?? "?"; if (dstVer !== srcVer) { out.push(` ✗ 版本不一致:源码 v${srcVer} / 安装 v${dstVer}`); problems++; } const { missing, extra, changed } = diffTrees(src, dst); if (!missing.length && !changed.length && !extra.length) { out.push(` ✓ 与安装副本逐字节一致`); } else { // 未部署/内容不同 = 真漂移,失败。 // 副本独有分两种:本机 state 类(有意保留,只警告)vs 源码删掉的旧文件(残留,必须报)。 const deleted = extra.filter(isSourceManaged); const harmless = extra.filter((f) => !isSourceManaged(f)); if (missing.length || changed.length || deleted.length) problems++; if (harmless.length) warns++; out.push(` ${missing.length || changed.length || deleted.length ? "✗" : "⚠"} 副本与源码有差异:`); for (const f of missing) out.push(` 未部署:${f}`); for (const f of changed) out.push(` 内容不同:${f}`); for (const f of deleted) out.push(` 源码已删除但副本还在(残留代码仍会被加载):${f}`); for (const f of harmless) out.push(` 本地产物(有意保留,不影响):${f}`); if (missing.length || changed.length) out.push(` → 重装:cp -r "${src}/." "${dst}/"`); if (deleted.length) out.push(` → 残留文件需手工删除:rm "${dst}/${deleted[0]}"${deleted.length > 1 ? ` (另 ${deleted.length - 1} 个)` : ""}`); } if (DRIFT_ONLY) continue; // manifest 合规 const mf = JSON.parse(fs.readFileSync(path.join(src, ".zcode-plugin", "plugin.json"), "utf8")); if (!/^[a-z0-9][a-z0-9._-]{0,127}$/.test(mf.name)) { out.push(` ✗ name 不合规:${mf.name}`); problems++; } // 命令文档:front-matter + 引用脚本存在性 const cmdDir = path.join(src, "commands"); const docs = []; if (fs.existsSync(cmdDir)) { const walk = (d) => { for (const e of fs.readdirSync(d, { withFileTypes: true })) { const p = path.join(d, e.name); if (e.isDirectory()) walk(p); else if (e.name.endsWith(".md")) docs.push(p); } }; walk(cmdDir); } for (const d of docs.sort()) { const rel = path.relative(src, d).split(path.sep).join("/"); const body = fs.readFileSync(d, "utf8"); const fm = /^---\r?\n([\s\S]*?)\r?\n---/.exec(body); const desc = fm ? /description:\s*(.+)/.exec(fm[1])?.[1] : null; const refs = [...body.matchAll(/-name\s+(\S+\.mjs)/g)].map((m) => m[1]); const miss = refs.filter((r) => !fs.existsSync(path.join(src, "scripts", r))); const ok = !!fm && !!desc && miss.length === 0; if (!ok) problems++; out.push(` ${ok ? "✓" : "✗"} ${rel}${desc ? "" : " (缺 description)"}${miss.length ? ` 引用脚本缺失:${miss.join(",")}` : ""}`); } // 脚本语法自检(--check 只解析不执行) const scripts = fs.existsSync(path.join(src, "scripts")) ? fs.readdirSync(path.join(src, "scripts")) : []; for (const s of scripts.filter((x) => x.endsWith(".mjs"))) { try { execFileSync(process.execPath, ["--check", path.join(src, "scripts", s)], { stdio: "pipe" }); } catch (e) { out.push(` ✗ 语法 ${s}:${String(e.stderr ?? e).split("\n").find((l) => l.includes("Error")) ?? ""}`); problems++; } } const hasTests = fs.existsSync(path.join(src, "tests")); out.push(` ${hasTests ? "✓" : "⚠"} 文档 ${docs.length} / 脚本 ${scripts.filter((x) => x.endsWith(".mjs")).length} / 测试 ${hasTests ? "有" : "无"}`); } out.push(""); if (problems) { out.push(`结论:${problems} 处需要处理(见上面 ✗)。`); } else if (warns) { out.push(`结论:源码与安装副本一致,结构完整;${warns} 处提示不影响运行。`); } else { out.push(`结论:源码与安装副本一致,结构完整。`); } console.log(out.filter(Boolean).join("\n")); process.exit(problems ? 1 : 0);