fix(scanners)!: a finding ID names the check, not the emission (M-BUG-28)
BREAKING CHANGE: the {NNN} in CA-{SCANNER}-{NNN} identifies the check that
produced the finding. It used to be the finding's position in that scanner's
output for that run, which made it unstable across CONFIGURATIONS, not just
across releases as STATE framed it. Measured on two fixtures: "No custom
subagents" was CA-GAP-007 on minimal-project and CA-GAP-004 on healthy-project.
A user who fixed an unrelated earlier gap silently renumbered every later one,
so a .config-audit-ignore pin retargeted to a neighbouring finding with no
version change at all.
Second measured arm: README already documented the opposite scheme. It and the
scanner headers describe ~20 numbers as check codes (CA-SKL-003 = oversized
body, CA-PLH-015 = folder shadowing, CA-TOK-006 = schema deferral), and the
counter could only produce those in the all-fire case -- source-order positions
are 4, 3 and 8. The documentation described the scheme; the implementation was
what was wrong. Every published number is preserved by construction and pinned
exhaustively in tests/lib/finding-codes.test.mjs.
scanners/lib/finding-codes.mjs is the single authority. Every finding() call
passes a `code`; an undeclared or missing one THROWS. No counter fallback --
that would reproduce D1's findGapId -> 'unknown' silent degradation and let a
half-converted scanner ship IDs that look valid. findingCounter/resetCounter
are deleted outright, not left as no-ops. Retirement is now a mechanism:
RETIRED_CODES tombstones a withdrawn key so its number is never reissued,
seeded with GAP t3_8 -- the D1 removal that opened this chunk.
IDs are consequently NOT unique per finding: one check failing in three files
emits three findings sharing an ID. That inverts which consumer is correct, so
every f.id/findingId site was classified before the change. diff-engine and
most of fix-engine already keyed on scanner+title+file (drift was never lying);
fix-engine's verification did not, and keyed on the ID alone -- fixing one of
two sibling instances marked both fixed, and the untouched one, still present
in the re-scan, was reported as a REGRESSION. Red test first, then keyed on
(findingId, file), which both planFixes and applyFixes already carry.
plugin-health's crossIds Set was measured and is a clean negative: cross
findings are allFindings.slice(crossPluginStart) and codes 18/19 are emitted
only in that tail, so the partition holds by construction.
unknownSuppressions() reports a pin that names no declared check, in the
--output-file payload (ux-rules rule 2 -- a stderr-only warning is invisible to
the commands) and only when one exists, so a clean config is byte-identical.
That is what makes the break safe: a stale pin goes loud instead of dying quiet.
Frozen tests/snapshots/v5.0.0/ untouched on disk. IDs are masked out of that
comparison (mask-finding-ids.mjs) rather than re-derived -- re-deriving
positional IDs would assert the retired scheme against itself, and #58's
isGapEntry off-by-one is the measured example of that misfiring. The dead
re-derivation is removed from strip-retired-gap.mjs. default-output snapshots
re-approved after confirming the diff is IDs and nothing else.
Guards, each seen red against its own defect: a missing code (scanner errors
out mid-sweep), an orphan declaration, a resurrected retired key, and a
documented ID naming no check. The sweep asserts the union across all 16
scanners, never per scanner -- a per-scanner assertion goes green on a partial
conversion.
Fasit written before implementation: docs/mbug28-id-semantics-fasit.local.md,
including one correction made before running (CML has 12 checks over 13 call
sites -- the anchored and calibrated char-budget arms are one check, which a
repeated-title sweep found and my call-site count had missed).
Suite 1535 -> 1573, 0 failing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MyqCQKK2ornJ1jFWwqx17E
This commit is contained in:
parent
4027cdcf54
commit
7a794b47eb
62 changed files with 1092 additions and 279 deletions
|
|
@ -65,6 +65,7 @@ export async function scan(_targetPath, _discovery) {
|
|||
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'description-bloat',
|
||||
severity: SEVERITY.low,
|
||||
title: 'Agent description is long (re-sent every turn in the always-loaded listing)',
|
||||
description:
|
||||
|
|
@ -91,6 +92,7 @@ export async function scan(_targetPath, _discovery) {
|
|||
if (aggregate.overBudget) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'aggregate-listing-budget',
|
||||
severity: SEVERITY.low,
|
||||
title: 'Aggregate agent listing may exceed the always-loaded budget',
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -157,6 +157,7 @@ export async function scan(targetPath, discovery) {
|
|||
.join('; ');
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'volatile-in-prefix',
|
||||
severity: SEVERITY.medium,
|
||||
title: 'Volatile content inside cached prefix breaks reuse',
|
||||
description:
|
||||
|
|
@ -199,6 +200,7 @@ export async function scan(targetPath, discovery) {
|
|||
.join('; ');
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'volatile-in-import',
|
||||
severity: SEVERITY.medium,
|
||||
title: 'Volatile content in @imported file breaks cached prefix',
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
*/
|
||||
|
||||
import { readTextFile } from './lib/file-discovery.mjs';
|
||||
import { finding, scannerResult, resetCounter } from './lib/output.mjs';
|
||||
import { finding, scannerResult } from './lib/output.mjs';
|
||||
import { SEVERITY } from './lib/severity.mjs';
|
||||
import { parseFrontmatter, extractSections, findImports } from './lib/yaml-parser.mjs';
|
||||
import { lineCount, truncate } from './lib/string-utils.mjs';
|
||||
|
|
@ -62,6 +62,7 @@ export async function scan(targetPath, discovery, opts = {}) {
|
|||
return scannerResult(SCANNER, 'ok', [
|
||||
finding({
|
||||
scanner: SCANNER,
|
||||
code: 'no-claude-md',
|
||||
severity: SEVERITY.high,
|
||||
title: 'No CLAUDE.md found',
|
||||
description: 'No CLAUDE.md files were discovered. This is the primary configuration surface for Claude Code.',
|
||||
|
|
@ -91,6 +92,7 @@ export async function scan(targetPath, discovery, opts = {}) {
|
|||
if (file.scope === 'project' && relDir !== '.' && relDir !== '.claude' && lines > 5) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'nested-not-reinjected',
|
||||
severity: SEVERITY.low,
|
||||
title: 'Nested CLAUDE.md is not re-injected after compaction',
|
||||
description: `${file.relPath} is a nested (subdirectory) CLAUDE.md. It loads when Claude reads a file in that directory, but after a context compaction it is not re-injected (only the project-root CLAUDE.md is) — its instructions silently drop until a file in that directory is read again.`,
|
||||
|
|
@ -109,6 +111,7 @@ export async function scan(targetPath, discovery, opts = {}) {
|
|||
if (lines > MAX_ABSOLUTE_LINES) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'over-500-lines',
|
||||
severity: SEVERITY.medium,
|
||||
title: 'CLAUDE.md exceeds 500 lines',
|
||||
description: `${file.relPath} has ${lines} lines. A file this size loads in full on every turn (token cost) and, on smaller-context models, can crowd out instructions. Large-context models tolerate longer files when the cache prefix stays stable — raw line count is no longer an absolute adherence threshold (CC 2.1.169 scales it by context window).`,
|
||||
|
|
@ -120,6 +123,7 @@ export async function scan(targetPath, discovery, opts = {}) {
|
|||
} else if (lines > MAX_RECOMMENDED_LINES) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'over-200-lines',
|
||||
severity: SEVERITY.medium,
|
||||
title: 'CLAUDE.md exceeds recommended 200 lines',
|
||||
description: `${file.relPath} has ${lines} lines. Under ~200 lines is the safe default across models; larger is fine on large-context models when the cache prefix stays stable. A long file still costs tokens every turn.`,
|
||||
|
|
@ -142,6 +146,7 @@ export async function scan(targetPath, discovery, opts = {}) {
|
|||
// Conservative 200k anchor — byte-identical to the pre-B8 finding.
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'over-char-budget',
|
||||
severity: SEVERITY.medium,
|
||||
title: 'CLAUDE.md exceeds Claude Code\'s performance-warning threshold',
|
||||
description: `${file.relPath} is ${withCommas(chars)} chars. Claude Code shows a startup warning ("Large CLAUDE.md will impact performance ... chars > 40.0k") once a CLAUDE.md passes ~40.0k chars on a 200k-context model — it loads in full on every turn. CC 2.1.169 scales that threshold with the context window, so on a ${withCommas(LARGE_CONTEXT_WINDOW)}-token model it relaxes to ~${withCommas(CLAUDE_MD_CHAR_WARN_LARGE)} chars and you are likely within it.`,
|
||||
|
|
@ -156,6 +161,7 @@ export async function scan(targetPath, discovery, opts = {}) {
|
|||
const threshLabel = withCommas(charThreshold);
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'over-char-budget',
|
||||
severity: advisory ? SEVERITY.info : SEVERITY.medium,
|
||||
title: 'CLAUDE.md exceeds Claude Code\'s performance-warning threshold',
|
||||
description: `${file.relPath} is ${withCommas(chars)} chars, over the ~${threshLabel}-char performance-warning threshold Claude Code applies at a ${winLabel}-token context window (it scales the ~40.0k-char @ 200k warning by the context window, CC 2.1.169) — it loads in full on every turn.` +
|
||||
|
|
@ -172,6 +178,7 @@ export async function scan(targetPath, discovery, opts = {}) {
|
|||
if (lines < 3) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'nearly-empty',
|
||||
severity: SEVERITY.medium,
|
||||
title: 'CLAUDE.md is nearly empty',
|
||||
description: `${file.relPath} has only ${lines} lines.`,
|
||||
|
|
@ -197,6 +204,7 @@ export async function scan(targetPath, discovery, opts = {}) {
|
|||
if (missingSections.length > 0) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'missing-sections',
|
||||
severity: SEVERITY.low,
|
||||
title: 'Missing recommended sections',
|
||||
description: `${file.relPath} is missing: ${missingSections.join(', ')}`,
|
||||
|
|
@ -212,6 +220,7 @@ export async function scan(targetPath, discovery, opts = {}) {
|
|||
if (sections.length === 0 && lines > 10) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'no-headings',
|
||||
severity: SEVERITY.medium,
|
||||
title: 'CLAUDE.md has no markdown headings',
|
||||
description: `${file.relPath} has ${lines} lines but no ## headings. Structured content with headers improves Claude's ability to find and follow instructions.`,
|
||||
|
|
@ -228,6 +237,7 @@ export async function scan(targetPath, discovery, opts = {}) {
|
|||
if (imp.path.includes('..') && imp.path.split('..').length > 3) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'deep-relative-import',
|
||||
severity: SEVERITY.low,
|
||||
title: '@import with deep relative path',
|
||||
description: `${file.relPath}:${imp.line} imports "${truncate(imp.path, 60)}" with multiple parent traversals.`,
|
||||
|
|
@ -245,6 +255,7 @@ export async function scan(targetPath, discovery, opts = {}) {
|
|||
if (htmlComments > 0) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'html-comments',
|
||||
severity: SEVERITY.info,
|
||||
title: 'Uses HTML comments',
|
||||
description: `${file.relPath} uses ${htmlComments} HTML comment(s). These are stripped before injection, saving tokens.`,
|
||||
|
|
@ -266,6 +277,7 @@ export async function scan(targetPath, discovery, opts = {}) {
|
|||
if (duplicates.length > 0) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'repeated-content',
|
||||
severity: SEVERITY.low,
|
||||
title: 'Repeated content detected',
|
||||
description: `${file.relPath} has ${duplicates.length} line(s) repeated 3+ times.`,
|
||||
|
|
@ -281,6 +293,7 @@ export async function scan(targetPath, discovery, opts = {}) {
|
|||
if (todos.length > 0) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'todo-markers',
|
||||
severity: SEVERITY.info,
|
||||
title: 'Contains TODO/FIXME markers',
|
||||
description: `${file.relPath} has ${todos.length} TODO/FIXME/HACK marker(s).`,
|
||||
|
|
|
|||
|
|
@ -74,6 +74,7 @@ export async function scan(_targetPath, _discovery) {
|
|||
];
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'skill-user-vs-plugin',
|
||||
severity: SEVERITY.medium,
|
||||
title: `Skill name "${name}" collides between user-level and plugin sources`,
|
||||
description:
|
||||
|
|
@ -97,6 +98,7 @@ export async function scan(_targetPath, _discovery) {
|
|||
const pluginNames = pluginSkills.map(s => s.pluginName);
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'skill-multi-plugin',
|
||||
severity: SEVERITY.low,
|
||||
title: `Skill name "${name}" used by multiple plugins`,
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -129,6 +129,7 @@ export async function scan(targetPath, discovery) {
|
|||
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'settings-key-conflict',
|
||||
severity: SEVERITY.medium,
|
||||
title: `Settings key conflict: "${key}"`,
|
||||
description: `Key "${key}" has different values across scopes. ${details}`,
|
||||
|
|
@ -160,6 +161,7 @@ export async function scan(targetPath, discovery) {
|
|||
if (rulesIntersect(allowRule, denyRule)) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'permission-allow-deny',
|
||||
severity: SEVERITY.high,
|
||||
title: 'Permission allow/deny conflict',
|
||||
description: `"${allowRule}" is allowed in ${a.scope} (${a.file}) but denied in ${b.scope} (${b.file}).`,
|
||||
|
|
@ -177,6 +179,7 @@ export async function scan(targetPath, discovery) {
|
|||
if (rulesIntersect(allowRule, denyRule)) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'permission-allow-deny',
|
||||
severity: SEVERITY.high,
|
||||
title: 'Permission allow/deny conflict',
|
||||
description: `"${allowRule}" is allowed in ${b.scope} (${b.file}) but denied in ${a.scope} (${a.file}).`,
|
||||
|
|
@ -227,6 +230,7 @@ export async function scan(targetPath, discovery) {
|
|||
const [event, matcher] = key.split(':');
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'duplicate-hook',
|
||||
severity: SEVERITY.low,
|
||||
title: 'Duplicate hook definition',
|
||||
description: `Hook "${event}" with matcher "${matcher}" is defined in ${uniqueSources.length} sources.`,
|
||||
|
|
|
|||
|
|
@ -113,6 +113,7 @@ export async function scan(targetPath, discovery) {
|
|||
.join('; ');
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'deny-and-allow',
|
||||
severity: SEVERITY.low,
|
||||
title: 'Tool listed in both permissions.deny and permissions.allow',
|
||||
description:
|
||||
|
|
@ -134,6 +135,7 @@ export async function scan(targetPath, discovery) {
|
|||
const evidence = `allow: ${ineffective.slice(0, 5).map(e => `"${e}"`).join(', ')}`;
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'ineffective-allow-wildcard',
|
||||
severity: SEVERITY.low,
|
||||
title: 'Ineffective allow wildcard — Claude Code ignores this rule',
|
||||
description:
|
||||
|
|
@ -160,6 +162,7 @@ export async function scan(targetPath, discovery) {
|
|||
.join('; ');
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'forbidden-param-deny',
|
||||
severity: SEVERITY.medium,
|
||||
title: 'Permission rule silently ignored — deny/ask uses a forbidden param key',
|
||||
description:
|
||||
|
|
@ -184,6 +187,7 @@ export async function scan(targetPath, discovery) {
|
|||
.join('; ');
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'forbidden-param-allow',
|
||||
severity: SEVERITY.low,
|
||||
title: 'Permission rule silently ignored — allow uses a forbidden param key (dead config)',
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -177,6 +177,7 @@ export function bundledSkillsLeverFinding({ leverPulled, aggregate }) {
|
|||
return finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.low,
|
||||
code: 'bundled-skills-lever',
|
||||
title: 'Bundled skills add to an over-budget skill listing',
|
||||
description:
|
||||
`Your ${aggregate.scanned} active skills already carry ~${aggregate.aggregateTokens} tokens of ` +
|
||||
|
|
@ -221,6 +222,7 @@ export function cliOverMcpLeverFinding({ assessment } = {}) {
|
|||
return finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.low,
|
||||
code: 'cli-over-mcp-lever',
|
||||
title: 'Prefer CLI over MCP for common operations',
|
||||
description:
|
||||
`Your active project MCP tool schemas (~${assessment.aggregateTokens} tokens) are forced into the ` +
|
||||
|
|
@ -258,6 +260,7 @@ export function filterHookLeverFinding({ flaggedHooks } = {}) {
|
|||
return finding({
|
||||
scanner: SCANNER,
|
||||
severity: SEVERITY.info,
|
||||
code: 'filter-hook-output-lever',
|
||||
title: 'Filter hook output before it enters context',
|
||||
description:
|
||||
`${hooks.length} active hook${hooks.length === 1 ? '' : 's'} build hookSpecificOutput.additionalContext ` +
|
||||
|
|
@ -574,6 +577,7 @@ export async function scan(targetPath, sharedDiscovery) {
|
|||
if (!present) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: gap.id,
|
||||
severity: TIER_SEVERITY[gap.tier],
|
||||
title: gap.title,
|
||||
description: `Feature gap: ${gap.title}. ${gap.recommendation}`,
|
||||
|
|
|
|||
|
|
@ -629,9 +629,13 @@ export async function verifyFixes(originalEnvelope, appliedResults, opts = {}) {
|
|||
// "fixed" report for files nothing had touched.
|
||||
const newEnvelope = await runAllScanners(targetPath, { includeGlobal: opts.includeGlobal === true });
|
||||
|
||||
// Build set of original finding IDs that were fixed
|
||||
const fixedIds = new Set(
|
||||
appliedResults.filter(r => r.status === 'applied').map(r => r.findingId),
|
||||
// Build the set of fixed finding INSTANCES. A finding ID names the check, so
|
||||
// one check failing in two files yields two findings sharing an ID; keying on
|
||||
// the ID alone marks both fixed when one was, and the untouched sibling — still
|
||||
// present in the re-scan — is then reported as a regression (M-BUG-28).
|
||||
const instanceKey = (findingId, file) => `${findingId}::${file || ''}`;
|
||||
const fixedInstances = new Set(
|
||||
appliedResults.filter(r => r.status === 'applied').map(r => instanceKey(r.findingId, r.file)),
|
||||
);
|
||||
|
||||
// Build set of new finding titles for comparison
|
||||
|
|
@ -645,11 +649,13 @@ export async function verifyFixes(originalEnvelope, appliedResults, opts = {}) {
|
|||
// Check that fixed findings are gone
|
||||
for (const scanner of originalEnvelope.scanners) {
|
||||
for (const f of scanner.findings) {
|
||||
if (!fixedIds.has(f.id)) continue;
|
||||
if (!fixedInstances.has(instanceKey(f.id, f.file))) continue;
|
||||
|
||||
const key = `${f.scanner}:${f.title}:${f.file}`;
|
||||
// For file-rename fixes, the original file path won't exist anymore
|
||||
const fixResult = appliedResults.find(r => r.findingId === f.id);
|
||||
const fixResult = appliedResults.find(
|
||||
r => instanceKey(r.findingId, r.file) === instanceKey(f.id, f.file),
|
||||
);
|
||||
if (fixResult && fixResult.type === 'file-rename') {
|
||||
// Check that the finding doesn't reappear at the new path
|
||||
verified.push(f.id);
|
||||
|
|
|
|||
|
|
@ -70,6 +70,7 @@ export async function scan(targetPath, discovery) {
|
|||
if (parsed === null) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'invalid-json',
|
||||
severity: SEVERITY.critical,
|
||||
title: 'Invalid JSON in hooks.json',
|
||||
description: `${file.relPath} contains invalid JSON. All hooks in this file will be ignored.`,
|
||||
|
|
@ -120,6 +121,7 @@ async function validateHooksObject(hooks, file, findings, baseDir) {
|
|||
if (typeof hooks !== 'object' || Array.isArray(hooks)) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'hooks-not-object',
|
||||
severity: SEVERITY.critical,
|
||||
title: 'Hooks must be an object with event keys',
|
||||
description: `${file.relPath}: hooks is ${Array.isArray(hooks) ? 'an array' : typeof hooks}. Expected object with event names as keys.`,
|
||||
|
|
@ -135,6 +137,7 @@ async function validateHooksObject(hooks, file, findings, baseDir) {
|
|||
if (!VALID_EVENTS.has(event)) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'unknown-event',
|
||||
severity: SEVERITY.high,
|
||||
title: 'Unknown hook event',
|
||||
description: `${file.relPath}: "${event}" is not a valid hook event. This hook will never fire.`,
|
||||
|
|
@ -149,6 +152,7 @@ async function validateHooksObject(hooks, file, findings, baseDir) {
|
|||
if (!Array.isArray(handlers)) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'handlers-not-array',
|
||||
severity: SEVERITY.high,
|
||||
title: 'Hook handlers must be an array',
|
||||
description: `${file.relPath}: handlers for "${event}" is not an array.`,
|
||||
|
|
@ -166,6 +170,7 @@ async function validateHooksObject(hooks, file, findings, baseDir) {
|
|||
if (typeof handlerGroup.matcher === 'object') {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'matcher-not-string',
|
||||
severity: SEVERITY.high,
|
||||
title: 'Matcher must be a string, not an object',
|
||||
description: `${file.relPath}: "${event}" has a matcher that is an object. Matcher should be a simple string like "Bash" or "Edit|Write".`,
|
||||
|
|
@ -180,6 +185,7 @@ async function validateHooksObject(hooks, file, findings, baseDir) {
|
|||
if (!handlerGroup.hooks || !Array.isArray(handlerGroup.hooks)) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'missing-hooks-array',
|
||||
severity: SEVERITY.high,
|
||||
title: 'Missing hooks array in handler group',
|
||||
description: `${file.relPath}: "${event}" handler group is missing the "hooks" array.`,
|
||||
|
|
@ -195,6 +201,7 @@ async function validateHooksObject(hooks, file, findings, baseDir) {
|
|||
if (!hook.type || !VALID_TYPES.has(hook.type)) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'invalid-handler-type',
|
||||
severity: SEVERITY.high,
|
||||
title: 'Invalid hook handler type',
|
||||
description: `${file.relPath}: "${event}" has handler with type "${hook.type || '(missing)'}".`,
|
||||
|
|
@ -216,6 +223,7 @@ async function validateHooksObject(hooks, file, findings, baseDir) {
|
|||
} catch {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'script-not-found',
|
||||
severity: SEVERITY.high,
|
||||
title: 'Hook script not found',
|
||||
description: `${file.relPath}: "${event}" references script that does not exist.`,
|
||||
|
|
@ -232,6 +240,7 @@ async function validateHooksObject(hooks, file, findings, baseDir) {
|
|||
if (verboseCount > VERBOSE_HOOK_LINE_THRESHOLD) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'verbose-output',
|
||||
severity: SEVERITY.low,
|
||||
title: 'Verbose hook output (loud script)',
|
||||
description:
|
||||
|
|
@ -259,6 +268,7 @@ async function validateHooksObject(hooks, file, findings, baseDir) {
|
|||
if (ac.flagged) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'unfiltered-additional-context',
|
||||
severity: SEVERITY.info,
|
||||
title: 'Hook injects unfiltered output into context',
|
||||
description:
|
||||
|
|
@ -287,6 +297,7 @@ async function validateHooksObject(hooks, file, findings, baseDir) {
|
|||
if (typeof hook.timeout !== 'number') {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'timeout-not-number',
|
||||
severity: SEVERITY.medium,
|
||||
title: 'Hook timeout must be a number',
|
||||
description: `${file.relPath}: "${event}" has non-numeric timeout.`,
|
||||
|
|
@ -298,6 +309,7 @@ async function validateHooksObject(hooks, file, findings, baseDir) {
|
|||
} else if (hook.timeout < MIN_TIMEOUT || hook.timeout > MAX_TIMEOUT) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'timeout-out-of-range',
|
||||
severity: SEVERITY.low,
|
||||
title: 'Hook timeout outside recommended range',
|
||||
description: `${file.relPath}: "${event}" timeout is ${hook.timeout}ms. Recommended range: ${MIN_TIMEOUT}-${MAX_TIMEOUT}ms.`,
|
||||
|
|
|
|||
|
|
@ -74,6 +74,7 @@ async function walkImports(file, chain, reported, findings) {
|
|||
reported.add(`tilde::${resolved}`);
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'tilde-path',
|
||||
severity: SEVERITY.medium,
|
||||
title: 'Tilde path in @import',
|
||||
description: `@${imp.path} uses ~ which may not expand correctly in all contexts.`,
|
||||
|
|
@ -91,6 +92,7 @@ async function walkImports(file, chain, reported, findings) {
|
|||
reported.add(reportKey);
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'broken-link',
|
||||
severity: SEVERITY.high,
|
||||
title: 'Broken @import link',
|
||||
description: `@${imp.path} references a file that does not exist.`,
|
||||
|
|
@ -111,6 +113,7 @@ async function walkImports(file, chain, reported, findings) {
|
|||
const cycle = chain.slice(cycleStart).map(f => basename(f)).join(' → ');
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'circular-reference',
|
||||
severity: SEVERITY.medium,
|
||||
title: 'Circular @import reference',
|
||||
description: `@${imp.path} creates a circular import chain.`,
|
||||
|
|
@ -129,6 +132,7 @@ async function walkImports(file, chain, reported, findings) {
|
|||
reported.add(`deep::${resolved}`);
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'deep-chain',
|
||||
severity: SEVERITY.low,
|
||||
title: 'Deep @import chain',
|
||||
description: `@${imp.path} is at depth ${chain.length} (>${MAX_CHAIN_DEPTH} hops).`,
|
||||
|
|
|
|||
316
scanners/lib/finding-codes.mjs
Normal file
316
scanners/lib/finding-codes.mjs
Normal file
|
|
@ -0,0 +1,316 @@
|
|||
/**
|
||||
* Finding-code registry — the authority for the {NNN} in `CA-{SCANNER}-{NNN}`.
|
||||
*
|
||||
* A finding ID names the CHECK, not the finding's position in a run (M-BUG-28).
|
||||
* Before this registry, `{NNN}` came from an emission counter, so the same check
|
||||
* carried different IDs on different configurations: fixing an unrelated earlier
|
||||
* gap silently renumbered every later one, and a `.config-audit-ignore` entry
|
||||
* retargeted to a neighbouring finding without the user changing anything.
|
||||
*
|
||||
* Rules for editing this file:
|
||||
*
|
||||
* 1. A number, once published, belongs to its check forever. Adding a check
|
||||
* takes the next free number for that scanner — never the next source-order
|
||||
* position, and never a number listed in RETIRED.
|
||||
* 2. Removing a check moves its key to RETIRED. The number is never reissued;
|
||||
* a user's suppression must go dead rather than quietly point at a
|
||||
* different finding. (D1 retired GAP `t3_8` under the old scheme, which is
|
||||
* the incident that motivated the registry.)
|
||||
* 3. Several call sites may share one code when they are arms of one check —
|
||||
* e.g. the forward/reverse arms of a permission conflict. Duplicate
|
||||
* emission is legal; a finding is identified by (id, file, line).
|
||||
* 4. Numbers below are NOT all source order: the ones marked "documented"
|
||||
* are pinned by README / command copy that shipped before the registry.
|
||||
*
|
||||
* GAP keys are the `GAP_CHECKS[].id` values from `feature-gap-scanner.mjs`,
|
||||
* which were already stable. They are declared here rather than derived, so
|
||||
* numbers live in exactly one place; `tests/lib/finding-codes.test.mjs` binds
|
||||
* the two together instead of a second copy of the table drifting.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @type {Record<string, Record<string, number>>}
|
||||
* scanner prefix → check key → number
|
||||
*/
|
||||
export const FINDING_CODES = {
|
||||
// ── CML: claude-md-linter (source order) ────────────────────────────────
|
||||
// `over-char-budget` has two call sites: the conservative 200k anchor and the
|
||||
// `--context-window` calibrated variant. One check, one code.
|
||||
CML: {
|
||||
'no-claude-md': 1,
|
||||
'nested-not-reinjected': 2,
|
||||
'over-500-lines': 3,
|
||||
'over-200-lines': 4,
|
||||
'over-char-budget': 5,
|
||||
'nearly-empty': 6,
|
||||
'missing-sections': 7,
|
||||
'no-headings': 8,
|
||||
'deep-relative-import': 9,
|
||||
'html-comments': 10,
|
||||
'repeated-content': 11,
|
||||
'todo-markers': 12,
|
||||
},
|
||||
|
||||
// ── SET: settings-validator (source order) ──────────────────────────────
|
||||
SET: {
|
||||
'invalid-json': 1,
|
||||
'key-typo': 2,
|
||||
'deprecated-key': 3,
|
||||
'type-mismatch': 4,
|
||||
'invalid-effort-level': 5,
|
||||
'missing-schema': 6,
|
||||
'no-deny-rules': 7,
|
||||
'no-allow-rules': 8,
|
||||
'many-additional-dirs': 9,
|
||||
'automode-not-object': 10,
|
||||
'automode-unknown-subkey': 11,
|
||||
'automode-subkey-not-string-array': 12,
|
||||
'automode-in-shared-settings': 13,
|
||||
'hooks-as-array': 14,
|
||||
},
|
||||
|
||||
// ── HKV: hook-validator (source order) ──────────────────────────────────
|
||||
HKV: {
|
||||
'invalid-json': 1,
|
||||
'hooks-not-object': 2,
|
||||
'unknown-event': 3,
|
||||
'handlers-not-array': 4,
|
||||
'matcher-not-string': 5,
|
||||
'missing-hooks-array': 6,
|
||||
'invalid-handler-type': 7,
|
||||
'script-not-found': 8,
|
||||
'verbose-output': 9,
|
||||
'unfiltered-additional-context': 10,
|
||||
'timeout-not-number': 11,
|
||||
'timeout-out-of-range': 12,
|
||||
},
|
||||
|
||||
// ── RUL: rules-validator (source order) ─────────────────────────────────
|
||||
RUL: {
|
||||
'no-frontmatter': 1,
|
||||
'globs-instead-of-paths': 2,
|
||||
'pattern-matches-nothing': 3,
|
||||
'nearly-empty': 4,
|
||||
'large-unscoped': 5,
|
||||
'large-scoped-lost-after-compaction': 6,
|
||||
'not-markdown': 7,
|
||||
},
|
||||
|
||||
// ── MCP: mcp-config-validator (source order) ────────────────────────────
|
||||
MCP: {
|
||||
'invalid-json': 1,
|
||||
'unknown-server-type': 2,
|
||||
'sse-transport': 3,
|
||||
'unreferenced-env-var': 4,
|
||||
'unknown-server-field': 5,
|
||||
},
|
||||
|
||||
// ── IMP: import-resolver (source order) ─────────────────────────────────
|
||||
IMP: {
|
||||
'tilde-path': 1,
|
||||
'broken-link': 2,
|
||||
'circular-reference': 3,
|
||||
'deep-chain': 4,
|
||||
},
|
||||
|
||||
// ── CNF: conflict-detector ──────────────────────────────────────────────
|
||||
// `permission-allow-deny` covers both arms (allow-in-A/deny-in-B and reverse).
|
||||
CNF: {
|
||||
'settings-key-conflict': 1,
|
||||
'permission-allow-deny': 2,
|
||||
'duplicate-hook': 3,
|
||||
},
|
||||
|
||||
// ── DIS: disabled-in-schema-scanner (source order) ──────────────────────
|
||||
DIS: {
|
||||
'deny-and-allow': 1,
|
||||
'ineffective-allow-wildcard': 2,
|
||||
'forbidden-param-deny': 3,
|
||||
'forbidden-param-allow': 4,
|
||||
},
|
||||
|
||||
// ── CPS: cache-prefix-scanner (CPS-001 documented) ──────────────────────
|
||||
CPS: {
|
||||
'volatile-in-prefix': 1,
|
||||
'volatile-in-import': 2,
|
||||
},
|
||||
|
||||
// ── COL: collision-scanner (source order) ───────────────────────────────
|
||||
COL: {
|
||||
'skill-user-vs-plugin': 1,
|
||||
'skill-multi-plugin': 2,
|
||||
},
|
||||
|
||||
// ── AGT: agent-listing-scanner (both documented; source order matches) ──
|
||||
AGT: {
|
||||
'description-bloat': 1,
|
||||
'aggregate-listing-budget': 2,
|
||||
},
|
||||
|
||||
// ── OST: output-style-scanner (all three documented) ────────────────────
|
||||
OST: {
|
||||
'strips-coding-instructions': 1,
|
||||
'plugin-forces-style': 2,
|
||||
'style-not-found': 3,
|
||||
},
|
||||
|
||||
// ── OPT: optimization-lens-scanner (documented) ─────────────────────────
|
||||
OPT: {
|
||||
'procedure-should-be-skill': 1,
|
||||
},
|
||||
|
||||
// ── SKL: skill-listing-scanner (all three documented) ───────────────────
|
||||
// `aggregate-listing-budget` has two call sites: the conservative 200k anchor
|
||||
// and the calibrated `--context-window` variant. One check, one code.
|
||||
SKL: {
|
||||
'description-over-cap': 1,
|
||||
'aggregate-listing-budget': 2,
|
||||
'oversized-body': 3,
|
||||
},
|
||||
|
||||
// ── TOK: token-hotspots ─────────────────────────────────────────────────
|
||||
// 1/2/3/5/6 are documented (README + commands/tokens.md). `mcp-schema-deferral`
|
||||
// is documented as 006 although it is the 8th call site in source order, so
|
||||
// `cascade-over-budget` and `stale-plugin-cache` take the free 7 and 8.
|
||||
TOK: {
|
||||
'volatile-top': 1,
|
||||
'redundant-permissions': 2,
|
||||
'deep-import-chain': 3,
|
||||
'bloated-skill-description': 4,
|
||||
'mcp-schema-budget': 5,
|
||||
'mcp-schema-deferral': 6,
|
||||
'cascade-over-budget': 7,
|
||||
'stale-plugin-cache': 8,
|
||||
},
|
||||
|
||||
// ── PLH: plugin-health-scanner ──────────────────────────────────────────
|
||||
// 15 and 16 are documented (README v5.4.0 entry) but sit at source positions
|
||||
// 3 and 4; the remaining checks take {1…14, 17, 18, 19} in source order.
|
||||
PLH: {
|
||||
'invalid-plugin-json': 1,
|
||||
'missing-required-field': 2,
|
||||
'missing-plugin-json': 3,
|
||||
'claude-md-missing-section': 4,
|
||||
'missing-claude-md': 5,
|
||||
'command-missing-frontmatter': 6,
|
||||
'command-missing-field': 7,
|
||||
'agent-missing-frontmatter': 8,
|
||||
'agent-missing-field': 9,
|
||||
'agent-ignored-key': 10,
|
||||
'hooks-json-invalid-structure': 11,
|
||||
'hooks-json-array': 12,
|
||||
'hooks-json-invalid': 13,
|
||||
'unknown-plugin-file': 14,
|
||||
'plugin-json-shadows-default': 15,
|
||||
'skills-array-entry': 16,
|
||||
'no-plugins-found': 17,
|
||||
'command-name-collision': 18,
|
||||
'namespace-collision': 19,
|
||||
},
|
||||
|
||||
// ── GAP: feature-gap-scanner ────────────────────────────────────────────
|
||||
// Keys are GAP_CHECKS[].id (already stable). Dimensions 1–24 in table order,
|
||||
// then the three conditional levers, which the scanner emits after the loop.
|
||||
GAP: {
|
||||
t1_1: 1,
|
||||
t1_2: 2,
|
||||
t1_3: 3,
|
||||
t1_4: 4,
|
||||
t1_5: 5,
|
||||
t2_1: 6,
|
||||
t2_2: 7,
|
||||
t2_3: 8,
|
||||
t2_4: 9,
|
||||
t2_5: 10,
|
||||
t2_6: 11,
|
||||
t2_7: 12,
|
||||
t3_1: 13,
|
||||
t3_2: 14,
|
||||
t3_3: 15,
|
||||
t3_4: 16,
|
||||
t3_5: 17,
|
||||
t3_6: 18,
|
||||
t3_7: 19,
|
||||
t4_1: 20,
|
||||
t4_2: 21,
|
||||
t4_3: 22,
|
||||
t4_4: 23,
|
||||
t4_5: 24,
|
||||
'bundled-skills-lever': 25,
|
||||
'cli-over-mcp-lever': 26,
|
||||
'filter-hook-output-lever': 27,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Keys withdrawn from a scanner. Their numbers are never reissued, so a stale
|
||||
* suppression goes dead instead of silently naming a different check.
|
||||
* @type {Record<string, string[]>}
|
||||
*/
|
||||
export const RETIRED_CODES = {
|
||||
// D1 (4027cdc, 2026-08-09): "No autoMode classifier" — /doctor Check 8 covers
|
||||
// auto mode with usage-weighted judgement, so the nudge went. The number it
|
||||
// occupied under the old counter scheme is not reused.
|
||||
GAP: ['t3_8'],
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve a check key to its published number.
|
||||
* Throws rather than falling back: a fallback would let a half-converted scanner
|
||||
* ship IDs that look valid, which is the silent-degradation class this registry
|
||||
* exists to remove.
|
||||
* @param {string} scanner - scanner prefix, e.g. 'GAP'
|
||||
* @param {string} code - check key, e.g. 't3_7'
|
||||
* @returns {number}
|
||||
*/
|
||||
export function codeNumber(scanner, code) {
|
||||
const table = FINDING_CODES[scanner];
|
||||
if (!table) {
|
||||
throw new Error(`finding(): unknown scanner "${scanner}" — add it to FINDING_CODES`);
|
||||
}
|
||||
if (code === undefined || code === null || code === '') {
|
||||
throw new Error(`finding(): missing "code" for scanner ${scanner} — every finding must name its check`);
|
||||
}
|
||||
if (!Object.prototype.hasOwnProperty.call(table, code)) {
|
||||
const retired = (RETIRED_CODES[scanner] || []).includes(code);
|
||||
throw new Error(
|
||||
retired
|
||||
? `finding(): check "${code}" is RETIRED for ${scanner} — retired numbers are never reissued`
|
||||
: `finding(): undeclared check "${code}" for ${scanner} — add it to FINDING_CODES with the next free number`
|
||||
);
|
||||
}
|
||||
return table[code];
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a finding ID from a check key.
|
||||
* @param {string} scanner
|
||||
* @param {string} code
|
||||
* @returns {string} e.g. 'CA-GAP-019'
|
||||
*/
|
||||
export function findingId(scanner, code) {
|
||||
return `CA-${scanner}-${String(codeNumber(scanner, code)).padStart(3, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every declared ID, as a flat set — used to validate suppression patterns so a
|
||||
* stale pin is reported instead of silently matching nothing.
|
||||
* @returns {Set<string>}
|
||||
*/
|
||||
export function allFindingIds() {
|
||||
const ids = new Set();
|
||||
for (const [scanner, table] of Object.entries(FINDING_CODES)) {
|
||||
for (const n of Object.values(table)) {
|
||||
ids.add(`CA-${scanner}-${String(n).padStart(3, '0')}`);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scanner prefixes the registry knows about.
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function knownScanners() {
|
||||
return Object.keys(FINDING_CODES);
|
||||
}
|
||||
|
|
@ -5,18 +5,13 @@
|
|||
*/
|
||||
|
||||
import { riskScore, riskBand, verdict } from './severity.mjs';
|
||||
|
||||
let findingCounter = 0;
|
||||
|
||||
/** Reset the finding counter. Call in beforeEach of tests and before each scanner run. */
|
||||
export function resetCounter() {
|
||||
findingCounter = 0;
|
||||
}
|
||||
import { findingId } from './finding-codes.mjs';
|
||||
|
||||
/**
|
||||
* Create a finding object with auto-incremented ID.
|
||||
* Create a finding object. The ID names the CHECK — see `finding-codes.mjs`.
|
||||
* @param {object} opts
|
||||
* @param {string} opts.scanner - 3-letter scanner prefix (CML, SET, HKV, RUL, etc.)
|
||||
* @param {string} opts.code - check key declared in FINDING_CODES for this scanner
|
||||
* @param {string} opts.severity - critical | high | medium | low | info
|
||||
* @param {string} opts.title
|
||||
* @param {string} opts.description
|
||||
|
|
@ -30,10 +25,8 @@ export function resetCounter() {
|
|||
* @returns {object}
|
||||
*/
|
||||
export function finding(opts) {
|
||||
findingCounter++;
|
||||
const id = `CA-${opts.scanner}-${String(findingCounter).padStart(3, '0')}`;
|
||||
const result = {
|
||||
id,
|
||||
id: findingId(opts.scanner, opts.code),
|
||||
scanner: opts.scanner,
|
||||
severity: opts.severity,
|
||||
title: opts.title,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
import { readFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { allFindingIds, knownScanners } from './finding-codes.mjs';
|
||||
|
||||
/**
|
||||
* Load suppressions from .config-audit-ignore files.
|
||||
|
|
@ -69,6 +70,39 @@ export function parseIgnoreFile(content) {
|
|||
return suppressions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find suppression patterns that can never match anything.
|
||||
*
|
||||
* A finding ID names a check (see `finding-codes.mjs`), so an exact pin either
|
||||
* names a declared check or names nothing at all. Silently keeping a dead pin
|
||||
* would reproduce, in the other direction, the very failure the check-code
|
||||
* scheme removed: the user believes a finding is suppressed when it is not.
|
||||
*
|
||||
* Globs are validated only down to the scanner prefix — `CA-GAP-*` stays valid
|
||||
* however GAP's checks change, which is why a glob is the safe way to pin.
|
||||
*
|
||||
* @param {Array<{ pattern: string }>} suppressions
|
||||
* @returns {string[]} patterns that match no declared check
|
||||
*/
|
||||
export function unknownSuppressions(suppressions) {
|
||||
if (!suppressions || suppressions.length === 0) return [];
|
||||
|
||||
const ids = allFindingIds();
|
||||
const scanners = new Set(knownScanners());
|
||||
const unknown = [];
|
||||
|
||||
for (const { pattern } of suppressions) {
|
||||
if (pattern.endsWith('-*')) {
|
||||
const scanner = pattern.slice(3, -2); // "CA-GAP-*" → "GAP"
|
||||
if (!scanners.has(scanner)) unknown.push(pattern);
|
||||
continue;
|
||||
}
|
||||
if (!ids.has(pattern)) unknown.push(pattern);
|
||||
}
|
||||
|
||||
return unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply suppressions to a findings array.
|
||||
* @param {object[]} findings - Array of finding objects with .id
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ export async function scan(targetPath, discovery) {
|
|||
if (!parsed) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'invalid-json',
|
||||
severity: SEVERITY.critical,
|
||||
title: 'Invalid JSON in MCP config',
|
||||
description: `${file.relPath}: Failed to parse as JSON.`,
|
||||
|
|
@ -75,6 +76,7 @@ export async function scan(targetPath, discovery) {
|
|||
if (config.type && !VALID_SERVER_TYPES.has(config.type)) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'unknown-server-type',
|
||||
severity: SEVERITY.high,
|
||||
title: 'Unknown MCP server type',
|
||||
description: `${file.relPath}: Server "${name}" has unknown type "${config.type}".`,
|
||||
|
|
@ -88,6 +90,7 @@ export async function scan(targetPath, discovery) {
|
|||
if (config.type === 'sse') {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'sse-transport',
|
||||
severity: SEVERITY.info,
|
||||
title: 'SSE server type — consider HTTP',
|
||||
description: `${file.relPath}: Server "${name}" uses "sse" type. The "http" type is the current standard.`,
|
||||
|
|
@ -110,6 +113,7 @@ export async function scan(targetPath, discovery) {
|
|||
if (!hasEnvBlock) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'unreferenced-env-var',
|
||||
severity: SEVERITY.medium,
|
||||
title: 'Unreferenced env var in args',
|
||||
description: `${file.relPath}: Server "${name}" references \${${varName}} in args but has no env block defining it.`,
|
||||
|
|
@ -127,6 +131,7 @@ export async function scan(targetPath, discovery) {
|
|||
if (!VALID_SERVER_FIELDS.has(key)) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'unknown-server-field',
|
||||
severity: SEVERITY.medium,
|
||||
title: 'Unknown MCP server field',
|
||||
description: `${file.relPath}: Server "${name}" has unknown field "${key}".`,
|
||||
|
|
|
|||
|
|
@ -122,6 +122,7 @@ export async function scan(targetPath, discovery) {
|
|||
findings.push(
|
||||
finding({
|
||||
scanner: SCANNER,
|
||||
code: 'procedure-should-be-skill',
|
||||
severity: SEVERITY.low,
|
||||
title: PROCEDURE_TITLE,
|
||||
description: claim,
|
||||
|
|
|
|||
|
|
@ -28,7 +28,6 @@ import { resolve, sep } from 'node:path';
|
|||
import { readFile, stat } from 'node:fs/promises';
|
||||
import { writeOutputFile } from './lib/write-output.mjs';
|
||||
import { discoverConfigFiles } from './lib/file-discovery.mjs';
|
||||
import { resetCounter } from './lib/output.mjs';
|
||||
import { parseFrontmatter } from './lib/yaml-parser.mjs';
|
||||
import { loadRegister, getEntry } from './lib/best-practices-register.mjs';
|
||||
import { prefilterClaudeMd, LENS_DETECTORS } from './lib/lens-prefilter.mjs';
|
||||
|
|
@ -91,7 +90,6 @@ async function main() {
|
|||
register = null;
|
||||
}
|
||||
|
||||
resetCounter();
|
||||
const rawDiscovery = await discoverConfigFiles(absPath, { includeGlobal });
|
||||
// Scope the lens to the user's authored config: drop plugin-bundled files for
|
||||
// BOTH halves of the motor (the OPT scanner reads discovery.files directly).
|
||||
|
|
|
|||
|
|
@ -100,6 +100,7 @@ export async function scan(targetPath, _discovery) {
|
|||
if (kci === true) continue;
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'strips-coding-instructions',
|
||||
severity: SEVERITY.medium,
|
||||
title: 'Custom output style removes built-in coding instructions',
|
||||
description:
|
||||
|
|
@ -127,6 +128,7 @@ export async function scan(targetPath, _discovery) {
|
|||
if (ffp !== true) continue;
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'plugin-forces-style',
|
||||
severity: SEVERITY.low,
|
||||
title: 'Plugin output style overrides your selected output style',
|
||||
description:
|
||||
|
|
@ -155,6 +157,7 @@ export async function scan(targetPath, _discovery) {
|
|||
const customNames = styles.map(s => s.name);
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'style-not-found',
|
||||
severity: SEVERITY.medium,
|
||||
title: 'Configured output style does not exist',
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import { readdir, stat, readFile } from 'node:fs/promises';
|
|||
import { writeOutputFile } from './lib/write-output.mjs';
|
||||
import { requireTargetDir } from './lib/require-target-dir.mjs';
|
||||
import { join, basename, resolve, sep } from 'node:path';
|
||||
import { finding, scannerResult, resetCounter } from './lib/output.mjs';
|
||||
import { finding, scannerResult } from './lib/output.mjs';
|
||||
import { SEVERITY } from './lib/severity.mjs';
|
||||
import { parseFrontmatter } from './lib/yaml-parser.mjs';
|
||||
import { humanizeFindings } from './lib/humanizer.mjs';
|
||||
|
|
@ -222,6 +222,7 @@ async function scanSinglePlugin(pluginDir) {
|
|||
} catch {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'invalid-plugin-json',
|
||||
severity: SEVERITY.critical,
|
||||
title: 'Invalid plugin.json',
|
||||
description: `plugin.json is not valid JSON in ${pluginName}`,
|
||||
|
|
@ -238,6 +239,7 @@ async function scanSinglePlugin(pluginDir) {
|
|||
if (!parsed[field]) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'missing-required-field',
|
||||
severity: SEVERITY.high,
|
||||
title: `Missing required field in plugin.json: ${field}`,
|
||||
description: `Plugin "${pluginName}" plugin.json is missing required field "${field}"`,
|
||||
|
|
@ -259,6 +261,7 @@ async function scanSinglePlugin(pluginDir) {
|
|||
if (!(await dirExists(join(pluginDir, defaultDir)))) continue;
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'plugin-json-shadows-default',
|
||||
severity: SEVERITY.medium,
|
||||
title: `plugin.json "${key}" path shadows the default ${defaultDir}/ folder`,
|
||||
description:
|
||||
|
|
@ -298,6 +301,7 @@ async function scanSinglePlugin(pluginDir) {
|
|||
const m = SKILLS_ENTRY_MESSAGES[problem];
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'skills-array-entry',
|
||||
severity: SEVERITY.medium,
|
||||
title: m.title(entry),
|
||||
description: `Plugin "${pluginName}": ${m.description}`,
|
||||
|
|
@ -313,6 +317,7 @@ async function scanSinglePlugin(pluginDir) {
|
|||
} catch {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'missing-plugin-json',
|
||||
severity: SEVERITY.critical,
|
||||
title: 'Missing plugin.json',
|
||||
description: `No .claude-plugin/plugin.json found in ${pluginName}`,
|
||||
|
|
@ -338,6 +343,7 @@ async function scanSinglePlugin(pluginDir) {
|
|||
if (!hasSection) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'claude-md-missing-section',
|
||||
severity: SEVERITY.medium,
|
||||
title: `CLAUDE.md missing ${section} section`,
|
||||
description: `Plugin "${pluginName}" CLAUDE.md should have a ${section} table or section`,
|
||||
|
|
@ -349,6 +355,7 @@ async function scanSinglePlugin(pluginDir) {
|
|||
} catch {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'missing-claude-md',
|
||||
severity: SEVERITY.high,
|
||||
title: 'Missing CLAUDE.md',
|
||||
description: `Plugin "${pluginName}" has no CLAUDE.md`,
|
||||
|
|
@ -372,6 +379,7 @@ async function scanSinglePlugin(pluginDir) {
|
|||
if (!frontmatter) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'command-missing-frontmatter',
|
||||
severity: SEVERITY.high,
|
||||
title: 'Command missing frontmatter',
|
||||
description: `Command "${file}" in plugin "${pluginName}" has no frontmatter`,
|
||||
|
|
@ -385,6 +393,7 @@ async function scanSinglePlugin(pluginDir) {
|
|||
if (!frontmatter[key]) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'command-missing-field',
|
||||
severity: SEVERITY.medium,
|
||||
title: `Command missing frontmatter field: ${display}`,
|
||||
description: `Command "${file}" in plugin "${pluginName}" is missing "${display}" in frontmatter`,
|
||||
|
|
@ -411,6 +420,7 @@ async function scanSinglePlugin(pluginDir) {
|
|||
if (!frontmatter) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'agent-missing-frontmatter',
|
||||
severity: SEVERITY.high,
|
||||
title: 'Agent missing frontmatter',
|
||||
description: `Agent "${file}" in plugin "${pluginName}" has no frontmatter`,
|
||||
|
|
@ -424,6 +434,7 @@ async function scanSinglePlugin(pluginDir) {
|
|||
if (!frontmatter[key]) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'agent-missing-field',
|
||||
severity: SEVERITY.medium,
|
||||
title: `Agent missing frontmatter field: ${display}`,
|
||||
description: `Agent "${file}" in plugin "${pluginName}" is missing "${display}" in frontmatter`,
|
||||
|
|
@ -439,6 +450,7 @@ async function scanSinglePlugin(pluginDir) {
|
|||
if (frontmatter[key] !== undefined) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'agent-ignored-key',
|
||||
severity,
|
||||
title: `Plugin agent sets "${key}", which Claude Code ignores`,
|
||||
description: `Agent "${file}" in plugin "${pluginName}" sets "${key}" in frontmatter, but Claude Code ignores ${key} for plugin subagents — ${key === 'permissionMode' ? 'the agent runs with default permissions, not the restricted mode this implies' : 'this configuration has no effect'}.`,
|
||||
|
|
@ -461,6 +473,7 @@ async function scanSinglePlugin(pluginDir) {
|
|||
if (!parsed.hooks || typeof parsed.hooks !== 'object') {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'hooks-json-invalid-structure',
|
||||
severity: SEVERITY.high,
|
||||
title: 'Invalid hooks.json structure',
|
||||
description: `hooks.json in "${pluginName}" missing "hooks" object`,
|
||||
|
|
@ -470,6 +483,7 @@ async function scanSinglePlugin(pluginDir) {
|
|||
} else if (Array.isArray(parsed.hooks)) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'hooks-json-array',
|
||||
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`,
|
||||
|
|
@ -480,6 +494,7 @@ async function scanSinglePlugin(pluginDir) {
|
|||
} catch {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'hooks-json-invalid',
|
||||
severity: SEVERITY.high,
|
||||
title: 'Invalid hooks.json',
|
||||
description: `hooks.json is not valid JSON in "${pluginName}"`,
|
||||
|
|
@ -503,6 +518,7 @@ async function scanSinglePlugin(pluginDir) {
|
|||
if (!known.has(entry)) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'unknown-plugin-file',
|
||||
severity: SEVERITY.low,
|
||||
title: 'Unknown file in .claude-plugin/',
|
||||
description: `Unexpected file "${entry}" in .claude-plugin/ of "${pluginName}"`,
|
||||
|
|
@ -554,7 +570,6 @@ export async function scan(targetPath) {
|
|||
*/
|
||||
export async function scanDetailed(targetPath) {
|
||||
const start = Date.now();
|
||||
resetCounter();
|
||||
|
||||
const pluginDirs = await discoverPlugins(resolve(targetPath));
|
||||
|
||||
|
|
@ -563,6 +578,7 @@ export async function scanDetailed(targetPath) {
|
|||
result: scannerResult(SCANNER, 'ok', [
|
||||
finding({
|
||||
scanner: SCANNER,
|
||||
code: 'no-plugins-found',
|
||||
severity: SEVERITY.info,
|
||||
title: 'No plugins found',
|
||||
description: `No Claude Code plugins found under ${targetPath}`,
|
||||
|
|
@ -621,6 +637,7 @@ export async function scanDetailed(targetPath) {
|
|||
const namespaceList = entries.map(e => e.namespace).join(', ');
|
||||
allFindings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'command-name-collision',
|
||||
severity: SEVERITY.low,
|
||||
title: `Command name "${cmdName}" used by multiple plugins`,
|
||||
description:
|
||||
|
|
@ -656,6 +673,7 @@ export async function scanDetailed(targetPath) {
|
|||
if (dirs.length < 2) continue;
|
||||
allFindings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'namespace-collision',
|
||||
severity: SEVERITY.medium,
|
||||
title: `Plugin namespace collision: "${declaredName}"`,
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ export async function scan(targetPath, discovery) {
|
|||
if (lines > 5) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'no-frontmatter',
|
||||
severity: SEVERITY.info,
|
||||
title: 'Rule has no frontmatter (always active)',
|
||||
description: `${file.relPath} has no YAML frontmatter. It will be loaded for ALL files. Add paths: frontmatter to scope it.`,
|
||||
|
|
@ -69,6 +70,7 @@ export async function scan(targetPath, discovery) {
|
|||
if (frontmatter.globs && !frontmatter.paths) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'globs-instead-of-paths',
|
||||
severity: SEVERITY.low,
|
||||
title: 'Rule uses "globs" instead of documented "paths"',
|
||||
description: `${file.relPath} uses "globs:" for scoping. Claude Code's documentation specifies "paths:" as the rule-scoping field; "globs:" is not documented. Rename to "paths:" so the rule scopes as intended.`,
|
||||
|
|
@ -99,6 +101,7 @@ export async function scan(targetPath, discovery) {
|
|||
if (matchCount === 0) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'pattern-matches-nothing',
|
||||
severity: SEVERITY.high,
|
||||
title: 'Rule path pattern matches no files',
|
||||
description: `${file.relPath}: pattern "${pattern}" matches 0 files. This rule will never activate.`,
|
||||
|
|
@ -117,6 +120,7 @@ export async function scan(targetPath, discovery) {
|
|||
if (lines < 2) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'nearly-empty',
|
||||
severity: SEVERITY.low,
|
||||
title: 'Rule file is nearly empty',
|
||||
description: `${file.relPath} has only ${lines} line(s).`,
|
||||
|
|
@ -130,6 +134,7 @@ export async function scan(targetPath, discovery) {
|
|||
if (!frontmatter?.paths && !frontmatter?.globs && lines > 50) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'large-unscoped',
|
||||
severity: SEVERITY.medium,
|
||||
title: 'Large unscoped rule file',
|
||||
description: `${file.relPath} has ${lines} lines and no path scoping. It loads into context for every file interaction.`,
|
||||
|
|
@ -147,6 +152,7 @@ export async function scan(targetPath, discovery) {
|
|||
if (frontmatter?.paths && lines > 50) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'large-scoped-lost-after-compaction',
|
||||
severity: SEVERITY.low,
|
||||
title: 'Large path-scoped rule is lost after compaction',
|
||||
description: `${file.relPath} is path-scoped (${lines} lines). Path-scoped rules load only when a matching file is read, and after a context compaction they are not re-injected until a matching file is read again — so a large scoped rule carrying must-always-hold instructions can silently drop out mid-session.`,
|
||||
|
|
@ -161,6 +167,7 @@ export async function scan(targetPath, discovery) {
|
|||
if (!file.absPath.endsWith('.md')) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'not-markdown',
|
||||
severity: SEVERITY.medium,
|
||||
title: 'Rule file is not .md',
|
||||
description: `${file.relPath} is not a .md file. Only .md files are loaded from rules/.`,
|
||||
|
|
|
|||
|
|
@ -11,10 +11,9 @@ import { resolve, sep } from 'node:path';
|
|||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { writeOutputFile } from './lib/write-output.mjs';
|
||||
import { requireTargetDir } from './lib/require-target-dir.mjs';
|
||||
import { resetCounter } from './lib/output.mjs';
|
||||
import { envelope } from './lib/output.mjs';
|
||||
import { discoverConfigFiles, discoverConfigFilesMulti, discoverFullMachinePaths } from './lib/file-discovery.mjs';
|
||||
import { loadSuppressions, applySuppressions, formatSuppressionSummary } from './lib/suppression.mjs';
|
||||
import { loadSuppressions, applySuppressions, formatSuppressionSummary, unknownSuppressions } from './lib/suppression.mjs';
|
||||
import { humanizeEnvelope } from './lib/humanizer.mjs';
|
||||
import { resolveContextWindow } from './lib/context-window.mjs';
|
||||
import { resolveActiveModel } from './lib/active-model.mjs';
|
||||
|
|
@ -134,7 +133,6 @@ export async function runAllScanners(targetPath, opts = {}) {
|
|||
const results = [];
|
||||
|
||||
for (const scanner of SCANNERS) {
|
||||
resetCounter();
|
||||
const scanStart = Date.now();
|
||||
try {
|
||||
const result = await scanner.fn(resolvedPath, discovery, { contextWindow });
|
||||
|
|
@ -194,8 +192,14 @@ export async function runAllScanners(targetPath, opts = {}) {
|
|||
const shouldSuppress = opts.suppress !== false;
|
||||
let suppressedFindings = [];
|
||||
|
||||
let deadSuppressions = [];
|
||||
|
||||
if (shouldSuppress) {
|
||||
const { suppressions } = await loadSuppressions(resolvedPath);
|
||||
// A pin that names no declared check can never match. Report it: a silently
|
||||
// dead suppression leaves the user believing a finding is hidden when it is
|
||||
// not (M-BUG-28).
|
||||
deadSuppressions = unknownSuppressions(suppressions);
|
||||
if (suppressions.length > 0) {
|
||||
for (const result of results) {
|
||||
const { active, suppressed } = applySuppressions(result.findings, suppressions);
|
||||
|
|
@ -221,6 +225,12 @@ export async function runAllScanners(targetPath, opts = {}) {
|
|||
if (suppressedFindings.length > 0) {
|
||||
env.suppressed_findings = suppressedFindings;
|
||||
}
|
||||
// ux-rules rule 2: commands run scanners with `2>/dev/null`, so anything they
|
||||
// must ACT on rides in the payload, never in a stderr-only warning. Added only
|
||||
// when a dead pin exists, so a config without one is byte-identical.
|
||||
if (deadSuppressions.length > 0) {
|
||||
env.unknown_suppressions = deadSuppressions;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -121,6 +121,7 @@ export async function scan(targetPath, discovery) {
|
|||
if (parsed === null) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'invalid-json',
|
||||
severity: SEVERITY.critical,
|
||||
title: 'Invalid JSON in settings file',
|
||||
description: `${file.relPath} contains invalid JSON and will be ignored by Claude Code.`,
|
||||
|
|
@ -151,6 +152,7 @@ export async function scan(targetPath, discovery) {
|
|||
if (nearest) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'key-typo',
|
||||
severity: SEVERITY.low,
|
||||
title: 'Possible typo in settings key',
|
||||
description: `${file.relPath}: "${key}" is not a recognized settings.json key, but it closely matches "${nearest}". Claude Code forwards unrecognized keys unchanged (it does not reject them), so if "${key}" is a typo of "${nearest}" the intended setting silently has no effect.`,
|
||||
|
|
@ -167,6 +169,7 @@ export async function scan(targetPath, discovery) {
|
|||
if (parsed[key] !== undefined) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'deprecated-key',
|
||||
severity: SEVERITY.medium,
|
||||
title: 'Deprecated settings key',
|
||||
description: `${file.relPath}: "${key}" is deprecated. ${migration}`,
|
||||
|
|
@ -183,6 +186,7 @@ export async function scan(targetPath, discovery) {
|
|||
if (parsed[key] !== undefined && typeof parsed[key] !== expectedType) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'type-mismatch',
|
||||
severity: SEVERITY.high,
|
||||
title: 'Type mismatch in settings',
|
||||
description: `${file.relPath}: "${key}" should be ${expectedType}, got ${typeof parsed[key]}.`,
|
||||
|
|
@ -198,6 +202,7 @@ export async function scan(targetPath, discovery) {
|
|||
if (parsed.effortLevel && !VALID_EFFORT_LEVELS.has(parsed.effortLevel)) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'invalid-effort-level',
|
||||
severity: SEVERITY.medium,
|
||||
title: 'Invalid effortLevel value',
|
||||
description: `${file.relPath}: effortLevel "${parsed.effortLevel}" is not valid.`,
|
||||
|
|
@ -212,6 +217,7 @@ export async function scan(targetPath, discovery) {
|
|||
if (!parsed.$schema) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'missing-schema',
|
||||
severity: SEVERITY.info,
|
||||
title: 'Missing $schema reference',
|
||||
description: `${file.relPath} lacks a $schema reference. Adding one enables autocomplete in VS Code/Cursor.`,
|
||||
|
|
@ -228,6 +234,7 @@ export async function scan(targetPath, discovery) {
|
|||
if (!perms.deny || (Array.isArray(perms.deny) && perms.deny.length === 0)) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'no-deny-rules',
|
||||
severity: SEVERITY.medium,
|
||||
title: 'No deny rules configured',
|
||||
description: `${file.relPath}: No permission deny rules. Claude can access all files including .env and secrets.`,
|
||||
|
|
@ -240,6 +247,7 @@ export async function scan(targetPath, discovery) {
|
|||
if (!perms.allow || (Array.isArray(perms.allow) && perms.allow.length === 0)) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'no-allow-rules',
|
||||
severity: SEVERITY.low,
|
||||
title: 'No allow rules configured',
|
||||
description: `${file.relPath}: No permission allow rules. This means frequent permission prompts for common operations.`,
|
||||
|
|
@ -255,6 +263,7 @@ export async function scan(targetPath, discovery) {
|
|||
parsed.additionalDirectories.length > ADDITIONAL_DIRS_THRESHOLD) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'many-additional-dirs',
|
||||
severity: SEVERITY.low,
|
||||
title: 'Many additionalDirectories entries',
|
||||
description:
|
||||
|
|
@ -281,6 +290,7 @@ export async function scan(targetPath, discovery) {
|
|||
if (typeof am !== 'object' || am === null || Array.isArray(am)) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'automode-not-object',
|
||||
severity: SEVERITY.medium,
|
||||
title: 'autoMode must be an object',
|
||||
description: `${file.relPath}: "autoMode" must be an object with environment/allow/soft_deny/hard_deny arrays, got ${Array.isArray(am) ? 'array' : typeof am}.`,
|
||||
|
|
@ -295,6 +305,7 @@ export async function scan(targetPath, discovery) {
|
|||
if (!AUTO_MODE_SUBKEYS.has(subKey)) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'automode-unknown-subkey',
|
||||
severity: SEVERITY.medium,
|
||||
title: `autoMode has an unknown sub-key: ${subKey}`,
|
||||
description: `${file.relPath}: "autoMode.${subKey}" is not a recognized sub-key. Valid keys are environment, allow, soft_deny, hard_deny. It is silently ignored — a typo of a real key (e.g. "hard_denies") means those rules never apply.`,
|
||||
|
|
@ -311,6 +322,7 @@ export async function scan(targetPath, discovery) {
|
|||
if (!isStringArray) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'automode-subkey-not-string-array',
|
||||
severity: SEVERITY.medium,
|
||||
title: `autoMode.${subKey} must be an array of strings`,
|
||||
description: `${file.relPath}: "autoMode.${subKey}" must be an array of prose-rule strings (the literal "$defaults" is allowed), got ${Array.isArray(val) ? 'an array with a non-string entry' : typeof val}.`,
|
||||
|
|
@ -327,6 +339,7 @@ export async function scan(targetPath, discovery) {
|
|||
if (file.scope === 'project') {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'automode-in-shared-settings',
|
||||
severity: SEVERITY.low,
|
||||
title: 'autoMode in shared project settings is ignored by Claude Code',
|
||||
description: `${file.relPath}: Claude Code does not read "autoMode" from shared project settings (.claude/settings.json), so a checked-in repo cannot inject its own rules. This autoMode block has no effect where it is.`,
|
||||
|
|
@ -344,6 +357,7 @@ export async function scan(targetPath, discovery) {
|
|||
if (Array.isArray(parsed.hooks)) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'hooks-as-array',
|
||||
severity: SEVERITY.critical,
|
||||
title: 'Hooks configured as array instead of object',
|
||||
description: `${file.relPath}: "hooks" must be an object with event keys, not an array. All hooks will be ignored.`,
|
||||
|
|
|
|||
|
|
@ -87,6 +87,7 @@ export async function scan(_targetPath, _discovery, opts = {}) {
|
|||
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'description-over-cap',
|
||||
severity: SEVERITY.medium,
|
||||
title: 'Skill description exceeds the listing cap (Claude Code truncates it)',
|
||||
description:
|
||||
|
|
@ -116,6 +117,7 @@ export async function scan(_targetPath, _discovery, opts = {}) {
|
|||
// Conservative 200k anchor — byte-identical to the pre-B8 finding.
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'aggregate-listing-budget',
|
||||
severity: SEVERITY.low,
|
||||
title: 'Aggregate skill descriptions may exceed the listing budget',
|
||||
description:
|
||||
|
|
@ -138,6 +140,7 @@ export async function scan(_targetPath, _discovery, opts = {}) {
|
|||
const winLabel = withCommas(window);
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'aggregate-listing-budget',
|
||||
severity: advisory ? SEVERITY.info : SEVERITY.low,
|
||||
title: 'Aggregate skill descriptions may exceed the listing budget',
|
||||
description:
|
||||
|
|
@ -173,6 +176,7 @@ export async function scan(_targetPath, _discovery, opts = {}) {
|
|||
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'oversized-body',
|
||||
severity: SEVERITY.low,
|
||||
title: 'Skill body is large (loads on demand when the skill runs)',
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ import { fileURLToPath } from 'node:url';
|
|||
import { readFile, stat } from 'node:fs/promises';
|
||||
import { writeOutputFile } from './lib/write-output.mjs';
|
||||
import { discoverConfigFiles } from './lib/file-discovery.mjs';
|
||||
import { resetCounter } from './lib/output.mjs';
|
||||
import { scan } from './token-hotspots.mjs';
|
||||
import * as tokenizerApi from './lib/tokenizer-api.mjs';
|
||||
import { humanizeFindings } from './lib/humanizer.mjs';
|
||||
|
|
@ -99,7 +98,6 @@ async function main() {
|
|||
return;
|
||||
}
|
||||
|
||||
resetCounter();
|
||||
const discovery = await discoverConfigFiles(absPath, { includeGlobal, excludeCache });
|
||||
const result = await scan(absPath, discovery);
|
||||
|
||||
|
|
|
|||
|
|
@ -400,6 +400,7 @@ export async function scan(targetPath, discovery) {
|
|||
if (detectVolatileTop(content)) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'volatile-top',
|
||||
severity: SEVERITY.high,
|
||||
title: 'Cache-breaking volatile content at top of CLAUDE.md',
|
||||
description:
|
||||
|
|
@ -428,6 +429,7 @@ export async function scan(targetPath, discovery) {
|
|||
if (issues.length === 0) continue;
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'redundant-permissions',
|
||||
severity: SEVERITY.medium,
|
||||
title: 'Redundant permission declarations',
|
||||
description:
|
||||
|
|
@ -452,6 +454,7 @@ export async function scan(targetPath, discovery) {
|
|||
if (depth > MAX_IMPORT_DEPTH) {
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'deep-import-chain',
|
||||
severity: SEVERITY.low,
|
||||
title: 'Deep @import chain defeats prompt-cache reuse',
|
||||
description:
|
||||
|
|
@ -484,6 +487,7 @@ export async function scan(targetPath, discovery) {
|
|||
const skillName = (fm && fm.name) || f.absPath.split('/').slice(-2, -1)[0] || f.absPath;
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'bloated-skill-description',
|
||||
severity: SEVERITY.low,
|
||||
title: 'Bloated skill description (loads on every turn)',
|
||||
description:
|
||||
|
|
@ -535,6 +539,7 @@ export async function scan(targetPath, discovery) {
|
|||
'and user-scopes so per-project budget stays tight.';
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'mcp-schema-budget',
|
||||
severity,
|
||||
title: `High MCP tool-schema budget on server "${m.name}"`,
|
||||
description,
|
||||
|
|
@ -552,6 +557,7 @@ export async function scan(targetPath, discovery) {
|
|||
const fileCount = activeConfig.claudeMd.files?.length ?? 0;
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'cascade-over-budget',
|
||||
severity: SEVERITY.medium,
|
||||
title: 'CLAUDE.md cascade exceeds 10k tokens per turn',
|
||||
description:
|
||||
|
|
@ -584,6 +590,7 @@ export async function scan(targetPath, discovery) {
|
|||
const keys = stale.map(v => v.key);
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'stale-plugin-cache',
|
||||
severity: SEVERITY.low,
|
||||
title: 'Stale plugin-cache versions (disk cleanup, zero live-context impact)',
|
||||
description:
|
||||
|
|
@ -654,6 +661,7 @@ export async function scan(targetPath, discovery) {
|
|||
'(gh / aws / gcloud) over MCP for common operations.';
|
||||
findings.push(finding({
|
||||
scanner: SCANNER,
|
||||
code: 'mcp-schema-deferral',
|
||||
severity,
|
||||
title: 'MCP tool schemas forced into the always-loaded prefix',
|
||||
file: null,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue