fix(plh): require CLAUDE.md commands/agents/hooks section only for shipped components
plugin-health flagged "CLAUDE.md missing <commands|agents|hooks> section" regardless of whether the plugin actually had that component — e.g. graceful-handoff (no commands/ or agents/ dir) got two spurious medium findings. Same over-report class as the model-field fix. Now gated on component presence (pluginShipsComponent): a section is required only if the plugin ships that component (commands/ or agents/ with .md, or hooks/hooks.json). Across the 5 stable plugins this drops 12 spurious findings to 3 legitimate ones (graceful-handoff hooks, ai-psychosis commands+hooks). New fixture plugin-section-coverage proves both directions. Found via the marketplace-wide review. Suite 950/950, self-audit A/A, scanner count 13. tests badge 949 -> 950. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ter3E2JSi1Khgmuf2kady8
This commit is contained in:
parent
a5cfc331fd
commit
292352eff8
6 changed files with 56 additions and 1 deletions
|
|
@ -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. 13 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, and cross-plugin collision detection. Zero external dependencies.
|
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. 13 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, and cross-plugin collision detection. Zero external dependencies.
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,19 @@ const SCANNER = 'PLH';
|
||||||
|
|
||||||
const REQUIRED_PLUGIN_JSON_FIELDS = ['name', 'description', 'version'];
|
const REQUIRED_PLUGIN_JSON_FIELDS = ['name', 'description', 'version'];
|
||||||
const RECOMMENDED_CLAUDE_MD_SECTIONS = ['commands', 'agents', 'hooks'];
|
const RECOMMENDED_CLAUDE_MD_SECTIONS = ['commands', 'agents', 'hooks'];
|
||||||
|
|
||||||
|
// A CLAUDE.md need only document the component types the plugin actually ships. Mirrors the
|
||||||
|
// optional-frontmatter rule: do not demand docs for commands/agents/hooks that do not exist.
|
||||||
|
async function pluginShipsComponent(pluginDir, section) {
|
||||||
|
if (section === 'hooks') {
|
||||||
|
try { await readFile(join(pluginDir, 'hooks', 'hooks.json'), 'utf-8'); return true; }
|
||||||
|
catch { return false; }
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const entries = await readdir(join(pluginDir, section));
|
||||||
|
return entries.some(f => f.endsWith('.md'));
|
||||||
|
} catch { return false; }
|
||||||
|
}
|
||||||
// Keys as they appear after yaml-parser normalizeKey (hyphens → underscores).
|
// Keys as they appear after yaml-parser normalizeKey (hyphens → underscores).
|
||||||
// Field requirements are pinned to the primary docs, NOT to "every field a plugin could set":
|
// Field requirements are pinned to the primary docs, NOT to "every field a plugin could set":
|
||||||
// - Commands/skills (code.claude.com/docs slash-commands): "All fields are optional. Only
|
// - Commands/skills (code.claude.com/docs slash-commands): "All fields are optional. Only
|
||||||
|
|
@ -302,6 +315,9 @@ async function scanSinglePlugin(pluginDir) {
|
||||||
const lower = content.toLowerCase();
|
const lower = content.toLowerCase();
|
||||||
|
|
||||||
for (const section of RECOMMENDED_CLAUDE_MD_SECTIONS) {
|
for (const section of RECOMMENDED_CLAUDE_MD_SECTIONS) {
|
||||||
|
// Only require a section for a component the plugin actually ships (mirrors the
|
||||||
|
// optional-frontmatter rule — no docs demanded for absent commands/agents/hooks).
|
||||||
|
if (!(await pluginShipsComponent(pluginDir, section))) continue;
|
||||||
// Look for markdown table header or section header
|
// Look for markdown table header or section header
|
||||||
const hasSection = lower.includes(`## ${section}`) ||
|
const hasSection = lower.includes(`## ${section}`) ||
|
||||||
lower.includes(`| ${section}`) ||
|
lower.includes(`| ${section}`) ||
|
||||||
|
|
|
||||||
5
tests/fixtures/plugin-section-coverage/.claude-plugin/plugin.json
vendored
Normal file
5
tests/fixtures/plugin-section-coverage/.claude-plugin/plugin.json
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"name": "section-coverage",
|
||||||
|
"description": "Fixture: ships one command, no agents or hooks",
|
||||||
|
"version": "1.0.0"
|
||||||
|
}
|
||||||
7
tests/fixtures/plugin-section-coverage/CLAUDE.md
vendored
Normal file
7
tests/fixtures/plugin-section-coverage/CLAUDE.md
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
# Section Coverage Fixture
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This plugin ships one command and no agents or hooks. Its CLAUDE.md intentionally omits the
|
||||||
|
Commands section, so the missing-section check should fire for `commands` (a component it has)
|
||||||
|
but NOT for `agents` or `hooks` (components it does not have).
|
||||||
7
tests/fixtures/plugin-section-coverage/commands/foo.md
vendored
Normal file
7
tests/fixtures/plugin-section-coverage/commands/foo.md
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
---
|
||||||
|
description: A demo command for the section-coverage fixture
|
||||||
|
---
|
||||||
|
|
||||||
|
# Foo
|
||||||
|
|
||||||
|
Demo command body.
|
||||||
|
|
@ -13,6 +13,7 @@ const DUP_NAME = resolve(FIXTURES, 'duplicate-plugin-name');
|
||||||
const DUP_CMD = resolve(FIXTURES, 'duplicate-command-name');
|
const DUP_CMD = resolve(FIXTURES, 'duplicate-command-name');
|
||||||
const SHADOW = resolve(FIXTURES, 'plugin-shadow-folder');
|
const SHADOW = resolve(FIXTURES, 'plugin-shadow-folder');
|
||||||
const SKILLS_ARR = resolve(FIXTURES, 'plugin-skills-array');
|
const SKILLS_ARR = resolve(FIXTURES, 'plugin-skills-array');
|
||||||
|
const SECTION_COV = resolve(FIXTURES, 'plugin-section-coverage');
|
||||||
|
|
||||||
describe('discoverPlugins', () => {
|
describe('discoverPlugins', () => {
|
||||||
it('discovers a single plugin when pointed at plugin dir', async () => {
|
it('discovers a single plugin when pointed at plugin dir', async () => {
|
||||||
|
|
@ -72,6 +73,25 @@ describe('scan on valid test-plugin', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('CLAUDE.md section findings track present components', () => {
|
||||||
|
it('flags a missing section only for components the plugin actually ships', async () => {
|
||||||
|
resetCounter();
|
||||||
|
const result = await scan(SECTION_COV);
|
||||||
|
// section-coverage ships commands/ but no agents/ or hooks, and its CLAUDE.md omits the
|
||||||
|
// Commands section. Per the component-aware rule: flag the present component's missing
|
||||||
|
// section, never require docs for absent components.
|
||||||
|
const sectionFindings = result.findings.filter(f =>
|
||||||
|
f.scanner === 'PLH' && /missing.{0,40}section/i.test(f.title || '')
|
||||||
|
);
|
||||||
|
assert.ok(sectionFindings.some(f => /command/i.test(f.title)),
|
||||||
|
'missing Commands section must be flagged when commands/ exists');
|
||||||
|
assert.ok(!sectionFindings.some(f => /agent/i.test(f.title)),
|
||||||
|
'agents section must not be flagged (no agents/)');
|
||||||
|
assert.ok(!sectionFindings.some(f => /hook/i.test(f.title)),
|
||||||
|
'hooks section must not be flagged (no hooks)');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('scan on broken-plugin', () => {
|
describe('scan on broken-plugin', () => {
|
||||||
it('detects missing plugin.json fields', async () => {
|
it('detects missing plugin.json fields', async () => {
|
||||||
resetCounter();
|
resetCounter();
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue