/** * Session #56 — shape invariants for the ROUTER (`commands/config-audit.md`). * * `/config-audit` with no arguments is the plugin's front door: it auto-detects * scope, runs `scan-orchestrator` + `posture`, and renders the result. Dogfooding * it surfaced four defects that a scanner test could not see, because each lives * at the seam between the command template and something it does not own — the * shell, the scanner registry, the area registry, or the stderr stream. * * 1. **The orchestrator's exit code is discarded.** Step 3 runs both CLIs on one * line and appends a single `echo $?`, which reports only the LAST command. * Measured: * * $ node -e "process.exitCode=3" >/dev/null 2>/dev/null; \ * node -e "process.exitCode=0" 2>/dev/null; echo $? * 0 * * An orchestrator hard-error (exit 3) is therefore invisible to the very gate * step 3 defines for it ("3 → tell user … and stop"). * * 2. **The narrated scanner count is stale.** Step 3 tells the user "Running 12 * configuration scanners"; the orchestrator registers and runs 16. Asserted * against the registry rather than a literal, so the next scanner addition * cannot re-stale it silently. * * 3. **The Area Breakdown table drops areas.** Posture emits 10 areas (9 quality * areas plus Feature Coverage, which the template excludes by design). The * table hardcodes 7 rows, so `Token Efficiency` and `Plugin Hygiene` — both * real, both graded, one of them a B on this very repo — never reach the * user. Asserted against the area registry in `lib/scoring.mjs`. * * 4. **The template consumes a stream the same file mandates be thrown away.** * Step 6 instructs: "Use the headline line from the humanized stderr * scorecard … Avoid hardcoding a separate per-grade prose ladder." That * headline (`Health: A (93/100) — Healthy setup, only minor polish needed`) * exists ONLY on posture's stderr — measured absent from the JSON payload — * and step 3's fence sends posture's stderr to /dev/null, as UX rule 2 * requires. So the router is told to render something it cannot obtain and * forbidden from deriving a replacement; the slot can only be improvised. * `commands/posture.md` already shows the fix in-repo: redirect stderr to a * FILE (`2>/tmp/…-stderr.txt`), which satisfies "the user never sees it" * while keeping the text readable. * * This is the mirror image of [[stderr-only-warnings-invisible-to-commands]]: * there a WARNING was lost to /dev/null, here a REQUIRED INPUT is. */ import { test } from 'node:test'; import { strict as assert } from 'node:assert'; import { readFile } from 'node:fs/promises'; import { resolve, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT = resolve(__dirname, '..', '..'); const ROUTER = resolve(ROOT, 'commands', 'config-audit.md'); const router = await readFile(ROUTER, 'utf-8'); /** Scanner ids the orchestrator actually registers, read from its registry. */ async function registeredScanners() { const src = await readFile(resolve(ROOT, 'scanners', 'scan-orchestrator.mjs'), 'utf-8'); const block = src.slice(src.indexOf('const SCANNERS = [')); const arr = block.slice(0, block.indexOf('\n];')); return [...arr.matchAll(/\{\s*name:\s*'([A-Z]+)'/g)].map((m) => m[1]); } /** Distinct posture area names, read from the scanner→area registry. */ async function registeredAreas() { const src = await readFile(resolve(ROOT, 'scanners', 'lib', 'scoring.mjs'), 'utf-8'); const block = src.slice(src.indexOf('const SCANNER_AREA_MAP = {')); const arr = block.slice(0, block.indexOf('\n};')); return [...new Set([...arr.matchAll(/:\s*'([^']+)'/g)].map((m) => m[1]))]; } test('the router surfaces the exit code of BOTH scanner invocations', async () => { const fence = router.slice(router.indexOf('### Step 3'), router.indexOf('### Step 4')); const chained = fence .split('\n') .filter((l) => (l.match(/\bnode \$\{CLAUDE_PLUGIN_ROOT\}/g) || []).length > 1); assert.deepEqual( chained, [], 'Step 3 runs scan-orchestrator and posture on ONE line with a single trailing\n' + '`echo $?`, which reports only the LAST command. An orchestrator exit 3 echoes\n' + 'as posture\'s 0 and the "3 → stop" gate never fires. Capture each status into\n' + 'its own variable and echo both.\n' + 'Offending line(s):\n ' + chained.map((l) => l.trim().slice(0, 120)).join('\n '), ); assert.match( fence, /echo "\$[A-Z_]+ \$[A-Z_]+"/, 'Step 3 must echo both captured exit codes (e.g. `echo "$ORCH_STATUS $POSTURE_STATUS"`)\n' + 'so the gate can act on either scanner failing.', ); }); test('the scanner count the router narrates matches the orchestrator registry', async () => { const scanners = await registeredScanners(); const narrated = [...router.matchAll(/Running (\d+) configuration scanners/g)].map((m) => Number(m[1]), ); assert.ok(narrated.length > 0, 'The router should still narrate how many scanners are running.'); assert.deepEqual( narrated, narrated.map(() => scanners.length), `The router tells the user it is running ${narrated.join('/')} scanners; the ` + `orchestrator registers ${scanners.length} (${scanners.join(', ')}). A user-facing ` + 'count that no test binds to the registry goes stale on the next scanner added.', ); }); test('the Area Breakdown table has a row for every quality area posture emits', async () => { const areas = (await registeredAreas()).filter((a) => a !== 'Feature Coverage'); const table = router.slice(router.indexOf('### Area Breakdown'), router.indexOf('{For the status column')); const missing = areas.filter((a) => !table.includes(`| ${a} |`)); assert.deepEqual( missing, [], 'Posture grades these areas but the router\'s table has no row for them, so they\n' + 'are silently dropped from the user\'s results. (Feature Coverage is excluded by\n' + 'design — it is reported as opportunities, not as a quality grade.)\n' + `Areas emitted: ${areas.join(', ')}\n` + `Missing rows: ${missing.join(', ')}`, ); }); test('the router does not consume a stderr scorecard it discards', async () => { const fence = router.slice(router.indexOf('### Step 3'), router.indexOf('### Step 4')); const dependsOnStderr = /stderr scorecard/i.test(router); const postureDiscardsStderr = /posture\.mjs[^\n]*2>\/dev\/null/.test(fence); assert.ok( !(dependsOnStderr && postureDiscardsStderr), 'Step 6 renders "the headline line from the humanized stderr scorecard" and forbids\n' + 'deriving a grade-prose ladder instead — but step 3 sends posture\'s stderr to\n' + '/dev/null, and the prose is absent from the JSON payload (measured). The slot can\n' + 'only be improvised. Capture posture stderr to a FILE and read the headline from\n' + 'it, as commands/posture.md already does.', ); }); test('the --raw detection is not substring-based', async () => { const substringMatch = /grep -q -- "--raw"/.test(router); assert.ok( !substringMatch, 'Measured: `echo "$ARGUMENTS" | grep -q -- "--raw"` turns raw mode ON for `--rawdog`\n' + 'and for any path containing `--raw`. Anchor the match to whole arguments.', ); assert.match( router, /grep -qE -- '\(\^\| \)--raw\( \|\$\)'/, 'The --raw check must match a whole argument, not a substring.', ); });