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
91 lines
3.9 KiB
JavaScript
91 lines
3.9 KiB
JavaScript
/**
|
|
* Session #51 — command-template flag-value portability.
|
|
*
|
|
* Dogfooding `knowledge-refresh` surfaced a defect that only exists at the
|
|
* seam between the command template and the shell that runs it:
|
|
*
|
|
* STALE_AFTER="--stale-after 30"
|
|
* node …-cli.mjs --reference-date "$TODAY" $STALE_AFTER --output-file …
|
|
*
|
|
* The unquoted `$STALE_AFTER` is meant to split into TWO argv entries. Under
|
|
* **bash** it does. Under **zsh** — the macOS default since Catalina, and the
|
|
* shell the Bash tool actually runs on this machine — unquoted parameter
|
|
* expansions are NOT word-split, so the CLI receives ONE argv entry with the
|
|
* literal text `--stale-after 30`, matches no known flag, and (because the CLI
|
|
* silently ignored unknown flags — see cli-unknown-flag-rejection.test.mjs)
|
|
* falls back to the 90-day default while reporting success. Measured:
|
|
*
|
|
* $ STALE_AFTER="--stale-after 30"; set -- $STALE_AFTER; echo $#
|
|
* 1 # zsh (bash prints 2)
|
|
* → payload staleAfterDays: 90, exit 0, "✓ All 14 entries fresh"
|
|
*
|
|
* The user-facing knob was silently dead. Note the asymmetry that makes this
|
|
* survivable elsewhere: an EMPTY unquoted expansion yields ZERO argv entries in
|
|
* both shells, so the `FLAG=""` idiom used by ~25 other sites is portable. Only
|
|
* a variable that can hold a flag AND its value is affected.
|
|
*
|
|
* The invariant asserted here is therefore about VALUE-carrying flags, not
|
|
* about quoting in general: a command template must never depend on the shell
|
|
* splitting one variable into a flag plus its argument. Pass the value through
|
|
* its own quoted variable instead.
|
|
*/
|
|
|
|
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');
|
|
|
|
async function commandFiles() {
|
|
const entries = await readdir(COMMANDS_DIR);
|
|
return entries.filter((e) => e.endsWith('.md')).sort();
|
|
}
|
|
|
|
/**
|
|
* Assignments whose right-hand side contains a flag followed by a value —
|
|
* i.e. the value only reaches argv if the shell word-splits. Matches both
|
|
* `X="--flag value"` and `X="--flag $(cmd)"`.
|
|
*/
|
|
const MULTIWORD_FLAG_ASSIGN = /^\s*([A-Z_][A-Z0-9_]*)=(["'])(--[a-z0-9-]+)[ \t]+\S.*\2\s*$/;
|
|
|
|
test('no command template builds a flag AND its value into one shell variable', async () => {
|
|
const offenders = [];
|
|
|
|
for (const file of await commandFiles()) {
|
|
const content = await readFile(resolve(COMMANDS_DIR, file), 'utf-8');
|
|
content.split('\n').forEach((line, i) => {
|
|
const m = line.match(MULTIWORD_FLAG_ASSIGN);
|
|
if (m) offenders.push(`${file}:${i + 1} ${m[1]}=${m[2]}${m[3]} …${m[2]}`);
|
|
});
|
|
}
|
|
|
|
assert.deepEqual(
|
|
offenders,
|
|
[],
|
|
'A variable holding "--flag value" only reaches argv correctly if the shell\n' +
|
|
'word-splits an unquoted expansion. zsh does not. Pass the value in its own\n' +
|
|
'quoted variable instead:\n' +
|
|
' N=$(… extract …); [ -n "$N" ] && node cli.mjs --flag "$N"\n' +
|
|
'Offending assignments:\n ' + offenders.join('\n '),
|
|
);
|
|
});
|
|
|
|
test('knowledge-refresh.md passes --stale-after with a quoted value', async () => {
|
|
const content = await readFile(resolve(COMMANDS_DIR, 'knowledge-refresh.md'), 'utf-8');
|
|
|
|
assert.ok(
|
|
!/\$STALE_AFTER\b(?!")/.test(content.replace(/"\$STALE_AFTER"/g, '')),
|
|
'knowledge-refresh.md still expands a flag-carrying variable unquoted; under zsh the\n' +
|
|
'threshold silently reverts to the 90-day default while the command reports success.',
|
|
);
|
|
|
|
assert.match(
|
|
content,
|
|
/--stale-after "\$[A-Z_]+"/,
|
|
'knowledge-refresh.md must pass the extracted threshold as its own quoted argument\n' +
|
|
'(`--stale-after "$STALE_AFTER_DAYS"`), so no word-splitting is required.',
|
|
);
|
|
});
|