#!/usr/bin/env node /** * write-scope CLI — classify the write targets a command is about to touch, * relative to the repo the session stands in (M-BUG-41). * * Exists so the gate has ONE implementation. Five command templates need the * same answer before their approval surface; five prose paraphrases of the * class table would be five policies drifting apart — the shape that put the * lever table in five copies (#61). The templates call this and render what * comes back. * * Usage: * node write-scope-cli.mjs --target [--target ...] * [--repo ] * [--output-file ] [--json] * * Exit codes: 0 = classified, 3 = argument or tool error. * * A gated target is NOT an error exit. The exit-code contract reserves 3 for * "the scanner could not do its job"; "this write leaves the repo" is a verdict * about a write, and it rides in the payload — a command cannot act on * something that only ever reached stderr (F3's class). * * Zero external dependencies. */ import { resolve } from 'node:path'; import { writeOutputFile } from './lib/write-output.mjs'; import { requireValidArgs } from './lib/cli-args.mjs'; import { SCOPE_CLASSES, classifyWriteTarget, strongestGate } from './lib/write-scope.mjs'; /** Flag surface. Anything else is exit 3. */ const ARG_SPEC = { boolean: ['--json'], value: ['--target', '--repo', '--output-file'] }; async function main() { const args = process.argv.slice(2); if (!requireValidArgs(args, ARG_SPEC)) return; const targets = []; let repo = process.cwd(); let outputFile = null; let jsonMode = false; for (let i = 0; i < args.length; i++) { if (args[i] === '--json') jsonMode = true; else if (args[i] === '--target') targets.push(args[++i]); else if (args[i] === '--repo') repo = args[++i]; else if (args[i] === '--output-file') outputFile = args[++i]; } if (targets.length === 0) { process.stderr.write('Error: at least one --target is required\n'); process.exitCode = 3; return; } const classified = targets.map((t) => classifyWriteTarget(t, repo)); const gate = strongestGate(classified); const payload = { meta: { repo: resolve(repo), targetCount: classified.length, // The class table travels with the answer so a template never has to // restate what a class means. classes: Object.fromEntries( Object.entries(SCOPE_CLASSES).map(([name, spec]) => [name, { gate: spec.gate }]), ), }, gate, requiresApproval: gate === 'require-ok', // Distinct disclosure lines, in class order, ready to render verbatim. disclosures: [...new Set(classified.map((t) => t.disclosure).filter(Boolean))], targets: classified, }; const json = `${JSON.stringify(payload, null, 2)}\n`; if (outputFile) { await writeOutputFile(outputFile, json); // Nothing on stdout when writing to a file: a command that also renders // this would otherwise show the user the raw payload (ux-rules rule 1). } else if (jsonMode) { process.stdout.write(json); } else { for (const t of payload.targets) { process.stdout.write(`${t.scopeClass}\t${t.gate}\t${t.target}\n`); } } } try { await main(); } catch (err) { process.stderr.write(`Error: ${err.message}\n`); process.exitCode = 3; }