config-audit/scanners/claude-md-linter.mjs
Kjell Tore Guttormsen 2082b7d112 feat(skl,cml): --context-window calibration, advisory when unknown (v5.11 B8) [skip-docs]
SKL-002 (skill-listing budget) and CML char-budget now calibrate to a
resolved context window instead of always anchoring at 200k:

- resolveContextWindow(): --context-window <n> calibrates; 'auto' keeps the
  conservative 200k anchor but marks advisory (model→window probing deferred
  to B8b); no flag → 200k anchor, byte-identical to pre-B8 default.
- scaleForWindow(): linear off the 200k anchor (identity at the anchor).
- SKL + CML each keep an untouched default branch (window===200k && !advisory)
  for byte-stability and a calibrated branch; advisory downgrades the budget
  finding from a breach (low/medium) to info.
- Flag wired through scan-orchestrator + posture; runAllScanners resolves once
  and threads { contextWindow } to scanners (others ignore the 3rd arg).
- CPS intentionally excluded: it has no window-anchored budget (fixed
  150-line volatility heuristic), so there is nothing to calibrate.

15 new tests; e2e CLI verified (1M suppresses SKL-002, auto → info, default
unchanged); full suite 1279 green; snapshots byte-stable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 21:44:52 +02:00

294 lines
14 KiB
JavaScript

/**
* CML Scanner — CLAUDE.md Linter
* Validates structure, sections, length, @imports, frontmatter, and HTML comments.
* Finding IDs: CA-CML-NNN
*/
import { readTextFile } from './lib/file-discovery.mjs';
import { finding, scannerResult, resetCounter } 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';
import { CONTEXT_WINDOW_ANCHOR, LARGE_CONTEXT_WINDOW, LARGE_CONTEXT_SCALE, scaleForWindow, withCommas } from './lib/context-window.mjs';
import { dirname } from 'node:path';
const SCANNER = 'CML';
const MAX_RECOMMENDED_LINES = 200;
const MAX_ABSOLUTE_LINES = 500;
// Shared remediation for the char-budget finding (byte-identical across the
// default and the B8 window-calibrated branches).
const CHAR_BUDGET_RECOMMENDATION =
'Split detail into @imports and .claude/rules/ files so only the relevant rules load, and keep the top of CLAUDE.md byte-stable for cache hits.';
// Claude Code's own startup warning ("Large CLAUDE.md will impact performance
// (X chars > 40.0k)") fires once a CLAUDE.md passes ~40.0k chars on a
// 200k-context model. CC 2.1.169 made that threshold scale with the model's
// context window. We mirror it in the same unit CC uses (chars, not lines):
// anchor on the conservative 200k window (we cannot observe the user's window,
// and the anchor fires earliest) and disclose the relaxed 1M figure.
const CLAUDE_MD_CHAR_WARN_ANCHOR = 40_000; // chars @ 200k context (CC startup warning)
const CLAUDE_MD_CHAR_WARN_LARGE = CLAUDE_MD_CHAR_WARN_ANCHOR * LARGE_CONTEXT_SCALE; // 200,000 @ 1M
/** Recommended sections for a project CLAUDE.md */
const RECOMMENDED_SECTIONS = [
{ pattern: /project|overview|description|what/i, label: 'Project overview' },
{ pattern: /command|workflow|how to|getting started|usage/i, label: 'Commands/Workflows' },
{ pattern: /architect|structure|directory|layout/i, label: 'Architecture' },
{ pattern: /convention|pattern|rule|style/i, label: 'Conventions/Patterns' },
];
/**
* Scan all CLAUDE.md files discovered.
* @param {string} targetPath
* @param {{ files: import('./lib/file-discovery.mjs').ConfigFile[] }} discovery
* @returns {Promise<object>}
*/
export async function scan(targetPath, discovery, opts = {}) {
const start = Date.now();
const claudeFiles = discovery.files.filter(f => f.type === 'claude-md');
// B8 — calibrate the char-budget threshold to the resolved context window. The
// default (no opts) is the conservative 200k anchor (40k chars) at full
// severity — byte-identical to the pre-B8 finding. An unknown (advisory) window
// keeps the anchor but downgrades the finding to info instead of a breach.
const cw = opts.contextWindow;
const window = (cw && typeof cw.window === 'number') ? cw.window : CONTEXT_WINDOW_ANCHOR;
const advisory = !!(cw && cw.advisory);
const isDefaultWindow = window === CONTEXT_WINDOW_ANCHOR && !advisory;
const charThreshold = scaleForWindow(CLAUDE_MD_CHAR_WARN_ANCHOR, window);
if (claudeFiles.length === 0) {
return scannerResult(SCANNER, 'ok', [
finding({
scanner: SCANNER,
severity: SEVERITY.high,
title: 'No CLAUDE.md found',
description: 'No CLAUDE.md files were discovered. This is the primary configuration surface for Claude Code.',
recommendation: 'Run `/init` to create a starter CLAUDE.md, or create one manually.',
autoFixable: false,
}),
], 0, Date.now() - start);
}
const findings = [];
let filesScanned = 0;
for (const file of claudeFiles) {
const content = await readTextFile(file.absPath);
if (!content) continue;
filesScanned++;
const lines = lineCount(content);
const { frontmatter, body, bodyStartLine } = parseFrontmatter(content);
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
// 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.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).`,
file: file.absPath,
evidence: `${lines} 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) {
findings.push(finding({
scanner: SCANNER,
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.`,
file: file.absPath,
evidence: `${lines} lines`,
recommendation: 'Consider using @imports or .claude/rules/ for detailed content.',
autoFixable: false,
}));
}
// --- Char budget (mirrors Claude Code's own startup warning) ---
// Keyed on chars, not lines: CC's "Large CLAUDE.md will impact performance"
// warning is char-based (~40.0k @ 200k context) and CC 2.1.169 scales that
// threshold with the context window. A file can be long by lines yet under
// this budget (short lines), or short by lines yet over it (long lines), so
// this is complementary to the line-count checks above.
const chars = content.length;
if (chars > charThreshold) {
if (isDefaultWindow) {
// Conservative 200k anchor — byte-identical to the pre-B8 finding.
findings.push(finding({
scanner: SCANNER,
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.`,
file: file.absPath,
evidence: `${withCommas(chars)} chars > 40.0k (200k-context anchor; ~${withCommas(CLAUDE_MD_CHAR_WARN_LARGE)} at ${withCommas(LARGE_CONTEXT_WINDOW)} context). This is an estimate, not measured telemetry.`,
recommendation: CHAR_BUDGET_RECOMMENDATION,
autoFixable: false,
}));
} else {
// B8 — window-calibrated. Advisory (unknown window) downgrades to info.
const winLabel = withCommas(window);
const threshLabel = withCommas(charThreshold);
findings.push(finding({
scanner: SCANNER,
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.` +
(advisory ? ' Your context window is unknown, so this anchors on the conservative 200k window — advisory.' : ''),
file: file.absPath,
evidence: `${withCommas(chars)} chars > ${threshLabel} (calibrated to a ${winLabel}-token context window). This is an estimate, not measured telemetry.`,
recommendation: CHAR_BUDGET_RECOMMENDATION,
autoFixable: false,
}));
}
}
// --- Empty file ---
if (lines < 3) {
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.medium,
title: 'CLAUDE.md is nearly empty',
description: `${file.relPath} has only ${lines} lines.`,
file: file.absPath,
recommendation: 'Add project overview, commands/workflows, and conventions.',
autoFixable: false,
}));
continue; // Skip further checks for empty files
}
// --- Section checks (only for project/user scope) ---
if (file.scope === 'project' || file.scope === 'user') {
const sectionHeadings = sections.map(s => s.heading);
const missingSections = [];
for (const rec of RECOMMENDED_SECTIONS) {
const found = sectionHeadings.some(h => rec.pattern.test(h));
if (!found) {
missingSections.push(rec.label);
}
}
if (missingSections.length > 0) {
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.low,
title: 'Missing recommended sections',
description: `${file.relPath} is missing: ${missingSections.join(', ')}`,
file: file.absPath,
evidence: `Present sections: ${sectionHeadings.slice(0, 5).join(', ') || '(none)'}`,
recommendation: `Add sections for: ${missingSections.join(', ')}`,
autoFixable: false,
}));
}
}
// --- No headings at all ---
if (sections.length === 0 && lines > 10) {
findings.push(finding({
scanner: SCANNER,
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.`,
file: file.absPath,
recommendation: 'Add markdown headings (##) to organize content into scannable sections.',
autoFixable: false,
}));
}
// --- @import checks ---
for (const imp of imports) {
// Check for @imports referencing non-existent files
// (Full resolution is in import-resolver scanner, here we just flag obvious issues)
if (imp.path.includes('..') && imp.path.split('..').length > 3) {
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.low,
title: '@import with deep relative path',
description: `${file.relPath}:${imp.line} imports "${truncate(imp.path, 60)}" with multiple parent traversals.`,
file: file.absPath,
line: imp.line,
evidence: `@${imp.path}`,
recommendation: 'Consider using absolute paths or moving the imported file closer.',
autoFixable: false,
}));
}
}
// --- HTML comment info ---
const htmlComments = (content.match(/<!--[\s\S]*?-->/g) || []).length;
if (htmlComments > 0) {
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.info,
title: 'Uses HTML comments',
description: `${file.relPath} uses ${htmlComments} HTML comment(s). These are stripped before injection, saving tokens.`,
file: file.absPath,
evidence: `${htmlComments} HTML comment(s)`,
}));
}
// --- Duplicate content detection (simple: repeated lines) ---
const lineArr = content.split('\n');
const lineCounts = new Map();
for (const l of lineArr) {
const trimmed = l.trim();
if (trimmed.length > 20 && !trimmed.startsWith('#') && !trimmed.startsWith('|') && !trimmed.startsWith('-')) {
lineCounts.set(trimmed, (lineCounts.get(trimmed) || 0) + 1);
}
}
const duplicates = [...lineCounts.entries()].filter(([, count]) => count >= 3);
if (duplicates.length > 0) {
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.low,
title: 'Repeated content detected',
description: `${file.relPath} has ${duplicates.length} line(s) repeated 3+ times.`,
file: file.absPath,
evidence: truncate(duplicates[0][0], 80),
recommendation: 'Extract repeated content into a shared @import or rules file.',
autoFixable: false,
}));
}
// --- TODO/FIXME markers ---
const todos = lineArr.filter(l => /\bTODO\b|\bFIXME\b|\bHACK\b/i.test(l));
if (todos.length > 0) {
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.info,
title: 'Contains TODO/FIXME markers',
description: `${file.relPath} has ${todos.length} TODO/FIXME/HACK marker(s).`,
file: file.absPath,
evidence: truncate(todos[0].trim(), 80),
}));
}
}
return scannerResult(SCANNER, 'ok', findings, filesScanned, Date.now() - start);
}