diff --git a/README.md b/README.md index 01f3b68..e60bfd9 100644 --- a/README.md +++ b/README.md @@ -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 31–150 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 31–150 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____*`; (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`) | diff --git a/docs/scanner-internals.md b/docs/scanner-internals.md index a1a5893..befa00d 100644 --- a/docs/scanner-internals.md +++ b/docs/scanner-internals.md @@ -19,7 +19,7 @@ Scanner CLI: `node scanners/scan-orchestrator.mjs [--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 31–150 of CLAUDE.md cascade (beyond Pattern A's top-30 window) | +| `cache-prefix-scanner.mjs` | CPS | Volatile content in lines 31–150 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____` 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 (@ 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-1–30 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 diff --git a/scanners/cache-prefix-scanner.mjs b/scanners/cache-prefix-scanner.mjs index ff42208..62895ea 100644 --- a/scanners/cache-prefix-scanner.mjs +++ b/scanners/cache-prefix-scanner.mjs @@ -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); diff --git a/tests/fixtures/import-volatile/clean/CLAUDE.md b/tests/fixtures/import-volatile/clean/CLAUDE.md new file mode 100644 index 0000000..d9be4e5 --- /dev/null +++ b/tests/fixtures/import-volatile/clean/CLAUDE.md @@ -0,0 +1,7 @@ +# Clean Import Project + +Stable preamble. + +@shared/stable.md + +All content is stable. diff --git a/tests/fixtures/import-volatile/clean/shared/stable.md b/tests/fixtures/import-volatile/clean/shared/stable.md new file mode 100644 index 0000000..5b3621c --- /dev/null +++ b/tests/fixtures/import-volatile/clean/shared/stable.md @@ -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. diff --git a/tests/fixtures/import-volatile/positive/CLAUDE.md b/tests/fixtures/import-volatile/positive/CLAUDE.md new file mode 100644 index 0000000..739c4f2 --- /dev/null +++ b/tests/fixtures/import-volatile/positive/CLAUDE.md @@ -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. diff --git a/tests/fixtures/import-volatile/positive/shared/conventions.md b/tests/fixtures/import-volatile/positive/shared/conventions.md new file mode 100644 index 0000000..4a13156 --- /dev/null +++ b/tests/fixtures/import-volatile/positive/shared/conventions.md @@ -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. diff --git a/tests/scanners/cache-prefix.test.mjs b/tests/scanners/cache-prefix.test.mjs index 4e719fa..aecea00 100644 --- a/tests/scanners/cache-prefix.test.mjs +++ b/tests/scanners/cache-prefix.test.mjs @@ -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');