config-audit/scanners/cache-prefix-scanner.mjs
Kjell Tore Guttormsen dd9db60fc9 fix(acr): CPS ignores fenced/inline code + CC-stable path vars (M-BUG-7)
CPS flagged ${CLAUDE_PLUGIN_ROOT}/${CLAUDE_PROJECT_DIR} (CC-provided stable
paths) and {date}/timestamp tokens shown in documentation as cache-busters.
Fix: skip fenced code blocks, strip inline-code spans, and whitelist CC-stable
vars before pattern-matching. Suppress-only — frozen v5.0.0 snapshots untouched
(CPS yields findings:[] there), no re-seed. Suite 1316/0 (+6). Dogfood ~/.claude
5->2 (3 doc false-positives suppressed; 2 remaining = own volatile test fixtures).
2026-06-26 12:45:14 +02:00

222 lines
9.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* CPS Scanner — Cache-Prefix Stability Analyzer (v5 N3)
*
* Walks the CLAUDE.md cascade and flags volatile content anywhere in the
* cached prefix (≤ CACHED_PREFIX_LINES). Distinguishes from TOK Pattern A,
* which only inspects the top 30 lines: CPS catches a `!git log` at line 60
* or a `${TIMESTAMP}` at line 100. Volatile content anywhere in the cached
* prefix breaks prompt-cache reuse from that line forward.
*
* Volatile patterns extend the TOK set with shell-exec `!` prefix and
* `${VAR}` substitutions — both common cache-busters in real CLAUDE.md files.
*
* Finding ID: CA-CPS-NNN. Severity: medium.
*
* Zero external dependencies.
*/
import { resolve, dirname } from 'node:path';
import { tmpdir } from 'node:os';
import { readTextFile } from './lib/file-discovery.mjs';
import { finding, scannerResult } from './lib/output.mjs';
import { SEVERITY } from './lib/severity.mjs';
import { findImports } from './lib/yaml-parser.mjs';
const SCANNER = 'CPS';
// Cache-prefix line threshold: content below this line is unlikely to be
// part of a stable cached prefix in typical sessions. The number is
// heuristic — the goal is to flag volatility that genuinely costs cache
// hits per turn, not to chase every inline date in a long backlog file.
const CACHED_PREFIX_LINES = 150;
// CC-provided substitution variables that resolve to a stable per-install or
// per-project path (e.g. "${CLAUDE_PLUGIN_ROOT}/hooks/x.mjs"). CC expands them
// to the same value every turn, so they never break the prompt cache — unlike a
// runtime ${TIMESTAMP}. Excluded from the ${VAR} volatile flag (M-BUG-7).
const STABLE_CC_VARS = new Set(['CLAUDE_PLUGIN_ROOT', 'CLAUDE_PROJECT_DIR']);
// Matches every ${VAR} occurrence on a line so a line carrying only stable CC
// vars is not mistaken for a runtime cache-buster.
const VAR_RX = /\$\{([A-Z_][A-Z0-9_]*)\}/g;
/** True when a line contains at least one non-CC-stable ${VAR} substitution. */
function hasVolatileVar(line) {
VAR_RX.lastIndex = 0;
let m;
while ((m = VAR_RX.exec(line)) !== null) {
if (!STABLE_CC_VARS.has(m[1])) return true;
}
return false;
}
// Volatile-pattern set (extends token-hotspots.mjs Pattern A). The ${VAR} entry
// is `varAware` — flagged via hasVolatileVar() so CC-stable vars are excluded.
const VOLATILE_PATTERNS = [
{ rx: /\{timestamp\}/i, label: '{timestamp} placeholder' },
{ rx: /\{uuid\}/i, label: '{uuid} placeholder' },
{ rx: /\{date\}/i, label: '{date} placeholder' },
{ rx: /\{session(?:_id)?\}/i, label: '{session_id} placeholder' },
{ rx: /\bactivity log\b/i, label: 'activity-log section' },
{ rx: /^\s*\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/, label: 'ISO timestamp at line start' },
{ rx: /^\s*\[\d{4}-\d{2}-\d{2}/, label: 'dated log line [YYYY-MM-DD ...]' },
// v5 N3 extensions:
{ rx: /^\s*!/, label: 'shell-exec line (! prefix)' },
{ rx: /\$\{[A-Z_][A-Z0-9_]*\}/, label: '${VAR} substitution', varAware: true },
];
/**
* Resolve an @import path relative to the file that declares it.
* Mirrors import-resolver.mjs / token-hotspots.mjs path semantics.
* @param {string} importPath
* @param {string} containingFile
* @returns {string} absolute resolved path
*/
function resolveImportPath(importPath, containingFile) {
if (importPath.startsWith('~')) {
const home = process.env.HOME || process.env.USERPROFILE || tmpdir();
return resolve(importPath.replace(/^~/, home));
}
if (importPath.startsWith('/')) return importPath;
return resolve(dirname(containingFile), importPath);
}
/**
* Scan content for volatile lines within the cached prefix window.
* Returns array of {line, label, snippet}.
*/
function findVolatileLines(content) {
const out = [];
if (!content) return out;
const lines = content.split('\n').slice(0, CACHED_PREFIX_LINES);
let inFence = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Fenced code blocks (``` or ~~~) hold illustrative, byte-stable literal
// text — a ${VAR} or timestamp shown inside one is documentation, not a
// runtime cache-buster — so the fence delimiters and their content are
// skipped (M-BUG-7).
if (/^\s*(```|~~~)/.test(line)) {
inFence = !inFence;
continue;
}
if (inFence) continue;
// Strip `inline code` spans before pattern-testing: a {date} or ${VAR}
// shown inside backticks is literal documentation text, byte-stable, not a
// runtime cache-buster (M-BUG-7). The original line is still reported as the
// snippet so context is preserved.
const probe = line.replace(/`[^`]*`/g, '');
for (const { rx, label, varAware } of VOLATILE_PATTERNS) {
// The ${VAR} pattern flags only non-CC-stable substitutions; every other
// pattern keeps its plain line test.
if (varAware ? !hasVolatileVar(probe) : !rx.test(probe)) continue;
out.push({
line: i + 1,
label,
snippet: line.length > 120 ? line.slice(0, 117) + '...' : line,
});
break;
}
}
return out;
}
/**
* Main scanner entry point.
*
* @param {string} targetPath
* @param {{files: Array<{absPath:string, relPath:string, type:string, scope:string, size:number}>}} discovery
*/
export async function scan(targetPath, discovery) {
const start = Date.now();
const findings = [];
let filesScanned = 0;
// Files already scanned in-file below — an @import resolving to one of these
// is reported by its own iteration, not duplicated as an import finding.
const discoveredClaudeMd = new Set(
discovery.files.filter(f => f.type === 'claude-md').map(f => f.absPath));
// @imported files reported once, even when several CLAUDE.md files import them.
const reportedImports = new Set();
for (const f of discovery.files) {
if (f.type !== 'claude-md') continue;
filesScanned++;
const content = await readTextFile(f.absPath);
if (!content) continue;
// --- In-file volatility (unchanged behavior) ---
const volatile = findVolatileLines(content);
// Skip volatility that's already covered by TOK Pattern A (lines 130) —
// CPS' value is in the 31150 range. Pattern A handles 130.
const beyondTopThirty = volatile.filter(v => v.line > 30);
if (beyondTopThirty.length > 0) {
const evidence =
beyondTopThirty.slice(0, 5)
.map(v => `line ${v.line} (${v.label}): ${v.snippet}`)
.join('; ');
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.medium,
title: 'Volatile content inside cached prefix breaks reuse',
description:
`${f.relPath || f.absPath} contains ${beyondTopThirty.length} volatile ` +
`entr${beyondTopThirty.length === 1 ? 'y' : 'ies'} between lines 31 and ` +
`${CACHED_PREFIX_LINES}. The prompt cache covers the file's prefix; ` +
'any volatility forces a fresh cache write from that line down on every turn.',
file: f.absPath,
evidence,
recommendation:
'Move volatile sections (timestamps, !shell-exec, ${VAR} substitutions, dated logs) ' +
`below line ${CACHED_PREFIX_LINES} or extract them to an @import-ed file outside the ` +
'cached prefix. Stable content above, volatile content below.',
category: 'token-efficiency',
}));
}
// --- v5.10 B6: volatility inside @imported files ---
// @import-ed content is inlined into the cached prefix at the import site.
// TOK Pattern A and the in-file scan above never look past the importing
// file, so volatility in an imported file is otherwise invisible. We scan
// direct imports only (one hop); IMP owns deep-chain analysis. The whole
// imported-file prefix counts (no lines-130 skip — that exclusion is
// root-file-specific to avoid Pattern A overlap, which does not reach here).
for (const imp of findImports(content)) {
if (imp.line > CACHED_PREFIX_LINES) continue; // import site outside prefix
const resolved = resolveImportPath(imp.path, f.absPath);
if (discoveredClaudeMd.has(resolved)) continue; // scanned in its own iteration
if (reportedImports.has(resolved)) continue;
reportedImports.add(resolved);
const importedContent = await readTextFile(resolved);
if (!importedContent) continue;
const importedVolatile = findVolatileLines(importedContent);
if (importedVolatile.length === 0) continue;
const importEvidence =
`imported by ${f.relPath || f.absPath} (@${imp.path} at line ${imp.line}); ` +
importedVolatile.slice(0, 5)
.map(v => `line ${v.line} (${v.label}): ${v.snippet}`)
.join('; ');
findings.push(finding({
scanner: SCANNER,
severity: SEVERITY.medium,
title: 'Volatile content in @imported file breaks cached prefix',
description:
`@${imp.path} (imported by ${f.relPath || f.absPath} at line ${imp.line}) contains ` +
`${importedVolatile.length} volatile entr${importedVolatile.length === 1 ? 'y' : 'ies'} ` +
`within its first ${CACHED_PREFIX_LINES} lines. @import-ed content is inlined into the ` +
'prompt-cache prefix, so volatility there forces a fresh cache write every turn — even ' +
'when the importing CLAUDE.md is itself byte-stable.',
file: resolved,
evidence: importEvidence,
recommendation:
'Move volatile content (timestamps, !shell-exec, ${VAR} substitutions, dated logs) out ' +
'of the @imported file, or import it below the cached-prefix window. Keep imported config ' +
'byte-stable so the importing file\'s cache survives.',
category: 'token-efficiency',
}));
}
}
return scannerResult(SCANNER, 'ok', findings, filesScanned, Date.now() - start);
}