Files
skills/agent-swarm/scripts/audit-scope.mjs
T
2026-10-27 18:50:15 +03:00

217 lines
6.4 KiB
JavaScript
Executable File

#!/usr/bin/env node
/**
* audit-scope.mjs — compare workspace changes against the .swarm ownership map.
*
* Usage:
* node scripts/audit-scope.mjs --init [--root <dir>]
* node scripts/audit-scope.mjs [--root <dir>] [--json]
*
* --init writes .swarm/baseline.json (run before the first worker starts).
* The audit lists every changed path with its owning task(s) and fails when a
* path is unowned or owned by more than one task.
*
* Exit codes: 0 clean, 1 violations, 2 usage or I/O error.
*/
import { createHash } from "node:crypto";
import {
existsSync,
mkdirSync,
readFileSync,
readdirSync,
statSync,
writeFileSync
} from "node:fs";
import { join, relative, resolve, sep } from "node:path";
const IGNORED_DIRS = new Set([
".git",
".swarm",
"node_modules",
"dist",
"build",
".next",
".nuxt",
".venv",
"venv",
"__pycache__",
".cache",
"target",
"coverage",
".idea",
".vscode"
]);
const HASH_LIMIT = 512 * 1024;
function parseArgs(argv) {
const opts = { init: false, json: false, root: process.cwd() };
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === "--init") opts.init = true;
else if (arg === "--json") opts.json = true;
else if (arg === "--root") {
const value = argv[++i];
if (!value) throw new Error("--root requires a directory");
opts.root = resolve(value);
} else if (arg === "-h" || arg === "--help") {
console.log("Usage: node audit-scope.mjs [--init] [--root <dir>] [--json]");
process.exit(0);
} else {
throw new Error(`unknown argument: ${arg}`);
}
}
if (!existsSync(opts.root) || !statSync(opts.root).isDirectory()) {
throw new Error(`root is not a directory: ${opts.root}`);
}
return opts;
}
function walk(dir, base, out) {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (entry.isDirectory()) {
if (IGNORED_DIRS.has(entry.name)) continue;
walk(join(dir, entry.name), base, out);
} else if (entry.isFile()) {
const full = join(dir, entry.name);
const st = statSync(full);
const path = relative(base, full).split(sep).join("/");
const record = { size: st.size, mtime: Math.round(st.mtimeMs) };
if (st.size <= HASH_LIMIT) {
record.hash = createHash("sha1").update(readFileSync(full)).digest("hex").slice(0, 16);
}
out[path] = record;
}
}
}
function snapshot(root) {
const files = {};
walk(root, root, files);
return { version: 1, createdAt: new Date().toISOString(), files };
}
function same(a, b) {
if (!a || !b) return false;
if (a.hash && b.hash) return a.hash === b.hash && a.size === b.size;
return a.size === b.size && a.mtime === b.mtime;
}
function globToRegExp(glob) {
let out = "^";
for (let i = 0; i < glob.length; i++) {
const c = glob[i];
if (c === "*") {
if (glob[i + 1] === "*") {
i++;
if (glob[i + 1] === "/") {
i++;
out += "(?:[^/]+/)*";
} else {
out += ".*";
}
} else {
out += "[^/]*";
}
} else if (c === "?") {
out += "[^/]";
} else {
out += c.replace(/[.+^${}()|[\]\\]/g, "\\$&");
}
}
return new RegExp(out + "$");
}
function normalizeGlobs(task, root) {
const globs = Array.isArray(task.write) ? task.write : [];
return globs
.map((raw) => {
let glob = String(raw).trim().replace(/^\.\//, "");
if (!glob) return null;
const abs = resolve(root, glob);
const isDir = !/[*?[]/.test(glob) && existsSync(abs) && statSync(abs).isDirectory();
if (isDir) glob = glob.replace(/\/+$/, "") + "/**";
return { raw, re: globToRegExp(glob) };
})
.filter(Boolean);
}
function main() {
const opts = parseArgs(process.argv.slice(2));
const root = opts.root;
const swarmDir = join(root, ".swarm");
const baselinePath = join(swarmDir, "baseline.json");
const planPath = join(swarmDir, "plan.json");
if (opts.init) {
mkdirSync(swarmDir, { recursive: true });
const snap = snapshot(root);
writeFileSync(baselinePath, JSON.stringify(snap, null, 2) + "\n");
console.log(
`baseline: ${Object.keys(snap.files).length} files -> ${relative(root, baselinePath) || baselinePath}`
);
return 0;
}
if (!existsSync(baselinePath)) {
throw new Error(`missing ${baselinePath}; run with --init before the swarm starts`);
}
if (!existsSync(planPath)) {
throw new Error(`missing ${planPath}; the plan gate must pass before auditing`);
}
const baseline = JSON.parse(readFileSync(baselinePath, "utf8"));
const plan = JSON.parse(readFileSync(planPath, "utf8"));
const current = snapshot(root);
const tasks = (plan.tasks || []).map((task) => ({
id: task.id,
globs: normalizeGlobs(task, root)
}));
const changed = [];
for (const [path, record] of Object.entries(current.files)) {
if (!same(record, baseline.files[path])) changed.push({ path, deleted: false });
}
for (const path of Object.keys(baseline.files)) {
if (!(path in current.files)) changed.push({ path, deleted: true });
}
changed.sort((a, b) => a.path.localeCompare(b.path));
const rows = changed.map(({ path, deleted }) => {
const owners = tasks
.filter((task) => task.globs.some((glob) => glob.re.test(path)))
.map((task) => task.id);
const status = owners.length === 0 ? "unowned" : owners.length > 1 ? "conflict" : "ok";
return { path, deleted, owners, status };
});
const summary = {
changed: rows.length,
ok: rows.filter((row) => row.status === "ok").length,
unowned: rows.filter((row) => row.status === "unowned").length,
conflict: rows.filter((row) => row.status === "conflict").length
};
if (opts.json) {
console.log(JSON.stringify({ summary, changes: rows }, null, 2));
} else {
console.log(
`changed: ${summary.changed} | ok: ${summary.ok} | unowned: ${summary.unowned} | conflict: ${summary.conflict}`
);
for (const row of rows) {
const mark = row.status === "ok" ? "ok " : row.status === "unowned" ? "BAD " : "CONF";
const owners = row.owners.length ? row.owners.join(", ") : "-";
console.log(` ${mark} ${row.path}${row.deleted ? " [deleted]" : ""} ${owners}`);
}
}
return summary.unowned + summary.conflict > 0 ? 1 : 0;
}
try {
process.exitCode = main();
} catch (error) {
console.error(`audit-scope: ${error instanceof Error ? error.message : String(error)}`);
process.exitCode = 2;
}