config-audit/tests/commands/group-b-shape.test.mjs
Kjell Tore Guttormsen 09f817977c fix(commands): stop assuming shell state survives between blocks
Dogfooding `plan` + `implement` against a throwaway config surfaced one root
defect with many arms: the command templates treat consecutive fenced blocks as
one shell. They are not. Every ```bash fence runs as its own Bash call in its own
process, so a variable set in one block is empty in the next, and `$$` is a
different PID (measured: 21710 vs 22109).

The planner agent confirmed the sharpest arm at runtime, reporting that
`Mode: $RAW_FLAG` "arrived literally unsubstituted" — `--raw` was documented in
three command files while being functionally dead. A machine sweep found the same
root in 20 places across 9 files, well past the two the written fasit predicted:

  - `$RAW_FLAG` read from non-shell agent prompts (analyze, plan, implement)
  - `$TMPFILE` read across blocks (tokens, manifest, whats-active,
    plugin-health) — each command could not read the file it had just written
  - `$GLOBAL_FLAG` across blocks (fix)
  - `$TODAY` never assigned in any block (campaign), passing
    `--reference-date ""` to a write CLI in six places
  - three `$$` temp paths handed to the Read tool (fix), which expands neither

All now follow the hardened drift.md pattern: a fixed literal path, or a
re-derivation inside each block that needs it.

Also fixed, all confirmed against ground truth rather than inferred:

  - `implement` printed a rollback ID it never captured (the timestamp lived only
    inside a command substitution) — the one message a user reads after a bad run
  - `plan` reported "No analysis results found" for valid sessions, because Read
    was pointed at a glob it cannot expand; now uses Glob and verifies the
    analysis report exists before spawning the agent
  - five phase commands wrote state.yaml with two of four required fields; since
    the agent writes all four, a follow-up write silently deleted the rest
  - `implement` promised rollback deletes created files; rollback deliberately
    leaves them (M-BUG-26 still open) — the doc, not the engine, was wrong
  - `implement` claimed a score delta with no pre-change measurement
  - `verifier-agent` was told to write a report it has no tool to write
  - dead `Task` tool name in always-loaded rule context; planner-agent template
    demonstrated the inline file content its own line 110 forbids

The sweeps land as tests/commands/command-shell-state-shape.test.mjs, verified
red before the fix and proven able to fail by reintroducing the defect. Two
existing tests asserted the old bash-block mechanism rather than the intent and
were updated. Suite 1449/0; frozen v5.0.0 snapshots and all scanner code
untouched.

Not fixed, deliberately: neither command scope-gates its actions to the audit
target. The generated plan included an edit to a real file under ~/.claude,
outside the throwaway target, because the skill/agent scanners are machine-wide.
That is a design change, not a side fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0195udHgCcFegzm7ecKku2Yc
2026-08-01 20:12:17 +02:00

180 lines
7.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Wave 5 Step 14 — Group B command-template shape tests.
*
* Verifies that the 6 audit/analysis command templates in Group B have the
* correct structural shape after the humanizer integration:
*
* - All 6 files: contain a Bash invocation block, reference the Read tool,
* and contain the `--raw` flag (or the literal `"$ARGUMENTS"` string).
*
* - Findings-rendering files (drift.md, plugin-health.md, config-audit.md,
* discover.md, analyze.md): reference at least one of
* `userImpactCategory|userActionLanguage|relevanceContext`, and do NOT
* contain hardcoded grade-prose tables of the form `[ABCDF]\s+grade\s+is`.
*
* - status.md: phase-label table is present, the machine field name
* `current_phase` is preserved (machine contract), and at least one
* humanized phase label appears ("Looking at your config files",
* "Working out what to recommend", "Putting together your action plan",
* "Making the changes", "Double-checking everything worked").
*
* - Anchor must-contains from plan line 575579:
* - config-audit.md: contains userImpactCategory|userActionLanguage
* - drift.md: contains --raw OR humanized
*/
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 COMMANDS_DIR = resolve(__dirname, '..', '..', 'commands');
const GROUP_B_FILES = [
'drift.md',
'plugin-health.md',
'config-audit.md',
'discover.md',
'analyze.md',
'status.md',
];
const FINDINGS_RENDERING_FILES = [
'drift.md',
'plugin-health.md',
'config-audit.md',
'discover.md',
'analyze.md',
];
const HUMANIZED_FIELD_REGEX = /userImpactCategory|userActionLanguage|relevanceContext/;
const RAW_OR_ARGUMENTS_REGEX = /--raw|"\$ARGUMENTS"/;
const HARDCODED_GRADE_PROSE_REGEX = /[ABCDF]\s+grade\s+is/;
const BASH_BLOCK_REGEX = /```bash\b/;
const READ_TOOL_REGEX = /\bRead\s+tool\b|allowed-tools:.*\bRead\b/;
const HUMANIZED_PHASE_LABELS = [
'Looking at your config files',
'Working out what to recommend',
'Asking what you',
'Putting together your action plan',
'Making the changes',
'Double-checking everything worked',
];
async function readCommand(name) {
return await readFile(resolve(COMMANDS_DIR, name), 'utf-8');
}
// Agent-driven commands invoke no scanner, so they have no bash block to
// assert. analyze.md's only bash block used to be a `RAW_FLAG=` assignment
// referenced from the agent prompt below it — a prompt is not a shell, so the
// agent received the literal string `$RAW_FLAG` (session #49). Removing that
// block is the fix; requiring one here would re-assert the defect.
const AGENT_DRIVEN = new Set(['analyze.md']);
test('Group B: every scanner-invoking file contains a Bash invocation block', async () => {
for (const name of GROUP_B_FILES) {
if (AGENT_DRIVEN.has(name)) continue;
const content = await readCommand(name);
assert.match(content, BASH_BLOCK_REGEX, `${name} missing bash block`);
}
});
test('Group B: agent-driven files spawn an Agent instead of a scanner', async () => {
for (const name of AGENT_DRIVEN) {
const content = await readCommand(name);
assert.match(content, /Agent\(subagent_type:/, `${name} should spawn an Agent`);
assert.doesNotMatch(
content,
/RAW_FLAG=/,
`${name} must not assign a shell variable it then references from an agent prompt`,
);
}
});
test('Group B: every file references the Read tool', async () => {
for (const name of GROUP_B_FILES) {
const content = await readCommand(name);
assert.match(content, READ_TOOL_REGEX, `${name} missing Read tool reference`);
}
});
test('Group B: every file contains --raw or "$ARGUMENTS" (pass-through plumbing)', async () => {
for (const name of GROUP_B_FILES) {
const content = await readCommand(name);
assert.match(content, RAW_OR_ARGUMENTS_REGEX, `${name} missing --raw / $ARGUMENTS plumbing`);
}
});
test('Group B findings-renderers: reference at least one humanized field', async () => {
for (const name of FINDINGS_RENDERING_FILES) {
const content = await readCommand(name);
assert.match(
content,
HUMANIZED_FIELD_REGEX,
`${name} must reference userImpactCategory, userActionLanguage, or relevanceContext`,
);
}
});
test('Group B findings-renderers: no hardcoded grade-prose tables', async () => {
for (const name of FINDINGS_RENDERING_FILES) {
const content = await readCommand(name);
assert.doesNotMatch(
content,
HARDCODED_GRADE_PROSE_REGEX,
`${name} contains a hardcoded "[grade] grade is..." prose table — humanizer owns grade vocabulary now`,
);
}
});
test('Group B anchor: config-audit.md references userImpactCategory|userActionLanguage', async () => {
const content = await readCommand('config-audit.md');
assert.match(content, /userImpactCategory|userActionLanguage/);
});
test('Group B anchor: drift.md references --raw or humanized', async () => {
const content = await readCommand('drift.md');
assert.match(content, /--raw|humanized/);
});
test('status.md: preserves current_phase machine field and adds humanized phase labels', async () => {
const content = await readCommand('status.md');
// Machine contract preserved
assert.match(content, /\bcurrent_phase\b/, 'status.md must keep current_phase as machine field');
// At least 3 of the 6 humanized phase labels appear
const present = HUMANIZED_PHASE_LABELS.filter(label => content.includes(label));
assert.ok(
present.length >= 3,
`status.md must include at least 3 humanized phase labels; found ${present.length}: ${present.join(', ')}`,
);
});
// ---------------------------------------------------------------------------
// Økt #46 — ux-rules rule 2 for the plugin-health scanner.
//
// plugin-health.md passed the humanized-field assertion above while the data it
// names was unreachable: the scanner had no --output-file, and its default-mode
// report went to stderr, which the command discards with `2>/dev/null`. A .md
// contract test that only greps for prose cannot catch that — these assert the
// plumbing that makes the prose true.
// ---------------------------------------------------------------------------
test('plugin-health.md invokes the scanner with --output-file (ux-rules rule 2)', async () => {
const content = await readCommand('plugin-health.md');
const call = content.split('\n').find(l => l.includes('plugin-health-scanner.mjs'));
assert.ok(call, 'plugin-health.md must invoke plugin-health-scanner.mjs');
assert.match(call, /--output-file/, 'scanner call must write to a file, not stdout/stderr');
});
test('posture.md invokes the plugin-health and drift scanners with --output-file', async () => {
const content = await readCommand('posture.md');
for (const scanner of ['plugin-health-scanner.mjs', 'drift-cli.mjs']) {
const call = content.split('\n').find(l => l.includes(`scanners/${scanner}`));
assert.ok(call, `posture.md must invoke ${scanner}`);
assert.match(call, /--output-file/, `${scanner} call in posture.md discards its output`);
}
});