fix(commands): stop answering questions the caller did not ask
Dogfooding `campaign` + `knowledge-refresh` against a throwaway ledger. Seven
defects, all found by running the commands as written and measuring, not by
reading them.
The headline pair only existed together. `knowledge-refresh` built
`STALE_AFTER="--stale-after 30"` and expanded it unquoted, trusting the shell to
split it in two. bash does; zsh — the macOS default, and what the Bash tool runs
here — does not. The CLI got one argv entry, matched no flag, and because it had
no unknown-flag branch, silently kept the 90-day default and 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. Fixing either half alone
leaves a silent wrong answer or a loud one; both are fixed, and a guard now
rejects any template that packs a flag and its value into one variable.
`knowledge-refresh` also read one register and wrote another: step 6 named an
unanchored `knowledge/best-practices.json` while the CLI reads
`${CLAUDE_PLUGIN_ROOT}/…`, which for an installed plugin is the cache. The
validation gate then ran the cached test against the cached register — green no
matter what was written. The two copies were byte-identical that day, which is
exactly why it was invisible.
`campaign` vouched for repos it could not read. `add /finnes/ikke` returned
`added` + exit 0; `refresh-tokens` then put the phantom in `swept[]` with a
0-token delta and left `skipped[]` empty, so the machine-wide bill claimed
coverage of three repos on a machine with two. Paths stay tracked — an unmounted
volume is a legitimate absence — but are reported as `addedUnverified`, and the
command names them.
Two class sweeps, both measured rather than assumed. `posture` was the single
scanner (1 of 14) whose fatal catch exited 1, which ux-rules defines as a normal
WARNING grade — a crash indistinguishable from a result. And all 13 payload
writers failed on a `--output-file` whose parent did not exist, which on a fresh
machine turned `campaign`'s first run into "the ledger may be corrupt"; they now
share `scanners/lib/write-output.mjs`.
Predicted breadth was too wide for the first time in five sessions: 6 of 8 CLIs
predicted to lack unknown-flag rejection, 4 measured. `drift` and `fix` already
reject them, via a construct the grep did not recognise — a grep matches an
implementation, the invariant is a behaviour. The sweep was rewritten to run each
CLI with a bogus flag and read the exit code.
Suite 1453 → 1469/0. Frozen snapshots untouched. `optimize-lens-cli` and
`token-hotspots-cli` share the unknown-flag defect and are deferred to the v5.14
argument-handling chunk with their positional-swallow arm; the count is recorded
in the guard rather than rounded down to zero.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012NHWjN8EnoxSqRvMTLK2NE
This commit is contained in:
parent
acd1cf1248
commit
caea8aca23
23 changed files with 742 additions and 48 deletions
|
|
@ -23,7 +23,7 @@
|
|||
*/
|
||||
|
||||
import { resolve } from 'node:path';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import { writeOutputFile } from './lib/write-output.mjs';
|
||||
import {
|
||||
loadLedger,
|
||||
validateLedger,
|
||||
|
|
@ -52,6 +52,11 @@ async function main() {
|
|||
const a = args[i];
|
||||
if (a === '--ledger-file' && args[i + 1]) ledgerFile = args[++i];
|
||||
else if (a === '--output-file' && args[i + 1]) outputFile = args[++i];
|
||||
// A flag we do not understand must fail loudly. Silently dropping it lets a
|
||||
// caller-side mistake — a typo'd `--ledger-file`, or a shell that did not
|
||||
// word-split "--flag value" into two argv entries — produce a confident
|
||||
// report about the wrong ledger.
|
||||
else if (a.startsWith('--')) fail(`unknown flag "${a}"`);
|
||||
}
|
||||
|
||||
const ledgerPath = resolve(ledgerFile || defaultLedgerPath());
|
||||
|
|
@ -102,7 +107,7 @@ async function main() {
|
|||
}
|
||||
|
||||
const json = JSON.stringify(payload, null, 2);
|
||||
if (outputFile) await writeFile(outputFile, json, 'utf-8');
|
||||
if (outputFile) await writeOutputFile(outputFile, json, 'utf-8');
|
||||
else process.stdout.write(json + '\n');
|
||||
|
||||
process.exitCode = exitCode;
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@
|
|||
import { resolve, join, dirname } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
||||
import { writeOutputFile } from './lib/write-output.mjs';
|
||||
import {
|
||||
loadLedger,
|
||||
validateLedger,
|
||||
|
|
@ -78,7 +79,7 @@ function parseArgs(argv) {
|
|||
|
||||
async function emit(payload, outputFile, exitCode) {
|
||||
const json = JSON.stringify(payload, null, 2);
|
||||
if (outputFile) await writeFile(outputFile, json, 'utf-8');
|
||||
if (outputFile) await writeOutputFile(outputFile, json, 'utf-8');
|
||||
else process.stdout.write(json + '\n');
|
||||
process.exitCode = exitCode;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,7 +43,8 @@
|
|||
*/
|
||||
|
||||
import { resolve } from 'node:path';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import { stat } from 'node:fs/promises';
|
||||
import { writeOutputFile } from './lib/write-output.mjs';
|
||||
import {
|
||||
createLedger,
|
||||
addRepo,
|
||||
|
|
@ -89,6 +90,23 @@ function parseArgs(argv) {
|
|||
return { subcommand: positionals[0], rest: positionals.slice(1), flags };
|
||||
}
|
||||
|
||||
/**
|
||||
* Can this path actually be read as a repo right now?
|
||||
*
|
||||
* `readActiveConfig` resolves any string and its sub-readers all tolerate ENOENT, so a repo
|
||||
* that does not exist yields an EMPTY config instead of an error. Without this check the
|
||||
* sweep records a phantom repo as successfully swept with 0 tokens, and the machine-wide
|
||||
* bill claims coverage it does not have. A missing path is reported, never rejected — an
|
||||
* unmounted volume is a legitimate reason for a tracked repo to be absent today.
|
||||
*/
|
||||
async function isReadableRepoDir(path) {
|
||||
try {
|
||||
return (await stat(path)).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Load an existing ledger, treating a parse error as a hard failure (never clobber corrupt data). */
|
||||
async function loadOrFail(path) {
|
||||
try {
|
||||
|
|
@ -100,7 +118,7 @@ async function loadOrFail(path) {
|
|||
|
||||
async function emit(payload, outputFile, exitCode) {
|
||||
const json = JSON.stringify(payload, null, 2);
|
||||
if (outputFile) await writeFile(outputFile, json, 'utf-8');
|
||||
if (outputFile) await writeOutputFile(outputFile, json, 'utf-8');
|
||||
else process.stdout.write(json + '\n');
|
||||
process.exitCode = exitCode;
|
||||
}
|
||||
|
|
@ -146,6 +164,7 @@ async function main() {
|
|||
let ledger = loaded === null ? createLedger({ now }) : loaded;
|
||||
|
||||
const added = [];
|
||||
const addedUnverified = [];
|
||||
const skipped = [];
|
||||
for (const p of paths) {
|
||||
const resolved = resolve(p);
|
||||
|
|
@ -153,13 +172,17 @@ async function main() {
|
|||
// --name applies only to a lone path; multi-add lets the lib derive each basename.
|
||||
const name = paths.length === 1 ? flags.name || undefined : undefined;
|
||||
ledger = addRepo(ledger, { path: p, name }, { now });
|
||||
(present ? skipped : added).push(resolved);
|
||||
if (present) skipped.push(resolved);
|
||||
else if (await isReadableRepoDir(resolved)) added.push(resolved);
|
||||
// Tracked either way, but never silently vouched for: the command reports these
|
||||
// separately so a typo does not become a permanent phantom row in the backlog.
|
||||
else addedUnverified.push(resolved);
|
||||
}
|
||||
await saveLedger(ledgerPath, ledger);
|
||||
return emit(
|
||||
{
|
||||
status: 'ok', action: 'add', written: true, autoInitialized, ledgerPath,
|
||||
added, skipped, repos: ledger.repos, rollUp: rollUp(ledger),
|
||||
added, addedUnverified, skipped, repos: ledger.repos, rollUp: rollUp(ledger),
|
||||
},
|
||||
flags.outputFile,
|
||||
0,
|
||||
|
|
@ -216,6 +239,13 @@ async function main() {
|
|||
let sharedSummary = null;
|
||||
|
||||
for (const repo of ledger0.repos) {
|
||||
// Check readability FIRST: readActiveConfig returns an empty config for a path that
|
||||
// does not exist rather than throwing, so the catch below would never see it and the
|
||||
// repo would be recorded as swept with a 0-token delta — a bill that looks complete.
|
||||
if (!(await isReadableRepoDir(repo.path))) {
|
||||
skipped.push({ path: repo.path, reason: 'repo path is not readable (does not exist or is not a directory)' });
|
||||
continue;
|
||||
}
|
||||
let split;
|
||||
try {
|
||||
const activeConfig = await readActiveConfig(repo.path, { verbose: false });
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
*/
|
||||
|
||||
import { resolve } from 'node:path';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import { writeOutputFile } from './lib/write-output.mjs';
|
||||
import { runAllScanners } from './scan-orchestrator.mjs';
|
||||
import { diffEnvelopes, formatDiffReport } from './lib/diff-engine.mjs';
|
||||
import { saveBaseline, loadBaseline, listBaselines } from './lib/baseline.mjs';
|
||||
|
|
@ -73,7 +73,7 @@ async function main() {
|
|||
// human listing below goes to stderr, so without --output-file the command
|
||||
// received 0 bytes and could render nothing. The flag was already accepted
|
||||
// by the arg parser; only list mode ignored it.
|
||||
if (outputFile) await writeFile(outputFile, JSON.stringify(result, null, 2) + '\n', 'utf-8');
|
||||
if (outputFile) await writeOutputFile(outputFile, JSON.stringify(result, null, 2) + '\n', 'utf-8');
|
||||
if (jsonMode || rawMode) {
|
||||
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
||||
} else {
|
||||
|
|
@ -190,7 +190,7 @@ async function main() {
|
|||
// otherwise; stdout is unaffected.
|
||||
if (outputFile) {
|
||||
const fileDiff = (jsonMode || rawMode) ? diff : humanizedDiff;
|
||||
await writeFile(outputFile, JSON.stringify(fileDiff, null, 2), 'utf-8');
|
||||
await writeOutputFile(outputFile, JSON.stringify(fileDiff, null, 2), 'utf-8');
|
||||
process.stderr.write(`\nResults written to ${outputFile}\n`);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
*/
|
||||
|
||||
import { resolve } from 'node:path';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import { writeOutputFile } from './lib/write-output.mjs';
|
||||
import { runAllScanners } from './scan-orchestrator.mjs';
|
||||
import { planFixes, applyFixes, verifyFixes } from './fix-engine.mjs';
|
||||
import { createBackup } from './lib/backup.mjs';
|
||||
|
|
@ -134,7 +134,7 @@ async function main() {
|
|||
if (machineMode) {
|
||||
process.stdout.write(JSON.stringify(output, null, 2) + '\n');
|
||||
}
|
||||
if (outputFile) await writeFile(outputFile, JSON.stringify(output, null, 2) + '\n', 'utf-8');
|
||||
if (outputFile) await writeOutputFile(outputFile, JSON.stringify(output, null, 2) + '\n', 'utf-8');
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -235,7 +235,7 @@ async function main() {
|
|||
// --output-file carries the same payload to disk. ux-rules rule 2 requires
|
||||
// it: commands run scanners with `2>/dev/null`, so anything the command has
|
||||
// to act on must ride in a file, not in stdout or stderr.
|
||||
if (outputFile) await writeFile(outputFile, serialized, 'utf-8');
|
||||
if (outputFile) await writeOutputFile(outputFile, serialized, 'utf-8');
|
||||
|
||||
// Exit code follows the convention the other scanners use: 0 PASS,
|
||||
// 2 FAIL, 3 tool error. A failed fix used to exit 0, so a caller could not
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@
|
|||
*/
|
||||
|
||||
import { resolve } from 'node:path';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import { writeOutputFile } from './lib/write-output.mjs';
|
||||
import { loadRegister, REGISTER_PATH } from './lib/best-practices-register.mjs';
|
||||
import { assessFreshness, STALE_AFTER_DAYS_DEFAULT } from './lib/knowledge-refresh.mjs';
|
||||
|
||||
|
|
@ -60,6 +60,11 @@ async function main() {
|
|||
referenceDate = args[++i];
|
||||
if (!DATE_RE.test(referenceDate)) fail('--reference-date must be YYYY-MM-DD');
|
||||
}
|
||||
// A flag we do not understand must fail loudly. Silently dropping it is how
|
||||
// `--stale-after 30` — arriving as ONE argv entry from a shell that does not
|
||||
// word-split — became "all 14 entries are fresh within 90 days": a confident
|
||||
// answer to a question the caller did not ask.
|
||||
else if (a.startsWith('--')) fail(`unknown flag "${a}"`);
|
||||
}
|
||||
|
||||
// The clock is read here ONLY — the core takes an injected date and stays pure.
|
||||
|
|
@ -93,7 +98,7 @@ async function main() {
|
|||
};
|
||||
|
||||
const json = JSON.stringify(payload, null, 2);
|
||||
if (outputFile) await writeFile(outputFile, json, 'utf-8');
|
||||
if (outputFile) await writeOutputFile(outputFile, json, 'utf-8');
|
||||
else process.stdout.write(json + '\n');
|
||||
|
||||
process.exitCode = assessment.counts.stale > 0 ? 1 : 0;
|
||||
|
|
|
|||
39
scanners/lib/write-output.mjs
Normal file
39
scanners/lib/write-output.mjs
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
/**
|
||||
* write-output — the one place a scanner's `--output-file` payload is written.
|
||||
*
|
||||
* Every command in this plugin follows the same contract (`.claude/rules/ux-rules.md`):
|
||||
* run the scanner with `--output-file <path> 2>/dev/null`, check the exit code, then Read
|
||||
* the file. The path the command chooses is frequently one it has never created — e.g.
|
||||
* `commands/campaign.md` writes its report to
|
||||
* `~/.claude/config-audit/sessions/campaign-report.json`, which on a fresh machine does not
|
||||
* exist yet. That is precisely the FIRST run, the case campaign-cli otherwise handles
|
||||
* gracefully by reporting `initialized: false`.
|
||||
*
|
||||
* Before this helper existed, all 13 payload writers called `writeFile` directly and threw
|
||||
* ENOENT there. The exit code was 3, and the command's own exit-code table reads 3 as "the
|
||||
* input is missing or corrupt" — so the user was told the ledger might be corrupt and
|
||||
* warned off the one action that would have fixed anything. `saveLedger` had always created
|
||||
* its parent directory; the payload write simply never did. The asymmetry was accidental.
|
||||
*
|
||||
* Creating the parent is the honest behaviour: the caller asked for a file at a path, and
|
||||
* nothing about a missing intermediate directory is an error the caller can learn from.
|
||||
*/
|
||||
|
||||
import { writeFile, mkdir } from 'node:fs/promises';
|
||||
import { dirname } from 'node:path';
|
||||
|
||||
/**
|
||||
* Write a scanner payload, creating the parent directory if needed.
|
||||
*
|
||||
* Signature-compatible with `writeFile(path, contents, encoding)` so call sites are a pure
|
||||
* rename — the encoding argument is kept rather than defaulted away.
|
||||
*
|
||||
* @param {string} path - destination file
|
||||
* @param {string} contents - serialized payload
|
||||
* @param {string} [encoding='utf-8']
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function writeOutputFile(path, contents, encoding = 'utf-8') {
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
await writeFile(path, contents, encoding);
|
||||
}
|
||||
|
|
@ -40,7 +40,8 @@
|
|||
*/
|
||||
|
||||
import { resolve } from 'node:path';
|
||||
import { writeFile, stat } from 'node:fs/promises';
|
||||
import { stat } from 'node:fs/promises';
|
||||
import { writeOutputFile } from './lib/write-output.mjs';
|
||||
import { readActiveConfig, deriveLoadPattern } from './lib/active-config-reader.mjs';
|
||||
|
||||
// CLAUDE.md cascade files are all discovered by walking UP from the repo, so
|
||||
|
|
@ -287,7 +288,7 @@ async function main() {
|
|||
const json = JSON.stringify(output, null, 2);
|
||||
|
||||
if (outputFile) {
|
||||
await writeFile(outputFile, json, 'utf-8');
|
||||
await writeOutputFile(outputFile, json, 'utf-8');
|
||||
}
|
||||
|
||||
if (jsonMode || rawMode || !outputFile) {
|
||||
|
|
|
|||
|
|
@ -25,7 +25,8 @@
|
|||
*/
|
||||
|
||||
import { resolve, sep } from 'node:path';
|
||||
import { writeFile, readFile, stat } from 'node:fs/promises';
|
||||
import { readFile, stat } from 'node:fs/promises';
|
||||
import { writeOutputFile } from './lib/write-output.mjs';
|
||||
import { discoverConfigFiles } from './lib/file-discovery.mjs';
|
||||
import { resetCounter } from './lib/output.mjs';
|
||||
import { parseFrontmatter } from './lib/yaml-parser.mjs';
|
||||
|
|
@ -218,7 +219,7 @@ async function main() {
|
|||
|
||||
const json = JSON.stringify(payload, null, 2);
|
||||
if (outputFile) {
|
||||
await writeFile(outputFile, json, 'utf-8');
|
||||
await writeOutputFile(outputFile, json, 'utf-8');
|
||||
}
|
||||
if (!outputFile) {
|
||||
process.stdout.write(json + '\n');
|
||||
|
|
|
|||
|
|
@ -8,7 +8,8 @@
|
|||
* Zero external dependencies.
|
||||
*/
|
||||
|
||||
import { readdir, stat, readFile, writeFile } from 'node:fs/promises';
|
||||
import { readdir, stat, readFile } from 'node:fs/promises';
|
||||
import { writeOutputFile } from './lib/write-output.mjs';
|
||||
import { join, basename, resolve, sep } from 'node:path';
|
||||
import { finding, scannerResult, resetCounter } from './lib/output.mjs';
|
||||
import { SEVERITY } from './lib/severity.mjs';
|
||||
|
|
@ -803,7 +804,7 @@ async function main() {
|
|||
plugins,
|
||||
cross_plugin_findings: findings.filter(f => crossIds.has(f.id)),
|
||||
};
|
||||
await writeFile(outputFile, JSON.stringify(payload, null, 2), 'utf-8');
|
||||
await writeOutputFile(outputFile, JSON.stringify(payload, null, 2), 'utf-8');
|
||||
process.stderr.write(`\nResults written to ${outputFile}\n`);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
*/
|
||||
|
||||
import { resolve } from 'node:path';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import { writeOutputFile } from './lib/write-output.mjs';
|
||||
import { runAllScanners } from './scan-orchestrator.mjs';
|
||||
import { humanizeEnvelope } from './lib/humanizer.mjs';
|
||||
import {
|
||||
|
|
@ -123,7 +123,7 @@ async function main() {
|
|||
? result
|
||||
: { ...result, scannerEnvelope: humanizeEnvelope(result.scannerEnvelope) };
|
||||
const json = JSON.stringify(fileEnv, null, 2);
|
||||
await writeFile(outputFile, json, 'utf-8');
|
||||
await writeOutputFile(outputFile, json, 'utf-8');
|
||||
process.stderr.write(`\nResults written to ${outputFile}\n`);
|
||||
}
|
||||
}
|
||||
|
|
@ -133,6 +133,10 @@ const isDirectRun = process.argv[1] && resolve(process.argv[1]) === resolve(new
|
|||
if (isDirectRun) {
|
||||
main().catch(err => {
|
||||
process.stderr.write(`Fatal: ${err.message}\n`);
|
||||
process.exitCode = 1;
|
||||
// 3, not 1. Every command in this plugin is told that 0/1/2 are normal grades
|
||||
// (PASS/WARNING/FAIL) and only 3 is a real error, so exiting 1 here made a crash
|
||||
// indistinguishable from a WARNING — and the command went on to Read a payload file
|
||||
// that was never written.
|
||||
process.exitCode = 3;
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
|
||||
import { resolve, sep } from 'node:path';
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { writeOutputFile } from './lib/write-output.mjs';
|
||||
import { resetCounter } from './lib/output.mjs';
|
||||
import { envelope } from './lib/output.mjs';
|
||||
import { discoverConfigFiles, discoverConfigFilesMulti, discoverFullMachinePaths } from './lib/file-discovery.mjs';
|
||||
|
|
@ -278,7 +279,7 @@ async function main() {
|
|||
const json = JSON.stringify(output, null, 2);
|
||||
|
||||
if (outputFile) {
|
||||
await writeFile(outputFile, json, 'utf-8');
|
||||
await writeOutputFile(outputFile, json, 'utf-8');
|
||||
process.stderr.write(`\nResults written to ${outputFile}\n`);
|
||||
} else {
|
||||
process.stdout.write(json + '\n');
|
||||
|
|
|
|||
|
|
@ -14,7 +14,8 @@
|
|||
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { writeFile, readFile, stat } from 'node:fs/promises';
|
||||
import { readFile, stat } from 'node:fs/promises';
|
||||
import { writeOutputFile } from './lib/write-output.mjs';
|
||||
import { discoverConfigFiles } from './lib/file-discovery.mjs';
|
||||
import { resetCounter } from './lib/output.mjs';
|
||||
import { scan } from './token-hotspots.mjs';
|
||||
|
|
@ -131,7 +132,7 @@ async function main() {
|
|||
const json = JSON.stringify(payload, null, 2);
|
||||
|
||||
if (outputFile) {
|
||||
await writeFile(outputFile, json, 'utf-8');
|
||||
await writeOutputFile(outputFile, json, 'utf-8');
|
||||
}
|
||||
|
||||
if (jsonMode || rawMode || !outputFile) {
|
||||
|
|
|
|||
|
|
@ -13,7 +13,8 @@
|
|||
*/
|
||||
|
||||
import { resolve } from 'node:path';
|
||||
import { writeFile, stat } from 'node:fs/promises';
|
||||
import { stat } from 'node:fs/promises';
|
||||
import { writeOutputFile } from './lib/write-output.mjs';
|
||||
import { readActiveConfig } from './lib/active-config-reader.mjs';
|
||||
|
||||
async function main() {
|
||||
|
|
@ -54,7 +55,7 @@ async function main() {
|
|||
const json = JSON.stringify(result, null, 2);
|
||||
|
||||
if (outputFile) {
|
||||
await writeFile(outputFile, json, 'utf-8');
|
||||
await writeOutputFile(outputFile, json, 'utf-8');
|
||||
}
|
||||
|
||||
if (jsonMode || rawMode || !outputFile) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue