feat(scanners): a path written in prose is now resolved, not assumed (C3)
`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
This commit is contained in:
parent
33bfd5ff5b
commit
dbb6a6a3cf
10 changed files with 478 additions and 3 deletions
|
|
@ -10,7 +10,8 @@ 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';
|
||||
import { dirname, resolve as resolvePath, sep } from 'node:path';
|
||||
import { stat } from 'node:fs/promises';
|
||||
|
||||
const SCANNER = 'CML';
|
||||
const MAX_RECOMMENDED_LINES = 200;
|
||||
|
|
@ -30,6 +31,134 @@ const CHAR_BUDGET_RECOMMENDATION =
|
|||
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' },
|
||||
|
|
@ -301,6 +430,40 @@ export async function scan(targetPath, discovery, opts = {}) {
|
|||
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);
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ export const FINDING_CODES = {
|
|||
'html-comments': 10,
|
||||
'repeated-content': 11,
|
||||
'todo-markers': 12,
|
||||
'dead-prose-reference': 13,
|
||||
},
|
||||
|
||||
// ── SET: settings-validator (source order) ──────────────────────────────
|
||||
|
|
|
|||
|
|
@ -77,6 +77,11 @@ export const TRANSLATIONS = {
|
|||
description: 'HTML comments still count as text sent to Claude on every turn — they don\'t actually hide anything.',
|
||||
recommendation: 'Delete the comment text if you don\'t want it sent, or convert it to a regular note.',
|
||||
},
|
||||
'CLAUDE.md points at files that are not there': {
|
||||
title: 'Your instructions file links to files that are not there',
|
||||
description: 'Some file paths written in `CLAUDE.md` point at files that do not exist — not next to the file, and not from your project root. Anyone following them finds nothing.',
|
||||
recommendation: 'Point each path at where the file actually lives, or drop the reference. Only clear relative paths are checked; web links, wildcards and plain file names are left alone.',
|
||||
},
|
||||
'Contains TODO/FIXME markers': {
|
||||
title: 'Your file has TODO or FIXME notes',
|
||||
description: 'These notes are sent to Claude on every turn even when they\'re internal reminders.',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue