`import-resolver` follows @import targets; a path written in ordinary prose was checked by nothing. CA-CML-013 resolves those too — one finding per file, severity low, against both the CLAUDE.md's own directory and the scan root, because a nested file may legitimately write repo-root-relative paths. The design work here is the SILENCE list, and every entry on it was measured against 407 real CLAUDE.md files rather than argued for: - Bare filenames excluded: admitting them tripled the output (2350 vs 810), led by name-drops of tools that exist elsewhere on the machine. - Org/repo slugs, npm packages, pytest node ids and prose enumerations excluded: 111 fires, inspected, all false positives. - Bare folder names excluded on the same reasoning one level up: 183 of the remaining 699 fires (26%), led by `open/` — a Forgejo remote namespace prefix, not a directory. This one overturned a premise the fasit had asserted without measuring; the deviation is recorded rather than the prediction quietly edited. - Containment is checked against the scan root, not the file's own dir: a base a `..` chain can escape is not a base. Measured — without it, `../../../../etc/passwd` resolved to the real file and silenced its own finding, while a legitimate `../docs/x.md` still resolves. Rule ORDER is the reported reason (first match wins), so `npm test` is silenced as a command rather than as a bare token, and two silences with different causes keep their own fixtures. Twelve classes, pinned by name. Both load-bearing rules were seen RED against their own defect: deleting containment fails 1 test, deleting the slug rule fails 6. Dogfooded through the argv the command template itself constructs, which found a true positive in our own CLAUDE.md — `lib/humanizer.mjs` where the file is `scanners/lib/humanizer.mjs`. Fixed here. Suite 1662 -> 1701, 0 failing. Frozen v5.0.0 and default-output baselines: 0 changed files. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HJbfM3N8zWQ1wA2voTrZxz
470 lines
22 KiB
JavaScript
470 lines
22 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 } 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, resolve as resolvePath, sep } from 'node:path';
|
|
import { stat } from 'node:fs/promises';
|
|
|
|
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
|
|
|
|
// ── C3: dead prose references ───────────────────────────────────────────────
|
|
// `import-resolver` resolves @import targets; a path written in prose is not
|
|
// checked by anything. The whole design here is the SILENCE taxonomy — a
|
|
// precision-first check whose failure mode must be a miss, never a false alarm.
|
|
// Each rule below was measured against 407 real CLAUDE.md files, not reasoned
|
|
// about; the numbers live in docs/c3-deadref-fasit.local.md §2.
|
|
const KNOWN_EXTENSIONS = /\.(?:md|mjs|js|ts|tsx|jsx|json|ya?ml|sh|py|toml|txt|html|css)$/i;
|
|
|
|
// How many dead references the evidence names before it summarises the rest.
|
|
const MAX_LISTED_DEAD_REFS = 5;
|
|
|
|
/**
|
|
* Inline-code spans that sit in prose, i.e. outside fenced code blocks.
|
|
* Fenced code is illustrative — a dead path in a `bash` sample is a sample,
|
|
* not a reference (silence class S1).
|
|
* @param {string} content
|
|
* @returns {Array<{text: string, line: number}>}
|
|
*/
|
|
export function extractInlineSpans(content) {
|
|
const spans = [];
|
|
const lines = String(content == null ? '' : content).split('\n');
|
|
let inFence = false;
|
|
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const raw = lines[i];
|
|
if (/^\s*(?:```|~~~)/.test(raw)) {
|
|
inFence = !inFence;
|
|
continue;
|
|
}
|
|
if (inFence) continue;
|
|
|
|
const re = /`([^`\n]+)`/g;
|
|
let m;
|
|
while ((m = re.exec(raw)) !== null) {
|
|
const text = m[1].trim();
|
|
if (text) spans.push({ text, line: i + 1 });
|
|
}
|
|
}
|
|
return spans;
|
|
}
|
|
|
|
/**
|
|
* Lexical half of the taxonomy: is this token even a path reference?
|
|
* Order is load-bearing — the FIRST matching rule is the reported reason, so
|
|
* `npm test` is silenced as `whitespace` (a command) rather than as
|
|
* `no-separator`, and the taxonomy keeps describing what actually happened.
|
|
*
|
|
* @param {string} token - the text inside one backtick span
|
|
* @returns {{rule: string|null}} rule name, or null when the token is a
|
|
* candidate that still needs resolving against the filesystem
|
|
*/
|
|
export function classifyProseReference(token) {
|
|
const t = String(token == null ? '' : token);
|
|
|
|
// S2 — a command invocation, not a path.
|
|
if (/\s/.test(t)) return { rule: 'whitespace' };
|
|
// S3 — an external resource; on-disk existence is meaningless.
|
|
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(t) || /^(?:www\.|mailto:)/i.test(t)) return { rule: 'url' };
|
|
// S4 — a pattern or template: resolves to many, or to nothing until expanded.
|
|
if (/[*?[\]{}<>$]/.test(t)) return { rule: 'glob-or-placeholder' };
|
|
// S5 — outside project scope, and machine-dependent.
|
|
if (t.startsWith('/') || t.startsWith('~')) return { rule: 'absolute-or-home' };
|
|
// S6 — a config key or a CLI flag.
|
|
if (t.endsWith(':') || t.startsWith('-')) return { rule: 'key-or-flag' };
|
|
// S7 — a bare filename in prose is a concept or a tool name, not a reference.
|
|
// Measured: admitting bare names triples the output, and its top entries are
|
|
// name-drops of tools that exist elsewhere on the machine.
|
|
if (!t.includes('/')) return { rule: 'no-separator' };
|
|
// S8 — has a separator but no unambiguous path shape: org/repo slugs, npm
|
|
// packages, pytest node ids, prose enumerations. Also swallows S10, a path
|
|
// carrying a trailing `:54-56` locator — a known v1 gap, and a miss rather
|
|
// than a false alarm.
|
|
if (!t.endsWith('/') && !KNOWN_EXTENSIONS.test(t)) return { rule: 'ambiguous-slug' };
|
|
// S8b — a BARE folder name is a concept one level up from a bare filename,
|
|
// and the same D-A reasoning applies. Measured on the same corpus: 183 of 699
|
|
// fires (26 %) are single-segment directory tokens, led by `open/` (39x, a
|
|
// remote namespace prefix) and generic names — `tests/`, `src/`, `docs/`,
|
|
// `scripts/` — that prose almost always MENTIONS rather than references. A
|
|
// specific path like `tools/wiki_ingest/` still qualifies.
|
|
if (t.endsWith('/') && t.replace(/^\.\//, '').split('/').filter(Boolean).length === 1) {
|
|
return { rule: 'single-segment-directory' };
|
|
}
|
|
|
|
return { rule: null };
|
|
}
|
|
|
|
/** @returns {Promise<boolean>} */
|
|
async function pathExists(p) {
|
|
try {
|
|
await stat(p);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/** Is `p` the root itself or below it? */
|
|
function isInside(p, root) {
|
|
return p === root || p.startsWith(root.endsWith(sep) ? root : root + sep);
|
|
}
|
|
|
|
/**
|
|
* Filesystem half of the taxonomy. Two bases, because a nested CLAUDE.md
|
|
* routinely writes repo-root-relative paths.
|
|
*
|
|
* Containment is checked against the SCAN ROOT, not the file's own directory:
|
|
* a legitimate `../docs/x.md` inside the same repo must still resolve, while a
|
|
* `..` chain that leaves the tree must not. Measured: without this,
|
|
* `../../../../etc/passwd` resolved to the real /etc/passwd and silenced the
|
|
* finding by accident. A base a `..` chain can escape is not a base.
|
|
*
|
|
* @param {string} token
|
|
* @param {{fileDir: string, scanRoot: string}} bases
|
|
* @returns {Promise<{rule: string|null}>} null means the reference is dead
|
|
*/
|
|
export async function resolveProseReference(token, { fileDir, scanRoot }) {
|
|
const root = resolvePath(scanRoot);
|
|
const ownAbs = resolvePath(fileDir, token);
|
|
|
|
if (!isInside(ownAbs, root)) return { rule: 'outside-scan-tree' };
|
|
if (await pathExists(ownAbs)) return { rule: 'resolves-own-dir' };
|
|
|
|
const rootAbs = resolvePath(root, token);
|
|
if (isInside(rootAbs, root) && await pathExists(rootAbs)) return { rule: 'resolves-scan-root' };
|
|
|
|
return { rule: null };
|
|
}
|
|
|
|
/** 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,
|
|
code: 'no-claude-md',
|
|
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,
|
|
code: 'nested-not-reinjected',
|
|
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,
|
|
code: 'over-500-lines',
|
|
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,
|
|
code: 'over-200-lines',
|
|
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,
|
|
code: 'over-char-budget',
|
|
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,
|
|
code: 'over-char-budget',
|
|
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,
|
|
code: 'nearly-empty',
|
|
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,
|
|
code: 'missing-sections',
|
|
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,
|
|
code: 'no-headings',
|
|
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,
|
|
code: 'deep-relative-import',
|
|
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,
|
|
code: 'html-comments',
|
|
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,
|
|
code: 'repeated-content',
|
|
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,
|
|
code: 'todo-markers',
|
|
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),
|
|
}));
|
|
}
|
|
|
|
// --- Dead prose references (C3) ---
|
|
// One finding per FILE, matching the idiom of the two checks above: a
|
|
// machine-wide scan measured 699 dead references across 128 files, and
|
|
// per-token emission would bury the file that has ten of them.
|
|
const deadRefs = [];
|
|
for (const span of extractInlineSpans(content)) {
|
|
if (classifyProseReference(span.text).rule !== null) continue;
|
|
const { rule } = await resolveProseReference(span.text, {
|
|
fileDir: dirname(file.absPath),
|
|
scanRoot: targetPath,
|
|
});
|
|
if (rule === null) deadRefs.push(span);
|
|
}
|
|
|
|
if (deadRefs.length > 0) {
|
|
const listed = deadRefs
|
|
.slice(0, MAX_LISTED_DEAD_REFS)
|
|
.map(r => `${r.text} (line ${r.line})`)
|
|
.join(', ');
|
|
const rest = deadRefs.length - Math.min(deadRefs.length, MAX_LISTED_DEAD_REFS);
|
|
findings.push(finding({
|
|
scanner: SCANNER,
|
|
code: 'dead-prose-reference',
|
|
severity: SEVERITY.low,
|
|
title: 'CLAUDE.md points at files that are not there',
|
|
description: `${file.relPath} has ${deadRefs.length} backtick-quoted path reference(s) in prose that resolve to nothing — neither next to the file nor from the scan root. Anyone following them, human or Claude, finds nothing.`,
|
|
file: file.absPath,
|
|
line: deadRefs[0].line,
|
|
evidence: `${listed}${rest > 0 ? `, +${rest} more` : ''}`,
|
|
recommendation: 'Point each reference at where the file actually lives, or drop it. Only unambiguous relative paths are checked — URLs, globs, absolute paths and bare filenames are left alone.',
|
|
autoFixable: false,
|
|
}));
|
|
}
|
|
}
|
|
|
|
return scannerResult(SCANNER, 'ok', findings, filesScanned, Date.now() - start);
|
|
}
|