fix(acr): CPS ignores fenced/inline code + CC-stable path vars (M-BUG-7)

CPS flagged ${CLAUDE_PLUGIN_ROOT}/${CLAUDE_PROJECT_DIR} (CC-provided stable
paths) and {date}/timestamp tokens shown in documentation as cache-busters.
Fix: skip fenced code blocks, strip inline-code spans, and whitelist CC-stable
vars before pattern-matching. Suppress-only — frozen v5.0.0 snapshots untouched
(CPS yields findings:[] there), no re-seed. Suite 1316/0 (+6). Dogfood ~/.claude
5->2 (3 doc false-positives suppressed; 2 remaining = own volatile test fixtures).
This commit is contained in:
Kjell Tore Guttormsen 2026-06-26 12:45:14 +02:00
commit dd9db60fc9
8 changed files with 403 additions and 11 deletions

View file

@ -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;

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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 <path>` | 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.

View file

@ -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.

View file

@ -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.

View file

@ -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 3537 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');