#!/usr/bin/env node /** * audit-scope.mjs — compare workspace changes against the .swarm ownership map. * * Usage: * node scripts/audit-scope.mjs --init [--root ] * node scripts/audit-scope.mjs [--root ] [--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 ] [--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; }