217 lines
6.4 KiB
JavaScript
Executable File
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;
|
|
}
|