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).
213 lines
8.6 KiB
JavaScript
213 lines
8.6 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';
|
|
|
|
const SCANNER = 'CML';
|
|
const MAX_RECOMMENDED_LINES = 200;
|
|
const MAX_ABSOLUTE_LINES = 500;
|
|
|
|
/** 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) {
|
|
const start = Date.now();
|
|
const claudeFiles = discovery.files.filter(f => f.type === 'claude-md');
|
|
|
|
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);
|
|
|
|
// --- 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,
|
|
}));
|
|
}
|
|
|
|
// --- 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);
|
|
}
|