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:
Kjell Tore Guttormsen 2026-06-23 20:45:12 +02:00
commit fa1ddd963a
8 changed files with 183 additions and 26 deletions

View file

@ -323,7 +323,7 @@ By default, `/config-audit` auto-detects scope from your git context. Override w
| `conflict-detector.mjs` | CNF | Settings contradictions across scopes, permission conflicts, hook duplicates |
| `feature-gap-scanner.mjs` | GAP | 25 feature checks shown as opportunities, not grades — plus a conditional `disableBundledSkills` recommendation when the active skill listing is over budget, and a conditional **filter-before-Claude-reads** lever when a hook injects unfiltered output into `additionalContext` (companion to the HKV advisory; cites the documented `filter-test-output.sh` pattern) |
| `token-hotspots.mjs` | TOK | Cache-breaking volatile content, redundant tool permissions, deep import chains, oversized cascades, bloated skill descriptions, MCP tool-schema budget, and stale `~/.claude/plugins/cache` versions (disk-cleanup, zero live-context impact) — cache-aware ranking excludes superseded plugin versions by default (`--no-exclude-cache` to include) |
| `cache-prefix-scanner.mjs` | CPS | Volatile content in lines 31150 of the CLAUDE.md cascade — beyond the cache-prefix window but still re-loaded every turn |
| `cache-prefix-scanner.mjs` | CPS | Volatile content in lines 31150 of the CLAUDE.md cascade — beyond Pattern A's top-30 window but still re-loaded every turn — **plus** volatile content inside `@import`-ed files (inlined into the cached prefix, one hop, otherwise invisible to per-file scans) |
| `disabled-in-schema-scanner.mjs` | DIS | Dead/ineffective permission entries: (1) tools in BOTH `permissions.deny` and `permissions.allow` — deny wins (incl. the `Tool(*)` deny-all glob, equivalent to a bare deny); (2) unanchored allow wildcards (`*`, `B*`, `mcp__*`) that Claude Code silently skips — valid only as `mcp__<server>__*`; (3) `Tool(param:value)` rules whose key is the tool's own canonicalizing field (`command`/`file_path`/`path`/`notebook_path`/`url`) — CC ignores these and emits a startup warning |
| `collision-scanner.mjs` | COL | Cross-plugin skill name collisions; user-vs-plugin overlaps |
| `skill-listing-scanner.mjs` | SKL | Skill-listing token budget: a single skill description over the ~1,536-char listing cap Claude Code truncates (`CA-SKL-001`), and the summed active-skill descriptions exceeding the ~2%-of-context listing budget (`CA-SKL-002`) |

View file

@ -19,7 +19,7 @@ Scanner CLI: `node scanners/scan-orchestrator.mjs <path> [--global] [--full-mach
| `conflict-detector.mjs` | CNF | Settings conflicts, permission contradictions, hook duplicates |
| `feature-gap-scanner.mjs` | GAP | 25 feature checks across 4 tiers — shown as opportunities, not grades |
| `token-hotspots.mjs` | TOK | Cache-breaking volatile content, redundant tool permissions, deep import chains, oversized cascade, bloated SKILL.md descriptions, MCP tool-schema budget, MCP tool-schema deferral (CA-TOK-006), stale plugin-cache disk-cleanup (prompt-cache patterns) |
| `cache-prefix-scanner.mjs` | CPS | Volatile content in lines 31150 of CLAUDE.md cascade (beyond Pattern A's top-30 window) |
| `cache-prefix-scanner.mjs` | CPS | Volatile content in lines 31150 of CLAUDE.md cascade (beyond Pattern A's top-30 window); plus volatile content inside `@import`-ed files (v5.10 B6, one hop) |
| `disabled-in-schema-scanner.mjs` | DIS | Dead/ineffective permission entries (low). (1) Tools in BOTH `permissions.deny` AND `permissions.allow` — deny wins; dominance is param-aware and treats the `Tool(*)` deny-all glob as equivalent to a bare deny (covers a bare allow). (2) Unanchored allow wildcards (`*`, `B*`, `mcp__*`) that Claude Code silently skips — CC accepts allow globs only after a literal glob-free `mcp__<server>__` prefix. Predicates shared with CNF live in `lib/permission-rules.mjs` |
| `collision-scanner.mjs` | COL | Cross-plugin skill name collisions (low); user-vs-plugin overlaps (medium); `details.namespaces` payload |
| `skill-listing-scanner.mjs` | SKL | (1) `CA-SKL-001` (medium): active skill descriptions over the verified 1,536-char listing cap (CC 2.1.105) → silently truncated in the model's skill listing. (2) `CA-SKL-002` (low): sum of active descriptions (each counted up to the cap) over the listing budget (~2% of context, CC 2.1.32), anchored on a conservative 200k window with a calibration note that the budget scales 5× on 1M-context models — leads with the measured sum, an estimate not telemetry. HOME-scoped (all user + plugin skills). Remediation surfaces `disableBundledSkills` / `skillOverrides` / trim. Distinct lens from TOK pattern F (project-local 500-char bloat heuristic) |
@ -219,6 +219,41 @@ returns ≥1 chatty hook — surfaces the documented **filter-before-Claude-read
grep ERROR and return only matches instead of a 10,000-line log). No chatty hook → silent (opportunity,
not noise — same contract as the cliOverMcp / bundledSkills levers).
### cache-prefix-scanner — @import extension (v5.10 B6)
CPS originally scanned only the files discovery classifies as `claude-md`. But a CLAUDE.md can pull
arbitrary files into context with `@import` directives, and those targets are usually *not* `claude-md`
in discovery (e.g. `@shared/conventions.md`) — so their content was inlined into the cached prefix yet
never inspected. Neither TOK Pattern A (top-30 of cascade files) nor the in-file CPS scan reaches past
the importing file, so volatility in 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 (so the fix points at the right place), with evidence naming the
importer (`imported by <file> (@<path> at line N)`).
**Scope boundaries (deliberate):**
- **One hop only.** Imports-of-imports are not followed — IMP owns deep-chain analysis. The verified win
is the direct import; transitive resolution adds cycle/depth complexity for marginal coverage.
- **No lines-130 skip for imported content.** That exclusion exists only to avoid duplicating TOK
Pattern A's territory in the *root* file; Pattern A never reads imported files, so all of the imported
prefix counts.
- **No double-reporting.** An import resolving to a file that is itself a discovered `claude-md` is
skipped (it gets its own in-file iteration); a `reportedImports` set dedupes a target imported by
several CLAUDE.md files.
**Byte-stability.** The in-file finding is emitted under exactly the same condition and with byte-identical
evidence/description as before (the `continue`-skip was refactored to an `if`-emit — behaviour-preserving).
New findings fire only when a discovered CLAUDE.md imports a volatile file, which no frozen v5.0.0 fixture
does — snapshots and SC-5 verified untouched by the full suite.
**Dropped from B6 (per plan `verdict`).** Confident behavioral cache-buster detection (opusplan /
model-switch is a *runtime* behaviour, not static config a scanner can reliably flag) and jq-transcript
automation. "No overstated behavioral finding ships" — so even the permitted opusplan *info*-advisory was
left out; the verified @import extension is the whole of B6.
### CML scanner — context-window-scaled char budget
Beyond the line-count checks (200/500 lines, both MEDIUM), the CML scanner mirrors

View file

@ -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 130) —
// CPS' value is in the 31150 range. Pattern A handles 130.
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-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);

View file

@ -0,0 +1,7 @@
# Clean Import Project
Stable preamble.
@shared/stable.md
All content is stable.

View file

@ -0,0 +1,5 @@
# Stable Conventions
Fully stable content.
No timestamps, no shell-exec, no variable substitutions.
Just plain prose that is identical on every turn.

View file

@ -0,0 +1,8 @@
# Imported Volatile Project
Stable preamble. No volatile content lives in this file.
@shared/conventions.md
This importing file is byte-stable on its own.
Nothing below changes between turns.

View file

@ -0,0 +1,8 @@
# Conventions
These conventions are mostly stable prose.
Last build: ${BUILD_TIMESTAMP}
!git log -1 --format=%cd
More conventions text that does not change.

View file

@ -61,6 +61,31 @@ describe('CPS scanner — does not duplicate TOK Pattern A territory', () => {
});
});
describe('CPS scanner — volatile content in @imported files (v5.10 B6)', () => {
it('flags volatile content inside an @imported file even when the root file is stable', async () => {
const result = await runScanner('import-volatile/positive');
const f = result.findings.find(x => /volatile content in @imported file/i.test(x.title || ''));
assert.ok(f, `expected @import-volatile finding; got: ${result.findings.map(x => x.title).join(' | ')}`);
assert.equal(f.severity, 'medium', `expected medium, got ${f.severity}`);
assert.match(String(f.evidence || ''), /imported by/i);
assert.match(String(f.evidence || ''), /BUILD_TIMESTAMP|git log|VAR substitution|shell-exec/i);
assert.equal(f.category, 'token-efficiency');
});
it('does NOT emit the in-file CPS finding when the importing file itself is stable', async () => {
// Root CLAUDE.md carries no volatile lines; only the @imported file does.
const result = await runScanner('import-volatile/positive');
const inFile = result.findings.find(x => /volatile content inside cached prefix/i.test(x.title || ''));
assert.equal(inFile, undefined, 'a byte-stable importing file must not trip the in-file finding');
});
it('does NOT flag a clean @imported file', async () => {
const result = await runScanner('import-volatile/clean');
const f = result.findings.find(x => /volatile content in @imported file/i.test(x.title || ''));
assert.equal(f, undefined, `expected no finding for a clean import; got: ${f?.title}`);
});
});
describe('CPS scanner — orchestrator wiring', () => {
it('CPS appears in scan-orchestrator scanner list', async () => {
const orch = await import('../../scanners/scan-orchestrator.mjs');