/** * M-BUG-41 — scope-gate wiring across the write surface. * * The chain lacked a gate between "observation across repos" and "write action * outside the repo the session stands in". Five arms were measured carrying it * (`docs/mbug41-scope-gate-fasit.local.md` §2). STATE named two of them; the * three found by measuring were `implement`, `fix` and `rollback`, and two are * worse than the two originally known: * * - `implement` — the approval prompt named NO path at all, only a count, so a * plan editing `~/.claude/CLAUDE.md` and one editing `./CLAUDE.md` produced * byte-identical prompts. * - `rollback` — the file list rendered `.claude/settings.json`, a * repo-relative FORM, while the restore writes to the absolute original. * The other arms were silent; this one pointed the wrong way. * - `fix` — paths were visible, but unclassified, and `--global` mixed * machine-wide and project rows into one unmarked table. * * A CLI carries the answer instead of each template restating the class table, * because five prose paraphrases of one policy are five policies drifting apart * — the shape that put the lever table in five copies (#61). */ import { test } from 'node:test'; import { strict as assert } from 'node:assert'; import { readFile, readdir } from 'node:fs/promises'; import { resolve, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; const __dirname = dirname(fileURLToPath(import.meta.url)); const COMMANDS_DIR = resolve(__dirname, '..', '..', 'commands'); const SCANNERS_DIR = resolve(__dirname, '..', '..', 'scanners'); /** * Every command whose approval surface precedes a write that can leave the repo. * `gatesApproval` marks the ones that own an `AskUserQuestion` and must * therefore branch on `requiresApproval`; `plan` has no confirmation step at all * — its summary IS the approval surface — and `campaign` writes cross-repo by * design, where the gate discloses rather than blocks. */ const GATED_COMMANDS = [ { file: 'implement.md', gatesApproval: true }, { file: 'fix.md', gatesApproval: true }, { file: 'rollback.md', gatesApproval: true }, { file: 'plan.md', gatesApproval: false }, { file: 'campaign.md', gatesApproval: false }, ]; async function readCommand(name) { return await readFile(resolve(COMMANDS_DIR, name), 'utf-8'); } for (const { file, gatesApproval } of GATED_COMMANDS) { test(`${file} classifies its write targets before the approval surface`, async () => { const content = await readCommand(file); assert.match( content, /write-scope-cli\.mjs/, `${file} presents a write without classifying where it lands. Showing a count — or even a\n` + 'path — does not tell the user the write leaves this project.', ); assert.match( content, /\$\{CLAUDE_PLUGIN_ROOT\}\/scanners\/write-scope-cli\.mjs/, `${file} must anchor the CLI at \${CLAUDE_PLUGIN_ROOT} — a relative path resolves against\n` + "the user's working directory, not the plugin.", ); assert.match( content, /write-scope-cli\.mjs[^\n]*--output-file[^\n]*2>\/dev\/null/, `${file} must invoke the CLI as \`--output-file 2>/dev/null\` (ux-rules rule 2), or\n` + 'the user sees raw payload and diagnostics.', ); assert.match( content, /disclosures/, `${file} must render the payload's \`disclosures[]\`. Wording kept in the payload stays in\n` + 'one place; wording paraphrased per command is a policy copy that drifts.', ); }); if (gatesApproval) { test(`${file} branches its approval question on requiresApproval`, async () => { const content = await readCommand(file); assert.match( content, /requiresApproval/, `${file} owns an AskUserQuestion before a write, so it must branch on\n` + '`requiresApproval` — a machine-wide change must not share a prompt with a local one.', ); assert.match( content, /outside\s+this\s+project/, `${file} must say the write leaves this project, in words. The scope class name alone\n` + 'is vocabulary the user has not been taught.', ); }); } } test('rollback shows absolute originals, not a repo-relative-looking form', async () => { const content = await readCommand('rollback.md'); // The measured defect: the result list rendered `- .claude/settings.json`, // which reads as project-local while the restore targets an absolute path. assert.doesNotMatch( content, /^\s*-\s+\.claude\/settings\.json/m, 'rollback renders a restore target in repo-relative form. The restore writes to the absolute\n' + 'original recorded at backup time, which may be machine-wide — the display must not imply\n' + 'otherwise.', ); }); test('campaign discloses the cross-repo write and does not refuse it', async () => { const content = await readCommand('campaign.md'); assert.match( content, /different\s+project/, 'campaign export writes into another repo by design, so the gate has to SAY so.', ); assert.match( content, /does\s+not\s+refuse|not\s+turn\s+this\s+into\s+a\s+refusal/, 'The template must record that this arm discloses rather than blocks — a later edit that\n' + '"tightens" it into a refusal would break a by-design feature (fasit §6 A1).', ); }); test('the scope-class table lives in exactly ONE module', async () => { const entries = await readdir(SCANNERS_DIR, { recursive: true }); const owners = []; for (const name of entries) { if (!name.endsWith('.mjs')) continue; const src = await readFile(resolve(SCANNERS_DIR, name), 'utf-8'); if (src.includes('plugin-managed')) owners.push(name); } assert.deepEqual( owners, ['lib/write-scope.mjs'], 'A second module naming the scope classes is a second copy of the policy. The class, its\n' + 'gate, its disclosure wording and its predicate belong in one export (#61: the lever table\n' + 'was found in five copies).', ); }); test('no command file restates the class table in prose', async () => { for (const { file } of GATED_COMMANDS) { const content = await readCommand(file); assert.doesNotMatch( content, /plugin-managed/, `${file} names an internal scope class. Templates render what the payload returns; a\n` + 'template that enumerates the classes is a policy copy in prose.', ); } });