ktg-plugin-marketplace/plugins/config-audit/scanners/plugin-health-scanner.mjs
Kjell Tore Guttormsen c5c937e94e feat(humanizer): forbidden-words lint runner + test wrapper (SC-3) [skip-docs]
Step 8 of v5.1.0 humanizer Wave 4. Adds tests/lint-default-output.mjs
runner and tests/scanners/lint-default-output.test.mjs wrapper that
exercise SC-3 against the 6 prose CLIs (scan-orchestrator, posture,
token-hotspots-cli, plugin-health-scanner, drift-cli, fix-cli) running
in default (humanized) mode against tests/fixtures/marketplace-medium.

Lint scope is stderr only — JSON envelope keys ("scanner", "severity")
are structural, not prose. Humanized prose fields embedded inside JSON
are already covered by tests/lib/humanizer-data.test.mjs tier1/tier3
checks. Code references inside backticks pass the lint
(stripBacktickSpans) so technical identifiers can appear when wrapped.

Default-mode prose fixes to land lint at zero violations:

- scan-orchestrator: top banner switches to "Config-Audit v2.2.0" and
  per-scanner progress wraps "[XXX] Label" in backticks. --raw and
  --json paths preserve the v5.0.0 verbatim banner via new
  opts.humanizedProgress flag on runAllScanners.
- plugin-health-scanner: top banner switches to "Plugin Health v2.1.0"
  in default mode; --raw/--json keep "Plugin Health Scanner v2.1.0".
- scoring.mjs generateHealthScorecard humanized branch: area names
  (CLAUDE.md, Hooks, MCP, Settings, Rules, Imports, Conflicts, Token
  Efficiency, Plugin Hygiene) are wrapped in backticks; dot-padding
  compensates so column alignment matches v5.0.0 layout.
- posture / drift-cli / fix-cli: thread humanizedProgress flag through
  their runAllScanners calls so default mode emits humanized progress
  and --raw/--json preserve the v5.0.0 stderr snapshot.

Test infrastructure only — user-facing docs land in Wave 5/6 once
commands and agents consume the humanized payload.

Tests: 735 to 736 (+1 SC-3 wrapper). Full suite passes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 18:11:15 +02:00

462 lines
16 KiB
JavaScript

