/** * 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 1–30) — // CPS' value is in the 31–150 range. Pattern A handles 1–30. 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-1–30 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); }