/** * Q1 — the write gate, moved from prose into code. * * `write-scope.mjs` has existed since M-BUG-41, but only ONE writer in * `scanners/` ever imported it (`lib/subtraction-write.mjs`). Every other arm * answered "is this write gated?" the way the five copies of the lever table * answered "what is a lever?" — by reading a command template and trusting its * prose. Measured 2026-08-12: 9 files under `scanners/` write to disk, 1 * imported the gate; 21 command templates, 17 mention a write, 5 call * `write-scope-cli`. * * The defect was never "8 ungated writers = 8 bugs". Four of them legitimately * write the plugin's own bookkeeping and MUST stay ungated — gating them fires * the gate on every run, which is the failure mode `SCOPE_CLASSES`' own ordering * exists to prevent ("worse than having no gate"). The defect is that NOTHING * DECLARED WHICH. The question was answerable only by reading, so it was * answered differently every time it was asked. * * This guard makes the answer structural: every writer must either import the * gate or appear in `EXEMPT` with a rationale. A new writer that does neither * fails here, not in review. * * Two detection details are load-bearing, and both come from a measurement that * caught this file's own first draft: * * 1. SYNC VARIANTS COUNT. A regex for `writeFile(` does not match * `writeFileSync(`, and `lib/backup.mjs` — a real writer — uses ONLY the * sync forms. The first sweep of this defect scored it as a non-writer and * was green on its own subject. [[guard-can-be-green-on-its-own-defect]] * * 2. COMMENTS ARE STRIPPED FIRST. The broad sweep returned 13 files; 4 were * prose ("Coordinate naming across plugins, or rename one…", "removed or * renamed in a newer version"). Requiring `(` kills those, but JSDoc that * documents a signature (`write-output.mjs` line 28 spells * `writeFile(path, contents, encoding)`) survives it, so comments go first. */ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync, readdirSync, statSync } from 'node:fs'; import { join, relative } from 'node:path'; import { fileURLToPath } from 'node:url'; const SCANNERS_DIR = fileURLToPath(new URL('../../scanners/', import.meta.url)); /** * Files under `scanners/` that write to disk WITHOUT the scope gate, and why * that is correct for each one. * * A rationale here is a claim about where the bytes land. It has to be true: * "it felt like plugin bookkeeping" is how `scan-orchestrator.mjs` was carried * as exempt in the plan text while `--save-baseline` wrote * `resolve(targetPath, '.config-audit-baseline.json')` — into the SCAN TARGET, * so a `--global` run lands `~/.claude/.config-audit-baseline.json`, which is * `user-scope` / `require-ok`. It is gated now rather than listed here. */ const EXEMPT = { 'lib/backup.mjs': 'Writes only into the plugin backup root (`~/.claude/config-audit/`, legacy ' + '`~/.config-audit/`). Both are `plugin-managed`, so the gate classifies them ' + '`silent` anyway; importing it here would add a second classification of the ' + 'same path with no verdict to render. Backups are also the recovery artifact ' + 'a gated write depends on — gating the backup would order the gate behind itself.', 'lib/baseline.mjs': 'Writes only under `~/.config-audit/baselines` (BASELINES_DIR, line 11), a ' + '`plugin-managed` root. Distinct from `scan-orchestrator`\'s `--save-baseline`, ' + 'which writes into the scan target and is therefore gated.', 'lib/campaign-ledger.mjs': 'Writes only `~/.claude/config-audit/campaign-ledger.json` (line 339), a fixed ' + '`plugin-managed` path that cannot be redirected by a flag.', 'lib/write-output.mjs': 'Writes the scanner\'s own REPORT to the operator-named `--output-file`, never ' + 'configuration. The path is arbitrary, so classifying it would raise ' + '`require-ok` on any `--output-file` under `~/.claude` — on every run, for a ' + 'file the operator just named on the command line. That is the "fires on every ' + 'write and gets switched off" failure mode. A report is not a config change.', }; /** * Write calls, sync and async. `\b` before the name keeps `writeOutputFile(` * from matching `writeFile(`; the trailing `(` keeps prose out. */ const WRITE_CALL_RE = /\b(writeFile|writeFileSync|appendFile|appendFileSync|mkdir|mkdirSync|rename|renameSync|copyFile|copyFileSync|unlink|unlinkSync|rm|rmSync|createWriteStream)\s*\(/; /** Files that import the scope gate are gated by construction. */ const GATE_IMPORT_RE = /from\s+['"][^'"]*write-scope\.mjs['"]/; /** * Remove block comments and whole-line comments. Deliberately does NOT touch * `//` mid-line, so a `https://` inside a string cannot truncate real code. */ function stripComments(source) { return source .replace(/\/\*[\s\S]*?\*\//g, '') .split('\n') .filter((line) => { const t = line.trimStart(); return !t.startsWith('//') && !t.startsWith('*'); }) .join('\n'); } function walkMjs(dir, acc = []) { for (const entry of readdirSync(dir)) { const full = join(dir, entry); if (statSync(full).isDirectory()) walkMjs(full, acc); else if (entry.endsWith('.mjs')) acc.push(full); } return acc; } /** @returns {Array<{rel: string, gated: boolean, lines: number[]}>} */ function findWriters() { const writers = []; for (const full of walkMjs(SCANNERS_DIR)) { const raw = readFileSync(full, 'utf-8'); const code = stripComments(raw); if (!WRITE_CALL_RE.test(code)) continue; const lines = []; raw.split('\n').forEach((line, i) => { const t = line.trimStart(); if (t.startsWith('//') || t.startsWith('*') || t.startsWith('/*')) return; if (WRITE_CALL_RE.test(line)) lines.push(i + 1); }); writers.push({ rel: relative(SCANNERS_DIR, full), gated: GATE_IMPORT_RE.test(raw), lines, }); } return writers; } test('the sweep finds writers at all (an empty sweep certifies nothing)', () => { const writers = findWriters(); assert.ok( writers.length >= 8, `expected the writer sweep to find at least 8 files, found ${writers.length}. ` + 'A regex that stops matching makes every other assertion in this file ' + 'vacuously green (#63, #64).', ); }); test('every writer under scanners/ either imports the scope gate or is declared exempt', () => { const ungated = findWriters() .filter((w) => !w.gated && !(w.rel in EXEMPT)) .map((w) => `${w.rel} (writes at line ${w.lines.join(', ')})`); assert.deepEqual( ungated, [], 'These files write to disk without importing `write-scope.mjs` and without an ' + 'entry in EXEMPT. Either route the write through `classifyWriteTarget` + ' + '`strongestGate`, or add an EXEMPT entry stating where the bytes land and ' + 'why no verdict is owed:\n ' + ungated.join('\n '), ); }); test('no EXEMPT entry is stale — each named file still writes, and is still ungated', () => { const writers = new Map(findWriters().map((w) => [w.rel, w])); for (const rel of Object.keys(EXEMPT)) { const writer = writers.get(rel); assert.ok( writer, `EXEMPT names \`${rel}\`, which no longer writes to disk. Remove the entry — a ` + 'stale exemption is a standing permission nobody is using and nobody rechecks.', ); assert.equal( writer.gated, false, `EXEMPT names \`${rel}\`, but it now imports the scope gate. Remove the entry so ` + 'the file is covered by the gate, not by a leftover exemption that outranks it.', ); } }); test('every EXEMPT rationale is substantive', () => { for (const [rel, why] of Object.entries(EXEMPT)) { assert.ok( why.trim().length >= 80, `EXEMPT['${rel}'] needs a rationale naming where the bytes land, not a label.`, ); } });