diff --git a/scanners/cache-prefix-scanner.mjs b/scanners/cache-prefix-scanner.mjs index 62895ea..0e41822 100644 --- a/scanners/cache-prefix-scanner.mjs +++ b/scanners/cache-prefix-scanner.mjs @@ -30,7 +30,28 @@ const SCANNER = 'CPS'; // 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). +// 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' }, @@ -41,7 +62,7 @@ const VOLATILE_PATTERNS = [ { 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' }, + { rx: /\$\{[A-Z_][A-Z0-9_]*\}/, label: '${VAR} substitution', varAware: true }, ]; /** @@ -68,16 +89,33 @@ 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++) { - 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; - } + 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; diff --git a/tests/fixtures/cps-fenced/inside-fence/CLAUDE.md b/tests/fixtures/cps-fenced/inside-fence/CLAUDE.md new file mode 100644 index 0000000..5ccb2b6 --- /dev/null +++ b/tests/fixtures/cps-fenced/inside-fence/CLAUDE.md @@ -0,0 +1,46 @@ +# Fenced Documentation Project + +Stable preamble. All volatility below is illustrative, inside a code fence. +Body line 4. +Body line 5. +Body line 6. +Body line 7. +Body line 8. +Body line 9. +Body line 10. +Body line 11. +Body line 12. +Body line 13. +Body line 14. +Body line 15. +Body line 16. +Body line 17. +Body line 18. +Body line 19. +Body line 20. +Body line 21. +Body line 22. +Body line 23. +Body line 24. +Body line 25. +Body line 26. +Body line 27. +Body line 28. +Body line 29. +Body line 30. +Body line 31. +Body line 32. +Body line 33. +Body line 34. +```bash +export STAMP=${TIMESTAMP} +!deploy.sh --at 2026-06-26T10:00:00 +echo {date} > /tmp/log +[2026-06-26 12:00] starting +``` +Body line 41. +Body line 42. +Body line 43. +Body line 44. +Body line 45. +Body line 46. diff --git a/tests/fixtures/cps-fenced/mixed/CLAUDE.md b/tests/fixtures/cps-fenced/mixed/CLAUDE.md new file mode 100644 index 0000000..cd55d8a --- /dev/null +++ b/tests/fixtures/cps-fenced/mixed/CLAUDE.md @@ -0,0 +1,55 @@ +# Fenced Plus Prose Project + +Stable preamble. A fence below, plus one genuine volatile prose line. +Body line 4. +Body line 5. +Body line 6. +Body line 7. +Body line 8. +Body line 9. +Body line 10. +Body line 11. +Body line 12. +Body line 13. +Body line 14. +Body line 15. +Body line 16. +Body line 17. +Body line 18. +Body line 19. +Body line 20. +Body line 21. +Body line 22. +Body line 23. +Body line 24. +Body line 25. +Body line 26. +Body line 27. +Body line 28. +Body line 29. +Body line 30. +Body line 31. +Body line 32. +Body line 33. +Body line 34. +```bash +export STAMP=${TIMESTAMP} # documented, not live +``` +Body line 38. +Body line 39. +Body line 40. +Body line 41. +Body line 42. +Body line 43. +Body line 44. +Body line 45. +Body line 46. +Body line 47. +Body line 48. +Body line 49. +!git log -1 # genuine shell-exec in live prose at line 50 +Body line 51. +Body line 52. +Body line 53. +Body line 54. +Body line 55. diff --git a/tests/fixtures/cps-inline-code/mixed/CLAUDE.md b/tests/fixtures/cps-inline-code/mixed/CLAUDE.md new file mode 100644 index 0000000..c39f00e --- /dev/null +++ b/tests/fixtures/cps-inline-code/mixed/CLAUDE.md @@ -0,0 +1,55 @@ +# Inline Code Mixed Project + +Stable preamble. Backticked template plus a live var in prose. +Body line 4. +Body line 5. +Body line 6. +Body line 7. +Body line 8. +Body line 9. +Body line 10. +Body line 11. +Body line 12. +Body line 13. +Body line 14. +Body line 15. +Body line 16. +Body line 17. +Body line 18. +Body line 19. +Body line 20. +Body line 21. +Body line 22. +Body line 23. +Body line 24. +Body line 25. +Body line 26. +Body line 27. +Body line 28. +Body line 29. +Body line 30. +Body line 31. +Body line 32. +Body line 33. +Body line 34. +Body line 35. +Body line 36. +Body line 37. +Body line 38. +Body line 39. +Output template is `run-{date}.md` (documented). +Body line 41. +Body line 42. +Body line 43. +Body line 44. +Body line 45. +Body line 46. +Body line 47. +Body line 48. +Body line 49. +Deployed at ${RELEASE_STAMP} on every push. +Body line 51. +Body line 52. +Body line 53. +Body line 54. +Body line 55. diff --git a/tests/fixtures/cps-inline-code/pure/CLAUDE.md b/tests/fixtures/cps-inline-code/pure/CLAUDE.md new file mode 100644 index 0000000..241c303 --- /dev/null +++ b/tests/fixtures/cps-inline-code/pure/CLAUDE.md @@ -0,0 +1,46 @@ +# Inline Code Doc Project + +Stable preamble. A filename template is documented in backticks below. +Body line 4. +Body line 5. +Body line 6. +Body line 7. +Body line 8. +Body line 9. +Body line 10. +Body line 11. +Body line 12. +Body line 13. +Body line 14. +Body line 15. +Body line 16. +Body line 17. +Body line 18. +Body line 19. +Body line 20. +Body line 21. +Body line 22. +Body line 23. +Body line 24. +Body line 25. +Body line 26. +Body line 27. +Body line 28. +Body line 29. +Body line 30. +Body line 31. +Body line 32. +Body line 33. +Body line 34. +Body line 35. +Body line 36. +Body line 37. +Body line 38. +Body line 39. +| `--brief ` | writes to `.claude/plans/run-{date}-{slug}.md` | +Body line 41. +Body line 42. +Body line 43. +Body line 44. +Body line 45. +Body line 46. diff --git a/tests/fixtures/cps-stable-vars/non-whitelisted/CLAUDE.md b/tests/fixtures/cps-stable-vars/non-whitelisted/CLAUDE.md new file mode 100644 index 0000000..dd1ffe9 --- /dev/null +++ b/tests/fixtures/cps-stable-vars/non-whitelisted/CLAUDE.md @@ -0,0 +1,46 @@ +# Non-Whitelisted Var Project + +Stable preamble with a genuine runtime substitution below. +Body line 4. +Body line 5. +Body line 6. +Body line 7. +Body line 8. +Body line 9. +Body line 10. +Body line 11. +Body line 12. +Body line 13. +Body line 14. +Body line 15. +Body line 16. +Body line 17. +Body line 18. +Body line 19. +Body line 20. +Body line 21. +Body line 22. +Body line 23. +Body line 24. +Body line 25. +Body line 26. +Body line 27. +Body line 28. +Body line 29. +Body line 30. +Body line 31. +Body line 32. +Body line 33. +Body line 34. +Body line 35. +Body line 36. +Body line 37. +Body line 38. +Body line 39. +Deployed build tag: ${DEPLOY_TIMESTAMP} (changes every release). +Body line 41. +Body line 42. +Body line 43. +Body line 44. +Body line 45. +Body line 46. diff --git a/tests/fixtures/cps-stable-vars/whitelisted/CLAUDE.md b/tests/fixtures/cps-stable-vars/whitelisted/CLAUDE.md new file mode 100644 index 0000000..3fb0961 --- /dev/null +++ b/tests/fixtures/cps-stable-vars/whitelisted/CLAUDE.md @@ -0,0 +1,46 @@ +# Stable CC Vars Project + +Stable preamble — only CC-provided path vars appear below. +Body line 4. +Body line 5. +Body line 6. +Body line 7. +Body line 8. +Body line 9. +Body line 10. +Body line 11. +Body line 12. +Body line 13. +Body line 14. +Body line 15. +Body line 16. +Body line 17. +Body line 18. +Body line 19. +Body line 20. +Body line 21. +Body line 22. +Body line 23. +Body line 24. +Body line 25. +Body line 26. +Body line 27. +Body line 28. +Body line 29. +Body line 30. +Body line 31. +Body line 32. +Body line 33. +Body line 34. +Body line 35. +Body line 36. +Body line 37. +Body line 38. +Body line 39. +Hooks resolve under ${CLAUDE_PLUGIN_ROOT}/hooks and project root is ${CLAUDE_PROJECT_DIR}. +Body line 41. +Body line 42. +Body line 43. +Body line 44. +Body line 45. +Body line 46. diff --git a/tests/scanners/cache-prefix.test.mjs b/tests/scanners/cache-prefix.test.mjs index aecea00..e4f3d00 100644 --- a/tests/scanners/cache-prefix.test.mjs +++ b/tests/scanners/cache-prefix.test.mjs @@ -86,6 +86,66 @@ describe('CPS scanner — volatile content in @imported files (v5.10 B6)', () => }); }); +describe('CPS scanner — CC-stable vars are not cache-busters (M-BUG-7)', () => { + it('does NOT flag ${CLAUDE_PLUGIN_ROOT} / ${CLAUDE_PROJECT_DIR} in prose', async () => { + // These are CC-provided path substitutions that resolve to a stable value + // every turn — referencing them in CLAUDE.md prose is not a cache-buster. + const result = await runScanner('cps-stable-vars/whitelisted'); + const f = result.findings.find(x => /volatile content inside cached prefix/i.test(x.title || '')); + assert.equal(f, undefined, + `CC-stable path vars must not trip CPS; got: ${f?.evidence}`); + }); + + it('STILL flags a genuine non-CC ${VAR} substitution (whitelist is selective)', async () => { + const result = await runScanner('cps-stable-vars/non-whitelisted'); + const f = result.findings.find(x => /volatile content inside cached prefix/i.test(x.title || '')); + assert.ok(f, `expected a finding for a genuine runtime var; got none`); + assert.match(String(f.evidence || ''), /line 40/); + assert.match(String(f.evidence || ''), /\$\{VAR\} substitution/i); + }); +}); + +describe('CPS scanner — volatility inside fenced code blocks is documentation (M-BUG-7)', () => { + it('does NOT flag volatile-looking lines inside a ```fence```', async () => { + // Content inside a fenced code block is illustrative, byte-stable literal + // text — not runtime volatility — so it must not break the cached prefix. + const result = await runScanner('cps-fenced/inside-fence'); + const f = result.findings.find(x => /volatile content inside cached prefix/i.test(x.title || '')); + assert.equal(f, undefined, + `fenced code content must not trip CPS; got: ${f?.evidence}`); + }); + + it('STILL flags genuine volatility in live prose outside the fence', async () => { + const result = await runScanner('cps-fenced/mixed'); + const f = result.findings.find(x => /volatile content inside cached prefix/i.test(x.title || '')); + assert.ok(f, `expected a finding for the out-of-fence volatile line; got none`); + assert.match(String(f.evidence || ''), /line 50/); + assert.match(String(f.evidence || ''), /shell-exec/i); + assert.doesNotMatch(String(f.evidence || ''), /line 3[567]/, + 'fenced lines 35–37 must not appear in the evidence'); + }); +}); + +describe('CPS scanner — volatility inside inline code is documentation (M-BUG-7)', () => { + it('does NOT flag a {date} placeholder shown inside `inline code`', async () => { + // A filename template like `.claude/plans/run-{date}.md` in backticks is + // literal documentation text — byte-stable, not a runtime substitution. + const result = await runScanner('cps-inline-code/pure'); + const f = result.findings.find(x => /volatile content inside cached prefix/i.test(x.title || '')); + assert.equal(f, undefined, + `inline-code documentation must not trip CPS; got: ${f?.evidence}`); + }); + + it('STILL flags a live ${VAR} that sits outside backticks', async () => { + const result = await runScanner('cps-inline-code/mixed'); + const f = result.findings.find(x => /volatile content inside cached prefix/i.test(x.title || '')); + assert.ok(f, `expected a finding for the out-of-backtick volatile line; got none`); + assert.match(String(f.evidence || ''), /line 50/); + assert.doesNotMatch(String(f.evidence || ''), /line 40/, + 'the backticked {date} on line 40 must not appear in the evidence'); + }); +}); + describe('CPS scanner — orchestrator wiring', () => { it('CPS appears in scan-orchestrator scanner list', async () => { const orch = await import('../../scanners/scan-orchestrator.mjs');