diff --git a/scanners/optimize-lens-cli.mjs b/scanners/optimize-lens-cli.mjs index 21d0bc5..aa3b112 100644 --- a/scanners/optimize-lens-cli.mjs +++ b/scanners/optimize-lens-cli.mjs @@ -24,7 +24,7 @@ * 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 { discoverConfigFiles } from './lib/file-discovery.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 { 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. */ function confirmedEntry(register, id) { const e = getEntry(register, id); @@ -72,7 +81,13 @@ async function main() { } 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) ── const opt = await optScan(absPath, discovery); @@ -94,7 +109,11 @@ async function main() { const entry = register ? confirmedEntry(register, cand.registerId) : null; if (!entry) continue; // never surface an unverifiable recommendation 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, lensCheck: cand.lensCheck, mechanism: cand.mechanism, diff --git a/tests/scanners/optimize-lens-cli.test.mjs b/tests/scanners/optimize-lens-cli.test.mjs new file mode 100644 index 0000000..bb45fed --- /dev/null +++ b/tests/scanners/optimize-lens-cli.test.mjs @@ -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}`); + } + }); +});