#!/usr/bin/env node
/**
* PLH Scanner — Plugin Health
* Validates Claude Code plugin structure, frontmatter, and cross-plugin coherence.
* Finding IDs: CA-PLH-NNN
* NOT included in scan-orchestrator — runs independently on plugin directories.
* Zero external dependencies.
*/
import { readdir, stat, readFile } from 'node:fs/promises';
import { join, basename, resolve } from 'node:path';
import { finding, scannerResult, resetCounter } from './lib/output.mjs';
import { SEVERITY } from './lib/severity.mjs';
import { parseFrontmatter } from './lib/yaml-parser.mjs';
import { humanizeFindings } from './lib/humanizer.mjs';
const SCANNER = 'PLH';
const REQUIRED_PLUGIN_JSON_FIELDS = ['name', 'description', 'version'];
const RECOMMENDED_CLAUDE_MD_SECTIONS = ['commands', 'agents', 'hooks'];
// Keys as they appear after yaml-parser normalizeKey (hyphens → underscores)
const REQUIRED_COMMAND_FRONTMATTER = [
{ key: 'name', display: 'name' },
{ key: 'description', display: 'description' },
{ key: 'model', display: 'model' },
{ key: 'allowed_tools', display: 'allowed-tools' },
];
const REQUIRED_AGENT_FRONTMATTER = [
{ key: 'name', display: 'name' },
{ key: 'description', display: 'description' },
{ key: 'model', display: 'model' },
{ key: 'tools', display: 'tools' },
];
/**
* Discover plugins under a path.
* Looks for .claude-plugin/plugin.json pattern.
* @param {string} targetPath
* @returns {Promise<string[]>} Array of plugin root directories
*/
export async function discoverPlugins(targetPath) {
const plugins = [];
// Check if targetPath itself is a plugin
if (await isPlugin(targetPath)) {
plugins.push(targetPath);
return plugins;
}
// Look for plugins in subdirectories (marketplace layout: plugins/<name>/)
try {
const entries = await readdir(targetPath, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const subDir = join(targetPath, entry.name);
if (await isPlugin(subDir)) {
plugins.push(subDir);
continue;
}
// Also check one level deeper (plugins/<name>/ layout)
try {
const subEntries = await readdir(subDir, { withFileTypes: true });
for (const subEntry of subEntries) {
if (!subEntry.isDirectory()) continue;
const deepDir = join(subDir, subEntry.name);
if (await isPlugin(deepDir)) {
plugins.push(deepDir);
}
}
} catch { /* skip */ }
}
} catch { /* skip */ }
return plugins;
}
/**
* Check if a directory is a Claude Code plugin.
* @param {string} dir
* @returns {Promise<boolean>}
*/
async function isPlugin(dir) {
try {
await stat(join(dir, '.claude-plugin', 'plugin.json'));
return true;
} catch {
return false;
}
}
/**
* Scan a single plugin for health issues.
* @param {string} pluginDir - Plugin root directory
* @returns {Promise<{ name: string, findings: object[], commandCount: number, agentCount: number }>}
*/
async function scanSinglePlugin(pluginDir) {
const findings = [];
const pluginName = basename(pluginDir);
let commandCount = 0;
let agentCount = 0;
// 1. Validate plugin.json
const pluginJsonPath = join(pluginDir, '.claude-plugin', 'plugin.json');
try {
const content = await readFile(pluginJsonPath, 'utf-8');
let parsed;
try {
parsed = JSON.parse(content);
} catch {
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.critical,
title: 'Invalid plugin.json',
description: `plugin.json is not valid JSON in ${pluginName}`,
file: pluginJsonPath,
}));
parsed = null;
}
if (parsed) {
for (const field of REQUIRED_PLUGIN_JSON_FIELDS) {
if (!parsed[field]) {
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.high,
title: `Missing required field in plugin.json: ${field}`,
description: `Plugin "${pluginName}" plugin.json is missing required field "${field}"`,
file: pluginJsonPath,
recommendation: `Add "${field}" to plugin.json`,
}));
}
}
}
} catch {
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.critical,
title: 'Missing plugin.json',
description: `No .claude-plugin/plugin.json found in ${pluginName}`,
file: pluginDir,
recommendation: 'Create .claude-plugin/plugin.json with name, description, version',
}));
}
// 2. Validate CLAUDE.md
const claudeMdPath = join(pluginDir, 'CLAUDE.md');
try {
const content = await readFile(claudeMdPath, 'utf-8');
const lower = content.toLowerCase();
for (const section of RECOMMENDED_CLAUDE_MD_SECTIONS) {
// Look for markdown table header or section header
const hasSection = lower.includes(`## ${section}`) ||
lower.includes(`| ${section}`) ||
lower.includes(`|${section}`);
if (!hasSection) {
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.medium,
title: `CLAUDE.md missing ${section} section`,
description: `Plugin "${pluginName}" CLAUDE.md should have a ${section} table or section`,
file: claudeMdPath,
recommendation: `Add a "## ${section.charAt(0).toUpperCase() + section.slice(1)}" section with a table`,
}));
}
}
} catch {
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.high,
title: 'Missing CLAUDE.md',
description: `Plugin "${pluginName}" has no CLAUDE.md`,
file: pluginDir,
recommendation: 'Create CLAUDE.md with Commands, Agents, and Hooks tables',
}));
}
// 3. Validate commands frontmatter
const commandsDir = join(pluginDir, 'commands');
try {
const entries = await readdir(commandsDir);
const mdFiles = entries.filter(f => f.endsWith('.md'));
commandCount = mdFiles.length;
for (const file of mdFiles) {
const filePath = join(commandsDir, file);
const content = await readFile(filePath, 'utf-8');
const { frontmatter } = parseFrontmatter(content);
if (!frontmatter) {
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.high,
title: 'Command missing frontmatter',
description: `Command "${file}" in plugin "${pluginName}" has no frontmatter`,
file: filePath,
recommendation: 'Add YAML frontmatter with name, description, model',
}));
continue;
}
for (const { key, display } of REQUIRED_COMMAND_FRONTMATTER) {
if (!frontmatter[key]) {
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.medium,
title: `Command missing frontmatter field: ${display}`,
description: `Command "${file}" in plugin "${pluginName}" is missing "${display}" in frontmatter`,
file: filePath,
recommendation: `Add "${display}" to frontmatter`,
}));
}
}
}
} catch { /* no commands dir */ }
// 4. Validate agents frontmatter
const agentsDir = join(pluginDir, 'agents');
try {
const entries = await readdir(agentsDir);
const mdFiles = entries.filter(f => f.endsWith('.md'));
agentCount = mdFiles.length;
for (const file of mdFiles) {
const filePath = join(agentsDir, file);
const content = await readFile(filePath, 'utf-8');
const { frontmatter } = parseFrontmatter(content);
if (!frontmatter) {
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.high,
title: 'Agent missing frontmatter',
description: `Agent "${file}" in plugin "${pluginName}" has no frontmatter`,
file: filePath,
recommendation: 'Add YAML frontmatter with name, description, model, tools',
}));
continue;
}
for (const { key, display } of REQUIRED_AGENT_FRONTMATTER) {
if (!frontmatter[key]) {
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.medium,
title: `Agent missing frontmatter field: ${display}`,
description: `Agent "${file}" in plugin "${pluginName}" is missing "${display}" in frontmatter`,
file: filePath,
recommendation: `Add "${display}" to frontmatter`,
}));
}
}
}
} catch { /* no agents dir */ }
// 5. Validate hooks.json (if exists)
const hooksJsonPath = join(pluginDir, 'hooks', 'hooks.json');
try {
const content = await readFile(hooksJsonPath, 'utf-8');
try {
const parsed = JSON.parse(content);
if (!parsed.hooks || typeof parsed.hooks !== 'object') {
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.high,
title: 'Invalid hooks.json structure',
description: `hooks.json in "${pluginName}" missing "hooks" object`,
file: hooksJsonPath,
recommendation: 'hooks.json must have a "hooks" key with event-keyed object',
}));
} else if (Array.isArray(parsed.hooks)) {
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.high,
title: 'hooks.json uses array instead of object',
description: `hooks.json "hooks" in "${pluginName}" is an array — must be object with event keys`,
file: hooksJsonPath,
recommendation: 'Change hooks from array to object: { "PreToolUse": [...], ... }',
}));
}
} catch {
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.high,
title: 'Invalid hooks.json',
description: `hooks.json is not valid JSON in "${pluginName}"`,
file: hooksJsonPath,
}));
}
} catch { /* no hooks.json — fine */ }
// 6. Check for unknown files in .claude-plugin/
const pluginMetaDir = join(pluginDir, '.claude-plugin');
try {
const entries = await readdir(pluginMetaDir);
const known = new Set(['plugin.json']);
for (const entry of entries) {
if (!known.has(entry)) {
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.low,
title: 'Unknown file in .claude-plugin/',
description: `Unexpected file "${entry}" in .claude-plugin/ of "${pluginName}"`,
file: join(pluginMetaDir, entry),
recommendation: 'Only plugin.json should be in .claude-plugin/',
}));
}
}
} catch { /* skip */ }
return { name: pluginName, findings, commandCount, agentCount };
}
/**
* Scan one or more plugins and return aggregated results.
* @param {string} targetPath - Plugin dir or marketplace root
* @returns {Promise<object>} Scanner result
*/
export async function scan(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);
}
const allFindings = [];
const pluginResults = [];
for (const dir of pluginDirs) {
const result = await scanSinglePlugin(dir);
pluginResults.push(result);
allFindings.push(...result.findings);
}
// Cross-plugin checks: command name conflicts
const commandNames = new Map(); // name → plugin
for (let idx = 0; idx < pluginResults.length; idx++) {
const pr = pluginResults[idx];
const commandsDir = join(pluginDirs[idx], 'commands');
try {
const entries = await readdir(commandsDir);
for (const file of entries.filter(f => f.endsWith('.md'))) {
const filePath = join(commandsDir, file);
const content = await readFile(filePath, 'utf-8');
const { frontmatter } = parseFrontmatter(content);
if (frontmatter && frontmatter.name) {
const cmdName = frontmatter.name;
if (commandNames.has(cmdName)) {
allFindings.push(finding({
scanner: SCANNER,
severity: SEVERITY.high,
title: 'Cross-plugin command name conflict',
description: `Command "${cmdName}" exists in both "${commandNames.get(cmdName)}" and "${pr.name}"`,
file: filePath,
recommendation: `Rename one of the conflicting commands to avoid ambiguity`,
}));
} else {
commandNames.set(cmdName, pr.name);
}
}
}
} catch { /* no commands dir */ }
}
return scannerResult(SCANNER, 'ok', allFindings, pluginDirs.length, Date.now() - start);
}
/**
* Format a plugin health report for terminal output.
* @param {object} scanResult - Scanner result from scan()
* @param {Array<{ name: string, findings: object[], commandCount: number, agentCount: number }>} pluginResults
* @returns {string}
*/
export function formatPluginHealthReport(pluginResults, crossPluginFindings) {
const lines = [];
lines.push('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
lines.push(' Plugin Health Report');
lines.push('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
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 padding = '.'.repeat(Math.max(1, 25 - p.name.length));
lines.push(` ${p.name} ${padding} ${grade} (${score}) ${p.commandCount} commands, ${p.agentCount} agents`);
}
lines.push('');
if (crossPluginFindings.length > 0) {
lines.push(` Cross-plugin issues (${crossPluginFindings.length}):`);
for (const f of crossPluginFindings) {
lines.push(` - [${f.severity}] ${f.title}`);
}
} else {
lines.push(' Cross-plugin issues (0):');
lines.push(' (none)');
}
lines.push('');
lines.push('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
return lines.join('\n');
}
// --- CLI entry point ---
async function main() {
const args = process.argv.slice(2);
let targetPath = '.';
let jsonMode = false;
let rawMode = false;
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 humanizedProgress = !jsonMode && !rawMode;
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);
if (jsonMode || rawMode) {
// --json and --raw both write the v5.0.0-shape result (byte-identical).
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
} else {
// Default mode humanizes finding titles before writing the brief summary.
const findings = humanizeFindings(result.findings);
const count = findings.length;
process.stderr.write(`Findings: ${count}\n`);
for (const f of findings) {
process.stderr.write(` [${f.severity}] ${f.title}\n`);
}
}
}
const isDirectRun = process.argv[1] && resolve(process.argv[1]) === resolve(new URL(import.meta.url).pathname);
if (isDirectRun) {
main().catch(err => {
process.stderr.write(`Fatal: ${err.message}\n`);
process.exit(3);
});
}