fix(acr): optimize lens scopes out plugin-bundled CLAUDE.md + unique candidate paths (M-BUG-11)
The optimize lens CLI fed its precision-gate agent every CLAUDE.md that discovery returned, including the 256 files under ~/.claude/plugins/ — vendored plugin CLAUDE.md across every cached version (7 config-audit, 6 ms-ai-architect, 5 okr, ...) plus their bundled tests/fixtures and examples. Running `optimize --global` on this machine produced 454 candidates across 92 "files", ~250 of them sourced from plugin-internal files a user cannot act on (the plugin overwrites them on update). Same class as M-BUG-2: plugin-bundled config is not the user's cascade. Second defect: candidates were keyed by `relPath || absPath`, but relPath collides across scopes — a repo-root `CLAUDE.md` and the user-global `~/.claude/CLAUDE.md` both relPath to `CLAUDE.md`. The two files that actually matter were merged into one indistinguishable bucket (21 candidates), the agent's Read(file) would resolve the wrong one, and cache-file relPaths were not readable relative to cwd at all. Fix (lens-CLI-local, surgical): - Filter isPluginBundled (absPath under `.claude/plugins/`) from discovery for BOTH halves of the motor (candidate loop + the OPT scanner, which reads discovery.files directly). Drops vendored files regardless of active/stale version, so excludeCache is unnecessary here. - Key each candidate by absPath: unique + readable. No change to file-discovery.mjs or the OPT scanner, so their byte-stable snapshots are untouched. Suite 1348/0 (+4: candidate scoping, real-config survives, deterministic scoping, absolute-path identity). Frozen v5.0.0 + SC-5 snapshots untouched (the lens CLI has no snapshot; the command is agent-driven, not byte-stable). Dogfood ~/.claude `optimize --global`: candidates 454->45, deterministic 2->0 (both were stale plugin-cache copies), distinct files 92->11, repo vs user-global now distinct (12 + 9 = 21). Residual 45 includes config-audit's own tests/fixtures CLAUDE.md (repo-specific dogfooding artifact, not a general bug — left alone).
This commit is contained in:
parent
96e32df87b
commit
1d63492617
2 changed files with 124 additions and 3 deletions
|
|
@ -24,7 +24,7 @@
|
||||||
* Exit codes: 0=ok, 3=unrecoverable error. Zero external dependencies.
|
* Exit codes: 0=ok, 3=unrecoverable error. Zero external dependencies.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { resolve } from 'node:path';
|
import { resolve, sep } from 'node:path';
|
||||||
import { writeFile, readFile, stat } from 'node:fs/promises';
|
import { writeFile, readFile, stat } from 'node:fs/promises';
|
||||||
import { discoverConfigFiles } from './lib/file-discovery.mjs';
|
import { discoverConfigFiles } from './lib/file-discovery.mjs';
|
||||||
import { resetCounter } from './lib/output.mjs';
|
import { resetCounter } from './lib/output.mjs';
|
||||||
|
|
@ -33,6 +33,15 @@ import { loadRegister, getEntry } from './lib/best-practices-register.mjs';
|
||||||
import { prefilterClaudeMd, LENS_DETECTORS } from './lib/lens-prefilter.mjs';
|
import { prefilterClaudeMd, LENS_DETECTORS } from './lib/lens-prefilter.mjs';
|
||||||
import { scan as optScan } from './optimization-lens-scanner.mjs';
|
import { scan as optScan } from './optimization-lens-scanner.mjs';
|
||||||
|
|
||||||
|
// Files under `.claude/plugins/` are shipped by an installed plugin — vendored
|
||||||
|
// CLAUDE.md plus its bundled tests/fixtures and examples. They are not the user's
|
||||||
|
// authored config, so a mechanism-fit suggestion against them is not actionable
|
||||||
|
// (the user can't edit a file the plugin overwrites on update). Excluded from the
|
||||||
|
// lens regardless of active/stale version. (M-BUG-11; mirrors the M-BUG-2 rule
|
||||||
|
// that keeps plugin-bundled config out of the conflict detector.)
|
||||||
|
const PLUGIN_TREE_MARKER = `.claude${sep}plugins${sep}`;
|
||||||
|
const isPluginBundled = (file) => (file.absPath || '').includes(PLUGIN_TREE_MARKER);
|
||||||
|
|
||||||
/** Confirmed register entry for `id`, or null. */
|
/** Confirmed register entry for `id`, or null. */
|
||||||
function confirmedEntry(register, id) {
|
function confirmedEntry(register, id) {
|
||||||
const e = getEntry(register, id);
|
const e = getEntry(register, id);
|
||||||
|
|
@ -72,7 +81,13 @@ async function main() {
|
||||||
}
|
}
|
||||||
|
|
||||||
resetCounter();
|
resetCounter();
|
||||||
const discovery = await discoverConfigFiles(absPath, { includeGlobal });
|
const rawDiscovery = await discoverConfigFiles(absPath, { includeGlobal });
|
||||||
|
// Scope the lens to the user's authored config: drop plugin-bundled files for
|
||||||
|
// BOTH halves of the motor (the OPT scanner reads discovery.files directly).
|
||||||
|
const discovery = {
|
||||||
|
...rawDiscovery,
|
||||||
|
files: (rawDiscovery.files || []).filter((f) => !isPluginBundled(f)),
|
||||||
|
};
|
||||||
|
|
||||||
// ── Deterministic half: the OPT scanner (CA-OPT-001) ──
|
// ── Deterministic half: the OPT scanner (CA-OPT-001) ──
|
||||||
const opt = await optScan(absPath, discovery);
|
const opt = await optScan(absPath, discovery);
|
||||||
|
|
@ -94,7 +109,11 @@ async function main() {
|
||||||
const entry = register ? confirmedEntry(register, cand.registerId) : null;
|
const entry = register ? confirmedEntry(register, cand.registerId) : null;
|
||||||
if (!entry) continue; // never surface an unverifiable recommendation
|
if (!entry) continue; // never surface an unverifiable recommendation
|
||||||
candidates.push({
|
candidates.push({
|
||||||
file: file.relPath || file.absPath,
|
// Absolute path: unique + readable. relPath collides across scopes
|
||||||
|
// (a repo-root `CLAUDE.md` and the user-global `~/.claude/CLAUDE.md`
|
||||||
|
// both relPath to `CLAUDE.md`), which would send the agent's Read() to
|
||||||
|
// the wrong file. (M-BUG-11)
|
||||||
|
file: file.absPath,
|
||||||
line: bodyStartLine - 1 + cand.line,
|
line: bodyStartLine - 1 + cand.line,
|
||||||
lensCheck: cand.lensCheck,
|
lensCheck: cand.lensCheck,
|
||||||
mechanism: cand.mechanism,
|
mechanism: cand.mechanism,
|
||||||
|
|
|
||||||
102
tests/scanners/optimize-lens-cli.test.mjs
Normal file
102
tests/scanners/optimize-lens-cli.test.mjs
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
/**
|
||||||
|
* optimize-lens-cli tests — payload scoping + candidate identity (M-BUG-11).
|
||||||
|
*
|
||||||
|
* The lens CLI feeds the precision-gate agent. Two correctness invariants it must
|
||||||
|
* hold, both established elsewhere in the codebase but originally missing here:
|
||||||
|
*
|
||||||
|
* 1. SCOPING — plugin-bundled CLAUDE.md (anything under `.claude/plugins/`:
|
||||||
|
* vendored plugin config + its bundled tests/fixtures + examples, active or
|
||||||
|
* stale) is NOT the user's authored config. The user can't act on a
|
||||||
|
* mechanism-fit suggestion against a file a plugin ships. So the lens must
|
||||||
|
* drop them — the M-BUG-2 `isPluginBundled` rule, applied to the lens.
|
||||||
|
*
|
||||||
|
* 2. IDENTITY — a candidate's `file` must uniquely name a readable file. The
|
||||||
|
* user-global `~/.claude/CLAUDE.md` and a repo-root `CLAUDE.md` both have
|
||||||
|
* relPath `CLAUDE.md`; labelling candidates by relPath collides them, and
|
||||||
|
* the agent's `Read(file)` then resolves the wrong one. Candidates carry an
|
||||||
|
* absolute path.
|
||||||
|
*
|
||||||
|
* Hermetic: a temp target with a real CLAUDE.md and a nested plugin-bundled one.
|
||||||
|
* `.claude/plugins/` is not in SKIP_DIRS, so the normal walk discovers it —
|
||||||
|
* no ~/.claude / --global needed.
|
||||||
|
*/
|
||||||
|
import { describe, it } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { join, isAbsolute } from 'node:path';
|
||||||
|
import { mkdtemp, mkdir, writeFile, rm, readFile } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { execFile } from 'node:child_process';
|
||||||
|
import { promisify } from 'node:util';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const execFileP = promisify(execFile);
|
||||||
|
const CLI = fileURLToPath(new URL('../../scanners/optimize-lens-cli.mjs', import.meta.url));
|
||||||
|
|
||||||
|
/** Build a temp target, run the lens CLI on it, return the parsed payload. */
|
||||||
|
async function runLens(files) {
|
||||||
|
const root = await mkdtemp(join(tmpdir(), 'ca-lens-cli-'));
|
||||||
|
try {
|
||||||
|
for (const [rel, content] of Object.entries(files)) {
|
||||||
|
const abs = join(root, rel);
|
||||||
|
await mkdir(join(abs, '..'), { recursive: true });
|
||||||
|
await writeFile(abs, content, 'utf-8');
|
||||||
|
}
|
||||||
|
const out = join(root, 'payload.json');
|
||||||
|
await execFileP('node', [CLI, root, '--output-file', out]);
|
||||||
|
return JSON.parse(await readFile(out, 'utf-8'));
|
||||||
|
} finally {
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const REAL = '# Project\n\nNever commit secrets to the repo.\n';
|
||||||
|
const PLUGIN = '# Vendored plugin\n\nNever delete the plugin cache directory.\n';
|
||||||
|
|
||||||
|
describe('optimize-lens-cli — scoping (M-BUG-11)', () => {
|
||||||
|
it('excludes plugin-bundled CLAUDE.md from candidates', async () => {
|
||||||
|
const payload = await runLens({
|
||||||
|
'CLAUDE.md': REAL,
|
||||||
|
'.claude/plugins/cache/mkt/plug/1.0.0/CLAUDE.md': PLUGIN,
|
||||||
|
});
|
||||||
|
const bundled = payload.candidates.filter(
|
||||||
|
(c) => c.file.includes(`.claude/plugins/`) || c.file.includes(`/plugins/`),
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
bundled.map((c) => c.file),
|
||||||
|
[],
|
||||||
|
'no candidate should come from a file under .claude/plugins/',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still surfaces the user’s real CLAUDE.md', async () => {
|
||||||
|
const payload = await runLens({
|
||||||
|
'CLAUDE.md': REAL,
|
||||||
|
'.claude/plugins/cache/mkt/plug/1.0.0/CLAUDE.md': PLUGIN,
|
||||||
|
});
|
||||||
|
const real = payload.candidates.filter((c) => c.file.endsWith(`${join('', 'CLAUDE.md')}`));
|
||||||
|
assert.ok(real.length >= 1, 'the real CLAUDE.md never-instruction should survive scoping');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('excludes plugin-bundled CLAUDE.md from deterministic findings too', async () => {
|
||||||
|
const procedure =
|
||||||
|
'# Plugin release\n\n' +
|
||||||
|
Array.from({ length: 7 }, (_, i) => `${i + 1}. Do release step ${i + 1} and verify.`).join('\n') +
|
||||||
|
'\n';
|
||||||
|
const payload = await runLens({
|
||||||
|
'CLAUDE.md': REAL,
|
||||||
|
'.claude/plugins/cache/mkt/plug/1.0.0/CLAUDE.md': procedure,
|
||||||
|
});
|
||||||
|
const bundled = (payload.deterministic || []).filter((f) => String(f.file).includes('plugins/'));
|
||||||
|
assert.deepEqual(bundled, [], 'deterministic CA-OPT-001 must not fire on vendored plugin CLAUDE.md');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('optimize-lens-cli — candidate identity (M-BUG-11)', () => {
|
||||||
|
it('labels every candidate with an absolute, unique path', async () => {
|
||||||
|
const payload = await runLens({ 'CLAUDE.md': REAL });
|
||||||
|
assert.ok(payload.candidates.length >= 1, 'expected at least one candidate');
|
||||||
|
for (const c of payload.candidates) {
|
||||||
|
assert.ok(isAbsolute(c.file), `candidate file must be absolute, got: ${c.file}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue