fix(commands): close the promises the command templates could not keep
DEL B chunk `interview` (+ discover/status/cleanup/help). Fasit written before the run predicted 8 defects and refuted 4 candidates; all 8 confirmed, all 4 refutations held, and three predictions turned out too narrow. - M-BUG-36: `drift --list` reached the command as 0 bytes. drift-cli accepted --output-file but list mode ignored it, and the listing goes to stderr, which the command discards per ux-rules rule 2. Fixing the caller alone would not have helped. - M-BUG-37: feature-gap's "Create backup" step ran fix-cli without --apply. Dry-run is the default, so no backup existed (backupId: null) while the command went on to edit config believing it could roll back. - M-BUG-38: fix-cli told users to recover with scanners/rollback-cli.mjs, which does not exist. Dead reference in the one message read after a bad fix. - M-BUG-21 fourth arm: five templates carried literal [--global]/[--full-machine] inside executable bash blocks. A bracketed placeholder does not start with a dash, so every scanner's arg loop takes it as the scan target. - interview and analyze never said which session they act on; interview could rewind a finished session; cleanup interpolated an unvalidated id into rm -rf (an empty id deletes every session); status advertised a `resume` command that does not exist and documented an `all` argument it never parsed. TDD: 9 red tests first, including a machine sweep for dead /config-audit references and for bracketed flags in bash blocks. Suite 1432 -> 1441/0. Frozen v5.0.0 snapshots untouched; --raw/--json contracts unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UGvA1uUQn2hPBPMaCKK6x3
This commit is contained in:
parent
001090261e
commit
b85919f2ec
17 changed files with 318 additions and 28 deletions
41
CHANGELOG.md
41
CHANGELOG.md
|
|
@ -8,6 +8,47 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
- **`M-BUG-36` — `/config-audit drift --list` showed nothing.** `drift-cli.mjs` accepted
|
||||
`--output-file` but list mode ignored it, and the listing itself goes to **stderr**, which
|
||||
`commands/drift.md` discards with `2>/dev/null` (ux-rules rule 2). The command received **0 bytes**
|
||||
and could render no baselines at all. List mode now honours `--output-file`; `--raw`/`--json` stdout
|
||||
is unchanged and byte-stable. Fourth instance of the stderr-only class after `M-BUG-33`.
|
||||
- **`M-BUG-37` — `/config-audit feature-gap` promised a backup it never made.** Step 6's
|
||||
"Create backup" ran `fix-cli.mjs <path> --json`, but fix-cli is **dry-run by default**: no backup was
|
||||
written and `backupId` came back `null`, after which the command edited the user's configuration
|
||||
believing it could be restored. Passing `--apply` would have been worse — it executes unrelated
|
||||
auto-fixes the user never selected. The step now copies the files itself and states plainly that
|
||||
plain copies are restored by copying them back, not by `/config-audit rollback` (`M-BUG-31` class).
|
||||
- **`M-BUG-38` — `fix-cli.mjs` sent users to a script that does not exist.** After applying fixes it
|
||||
printed `Rollback: node scanners/rollback-cli.mjs <id>`; there is no `rollback-cli.mjs` — only
|
||||
`rollback-engine.mjs`, driven by `/config-audit rollback`. A dead reference in the one message a
|
||||
user reaches for after a bad fix. Now points at the command.
|
||||
- **`M-BUG-21` (fourth arm) — command templates fed bracketed placeholder flags to the arg loops.**
|
||||
Five templates (`config-audit.md`, `discover.md`, `fix.md`, `tokens.md`, `whats-active.md`) carried
|
||||
literal `[--global]` / `[--full-machine]` / `[--verbose]` inside executable bash blocks. A bracketed
|
||||
placeholder does not start with `-`, so every scanner's `else if (!args[i].startsWith('-'))` branch
|
||||
takes it as the **scan target** — silently scanning a path that does not exist. Replaced with empty
|
||||
shell variables that expand to nothing when the flag does not apply.
|
||||
- **`/config-audit interview` and `analyze` never said which session they act on.** Both referenced
|
||||
`{session-id}` with no resolution rule, while every other session-aware command globs
|
||||
`sessions/*/state.yaml` and takes the most recent. Two runs could write to two different sessions.
|
||||
Both now resolve the session explicitly and exit when none exists.
|
||||
- **`/config-audit interview` could rewind a finished session.** The mandated state write had no bound,
|
||||
so running the optional interview against a session that had already reached `implement` reset
|
||||
`current_phase` and re-added phases. It now appends `interview` only if absent and leaves the
|
||||
furthest phase reached intact.
|
||||
- **`/config-audit cleanup` interpolated an unvalidated id into `rm -rf`.** An empty or malformed
|
||||
`{session-id}` expands the path to `sessions//`, deleting **every** session. The id must now match
|
||||
`^[0-9]{8}_[0-9]{6}$` or come verbatim from the directory listing; anything else is refused and
|
||||
reported.
|
||||
- **`/config-audit status` advertised a command that does not exist.** It documented
|
||||
`/config-audit resume {session-id}`; there is no `resume` command. Replaced with how session
|
||||
selection actually works. A test now fails on any `/config-audit <word>` reference in `commands/`
|
||||
without a matching file.
|
||||
- **`/config-audit status all` was documented but never parsed.** The flag-parse step knew only
|
||||
`--raw`. It now parses `all` and routes to the all-sessions table.
|
||||
|
||||
### Fixed (previously)
|
||||
- **`M-BUG-21` (third arm) — `plugin-health-scanner.mjs` swallowed unknown flags, and the wrong
|
||||
target looked *green*.** The same `else if (!args[i].startsWith('-')) targetPath = args[i]` loop:
|
||||
`--output-file /tmp/x.json` was dropped and `/tmp/x.json` became the scan target. Where `drift`
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
A Claude Code plugin that checks configuration health, suggests context-aware improvements, and auto-fixes issues — `CLAUDE.md`, `settings.json`, hooks, rules, MCP servers, `@imports`, and plugins. 16 deterministic scanners across 10 quality areas, context-aware feature recommendations, auto-fix with backup/rollback, a prompt-cache-aware Token Hotspots scanner with optional API-calibrated `--accurate-tokens` mode, plus cache-prefix stability, dead-tool, cross-plugin collision, output-style, and always-loaded agent-listing-budget detection. Zero external dependencies.
|
||||
|
|
|
|||
|
|
@ -20,9 +20,16 @@ Generate comprehensive analysis report from discovery findings.
|
|||
|
||||
## Implementation
|
||||
|
||||
### Step 1: Verify session state
|
||||
### Step 1: Resolve the session and verify its state
|
||||
|
||||
Read `~/.claude/config-audit/sessions/{session-id}/state.yaml` using the Read tool and verify discovery phase completed. If not, tell the user: "Discovery hasn't been run yet. Start with `/config-audit discover` or just run `/config-audit` for a full audit."
|
||||
Find the session first — never guess which one `{session-id}` refers to:
|
||||
|
||||
```
|
||||
Glob: ~/.claude/config-audit/sessions/*/state.yaml
|
||||
Sort by modification time — the most recently modified session wins
|
||||
```
|
||||
|
||||
Every `{session-id}` below is that session's id. Read its `state.yaml` using the Read tool and verify discovery phase completed. If the Glob returns nothing, or discovery hasn't completed, tell the user: "Discovery hasn't been run yet. Start with `/config-audit discover` or just run `/config-audit` for a full audit."
|
||||
|
||||
### Step 2: Tell the user what's happening
|
||||
|
||||
|
|
|
|||
|
|
@ -75,7 +75,13 @@ Manage and clean up accumulated config-audit sessions in `~/.claude/config-audit
|
|||
- Warn before deleting active sessions: "Session {id} is still active (phase: {phase}). Delete anyway?"
|
||||
|
||||
6. **Execute cleanup**:
|
||||
- For each session to delete: `rm -rf ~/.claude/config-audit/sessions/{session-id}/`
|
||||
- **Validate the id before it ever reaches `rm -rf`.** Each `{session-id}`
|
||||
must match `^[0-9]{8}_[0-9]{6}$` (the id format `discover` generates) or be
|
||||
an existing directory name read verbatim from the Glob in step 1. If an id
|
||||
is empty or fails to match, **refuse to delete it**, report it, and continue
|
||||
with the rest. An empty id expands the path to
|
||||
`~/.claude/config-audit/sessions//`, which deletes *every* session.
|
||||
- For each validated session: `rm -rf ~/.claude/config-audit/sessions/{session-id}/`
|
||||
- Track deleted count and freed space
|
||||
|
||||
7. **Output summary**:
|
||||
|
|
|
|||
|
|
@ -90,7 +90,11 @@ Run both scanners and posture in a single Bash command. Default mode runs the hu
|
|||
```bash
|
||||
RAW_FLAG=""
|
||||
if echo "$ARGUMENTS" | grep -q -- "--raw"; then RAW_FLAG="--raw"; fi
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/scan-orchestrator.mjs <target-path> --output-file ~/.claude/config-audit/sessions/{session-id}/findings/scan-results.json [--full-machine] [--global] $RAW_FLAG 2>/dev/null; node ${CLAUDE_PLUGIN_ROOT}/scanners/posture.mjs <target-path> --output-file ~/.claude/config-audit/sessions/{session-id}/posture.json [--full-machine] [--global] $RAW_FLAG 2>/dev/null; echo $?
|
||||
# Set to the scope flags the detected scope calls for, otherwise leave empty.
|
||||
# A placeholder in square brackets does not start with a dash, so both CLIs'
|
||||
# arg loops would take it as the TARGET PATH instead of a flag.
|
||||
SCOPE_FLAGS="" # e.g. SCOPE_FLAGS="--full-machine" or SCOPE_FLAGS="--global"
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/scan-orchestrator.mjs <target-path> --output-file ~/.claude/config-audit/sessions/{session-id}/findings/scan-results.json $SCOPE_FLAGS $RAW_FLAG 2>/dev/null; node ${CLAUDE_PLUGIN_ROOT}/scanners/posture.mjs <target-path> --output-file ~/.claude/config-audit/sessions/{session-id}/posture.json $SCOPE_FLAGS $RAW_FLAG 2>/dev/null; echo $?
|
||||
```
|
||||
|
||||
Use `--full-machine` for `full` scope, `--global` for `home` scope. For `repo` and `current`, pass the resolved path directly.
|
||||
|
|
|
|||
|
|
@ -72,7 +72,12 @@ Run the scan orchestrator silently to discover and scan files. Default mode emit
|
|||
```bash
|
||||
RAW_FLAG=""
|
||||
if echo "$ARGUMENTS" | grep -q -- "--raw"; then RAW_FLAG="--raw"; fi
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/scan-orchestrator.mjs <target-path> --output-file ~/.claude/config-audit/sessions/{session-id}/findings/scan-results.json [--full-machine] [--global] $RAW_FLAG 2>/dev/null; echo $?
|
||||
# Set to the flag itself for full/home scope, otherwise leave empty. Never pass
|
||||
# a placeholder wrapped in square brackets: it does not start with a dash, so
|
||||
# the orchestrator's arg loop takes it as the SCAN TARGET and silently scans a
|
||||
# path that does not exist.
|
||||
SCOPE_FLAGS="" # e.g. SCOPE_FLAGS="--full-machine" or SCOPE_FLAGS="--global"
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/scan-orchestrator.mjs <target-path> --output-file ~/.claude/config-audit/sessions/{session-id}/findings/scan-results.json $SCOPE_FLAGS $RAW_FLAG 2>/dev/null; echo $?
|
||||
```
|
||||
|
||||
Check exit code: 0/1/2 → normal. 3 → "Discovery encountered an error. Try a narrower scope."
|
||||
|
|
|
|||
|
|
@ -102,9 +102,14 @@ When iterating new/resolved findings, prefer `userActionLanguage` over raw `seve
|
|||
If `$ARGUMENTS` contains `--list`:
|
||||
|
||||
```bash
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/drift-cli.mjs --list 2>/dev/null
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/drift-cli.mjs --list --output-file /tmp/config-audit-baselines.json 2>/dev/null; echo $?
|
||||
```
|
||||
|
||||
The human-readable listing goes to stderr, which `2>/dev/null` discards — read
|
||||
`/tmp/config-audit-baselines.json` with the Read tool and render the `baselines`
|
||||
array (`name`, `findingCount`, `savedAt`) as a table. If the array is empty, tell
|
||||
the user no baselines are saved yet and point at `/config-audit drift --save`.
|
||||
|
||||
### What's next
|
||||
|
||||
After viewing drift:
|
||||
|
|
|
|||
|
|
@ -128,15 +128,23 @@ If the user picks numbers: parse the selection and proceed to Step 6.
|
|||
|
||||
For each selected recommendation:
|
||||
|
||||
1. **Create backup** of any files that will be modified:
|
||||
1. **Create backup** of any files that will be modified.
|
||||
|
||||
Do **not** reach for `fix-cli.mjs` here. It is dry-run by default, so calling
|
||||
it without `--apply` creates no backup at all and returns `backupId: null` —
|
||||
and calling it *with* `--apply` would execute unrelated auto-fixes that the
|
||||
user never selected. Copy the files yourself:
|
||||
|
||||
```bash
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/fix-cli.mjs <target-path> --json 2>/dev/null
|
||||
BACKUP_DIR=~/.claude/config-audit/backups/$(date +%Y%m%d_%H%M%S)/files
|
||||
mkdir -p "$BACKUP_DIR" 2>/dev/null
|
||||
# repeat per file that will be touched:
|
||||
cp "<file-to-modify>" "$BACKUP_DIR/" 2>/dev/null; echo $?
|
||||
```
|
||||
Or create manual backup:
|
||||
```bash
|
||||
mkdir -p ~/.claude/config-audit/backups/$(date +%Y%m%d_%H%M%S)/files/ 2>/dev/null
|
||||
```
|
||||
Copy each file that will be touched.
|
||||
|
||||
Tell the user where the copies landed. These are plain file copies — they are
|
||||
restored by copying them back, **not** by `/config-audit rollback`, which only
|
||||
knows about backups written by `fix` and `implement`.
|
||||
|
||||
2. **Apply the template** from gap-closure-templates.md. Use the Write or Edit tool to create or modify the relevant configuration file.
|
||||
|
||||
|
|
|
|||
|
|
@ -39,7 +39,11 @@ Parse flags and run scanners silently. Default mode emits humanized JSON — eac
|
|||
```bash
|
||||
RAW_FLAG=""
|
||||
if echo "$ARGUMENTS" | grep -q -- "--raw"; then RAW_FLAG="--raw"; fi
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/scan-orchestrator.mjs <path> --output-file /tmp/config-audit-fix-scan-$$.json [--global] $RAW_FLAG 2>/dev/null; echo $?
|
||||
# Set to --global when the user asked for global scope, otherwise leave empty.
|
||||
# A placeholder in square brackets does not start with a dash, so the arg loop
|
||||
# would take it as the scan/fix TARGET instead of a flag.
|
||||
GLOBAL_FLAG=""
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/scan-orchestrator.mjs <path> --output-file /tmp/config-audit-fix-scan-$$.json $GLOBAL_FLAG $RAW_FLAG 2>/dev/null; echo $?
|
||||
```
|
||||
|
||||
Exit code 3 → tell user: "Scanner error. Try `/config-audit posture` to check your configuration."
|
||||
|
|
@ -49,7 +53,7 @@ Exit code 3 → tell user: "Scanner error. Try `/config-audit posture` to check
|
|||
Run fix planner silently. The fix-cli emits humanized prose to stderr in default mode and v5.0.0-shape JSON to stdout when `--json` is set; we use `--json` here for structured data and let the humanizer-aware rendering layer (this command's prose output below) supply the plain-language wording from the scan envelope above:
|
||||
|
||||
```bash
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/fix-cli.mjs <path> [--global] --output-file /tmp/config-audit-fix-plan-$$.json 2>/dev/null; echo $?
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/fix-cli.mjs <path> $GLOBAL_FLAG --output-file /tmp/config-audit-fix-plan-$$.json 2>/dev/null; echo $?
|
||||
```
|
||||
|
||||
Exit codes: 0 = plan produced, 2 = one or more fixes failed (apply step only), 3 = argument or tool error. On 3, show the stderr message — an unknown flag is rejected by design, not silently ignored.
|
||||
|
|
@ -100,7 +104,7 @@ AskUserQuestion:
|
|||
If confirmed, apply:
|
||||
|
||||
```bash
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/fix-cli.mjs <path> --apply [--global] --output-file /tmp/config-audit-fix-applied-$$.json 2>/dev/null; echo $?
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/fix-cli.mjs <path> --apply $GLOBAL_FLAG --output-file /tmp/config-audit-fix-applied-$$.json 2>/dev/null; echo $?
|
||||
```
|
||||
|
||||
Read `/tmp/config-audit-fix-applied-$$.json` with the Read tool to get applied/failed counts and the backup ID. Exit code 2 means at least one fix failed — report it; `failed[]` carries the reason per fix.
|
||||
|
|
|
|||
|
|
@ -32,10 +32,28 @@ AskUserQuestion requires synchronous terminal interaction and does not work when
|
|||
if echo "$ARGUMENTS" | grep -q -- "--raw"; then RAW_FLAG="--raw"; fi
|
||||
```
|
||||
|
||||
1. **Load session state**: Verify analysis phase completed, read analysis report for context
|
||||
1. **Resolve the session, then load its state**:
|
||||
|
||||
```
|
||||
Glob: ~/.claude/config-audit/sessions/*/state.yaml
|
||||
Sort by modification time — the most recently modified session wins
|
||||
```
|
||||
|
||||
Every path below substitutes that session's id for `{session-id}`. Never guess
|
||||
it: if the Glob returns nothing, say "No audit session found — run
|
||||
`/config-audit discover` first" and exit. Read the session's `state.yaml` and
|
||||
verify `completed_phases` contains `analyze`; if it doesn't, tell the user
|
||||
analysis hasn't run yet and exit. Then read the analysis report for context.
|
||||
2. **Conduct interview inline**: Use AskUserQuestion tool directly (NOT via Task). Adapt questions based on analysis findings.
|
||||
3. **Save interview results**: Write to `~/.claude/config-audit/sessions/{session-id}/interview.md`
|
||||
4. **Update state** (see state-management rule)
|
||||
4. **Update state** (see state-management rule), with one bound specific to this
|
||||
command: interview is optional and can be run against a session that already
|
||||
moved past it. If `completed_phases` already contains a later phase (`plan`,
|
||||
`implement`, `verify`), do **not** rewind `current_phase` and do not re-add a
|
||||
phase already in `completed_phases` — append `interview` only if it is absent,
|
||||
leave `current_phase`/`next_phase` pointing at the furthest phase reached, and
|
||||
tell the user the preferences will apply the next time `/config-audit plan`
|
||||
runs. Rewinding a finished session is how its progress gets lost.
|
||||
5. **Output summary**
|
||||
|
||||
## Interview Questions
|
||||
|
|
|
|||
|
|
@ -37,8 +37,13 @@ When `--raw` is in `$ARGUMENTS`, render the raw `current_phase` field value verb
|
|||
```bash
|
||||
RAW_FLAG=""
|
||||
if echo "$ARGUMENTS" | grep -q -- "--raw"; then RAW_FLAG="--raw"; fi
|
||||
ALL_FLAG=""
|
||||
if echo "$ARGUMENTS" | grep -qw -- "all"; then ALL_FLAG="all"; fi
|
||||
```
|
||||
|
||||
When `ALL_FLAG` is set, skip steps 2–4 and render the **List All Sessions**
|
||||
table below instead of a single session's status.
|
||||
|
||||
2. **Find active session**:
|
||||
```
|
||||
Glob: ~/.claude/config-audit/sessions/*/state.yaml
|
||||
|
|
@ -128,11 +133,13 @@ All config-audit sessions:
|
|||
| 20250120_160000 | implement | 2025-01-20 16:00 |
|
||||
```
|
||||
|
||||
## Resume Session
|
||||
## Resuming a session
|
||||
|
||||
If multiple sessions exist:
|
||||
```
|
||||
/config-audit resume {session-id}
|
||||
```
|
||||
There is no `resume` command. Sessions are selected by recency: every
|
||||
session-aware command globs `~/.claude/config-audit/sessions/*/state.yaml` and
|
||||
takes the most recently modified one.
|
||||
|
||||
Sets that session as active and continues from last phase.
|
||||
To continue an older session, run its next phase directly — `/config-audit plan`,
|
||||
`/config-audit implement`, and so on read `next_phase` from the state file. If
|
||||
the wrong session keeps winning, delete the stale ones with
|
||||
`/config-audit cleanup`.
|
||||
|
|
|
|||
|
|
@ -43,7 +43,12 @@ Default mode (no `--json`, no `--raw`) emits a humanized JSON envelope: each fin
|
|||
TMPFILE="/tmp/config-audit-tokens-$$.json"
|
||||
RAW_FLAG=""
|
||||
if echo "$ARGUMENTS" | grep -q -- "--raw"; then RAW_FLAG="--raw"; fi
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/token-hotspots-cli.mjs <path> --output-file "$TMPFILE" [--global] [--no-exclude-cache] $RAW_FLAG 2>/dev/null; echo $?
|
||||
# Set each to the flag itself when the user asked for it, otherwise leave empty.
|
||||
# A placeholder in square brackets does not start with a dash, so the CLI's arg
|
||||
# loop would take it as the TARGET PATH instead of a flag.
|
||||
GLOBAL_FLAG="" # --global
|
||||
CACHE_FLAG="" # --no-exclude-cache
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/token-hotspots-cli.mjs <path> --output-file "$TMPFILE" $GLOBAL_FLAG $CACHE_FLAG $RAW_FLAG 2>/dev/null; echo $?
|
||||
```
|
||||
|
||||
**Exit code handling:**
|
||||
|
|
|
|||
|
|
@ -36,7 +36,12 @@ Tell the user: **"Reading active configuration for `<path>`..."**
|
|||
TMPFILE="/tmp/ca-whats-active-$$.json"
|
||||
RAW_FLAG=""
|
||||
if echo "$ARGUMENTS" | grep -q -- "--raw"; then RAW_FLAG="--raw"; fi
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/whats-active.mjs <path> --output-file "$TMPFILE" [--verbose] [--suggest-disables] $RAW_FLAG 2>/dev/null; echo $?
|
||||
# Set each to the flag itself when the user asked for it, otherwise leave empty.
|
||||
# A placeholder in square brackets does not start with a dash, so the scanner's
|
||||
# arg loop would take it as the TARGET PATH instead of a flag.
|
||||
VERBOSE_FLAG="" # --verbose
|
||||
SUGGEST_FLAG="" # --suggest-disables
|
||||
node ${CLAUDE_PLUGIN_ROOT}/scanners/whats-active.mjs <path> --output-file "$TMPFILE" $VERBOSE_FLAG $SUGGEST_FLAG $RAW_FLAG 2>/dev/null; echo $?
|
||||
```
|
||||
|
||||
**Exit code handling:**
|
||||
|
|
|
|||
|
|
@ -69,6 +69,11 @@ async function main() {
|
|||
// --- List mode ---
|
||||
if (list) {
|
||||
const result = await listBaselines();
|
||||
// commands/drift.md runs this with `2>/dev/null` (ux-rules rule 2). The
|
||||
// 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 (jsonMode || rawMode) {
|
||||
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -184,7 +184,11 @@ async function main() {
|
|||
if (regressions.length > 0) {
|
||||
process.stderr.write(` Regressions: ${regressions.join(', ')}\n`);
|
||||
}
|
||||
process.stderr.write(`\n Rollback: node scanners/rollback-cli.mjs ${backupId}\n`);
|
||||
// There is no rollback-cli.mjs — the restore path is the command, which
|
||||
// drives rollback-engine.mjs. Pointing at a nonexistent script in the
|
||||
// one message a user reaches for after a bad fix is the worst place for
|
||||
// a dead reference.
|
||||
process.stderr.write(`\n Rollback: /config-audit rollback ${backupId}\n`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
|
|
|||
151
tests/commands/interview-chunk-shape.test.mjs
Normal file
151
tests/commands/interview-chunk-shape.test.mjs
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
/**
|
||||
* DEL B chunk `interview` (session #47) — command-contract shape tests.
|
||||
*
|
||||
* Every assertion here encodes a defect that was MEASURED against the shipped
|
||||
* command templates before the fix, per the fasit in
|
||||
* `docs/interview-fasit.local.md`:
|
||||
*
|
||||
* - interview.md / analyze.md referenced `{session-id}` with no rule for
|
||||
* resolving WHICH session — every other session-aware command has one.
|
||||
* - interview.md could push a finished session backwards to `interview`
|
||||
* with no guard (state-management.md mandates the write, nothing bounded it).
|
||||
* - status.md advertised `/config-audit resume {session-id}`; no resume
|
||||
* command exists. ux-rules: "Never reference commands that don't exist."
|
||||
* - status.md documented `/config-audit status all` but its flag-parse step
|
||||
* knew only `--raw`.
|
||||
* - cleanup.md ran `rm -rf .../sessions/{session-id}/` with no guard against
|
||||
* an empty/malformed id — an empty expansion deletes every session.
|
||||
* - discover.md carried literal `[--full-machine] [--global]` inside an
|
||||
* executable bash block; `[` is not `-`, so scan-orchestrator's arg loop
|
||||
* turns it into the SCAN TARGET (M-BUG-21 class).
|
||||
* - feature-gap.md called `fix-cli.mjs --json` under the heading
|
||||
* "Create backup". fix-cli is dry-run by default: no backup was created,
|
||||
* `backupId` came back null, and the command then edited config believing
|
||||
* it could roll back (M-BUG-31 class).
|
||||
* - fix-cli.mjs told the user to recover with `node scanners/rollback-cli.mjs`
|
||||
* — a file that does not exist.
|
||||
*/
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { readFileSync, existsSync, readdirSync } from 'node:fs';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = resolve(__dirname, '..', '..');
|
||||
const COMMANDS_DIR = resolve(ROOT, 'commands');
|
||||
|
||||
const read = (name) => readFileSync(resolve(COMMANDS_DIR, name), 'utf-8');
|
||||
|
||||
/** Bash fenced blocks only — prose may legitimately use bracket notation. */
|
||||
function bashBlocks(text) {
|
||||
return [...text.matchAll(/```bash\n([\s\S]*?)```/g)].map((m) => m[1]);
|
||||
}
|
||||
|
||||
test('interview.md and analyze.md resolve WHICH session before using {session-id}', () => {
|
||||
for (const file of ['interview.md', 'analyze.md']) {
|
||||
const text = read(file);
|
||||
assert.match(
|
||||
text,
|
||||
/sessions\/\*\/state\.yaml/,
|
||||
`${file} references {session-id} but never says how to find the session`
|
||||
);
|
||||
assert.match(
|
||||
text,
|
||||
/most recent|most recently modified/i,
|
||||
`${file} must state which session wins when several exist`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('interview.md guards against pushing a finished session backwards', () => {
|
||||
const text = read('interview.md');
|
||||
assert.match(
|
||||
text,
|
||||
/completed_phases/,
|
||||
'interview.md must reason about completed_phases before rewriting current_phase'
|
||||
);
|
||||
assert.match(
|
||||
text,
|
||||
/do not (re-?add|add)|already contains|leave .*completed_phases/i,
|
||||
'interview.md must forbid re-adding a phase already in completed_phases'
|
||||
);
|
||||
});
|
||||
|
||||
test('no command references a /config-audit subcommand that does not exist', () => {
|
||||
// Scope words are arguments to the bare router command, not subcommands.
|
||||
const SCOPE_WORDS = new Set(['current', 'repo', 'home', 'full']);
|
||||
const dead = [];
|
||||
for (const file of readdirSync(COMMANDS_DIR).filter((f) => f.endsWith('.md'))) {
|
||||
const text = readFileSync(resolve(COMMANDS_DIR, file), 'utf-8');
|
||||
for (const m of text.matchAll(/\/config-audit ([a-z][a-z-]+)/g)) {
|
||||
const word = m[1];
|
||||
if (SCOPE_WORDS.has(word)) continue;
|
||||
if (!existsSync(resolve(COMMANDS_DIR, `${word}.md`))) dead.push(`${file}: /config-audit ${word}`);
|
||||
}
|
||||
}
|
||||
assert.deepEqual(dead, [], `dead command references: ${dead.join(', ')}`);
|
||||
});
|
||||
|
||||
test('status.md documents how the `all` argument is parsed', () => {
|
||||
const text = read('status.md');
|
||||
assert.match(
|
||||
text,
|
||||
/ALL_FLAG|contains? `all`|\ball\b[^\n]*\$ARGUMENTS|\$ARGUMENTS[^\n]*\ball\b/i,
|
||||
'status.md advertises `/config-audit status all` but never parses `all`'
|
||||
);
|
||||
});
|
||||
|
||||
test('cleanup.md guards its rm -rf against an empty or malformed session id', () => {
|
||||
const text = read('cleanup.md');
|
||||
// The destructive line lives in prose, not a bash block, so scan the whole
|
||||
// file. An earlier version of this test matched the example session ids in
|
||||
// the display table and went green without any guard existing.
|
||||
assert.ok(/rm -rf/.test(text), 'cleanup.md is expected to document a deletion step');
|
||||
assert.match(
|
||||
text,
|
||||
/\^\[0-9\]\{8\}_\[0-9\]\{6\}\$|\^\\d\{8\}_\\d\{6\}\$/,
|
||||
'cleanup.md must state the exact session-id pattern it validates against before deleting'
|
||||
);
|
||||
assert.match(
|
||||
text,
|
||||
/(refuse|abort|skip)[^\n]*(delet|remov)|never[^\n]*empty[^\n]*(id|path)/i,
|
||||
'cleanup.md must say what happens when the id fails validation'
|
||||
);
|
||||
});
|
||||
|
||||
test('no executable bash block passes a bracketed placeholder flag', () => {
|
||||
// `[--full-machine]` does not start with `-`, so the scanner arg loops take
|
||||
// it as the scan target and silently scan a path that does not exist.
|
||||
const offenders = [];
|
||||
for (const file of readdirSync(COMMANDS_DIR).filter((f) => f.endsWith('.md'))) {
|
||||
for (const block of bashBlocks(readFileSync(resolve(COMMANDS_DIR, file), 'utf-8'))) {
|
||||
if (/\[--[a-z-]+\]/.test(block)) offenders.push(file);
|
||||
}
|
||||
}
|
||||
assert.deepEqual([...new Set(offenders)], [], `bracketed flags inside bash blocks: ${offenders.join(', ')}`);
|
||||
});
|
||||
|
||||
test('feature-gap.md does not present a dry-run fix-cli call as a backup', () => {
|
||||
const text = read('feature-gap.md');
|
||||
for (const block of bashBlocks(text)) {
|
||||
if (!/fix-cli\.mjs/.test(block)) continue;
|
||||
assert.match(
|
||||
block,
|
||||
/--apply/,
|
||||
'feature-gap.md calls fix-cli without --apply; dry-run creates no backup'
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('fix-cli.mjs recovery hint points at a command that exists', () => {
|
||||
const text = readFileSync(resolve(ROOT, 'scanners', 'fix-cli.mjs'), 'utf-8');
|
||||
const hints = [...text.matchAll(/scanners\/([a-z-]+\.mjs)/g)].map((m) => m[1]);
|
||||
for (const hint of hints) {
|
||||
assert.ok(
|
||||
existsSync(resolve(ROOT, 'scanners', hint)),
|
||||
`fix-cli.mjs tells the user to run scanners/${hint}, which does not exist`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
@ -46,6 +46,21 @@ describe('drift-cli --list', () => {
|
|||
const found = output.baselines.find(b => b.name === TEST_BASELINE);
|
||||
assert.ok(found, 'Should find test baseline in list');
|
||||
});
|
||||
|
||||
// commands/drift.md runs `--list` with `2>/dev/null` (ux-rules rule 2), so a
|
||||
// stderr-only listing reaches the command as 0 bytes and it can show nothing.
|
||||
// --output-file was accepted by the arg parser but ignored by list mode.
|
||||
it('writes the listing to --output-file so the command can read it', () => {
|
||||
execFileSync('node', [DRIFT_CLI, HEALTHY, '--save', '--name', TEST_BASELINE], RUN);
|
||||
|
||||
const outFile = join(mkdtempSync(join(tmpdir(), 'drift-list-')), 'list.json');
|
||||
execFileSync('node', [DRIFT_CLI, '--list', '--output-file', outFile], RUN);
|
||||
|
||||
assert.ok(existsSync(outFile), '--list must honour --output-file');
|
||||
const payload = JSON.parse(readFileSync(outFile, 'utf-8'));
|
||||
assert.ok(Array.isArray(payload.baselines));
|
||||
assert.ok(payload.baselines.find(b => b.name === TEST_BASELINE));
|
||||
});
|
||||
});
|
||||
|
||||
describe('drift-cli compare', () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue