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

@ -8,6 +8,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Fixed
- **`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`
produced phantom drift, this produced a **reassuring** answer — a non-existent path discovers no
plugins, so the scanner reported `No plugins found` (info) and exit `0`. Unknown options and a
value-less `--output-file` now exit `3`.
- **`M-BUG-33``/config-audit plugin-health` read zero bytes.** The scanner had no `--output-file`
(ux-rules rule 2) 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 output (JSON)". The command's
default path could not produce the report it documents. `--output-file` now writes a humanized
payload; `--raw`/`--json` stdout is unchanged and byte-stable.
- **`M-BUG-34` — the report's per-plugin table and Cross-Plugin section were unbuildable.** Per-plugin
data (`commandCount`, `agentCount`) and the grade formula never left `scan()` — the only grade code,
`formatPluginHealthReport`, had no caller — and cross-plugin findings were flattened into `findings`
behind a `category: 'plugin-hygiene'` they share with per-plugin findings. The command mandated both,
so it had to fabricate them. The payload now carries `plugins[]` (name, declaredName, counts, score,
grade via the shared `pluginGrade`) and `cross_plugin_findings[]` (also marked `crossPlugin: true`),
via a new `scanDetailed()`; `scan()`'s frozen v5.0.0 envelope is untouched.
- **`M-BUG-35``.claude-plugin/marketplace.json` was reported as an unknown file.** It is the
documented, required location for a marketplace catalog, and a marketplace entry with
`"source": "./"` makes the repo root its own plugin — such a repo legitimately carries both files.
Genuinely unexpected files in `.claude-plugin/` are still flagged.
- **`commands/posture.md` discarded both optional scanners' output.** Its `--drift` and
`--plugin-health` sections ran `drift-cli.mjs` / `plugin-health-scanner.mjs` in default mode under
`2>/dev/null` and read stdout, which is empty in that mode. Both calls now use `--output-file`.
- **`M-BUG-21``drift-cli.mjs` had no `--output-file`, and its argument loop turned the missing
flag into a wrong scan target.** The loop ended in `else if (!arg.startsWith('-')) targetPath = arg`
with no unknown-flag branch, so an unrecognised flag was dropped silently and its *value* fell

View file

@ -12,7 +12,7 @@
![Commands](https://img.shields.io/badge/commands-21-green)
![Agents](https://img.shields.io/badge/agents-7-orange)
![Hooks](https://img.shields.io/badge/hooks-4-red)
![Tests](https://img.shields.io/badge/tests-1420-brightgreen)
![Tests](https://img.shields.io/badge/tests-1432-brightgreen)
![License](https://img.shields.io/badge/license-MIT-lightgrey)
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.

View file

@ -32,15 +32,22 @@ Auditing {N} plugin(s) for structure, frontmatter quality, and cross-plugin conf
### Step 2: Run scanner
Run silently for each plugin. Default mode emits a humanized JSON envelope where each PLH finding carries `userImpactCategory`, `userActionLanguage`, and `relevanceContext` alongside the v5.0.0 fields. `--raw` is passed through verbatim when present.
Run silently for each plugin. Default mode writes a humanized JSON payload to `--output-file` where each PLH finding carries `userImpactCategory`, `userActionLanguage`, and `relevanceContext` alongside the v5.0.0 fields. `--raw` is passed through verbatim when present, and prints the byte-stable v5.0.0 envelope on stdout instead.
```bash
TMPFILE="/tmp/config-audit-plugin-health-$$.json"
RAW_FLAG=""
if echo "$ARGUMENTS" | grep -q -- "--raw"; then RAW_FLAG="--raw"; fi
node ${CLAUDE_PLUGIN_ROOT}/scanners/plugin-health-scanner.mjs <path> $RAW_FLAG 2>/dev/null
node ${CLAUDE_PLUGIN_ROOT}/scanners/plugin-health-scanner.mjs <path> --output-file "$TMPFILE" $RAW_FLAG 2>/dev/null; echo $?
```
Read stdout output (JSON) using the Read tool. Parse findings.
Read `$TMPFILE` with the Read tool. Exit codes 0, 1 and 2 are normal; only 3 is a real error.
The payload carries three things the report needs:
- `plugins[]` — one row per plugin: `name`, `declaredName`, `commandCount`, `agentCount`, `findingCount`, `score`, `grade`. Use these for the table; never estimate a grade yourself.
- `cross_plugin_findings[]` — the namespace-collision and shared-command-name findings, already separated from the per-plugin ones (they also carry `crossPlugin: true` in `findings`).
- `findings[]` — every finding, humanized.
### Step 3: Present results
@ -49,7 +56,7 @@ Read stdout output (JSON) using the Read tool. Parse findings.
| Plugin | Grade | Commands | Agents | Status |
|--------|-------|----------|--------|--------|
| {name} | {grade} ({score}) | {cmd_count} | {agent_count} | {Good/Issues found} |
| {plugins[].name} | {plugins[].grade} ({plugins[].score}) | {plugins[].commandCount} | {plugins[].agentCount} | {Good/Issues found} |
| ... | ... | ... | ... | ... |
{If cross-plugin issues:}

View file

@ -93,19 +93,19 @@ Avoid hardcoded grade-to-prose ladders here — the humanized scorecard headline
Run drift comparison silently:
```bash
node ${CLAUDE_PLUGIN_ROOT}/scanners/drift-cli.mjs <target-path> 2>/dev/null
node ${CLAUDE_PLUGIN_ROOT}/scanners/drift-cli.mjs <target-path> --output-file /tmp/config-audit-posture-drift-$$.json 2>/dev/null; echo $?
```
Read stdout output and append a "Configuration Drift" section showing what changed since the last baseline.
Read that file and append a "Configuration Drift" section showing what changed since the last baseline. Both scanners report to stderr in default mode, which `2>/dev/null` discards — the payload is the only readable output.
**If `--plugin-health` flag is present:**
Run plugin health scanner silently:
```bash
node ${CLAUDE_PLUGIN_ROOT}/scanners/plugin-health-scanner.mjs <target-path> 2>/dev/null
node ${CLAUDE_PLUGIN_ROOT}/scanners/plugin-health-scanner.mjs <target-path> --output-file /tmp/config-audit-posture-plh-$$.json 2>/dev/null; echo $?
```
Read stdout output and append a "Plugin Health" section.
Read that file and append a "Plugin Health" section, using its `plugins[]` rows for per-plugin grades.
**If both flags:** Use `scanners/lib/report-generator.mjs` to produce a unified markdown report.

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

View file

@ -8,7 +8,7 @@
* Zero external dependencies.
*/
import { readdir, stat, readFile } from 'node:fs/promises';
import { readdir, stat, readFile, writeFile } from 'node:fs/promises';
import { join, basename, resolve, sep } from 'node:path';
import { finding, scannerResult, resetCounter } from './lib/output.mjs';
import { SEVERITY } from './lib/severity.mjs';
@ -490,7 +490,13 @@ async function scanSinglePlugin(pluginDir) {
const pluginMetaDir = join(pluginDir, '.claude-plugin');
try {
const entries = await readdir(pluginMetaDir);
const known = new Set(['plugin.json']);
// `marketplace.json` belongs here: it is the documented, required location
// for a marketplace catalog (code.claude.com/docs plugin-marketplaces —
// "Create `.claude-plugin/marketplace.json` in your repository root"), and a
// marketplace entry with `"source": "./"` makes the repo root its own
// plugin. Such a repo legitimately carries both files, so flagging the
// catalog as an unknown file was a false positive.
const known = new Set(['plugin.json', 'marketplace.json']);
for (const entry of entries) {
if (!known.has(entry)) {
findings.push(finding({
@ -508,27 +514,62 @@ async function scanSinglePlugin(pluginDir) {
return { name: pluginName, declaredName, findings, commandCount, agentCount };
}
/**
* Per-plugin score and grade. Single source for both the terminal report and
* the --output-file payload the grade formula used to live only inside
* `formatPluginHealthReport`, which nothing called.
* @param {number} issueCount
* @returns {{ score: number, grade: string }}
*/
export function pluginGrade(issueCount) {
const score = Math.max(0, 100 - issueCount * 10);
const grade = score >= 90 ? 'A' : score >= 75 ? 'B' : score >= 60 ? 'C' : score >= 40 ? 'D' : 'F';
return { score, grade };
}
/**
* Scan one or more plugins and return aggregated results.
*
* The envelope is frozen at the v5.0.0 shape (byte-stable `--raw`/`--json`), so
* per-plugin rows and the cross-plugin/per-plugin split are NOT in it. Callers
* that need those the `--output-file` payload, and therefore
* `/config-audit plugin-health` use `scanDetailed`.
*
* @param {string} targetPath - Plugin dir or marketplace root
* @returns {Promise<object>} Scanner result
*/
export async function scan(targetPath) {
return (await scanDetailed(targetPath)).result;
}
/**
* Scan, and also return what `scan()`'s frozen envelope cannot carry: one row
* per plugin (name, declared namespace, component counts, grade) and the
* cross-plugin findings as a distinct set.
*
* @param {string} targetPath - Plugin dir or marketplace root
* @returns {Promise<{ result: object, plugins: object[], crossPluginFindings: object[] }>}
*/
export async function scanDetailed(targetPath) {
const start = Date.now();
resetCounter();
const pluginDirs = await discoverPlugins(resolve(targetPath));
if (pluginDirs.length === 0) {
return scannerResult(SCANNER, 'ok', [
finding({
scanner: SCANNER,
severity: SEVERITY.info,
title: 'No plugins found',
description: `No Claude Code plugins found under ${targetPath}`,
recommendation: 'Ensure plugins have .claude-plugin/plugin.json',
}),
], 0, Date.now() - start);
return {
result: scannerResult(SCANNER, 'ok', [
finding({
scanner: SCANNER,
severity: SEVERITY.info,
title: 'No plugins found',
description: `No Claude Code plugins found under ${targetPath}`,
recommendation: 'Ensure plugins have .claude-plugin/plugin.json',
}),
], 0, Date.now() - start),
plugins: [],
crossPluginFindings: [],
};
}
const allFindings = [];
@ -540,6 +581,12 @@ export async function scan(targetPath) {
allFindings.push(...result.findings);
}
// Everything pushed from here on is a cross-plugin finding — the boundary the
// payload uses to split them out (they are flattened into `findings` in the
// frozen envelope, where `category: 'plugin-hygiene'` cannot tell them apart
// from the per-plugin shadow/skills findings that share it).
const crossPluginStart = allFindings.length;
// Cross-plugin checks: command-name ambiguity across DIFFERENT plugin namespaces.
// Commands are namespaced by the plugin's declared name (/name:command), so a
// shared command name across DIFFERENT plugins is ambiguity — not a hard
@ -632,7 +679,19 @@ export async function scan(targetPath) {
}));
}
return scannerResult(SCANNER, 'ok', allFindings, pluginDirs.length, Date.now() - start);
return {
result: scannerResult(SCANNER, 'ok', allFindings, pluginDirs.length, Date.now() - start),
plugins: pluginResults.map((p, idx) => ({
name: p.name,
declaredName: p.declaredName,
path: pluginDirs[idx],
commandCount: p.commandCount,
agentCount: p.agentCount,
findingCount: p.findings.length,
...pluginGrade(p.findings.length),
})),
crossPluginFindings: allFindings.slice(crossPluginStart),
};
}
/**
@ -649,9 +708,7 @@ export function formatPluginHealthReport(pluginResults, crossPluginFindings) {
lines.push('');
for (const p of pluginResults) {
const issueCount = p.findings.length;
const score = Math.max(0, 100 - issueCount * 10);
const grade = score >= 90 ? 'A' : score >= 75 ? 'B' : score >= 60 ? 'C' : score >= 40 ? 'D' : 'F';
const { score, grade } = pluginGrade(p.findings.length);
const padding = '.'.repeat(Math.max(1, 25 - p.name.length));
lines.push(` ${p.name} ${padding} ${grade} (${score}) ${p.commandCount} commands, ${p.agentCount} agents`);
}
@ -675,19 +732,42 @@ export function formatPluginHealthReport(pluginResults, crossPluginFindings) {
}
// --- CLI entry point ---
const BOOL_FLAGS = ['--json', '--raw'];
const VALUE_FLAGS = ['--output-file'];
async function main() {
const args = process.argv.slice(2);
let targetPath = '.';
let jsonMode = false;
let rawMode = false;
let outputFile = null;
// M-BUG-21, third arm: this loop used to end in
// `else if (!args[i].startsWith('-')) targetPath = args[i]` with no
// unknown-flag branch. An unrecognised flag was dropped silently and its
// VALUE became the scan target, so `--output-file /tmp/x.json` scanned
// /tmp/x.json. Unlike drift-cli, the result LOOKS fine: a non-existent path
// discovers no plugins, so the scanner reported "No plugins found" (info) and
// exit 0 — a green answer to a question nobody asked. Now it fails loudly.
for (let i = 0; i < args.length; i++) {
if (args[i] === '--json') {
jsonMode = true;
} else if (args[i] === '--raw') {
rawMode = true;
} else if (!args[i].startsWith('-')) {
targetPath = args[i];
const arg = args[i];
if (BOOL_FLAGS.includes(arg)) {
if (arg === '--json') jsonMode = true;
else if (arg === '--raw') rawMode = true;
} else if (VALUE_FLAGS.includes(arg)) {
const value = args[i + 1];
if (value === undefined || value.startsWith('-')) {
throw new Error(`Option ${arg} requires a value.`);
}
outputFile = value;
i++;
} else if (arg.startsWith('-')) {
throw new Error(
`Unknown option: ${arg}\n` +
`Valid options: ${[...BOOL_FLAGS, ...VALUE_FLAGS].join(' ')}`
);
} else {
targetPath = arg;
}
}
@ -695,7 +775,7 @@ async function main() {
process.stderr.write(humanizedProgress ? `Plugin Health v2.1.0\n` : `Plugin Health Scanner v2.1.0\n`);
process.stderr.write(`Target: ${resolve(targetPath)}\n\n`);
const result = await scan(targetPath);
const { result, plugins, crossPluginFindings } = await scanDetailed(targetPath);
if (jsonMode || rawMode) {
// --json and --raw both write the v5.0.0-shape result (byte-identical).
@ -708,6 +788,24 @@ async function main() {
for (const f of findings) {
process.stderr.write(` [${f.severity}] ${f.title}\n`);
}
// ux-rules rule 2: the command runs with `2>/dev/null`, so anything it must
// ACT on has to ride in the --output-file payload. Everything above this
// point is stderr, i.e. invisible to `/config-audit plugin-health`.
if (outputFile) {
const crossIds = new Set(crossPluginFindings.map(f => f.id));
for (const f of findings) {
if (crossIds.has(f.id)) f.crossPlugin = true;
}
const payload = {
...result,
findings,
plugins,
cross_plugin_findings: findings.filter(f => crossIds.has(f.id)),
};
await writeFile(outputFile, JSON.stringify(payload, null, 2), 'utf-8');
process.stderr.write(`\nResults written to ${outputFile}\n`);
}
}
}

View file

@ -132,3 +132,29 @@ test('status.md: preserves current_phase machine field and adds humanized phase
`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`);
}
});

View file

@ -2,8 +2,9 @@ import { describe, it, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import { resolve, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises';
import { mkdtemp, mkdir, writeFile, rm, readFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { spawnSync } from 'node:child_process';
import { resetCounter } from '../../scanners/lib/output.mjs';
import { scan, discoverPlugins } from '../../scanners/plugin-health-scanner.mjs';
@ -423,3 +424,174 @@ describe('PLH — plugin agent declares fields Claude Code ignores (E)', () => {
}
});
});
// ---------------------------------------------------------------------------
// Økt #46 — `plugin-health` dogfood. Four defects, all found by running the
// command as written and comparing against a fasit registered BEFORE the run.
//
// F1 (M-BUG-21, third arm): the arg loop ended in
// `else if (!args[i].startsWith('-')) targetPath = args[i]` with no
// unknown-flag branch, so `--output-file /tmp/x.json` was dropped silently and
// `/tmp/x.json` became the scan target. Worse than in drift-cli: scanning a
// non-existent path yields "No plugins found" (info) and exit 0 — an
// apparently GREEN answer, not an error.
//
// F3 (stderr-only class): default mode wrote the report to STDERR only, so
// commands/plugin-health.md ("... 2>/dev/null" + "Read stdout output (JSON)")
// captured zero bytes. There was no --output-file at all (ux-rules rule 2).
//
// F5/F7: per-plugin data (name/commandCount/agentCount) and the grade formula
// never left scan(); cross-plugin findings were flattened into `findings` with
// no marker. commands/plugin-health.md mandates a
// `| Plugin | Grade | Commands | Agents |` table plus a separate Cross-Plugin
// section — both unbuildable, so the command had to fabricate them.
//
// F9: `.claude-plugin/marketplace.json` was reported as "Unknown file". It is
// the documented location for a marketplace catalog
// (code.claude.com/docs/en/plugin-marketplaces), and with `"source": "./"` one
// repo is legitimately both plugin and marketplace.
// ---------------------------------------------------------------------------
const PLH_CLI = resolve(__dirname, '../../scanners/plugin-health-scanner.mjs');
const PLH_RUN = { encoding: 'utf-8', timeout: 20000 };
function plhSpawn(args) {
const res = spawnSync('node', [PLH_CLI, ...args], PLH_RUN);
return { status: res.status, stdout: String(res.stdout || ''), stderr: String(res.stderr || '') };
}
function plhExpectFailure(args) {
const { status, stderr } = plhSpawn(args);
return { status, stderr };
}
describe('plugin-health-scanner argument validation (F1 / M-BUG-21 third arm)', () => {
it('rejects an unknown flag instead of swallowing its value as the scan target', () => {
const { status, stderr } = plhExpectFailure([TEST_PLUGIN, '--bogus', 'some-value', '--json']);
assert.equal(status, 3, 'unknown flag must fail loudly, not scan "some-value"');
assert.match(stderr, /unknown option/i);
assert.match(stderr, /--bogus/);
});
it('rejects --output-file without a value', () => {
const { status, stderr } = plhExpectFailure([TEST_PLUGIN, '--output-file']);
assert.equal(status, 3);
assert.match(stderr, /--output-file/);
assert.match(stderr, /requires a value/i);
});
it('still accepts a bare path as the scan target', () => {
const { status, stdout } = plhSpawn([TEST_PLUGIN, '--json']);
assert.equal(status, 0);
assert.equal(JSON.parse(stdout).files_scanned, 1);
});
});
describe('plugin-health-scanner --output-file (F3 / ux-rules rule 2)', () => {
let dir;
beforeEach(async () => { dir = await mkdtemp(join(tmpdir(), 'ca-plh-out-')); });
afterEach(async () => { await rm(dir, { recursive: true, force: true }); });
it('writes the payload to the file and keeps default-mode stdout empty', async () => {
const out = join(dir, 'plh.json');
const { status, stdout } = plhSpawn([TEST_PLUGIN, '--output-file', out]);
assert.equal(status, 0);
assert.equal(stdout, '', 'default mode must not print to stdout (ux-rules rule 1)');
const payload = JSON.parse(await readFile(out, 'utf-8'));
assert.equal(payload.scanner, 'PLH');
assert.equal(payload.files_scanned, 1);
});
it('carries humanizer fields the command renders (F4)', async () => {
const out = join(dir, 'plh.json');
plhSpawn([BROKEN_PLUGIN, '--output-file', out]);
const payload = JSON.parse(await readFile(out, 'utf-8'));
assert.ok(payload.findings.length > 0, 'broken-plugin must produce findings');
for (const f of payload.findings) {
assert.ok(f.userImpactCategory, `finding ${f.id} missing userImpactCategory`);
assert.ok(f.userActionLanguage, `finding ${f.id} missing userActionLanguage`);
}
});
it('exposes per-plugin rows with grade, score and component counts (F5)', async () => {
const out = join(dir, 'plh.json');
plhSpawn([TEST_PLUGIN, '--output-file', out]);
const payload = JSON.parse(await readFile(out, 'utf-8'));
assert.ok(Array.isArray(payload.plugins), 'payload must carry a plugins array');
assert.equal(payload.plugins.length, 1);
const p = payload.plugins[0];
assert.equal(p.name, 'test-plugin');
assert.ok(typeof p.commandCount === 'number');
assert.ok(typeof p.agentCount === 'number');
assert.ok(typeof p.score === 'number');
assert.match(p.grade, /^[ABCDF]$/);
});
it('separates cross-plugin findings from per-plugin findings (F7)', async () => {
const out = join(dir, 'plh.json');
plhSpawn([DUP_NAME, '--output-file', out]);
const payload = JSON.parse(await readFile(out, 'utf-8'));
assert.ok(Array.isArray(payload.cross_plugin_findings), 'payload must carry cross_plugin_findings');
assert.ok(payload.cross_plugin_findings.length > 0, 'duplicate-plugin-name must yield a namespace collision');
for (const f of payload.cross_plugin_findings) {
assert.equal(f.crossPlugin, true, 'cross-plugin findings must be marked');
}
const perPlugin = payload.findings.filter(f => f.crossPlugin !== true);
assert.ok(
perPlugin.length + payload.cross_plugin_findings.length === payload.findings.length,
'cross_plugin_findings must be a subset of findings, not a parallel universe'
);
});
it('leaves --raw and --json byte-stable (no new keys on the frozen envelope)', () => {
const raw = JSON.parse(plhSpawn([TEST_PLUGIN, '--raw']).stdout);
const json = JSON.parse(plhSpawn([TEST_PLUGIN, '--json']).stdout);
for (const env of [raw, json]) {
assert.deepEqual(
Object.keys(env).sort(),
['counts', 'duration_ms', 'files_scanned', 'findings', 'scanner', 'status'],
'frozen v5.0.0 envelope must not gain keys'
);
}
});
});
describe('.claude-plugin/marketplace.json is not an unknown file (F9)', () => {
let dir;
beforeEach(async () => { dir = await mkdtemp(join(tmpdir(), 'ca-plh-mp-')); });
afterEach(async () => { await rm(dir, { recursive: true, force: true }); });
async function writeMarketplacePlugin(root, extraFiles = {}) {
await mkdir(join(root, '.claude-plugin'), { recursive: true });
await writeFile(
join(root, '.claude-plugin', 'plugin.json'),
JSON.stringify({ name: 'mp-plugin', description: 'd', version: '1.0.0' })
);
await writeFile(
join(root, '.claude-plugin', 'marketplace.json'),
JSON.stringify({ name: 'cat', owner: { name: 'x' }, plugins: [] })
);
await writeFile(join(root, 'CLAUDE.md'), '# mp-plugin\n');
for (const [name, body] of Object.entries(extraFiles)) {
await writeFile(join(root, '.claude-plugin', name), body);
}
}
it('does not flag marketplace.json (documented catalog location)', async () => {
resetCounter();
await writeMarketplacePlugin(dir);
const result = await scan(dir);
const unknown = result.findings.filter(f => /Unknown file/i.test(f.title || ''));
assert.equal(unknown.length, 0,
`marketplace.json is documented; got: ${unknown.map(f => f.file).join(' | ')}`);
});
it('still flags a genuinely unexpected file', async () => {
resetCounter();
await writeMarketplacePlugin(dir, { 'notes.txt': 'scratch' });
const result = await scan(dir);
const unknown = result.findings.filter(f => /Unknown file/i.test(f.title || ''));
assert.equal(unknown.length, 1);
assert.match(unknown[0].file, /notes\.txt$/);
});
});