fix(plugin-health): make the command able to read what the scanner found

Dogfooding `/config-audit plugin-health` against a fasit registered before the
run: 11 of 12 predictions confirmed, 1 refuted with evidence, 0 deviations.
The command's default path could not produce the report it documents.

M-BUG-21 (third arm): the argument loop ended in
`else if (!args[i].startsWith('-')) targetPath = args[i]` with no unknown-flag
branch, so `--output-file /tmp/x.json` was dropped and its value became the scan
target. Worse than in drift-cli: a non-existent path discovers no plugins, so the
scanner answered "No plugins found" (info) with exit 0 — a reassuring answer, not
an error. Unknown options and a value-less `--output-file` now exit 3.

M-BUG-33: the scanner had no `--output-file` and its default-mode report goes to
stderr, which `commands/plugin-health.md` discards with `2>/dev/null` before
telling the agent to read stdout. Zero bytes captured.

M-BUG-34: per-plugin rows and the grade formula never left `scan()` — the only
grade code, `formatPluginHealthReport`, had no caller — and cross-plugin findings
were flattened behind a `category` they share with per-plugin findings. The
mandated table and Cross-Plugin section were unbuildable, so the command had to
fabricate them. `scanDetailed()` now returns them; `scan()`'s frozen v5.0.0
envelope is unchanged by construction.

M-BUG-35: `.claude-plugin/marketplace.json` was flagged as an unknown file. It is
the documented catalog location, and `"source": "./"` makes the repo root its own
plugin, so one `.claude-plugin/` legitimately holds both.

Also: `commands/posture.md` ran both optional scanners in default mode under
`2>/dev/null` and read stdout — the same class as feature-gap.md:133 in the fix
chunk. A CLI-side flag fix does not close its callers.

Tests 1420 -> 1432, red first. Frozen v5.0.0 snapshots untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XhhZ8zg1amR7YCAPqiZWdt
This commit is contained in:
Kjell Tore Guttormsen 2026-07-31 21:08:32 +02:00
commit 001090261e
8 changed files with 398 additions and 33 deletions

View file

@ -397,6 +397,43 @@ claimed CC "suggests the parent directory when an entry points at a file"; that
primary-source-verified conditions. `skills` is deliberately *not* in `SHADOWING_PATH_FIELDS`
(it adds to the default scan, never shadows).
### PLH scanner — `scanDetailed`, `--output-file`, and the marketplace.json exemption (økt #46)
`scan()` returns the **frozen v5.0.0 envelope** (`scanner, status, files_scanned, duration_ms,
findings, counts`) and nothing else — `--raw`/`--json` print it verbatim and are snapshot-gated. Two
things the `/config-audit plugin-health` report requires therefore cannot live there: one row per
plugin (the `| Plugin | Grade | Commands | Agents |` table) and the cross-plugin/per-plugin split.
Both were computed inside `scan()` and discarded at the return: `pluginResults` never escaped, and
the only grade code — `formatPluginHealthReport` — had no caller anywhere in the repo.
`scanDetailed(targetPath)` is the seam. It returns `{ result, plugins, crossPluginFindings }`;
`scan()` is now `(await scanDetailed(p)).result`, so the byte-stable envelope is unchanged by
construction. `plugins[]` carries `name, declaredName, path, commandCount, agentCount, findingCount`
plus `score`/`grade` from the shared `pluginGrade(issueCount)` helper (which
`formatPluginHealthReport` now also calls, so the formula has exactly one home).
Cross-plugin findings are identified **positionally**, not by predicate: `crossPluginStart =
allFindings.length` is taken immediately before the namespace/command-name sections, and the tail is
sliced off at the end. A predicate would have to key on `category: 'plugin-hygiene'`, which the
per-plugin shadow and skills findings share. The marker (`crossPlugin: true`) is stamped only on the
**humanized copies** in the `--output-file` payload — never inside `finding()`, which would add a key
to the frozen envelope.
`--output-file` follows the `drift-cli` contract: humanized payload in default mode, stdout
untouched. This matters because default mode writes its report to **stderr**, and ux-rules rule 2
requires the command to run under `2>/dev/null` — before this, `commands/plugin-health.md` (and both
optional scanner calls in `commands/posture.md`) captured zero bytes. Argument parsing uses the same
`BOOL_FLAGS`/`VALUE_FLAGS` + unknown-flag-throws shape as `drift-cli`/`fix-cli` (M-BUG-21, third
arm); here the swallowed-flag failure mode was *worse than an error* — scanning the dropped flag's
value found no plugins, so the scanner answered `No plugins found` (info) with exit `0`.
**`marketplace.json` exemption:** `.claude-plugin/`'s known-file set is `plugin.json` **and**
`marketplace.json`. The catalog's location is documented and required (*"Create
`.claude-plugin/marketplace.json` in your repository root"*), and a marketplace entry with
`"source": "./"` makes the repo root its own plugin — so one `.claude-plugin/` legitimately holds
both. Verified against the primary docs before the change; the check was a false positive, latent in
this marketplace only because `catalog/` ships no `plugin.json` and is thus not scanned as a plugin.
### SET scanner — autoMode validation (`CA-SET`)
Per-file check in `settings-validator.mjs` (`autoMode` was in `KNOWN_KEYS` but had no nested