diff --git a/README.md b/README.md index 6c744ff..d8c800f 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ ![Commands](https://img.shields.io/badge/commands-18-green) ![Agents](https://img.shields.io/badge/agents-6-orange) ![Hooks](https://img.shields.io/badge/hooks-4-red) -![Tests](https://img.shields.io/badge/tests-957+-brightgreen) +![Tests](https://img.shields.io/badge/tests-961+-brightgreen) ![License](https://img.shields.io/badge/license-MIT-lightgrey) A Claude Code plugin that checks configuration health, suggests context-aware improvements, and auto-fixes issues — `CLAUDE.md`, `settings.json`, hooks, rules, MCP servers, `@imports`, and plugins. 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. diff --git a/scanners/claude-md-linter.mjs b/scanners/claude-md-linter.mjs index 99c6763..316f702 100644 --- a/scanners/claude-md-linter.mjs +++ b/scanners/claude-md-linter.mjs @@ -10,6 +10,7 @@ import { SEVERITY } from './lib/severity.mjs'; import { parseFrontmatter, extractSections, findImports } from './lib/yaml-parser.mjs'; import { lineCount, truncate } from './lib/string-utils.mjs'; import { LARGE_CONTEXT_WINDOW, LARGE_CONTEXT_SCALE, withCommas } from './lib/context-window.mjs'; +import { dirname } from 'node:path'; const SCANNER = 'CML'; const MAX_RECOMMENDED_LINES = 200; @@ -68,6 +69,23 @@ export async function scan(targetPath, discovery) { const sections = extractSections(body); const imports = findImports(content); + // A nested (subdirectory) CLAUDE.md is NOT re-injected after a context + // compaction — only the project-root CLAUDE.md is (context-window.md). Its + // instructions silently drop until a file in that directory is read again. + const relDir = dirname(file.relPath); + if (file.scope === 'project' && relDir !== '.' && relDir !== '.claude' && lines > 5) { + findings.push(finding({ + scanner: SCANNER, + 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.`, + file: file.absPath, + evidence: `${lines} lines, nested (scope=project, dir="${relDir}")`, + recommendation: 'If these instructions must always apply, move the must-hold parts to the project-root CLAUDE.md (re-injected after compaction). Keep nested CLAUDE.md for guidance only needed when working in that directory.', + autoFixable: false, + })); + } + // --- Length checks --- // Raw line count is no longer an absolute adherence threshold: CC 2.1.169 // scales the "too long" warning by context window, and cache-prefix diff --git a/scanners/lib/humanizer-data.mjs b/scanners/lib/humanizer-data.mjs index 36dfd72..18b3f27 100644 --- a/scanners/lib/humanizer-data.mjs +++ b/scanners/lib/humanizer-data.mjs @@ -32,6 +32,11 @@ export const TRANSLATIONS = { description: 'Without `CLAUDE.md` at your project root, Claude has to work out your conventions from scratch every conversation. Project-specific guidance is the single highest-impact thing you can add.', recommendation: 'Create a file called `CLAUDE.md` in your project root. Start with a one-paragraph project overview, common commands, and any quirks Claude should know about.', }, + 'Nested CLAUDE.md is not re-injected after compaction': { + title: 'A `CLAUDE.md` in a subfolder can quietly drop out mid-session', + description: 'Only the main project `CLAUDE.md` is restored when Claude trims older history. A `CLAUDE.md` in a subfolder loads when you open a file there, but it does not come back after a trim until you open one again.', + recommendation: 'If its guidance must always apply, move that part into the main `CLAUDE.md`. Keep the subfolder file for things only needed when working in that folder.', + }, 'CLAUDE.md is nearly empty': { title: 'Your `CLAUDE.md` is mostly empty', description: 'An empty instructions file gives Claude no project-specific context, so behavior falls back to defaults.', @@ -254,6 +259,11 @@ export const TRANSLATIONS = { description: 'Without scoping, the rule loads on every conversation regardless of which files you\'re working with.', recommendation: 'Add a scoping block at the top of the file to limit when the rule loads (see the details).', }, + 'Large path-scoped rule is lost after compaction': { + title: 'A large scoped rule can quietly drop out mid-session', + description: 'Scoped rules load only when you open a matching file, and they fall out of context when Claude trims older history — they do not come back until you open a matching file again.', + recommendation: 'If part of it must always apply, move that part into the main `CLAUDE.md`, which is restored automatically.', + }, 'Rule uses "globs" instead of documented "paths"': { title: 'A rule uses an unrecognized scoping field', description: 'Claude Code\'s docs use `paths:` to scope a rule; `globs:` is not the documented field, so the rule may not scope the way you intend.', diff --git a/scanners/rules-validator.mjs b/scanners/rules-validator.mjs index 3861066..3bd358f 100644 --- a/scanners/rules-validator.mjs +++ b/scanners/rules-validator.mjs @@ -122,6 +122,23 @@ export async function scan(targetPath, discovery) { })); } + // A large PATH-SCOPED rule follows best practice, but path-scoped rules are + // NOT re-injected after a context compaction — they reload only when a + // matching file is read again (context-window.md). A big one carrying + // must-always-hold instructions can silently drop out mid-session. + if (frontmatter?.paths && lines > 50) { + findings.push(finding({ + scanner: SCANNER, + 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.`, + file: file.absPath, + evidence: `${lines} lines, path-scoped`, + recommendation: 'If parts of this rule must always apply, move them to the project-root CLAUDE.md (re-injected after compaction). Keep path-scoped rules for context only needed when those files are open.', + autoFixable: false, + })); + } + // Check file extension if (!file.absPath.endsWith('.md')) { findings.push(finding({ diff --git a/tests/scanners/claude-md-linter.test.mjs b/tests/scanners/claude-md-linter.test.mjs index 377b6b0..f720dbc 100644 --- a/tests/scanners/claude-md-linter.test.mjs +++ b/tests/scanners/claude-md-linter.test.mjs @@ -1,7 +1,9 @@ -import { describe, it, beforeEach } from 'node:test'; +import { describe, it, beforeEach, afterEach } from 'node:test'; import assert from 'node:assert/strict'; -import { resolve } from 'node:path'; +import { resolve, join } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; import { resetCounter } from '../../scanners/lib/output.mjs'; import { discoverConfigFiles } from '../../scanners/lib/file-discovery.mjs'; import { scan } from '../../scanners/claude-md-linter.mjs'; @@ -219,3 +221,43 @@ describe('CML scanner — minimal project', () => { assert.ok(found, 'Should detect nearly empty CLAUDE.md'); }); }); + +describe('CML — nested CLAUDE.md not re-injected after compaction (A)', () => { + // Only the project-root CLAUDE.md is re-injected after a compaction; a nested + // (subdirectory) CLAUDE.md is lost until a file in that dir is read again + // (V3, context-window.md). Hermetic temp fixture. + let tmpRoot; + let result; + + beforeEach(async () => { + resetCounter(); + tmpRoot = await mkdtemp(join(tmpdir(), 'ca-cml-nested-')); + await mkdir(join(tmpRoot, 'src'), { recursive: true }); + await writeFile(join(tmpRoot, 'CLAUDE.md'), '# Root\n\nProject overview goes here.\n', 'utf8'); + await writeFile( + join(tmpRoot, 'src', 'CLAUDE.md'), + '# Src rules\n\n' + Array.from({ length: 10 }, (_, i) => `- nested rule ${i + 1}`).join('\n') + '\n', + 'utf8', + ); + const discovery = await discoverConfigFiles(tmpRoot); + result = await scan(tmpRoot, discovery); + }); + + afterEach(async () => { + if (tmpRoot) await rm(tmpRoot, { recursive: true, force: true }); + }); + + it('flags the nested CLAUDE.md as low (compaction durability)', () => { + const f = result.findings.find(x => + x.scanner === 'CML' && /compaction/i.test(x.title || '') && + /src/.test(`${x.file || ''}${x.evidence || ''}${x.description || ''}`)); + assert.ok(f, `expected nested durability finding; got: ${result.findings.map(x => x.title).join(' | ')}`); + assert.equal(f.severity, 'low'); + }); + + it('does NOT flag the project-root CLAUDE.md', () => { + const rootFinding = result.findings.find(x => + x.scanner === 'CML' && /compaction/i.test(x.title || '') && !/src/.test(x.file || '')); + assert.equal(rootFinding, undefined, 'project-root CLAUDE.md must not get the compaction finding'); + }); +}); diff --git a/tests/scanners/rules-validator.test.mjs b/tests/scanners/rules-validator.test.mjs index ad98bf9..daf9da4 100644 --- a/tests/scanners/rules-validator.test.mjs +++ b/tests/scanners/rules-validator.test.mjs @@ -1,7 +1,9 @@ -import { describe, it, beforeEach } from 'node:test'; +import { describe, it, beforeEach, afterEach } from 'node:test'; import assert from 'node:assert/strict'; -import { resolve } from 'node:path'; +import { resolve, join } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; import { resetCounter } from '../../scanners/lib/output.mjs'; import { discoverConfigFiles } from '../../scanners/lib/file-discovery.mjs'; import { scan } from '../../scanners/rules-validator.mjs'; @@ -94,3 +96,58 @@ describe('RUL scanner — empty project', () => { assert.strictEqual(result.findings.length, 0); }); }); + +describe('RUL — large path-scoped rule lost after compaction (A)', () => { + // Path-scoped rules are NOT re-injected after a context compaction (V2, + // context-window.md) — a large one carrying must-hold rules silently drops. + let tmpRoot; + let result; + + async function writeProject(root, ruleBodyLines) { + await mkdir(join(root, '.claude', 'rules'), { recursive: true }); + await mkdir(join(root, 'src'), { recursive: true }); + // A matching file so the path glob is not flagged as "matches no files". + await writeFile(join(root, 'src', 'foo.ts'), 'export {};\n', 'utf8'); + const body = Array.from({ length: ruleBodyLines }, (_, i) => `- rule ${i + 1}`).join('\n'); + // Inline paths form: config-audit's lightweight frontmatter parser reads + // `paths: ` (comma-normalized), not YAML block sequences. The RUL + // scoped-detection (existing + this check) keys on that parsed value. + await writeFile( + join(root, '.claude', 'rules', 'scoped.md'), + `---\npaths: "src/**/*.ts"\n---\n\n# Scoped rule\n${body}\n`, + 'utf8', + ); + } + + beforeEach(async () => { + resetCounter(); + tmpRoot = await mkdtemp(join(tmpdir(), 'ca-rul-durability-')); + await writeProject(tmpRoot, 60); + const discovery = await discoverConfigFiles(tmpRoot); + result = await scan(tmpRoot, discovery); + }); + + afterEach(async () => { + if (tmpRoot) await rm(tmpRoot, { recursive: true, force: true }); + }); + + it('flags a large path-scoped rule as low (compaction durability)', () => { + const f = result.findings.find(x => x.scanner === 'RUL' && /compaction/i.test(x.title || '')); + assert.ok(f, `expected durability finding; got: ${result.findings.map(x => x.title).join(' | ')}`); + assert.equal(f.severity, 'low'); + }); + + it('does NOT flag a small path-scoped rule', async () => { + resetCounter(); + const small = await mkdtemp(join(tmpdir(), 'ca-rul-small-')); + try { + await writeProject(small, 3); + const d = await discoverConfigFiles(small); + const r = await scan(small, d); + const f = r.findings.find(x => x.scanner === 'RUL' && /compaction/i.test(x.title || '')); + assert.equal(f, undefined, 'a small scoped rule should not be flagged'); + } finally { + await rm(small, { recursive: true, force: true }); + } + }); +});