diff --git a/scanners/claude-md-linter.mjs b/scanners/claude-md-linter.mjs index bfdceed..15043e2 100644 --- a/scanners/claude-md-linter.mjs +++ b/scanners/claude-md-linter.mjs @@ -59,15 +59,19 @@ export async function scan(targetPath, discovery) { const imports = findImports(content); // --- 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 + // stability (not line count) is the dominant cost driver on large-context + // models. These are MEDIUM token-cost signals, not a HIGH adherence cliff. if (lines > MAX_ABSOLUTE_LINES) { findings.push(finding({ scanner: SCANNER, - severity: SEVERITY.high, + severity: SEVERITY.medium, title: 'CLAUDE.md exceeds 500 lines', - description: `${file.relPath} has ${lines} lines. Files over 500 lines significantly reduce Claude's adherence to instructions.`, + 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).`, file: file.absPath, evidence: `${lines} lines`, - recommendation: 'Split into @imports and .claude/rules/ files. Keep CLAUDE.md under 200 lines.', + recommendation: 'Split into @imports and .claude/rules/ files, and keep the top of CLAUDE.md byte-stable for cache hits (see token / cache-prefix findings). Under ~200 lines stays safest across models.', autoFixable: false, })); } else if (lines > MAX_RECOMMENDED_LINES) { @@ -75,7 +79,7 @@ export async function scan(targetPath, discovery) { scanner: SCANNER, severity: SEVERITY.medium, title: 'CLAUDE.md exceeds recommended 200 lines', - description: `${file.relPath} has ${lines} lines. Best practice is under 200 lines for optimal adherence.`, + 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.`, file: file.absPath, evidence: `${lines} lines`, recommendation: 'Consider using @imports or .claude/rules/ for detailed content.', diff --git a/tests/scanners/claude-md-linter.test.mjs b/tests/scanners/claude-md-linter.test.mjs index 9ef2ee8..2d8ceda 100644 --- a/tests/scanners/claude-md-linter.test.mjs +++ b/tests/scanners/claude-md-linter.test.mjs @@ -76,6 +76,59 @@ describe('CML scanner — broken project', () => { }); }); +describe('CML scanner — broken project: 200-tier stays MEDIUM (regression lock)', () => { + let result; + beforeEach(async () => { + resetCounter(); + const discovery = await discoverConfigFiles(resolve(FIXTURES, 'broken-project')); + result = await scan(resolve(FIXTURES, 'broken-project'), discovery); + }); + + it('the >200 length finding is MEDIUM', () => { + const f = result.findings.find(x => /exceeds recommended 200/.test(x.title || '')); + assert.ok(f, 'expected the 200-line recommendation finding'); + assert.strictEqual(f.severity, 'medium'); + }); +}); + +describe('CML scanner — large cascade (>500 lines): reframed, not absolute-adherence HIGH', () => { + // large-cascade/CLAUDE.md is 1024 lines. CC 2.1.169 scales the "too long" + // threshold by context window, and the plugin's own + // configuration-best-practices.md:97 footnote says raw line count is a + // Sonnet-era heuristic superseded by cache-prefix stability. So the absolute + // HIGH@500 + "significantly reduce adherence" claim is now-wrong. + let result; + beforeEach(async () => { + resetCounter(); + const discovery = await discoverConfigFiles(resolve(FIXTURES, 'large-cascade')); + result = await scan(resolve(FIXTURES, 'large-cascade'), discovery); + }); + + it('flags the >500 finding as MEDIUM, not HIGH', () => { + const f = result.findings.find(x => /exceeds 500/.test(x.title || '')); + assert.ok(f, 'expected the >500 length finding'); + assert.strictEqual(f.severity, 'medium'); + }); + + it('drops the absolute "significantly reduce adherence" claim', () => { + const f = result.findings.find(x => /exceeds 500/.test(x.title || '')); + assert.doesNotMatch(String(f?.description || ''), /significantly reduce/i); + }); + + it('reframes toward token cost / context window / cache-prefix', () => { + const f = result.findings.find(x => /exceeds 500/.test(x.title || '')); + assert.match( + `${f?.description || ''} ${f?.recommendation || ''}`, + /every turn|context window|cache/i, + ); + }); + + it('produces no HIGH-severity length finding', () => { + const highLen = result.findings.filter(f => f.severity === 'high' && /exceeds/.test(f.title || '')); + assert.strictEqual(highLen.length, 0, `unexpected HIGH length finding: ${highLen.map(f => f.title).join(', ')}`); + }); +}); + describe('CML scanner — empty project', () => { let result; beforeEach(async () => {