CPS originally inspected only files discovery classifies as claude-md, but a CLAUDE.md can pull arbitrary files into the cached prefix via @import — and those targets (e.g. @shared/conventions.md) are usually not claude-md in discovery, so their inlined content was never scanned. Neither TOK Pattern A (top-30 of cascade files) nor the in-file CPS scan reaches past the importing file, so volatility inside an imported file was invisible. B6 closes the gap: for each @import whose import site sits within the cached-prefix window (imp.line <= CACHED_PREFIX_LINES), CPS resolves the path (resolveImportPath, mirroring import-resolver/token-hotspots semantics), reads the target, and runs findVolatileLines over its first 150 lines. A hit emits a distinct medium finding — "Volatile content in @imported file breaks cached prefix" — keyed on the resolved file, evidence naming the importer. Scope boundaries (deliberate): - One hop only; imports-of-imports stay with IMP (deep-chain owner). - No lines-1-30 skip for imported content — that exclusion is root-file-specific to avoid Pattern A overlap, which never reaches imported files. - No double-reporting: an import resolving to a discovered claude-md is skipped (own iteration); a reportedImports set dedupes a target imported by several CLAUDE.md files. Dropped from B6 (per plan verdict): confident behavioral cache-buster detection (opusplan/model-switch is runtime, not static config) and jq-transcript automation. "No overstated behavioral finding ships" — even the permitted opusplan info-advisory was left out; the @import extension is the whole of B6. Byte-stability: the in-file finding keeps the same condition + byte-identical evidence/description (continue-skip refactored to if-emit, behaviour-preserving); new findings fire only on a volatile import, which no frozen v5.0.0 fixture has. docs: README + scanner-internals CPS rows + full B6 note; CLAUDE.md kept lean ([skip-docs]). Suite 1254 -> 1257 green; snapshots + SC-5 untouched. Version/badges/CHANGELOG wait for the v5.10 release cut. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
184 lines
7.8 KiB
JavaScript
184 lines
7.8 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;
|
||
|
||
// Volatile-pattern set (extends token-hotspots.mjs Pattern A).
|
||
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' },
|
||
];
|
||
|
||
/**
|
||
* 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);
|
||
for (let i = 0; i < lines.length; i++) {
|
||
for (const { rx, label } of VOLATILE_PATTERNS) {
|
||
if (rx.test(lines[i])) {
|
||
out.push({
|
||
line: i + 1,
|
||
label,
|
||
snippet: lines[i].length > 120 ? lines[i].slice(0, 117) + '...' : lines[i],
|
||
});
|
||
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);
|
||
}
|