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:
parent
bfd577aeee
commit
dd9db60fc9
8 changed files with 403 additions and 11 deletions
|
|
@ -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;
|
||||
|
|
|
|||
46
tests/fixtures/cps-fenced/inside-fence/CLAUDE.md
vendored
Normal file
46
tests/fixtures/cps-fenced/inside-fence/CLAUDE.md
vendored
Normal 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.
|
||||
55
tests/fixtures/cps-fenced/mixed/CLAUDE.md
vendored
Normal file
55
tests/fixtures/cps-fenced/mixed/CLAUDE.md
vendored
Normal 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.
|
||||
55
tests/fixtures/cps-inline-code/mixed/CLAUDE.md
vendored
Normal file
55
tests/fixtures/cps-inline-code/mixed/CLAUDE.md
vendored
Normal 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.
|
||||
46
tests/fixtures/cps-inline-code/pure/CLAUDE.md
vendored
Normal file
46
tests/fixtures/cps-inline-code/pure/CLAUDE.md
vendored
Normal 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.
|
||||
46
tests/fixtures/cps-stable-vars/non-whitelisted/CLAUDE.md
vendored
Normal file
46
tests/fixtures/cps-stable-vars/non-whitelisted/CLAUDE.md
vendored
Normal 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.
|
||||
46
tests/fixtures/cps-stable-vars/whitelisted/CLAUDE.md
vendored
Normal file
46
tests/fixtures/cps-stable-vars/whitelisted/CLAUDE.md
vendored
Normal 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.
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue