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:
parent
05f1e954d0
commit
001090261e
8 changed files with 398 additions and 33 deletions
|
|
@ -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`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue