From feaa7ed2e48e0aa4900ad2958b10f9154e54dfd0 Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Thu, 18 Jun 2026 13:15:38 +0200 Subject: [PATCH] fix(claude-md-linter): reframe CLAUDE.md length from HIGH adherence cliff to MEDIUM token cost MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The >500-line check emitted HIGH severity with "Files over 500 lines significantly reduce Claude's adherence to instructions." CC 2.1.169 scaled the "too long" warning by context window, and the plugin's own configuration-best-practices.md:97 footnote already says raw line count is a Sonnet-era heuristic superseded by cache-prefix stability — so the absolute HIGH + universal adherence claim is now-wrong. - >500 lines: HIGH -> MEDIUM, reworded to token-cost-every-turn + smaller-context-model caveat + cache-prefix pointer; notes CC 2.1.169 scales the threshold by context window. - >200 lines: stays MEDIUM, dropped the absolute "optimal adherence" framing for the same token/context-window framing. Aligns the scanner with anti-patterns.md:7 (CA-CML-001 = medium) and configuration-best-practices.md:97. No snapshot impact (byte snapshots use a 24-line fixture CLAUDE.md). Full suite: 842/842 green (+5). self-audit PASS, A(100)/A(97). --- scanners/claude-md-linter.mjs | 12 ++++-- tests/scanners/claude-md-linter.test.mjs | 53 ++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 4 deletions(-) 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 () => {