`optimize --subtract` has only ever proposed. `--apply` executes the blocks the operator picks, behind a backup whose coverage is verified and a scope gate the engine enforces rather than describes. The open design decision from plan §C6 was settled by two measurements, not by taste. It is NOT a fix-engine action: the subtraction axis appears nowhere in scan-orchestrator or optimization-lens-scanner, so verifyFixes' re-scan would mark every removal `verified` whether or not it happened -- a success-shaped no-op, the same shape that made restoreBackup silently do nothing. It is NOT a plan/implement step either: that pipeline needs a finding code, and OPT declares exactly one, for the deterministic check. The approval artifact is written by main context, not by the lens agent. That is where the operator's decision actually happens, and it keeps the feature off the still-unmeasured agent write surface (M-BUG-18 lists optimize as open). Three properties are load-bearing, and each was seen red against its own defect: removals validate against the ORIGINAL content and apply in descending line order; the range check is not redundant with the text check (`line: 0` makes `slice(-1, 0)` empty, so an empty text MATCHES and `splice(-1, 1)` deletes the file's last line); and createBackup skips a nonexistent path while still returning an id, so manifest coverage is asserted before a byte changes. Two guards were green on their own defect and were fixed after measuring: `/\b80\s*%\b/` never matches "80% of the file" -- `%` is a non-word character, so the trailing `\b` demands a word character next. And the caller-arm sweep passed vacuously against HEAD, iterating an empty list; only the added non-emptiness assertion caught it. The floor is repeated, not moved: floor-exclusion still vetoes before anything is proposed, and the engine refuses a load-bearing block again so a hand-built approval cannot route around it. `mv` to `_archive/` is a file-level rule and does not apply to a block excision -- the timestamped backup is the recovery artifact, and a second copy with no restorer would be worse than none. strongestGate moves into write-scope.mjs so the gate ordering has one owner. Dogfooded DRY-RUN against the real ~/.claude/CLAUDE.md: 29 candidates, gate refused all 29 with exit 0 until the scope was approved, then 29/29 spans validated with nothing written. ~789 tokens, ~18% of the file -- corroborating the #40 fasit's ~850, and well short of what a deletion feature is tempted to promise. Suite 1625 -> 1659/0. Frozen v5.0.0 and default-output baselines untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017A6vrtPKsVuM4DJ27p7jzw
198 lines
8.8 KiB
JavaScript
198 lines
8.8 KiB
JavaScript
/**
|
|
* Session #51 — CLIs must reject an unknown flag, never ignore it.
|
|
*
|
|
* Third arm of the argument-handling class first measured in #44/#47/#50. The
|
|
* earlier arms were about a flag's VALUE being swallowed as the scan target
|
|
* (`else if (!args[i].startsWith('-')) targetPath = args[i]`). This arm is
|
|
* quieter and worse: several CLIs have no `else` branch at all, so an
|
|
* unrecognised flag falls out of the parse loop leaving no trace — exit 0, a
|
|
* full payload, and an answer to a question the caller did not ask.
|
|
*
|
|
* Measured cost, live, in the same session that wrote this test: the
|
|
* `knowledge-refresh` command's only user-facing knob (`--stale-after N`)
|
|
* reached the CLI as one malformed argv entry (see
|
|
* command-flag-value-portability.test.mjs). Because the CLI ignored it, the
|
|
* command reported "✓ All 14 register entries were re-verified within the last
|
|
* 90 days" — a true-sounding sentence about a threshold the user had just
|
|
* overridden. Had the CLI failed loudly, the shell bug would have been a
|
|
* one-line exit-3 message instead of a silent wrong answer.
|
|
*
|
|
* Scope of this guard: the CLIs whose commands were dogfooded in this chunk.
|
|
* `optimize-lens-cli.mjs` and `token-hotspots-cli.mjs` share the defect but
|
|
* also carry the still-open positional-swallow arm; both are fixed together in
|
|
* the v5.14 arg-handling chunk, where every call site's flags can be audited at
|
|
* once. They are listed in KNOWN_OPEN so the number stays visible rather than
|
|
* being quietly rounded down to zero.
|
|
*/
|
|
|
|
import { test } from 'node:test';
|
|
import { strict as assert } from 'node:assert';
|
|
import { spawn } from 'node:child_process';
|
|
import { readFile, mkdtemp, readdir, rm } from 'node:fs/promises';
|
|
import { resolve, dirname, join } from 'node:path';
|
|
import { tmpdir } from 'node:os';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const SCANNERS_DIR = resolve(__dirname, '..', '..', 'scanners');
|
|
|
|
/**
|
|
* Every CLI in `scanners/` that reads `process.argv`, with the argv it needs to
|
|
* get past required-arg checks and — where it has one — a value-taking flag.
|
|
*
|
|
* KNOWN_OPEN is gone. It named two CLIs; measuring all fourteen (#57) found
|
|
* **seven** open on the unknown-flag arm and **ten** on the value arm, including
|
|
* two CLIs that were already in GUARDED and passing. The deferral list was a
|
|
* prediction from the previous session's field of view, never a measurement.
|
|
*/
|
|
const GUARDED = [
|
|
{ cli: 'campaign-cli.mjs', argv: [], valueFlag: '--output-file' },
|
|
{ cli: 'knowledge-refresh-cli.mjs', argv: [], valueFlag: '--output-file' },
|
|
{ cli: 'campaign-write-cli.mjs', argv: ['init'], valueFlag: '--output-file' },
|
|
{ cli: 'campaign-export-cli.mjs', argv: ['--repo', '.'], valueFlag: '--output-file' },
|
|
{ cli: 'drift-cli.mjs', argv: [], valueFlag: '--baseline' },
|
|
{ cli: 'fix-cli.mjs', argv: ['--dry-run'], valueFlag: '--output-file' },
|
|
{ cli: 'plugin-health-scanner.mjs', argv: [], valueFlag: '--output-file' },
|
|
{ cli: 'manifest.mjs', argv: [], valueFlag: '--output-file' },
|
|
{ cli: 'optimize-lens-cli.mjs', argv: [], valueFlag: '--output-file' },
|
|
{ cli: 'posture.mjs', argv: [], valueFlag: '--context-window' },
|
|
{ cli: 'scan-orchestrator.mjs', argv: [], valueFlag: '--output-file' },
|
|
{ cli: 'token-hotspots-cli.mjs', argv: [], valueFlag: '--output-file' },
|
|
{ cli: 'whats-active.mjs', argv: [], valueFlag: '--output-file' },
|
|
{ cli: 'self-audit.mjs', argv: [], valueFlag: null }, // no value-taking flag
|
|
{ cli: 'write-scope-cli.mjs', argv: ['--target', 'x'], valueFlag: '--output-file' },
|
|
{ cli: 'subtraction-write-cli.mjs', argv: ['--approved', 'x'], valueFlag: '--output-file' },
|
|
];
|
|
|
|
/**
|
|
* GUARDED was hand-maintained, and a hand-maintained list of what to sweep is a
|
|
* premise rather than a measurement — the shape that made `KNOWN_OPEN` wrong
|
|
* about its own breadth (#57). Measured when this guard was added: the list was
|
|
* complete at 14 of 14, so nothing was hiding. It is the NEXT CLI that is at
|
|
* risk, so the coverage is derived from the directory instead of asserted about
|
|
* a literal.
|
|
*/
|
|
test('every argv-reading CLI in scanners/ appears in GUARDED', async () => {
|
|
const entries = await readdir(SCANNERS_DIR);
|
|
const clis = [];
|
|
for (const name of entries) {
|
|
if (!name.endsWith('.mjs')) continue;
|
|
const src = await readFile(resolve(SCANNERS_DIR, name), 'utf-8');
|
|
if (src.includes('process.argv')) clis.push(name);
|
|
}
|
|
|
|
const guarded = new Set(GUARDED.map((g) => g.cli));
|
|
const missing = clis.filter((c) => !guarded.has(c)).sort();
|
|
|
|
assert.deepEqual(
|
|
missing,
|
|
[],
|
|
'A CLI that reads process.argv but is absent from GUARDED is swept by nothing: it can\n' +
|
|
'accept an unknown flag, or write a file named after one, and both arms of this test\n' +
|
|
'would still be green. Add it to GUARDED rather than relaxing this check.',
|
|
);
|
|
});
|
|
|
|
function run(cli, argv, cwd = resolve(__dirname, '..', '..')) {
|
|
return new Promise((res) => {
|
|
const child = spawn(process.execPath, [resolve(SCANNERS_DIR, cli), ...argv], { cwd });
|
|
let stderr = '';
|
|
child.stderr.on('data', (d) => { stderr += d; });
|
|
child.stdout.on('data', () => {});
|
|
child.on('close', (code) => res({ code, stderr }));
|
|
});
|
|
}
|
|
|
|
for (const { cli, argv } of GUARDED) {
|
|
test(`${cli} rejects an unknown flag with exit 3`, async () => {
|
|
const { code, stderr } = await run(cli, [...argv, '--zzz-not-a-real-flag']);
|
|
|
|
assert.equal(
|
|
code,
|
|
3,
|
|
`${cli} accepted an unknown flag (exit ${code}). A flag the CLI does not understand\n` +
|
|
'must fail loudly — silently ignoring it turns a caller-side bug into a confident\n' +
|
|
'wrong answer. stderr was: ' + JSON.stringify(stderr),
|
|
);
|
|
assert.match(
|
|
stderr,
|
|
/--zzz-not-a-real-flag/,
|
|
`${cli} must name the offending flag so the caller can find it.`,
|
|
);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Arm 2 — the value arm, and the sharper of the two.
|
|
*
|
|
* `else if (a === '--output-file' && args[i + 1])` only asks whether a next token
|
|
* EXISTS, never whether it is a value. So `--output-file --json` takes `--json`
|
|
* as the filename: measured live (#57), `manifest`, `campaign-cli` and
|
|
* `knowledge-refresh-cli` each wrote a file literally named `--json` into the
|
|
* caller's working directory, exit 0, with `--json` mode silently dropped. A
|
|
* wrong answer is bad; writing an unintended file is worse.
|
|
*
|
|
* Two of the CLIs this catches were already in GUARDED and green on arm 1 — the
|
|
* guard was passing on one arm while the other stood open a few lines away
|
|
* ([[guard-can-be-green-on-its-own-defect]]).
|
|
*/
|
|
for (const { cli, argv, valueFlag } of GUARDED) {
|
|
if (!valueFlag) continue;
|
|
|
|
test(`${cli} rejects ${valueFlag} followed by another flag instead of a value`, async () => {
|
|
const scratch = await mkdtemp(join(tmpdir(), 'config-audit-argsluk-'));
|
|
try {
|
|
const { code, stderr } = await run(cli, [...argv, valueFlag, '--json'], scratch);
|
|
|
|
assert.equal(
|
|
code,
|
|
3,
|
|
`${cli} treated "--json" as the value of ${valueFlag} (exit ${code}). The flag needs a\n` +
|
|
'value; the next flag is not one. stderr was: ' + JSON.stringify(stderr),
|
|
);
|
|
assert.deepEqual(
|
|
await readdir(scratch),
|
|
[],
|
|
`${cli} wrote a file into the caller's working directory named after a FLAG. A CLI that\n` +
|
|
'misreads its own argv must not leave anything on disk.',
|
|
);
|
|
} finally {
|
|
await rm(scratch, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test(`${cli} rejects ${valueFlag} with no value at all`, async () => {
|
|
const { code } = await run(cli, [...argv, valueFlag]);
|
|
assert.equal(
|
|
code,
|
|
3,
|
|
`${cli} ignored a trailing ${valueFlag} (exit ${code}). Under the ux-rule every command\n` +
|
|
'invokes these CLIs as `--output-file <path> 2>/dev/null` — an empty <path> must fail\n' +
|
|
'loudly, not fall through to stdout the command has been told to discard.',
|
|
);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* The caller arm. #45/#46/#47 all taught the same lesson: fixing a CLI does not fix the
|
|
* command that reads its payload. `addedUnverified` and the `skipped` reasons only reach the
|
|
* user if the command template is told to report them — otherwise the CLI is honest into a
|
|
* void, and the phantom repo is just as invisible as before.
|
|
*/
|
|
const CAMPAIGN_MD = resolve(__dirname, '..', '..', 'commands', 'campaign.md');
|
|
|
|
test('campaign.md reports the fields the write-CLI added for honest coverage', async () => {
|
|
const content = await readFile(CAMPAIGN_MD, 'utf-8');
|
|
|
|
assert.match(
|
|
content,
|
|
/addedUnverified/,
|
|
'campaign.md must report `addedUnverified` after an add — a tracked path the CLI could\n' +
|
|
'not read is exactly the row that silently pollutes the backlog and the token bill.',
|
|
);
|
|
assert.match(
|
|
content,
|
|
/skipped/,
|
|
'campaign.md must report `skipped[]` after a token sweep so the bill\'s coverage is honest.',
|
|
);
|
|
});
|