feat(cps): scan @imported files for volatile cached-prefix content (v5.10 B6) [skip-docs]
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>
This commit is contained in:
parent
d2c45a3bb8
commit
fa1ddd963a
8 changed files with 183 additions and 26 deletions
|
|
@ -15,9 +15,12 @@
|
|||
* 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';
|
||||
|
||||
|
|
@ -41,6 +44,22 @@ const VOLATILE_PATTERNS = [
|
|||
{ 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}.
|
||||
|
|
@ -75,40 +94,90 @@ export async function scan(targetPath, discovery) {
|
|||
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;
|
||||
const volatile = findVolatileLines(content);
|
||||
if (volatile.length === 0) 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) continue;
|
||||
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',
|
||||
}));
|
||||
}
|
||||
|
||||
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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue