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.
|
// hits per turn, not to chase every inline date in a long backlog file.
|
||||||
const CACHED_PREFIX_LINES = 150;
|
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 = [
|
const VOLATILE_PATTERNS = [
|
||||||
{ rx: /\{timestamp\}/i, label: '{timestamp} placeholder' },
|
{ rx: /\{timestamp\}/i, label: '{timestamp} placeholder' },
|
||||||
{ rx: /\{uuid\}/i, label: '{uuid} 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 ...]' },
|
{ rx: /^\s*\[\d{4}-\d{2}-\d{2}/, label: 'dated log line [YYYY-MM-DD ...]' },
|
||||||
// v5 N3 extensions:
|
// v5 N3 extensions:
|
||||||
{ rx: /^\s*!/, label: 'shell-exec line (! prefix)' },
|
{ 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 = [];
|
const out = [];
|
||||||
if (!content) return out;
|
if (!content) return out;
|
||||||
const lines = content.split('\n').slice(0, CACHED_PREFIX_LINES);
|
const lines = content.split('\n').slice(0, CACHED_PREFIX_LINES);
|
||||||
|
let inFence = false;
|
||||||
for (let i = 0; i < lines.length; i++) {
|
for (let i = 0; i < lines.length; i++) {
|
||||||
for (const { rx, label } of VOLATILE_PATTERNS) {
|
const line = lines[i];
|
||||||
if (rx.test(lines[i])) {
|
// Fenced code blocks (``` or ~~~) hold illustrative, byte-stable literal
|
||||||
out.push({
|
// text — a ${VAR} or timestamp shown inside one is documentation, not a
|
||||||
line: i + 1,
|
// runtime cache-buster — so the fence delimiters and their content are
|
||||||
label,
|
// skipped (M-BUG-7).
|
||||||
snippet: lines[i].length > 120 ? lines[i].slice(0, 117) + '...' : lines[i],
|
if (/^\s*(```|~~~)/.test(line)) {
|
||||||
});
|
inFence = !inFence;
|
||||||
break;
|
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;
|
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', () => {
|
describe('CPS scanner — orchestrator wiring', () => {
|
||||||
it('CPS appears in scan-orchestrator scanner list', async () => {
|
it('CPS appears in scan-orchestrator scanner list', async () => {
|
||||||
const orch = await import('../../scanners/scan-orchestrator.mjs');
|
const orch = await import('../../scanners/scan-orchestrator.mjs');
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue