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).
222 lines
9.6 KiB
JavaScript
222 lines
9.6 KiB
JavaScript
/**
|
||
* 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);
|
||
}
|