fix(acr): feature-gap scopes presence checks to authored config + reads settings cascade (M-BUG-13)
The GAP scanner's 25 presence checks ran over the full includeGlobal discovery, so this plugin's own examples/optimal-setup (vendored across plugin-cache versions) satisfied every tier-3 check — masking real feature gaps to GAP=0 on ANY target. And the real ~/.claude/settings.json is invisible to the settings-key checks (includeGlobal gotcha + maxFiles cap), which would flip statusLine/autoMode to false positives once the maskers were removed. - isAuthoredConfig: exclude plugin-bundled (~/.claude/plugins/) + nested examples/ and tests/fixtures/ (relPath-relative, so a fixture scanned AS the target keeps its own files) from ctx.files + parsedSettings. - readSettingsCascade: read the user->project->local settings cascade directly and merge into parsedSettings — immune to the discovery cap/gotcha. Empty target: ~0 (masked) -> 18 humanized opportunities; no statusLine/autoMode false positives. Frozen v5.0.0 snapshots + SC-5/6/7 byte-stable (marketplace-medium has no nested demo trees; hermetic-HOME cascade adds nothing). Suite 1350->1355/0. Found by dogfooding feature-gap against the machine. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01683eAqVecv9VZfQzL8CQ9h
This commit is contained in:
parent
b58393099a
commit
f4bf3ae2cb
3 changed files with 207 additions and 5 deletions
|
|
@ -7,7 +7,7 @@
|
|||
* Finding IDs: CA-GAP-NNN
|
||||
*/
|
||||
|
||||
import { resolve } from 'node:path';
|
||||
import { resolve, join, sep } from 'node:path';
|
||||
import { readTextFile, discoverConfigFiles } from './lib/file-discovery.mjs';
|
||||
import { finding, scannerResult } from './lib/output.mjs';
|
||||
import { SEVERITY } from './lib/severity.mjs';
|
||||
|
|
@ -46,6 +46,68 @@ function isTargetLocal(ctx, f) {
|
|||
return f.absPath.startsWith(ctx.targetPath);
|
||||
}
|
||||
|
||||
// Files that are test/demo/vendored config — NOT part of the user's authored
|
||||
// cascade — must not satisfy "is feature X present?" checks, or they mask real
|
||||
// gaps. The canonical case: this plugin's own examples/optimal-setup sets
|
||||
// outputStyle/statusLine/worktree/model/keybindings/.lsp.json, and (because GAP
|
||||
// always runs includeGlobal) its copies vendored under ~/.claude/plugins/cache
|
||||
// drive every tier-3 presence check to "present" — hiding the user's real gaps
|
||||
// on ANY target. Two classes to exclude:
|
||||
// - plugin-bundled: anything under ~/.claude/plugins/ (absPath marker, mirrors
|
||||
// the CNF conflict-detector exclusion from M-BUG-2).
|
||||
// - nested demo/test data: a file whose path RELATIVE TO THE SCAN TARGET sits
|
||||
// under an examples/ or tests/fixtures/ subtree. relPath (not absPath) is
|
||||
// deliberate: a fixture scanned AS the target keeps its own files, so the
|
||||
// frozen v5.0.0 byte-snapshots (scanned from tests/fixtures/marketplace-medium)
|
||||
// are untouched. (M-BUG-13)
|
||||
const PLUGIN_TREE_MARKER = `.claude${sep}plugins${sep}`;
|
||||
|
||||
/**
|
||||
* @param {import('./lib/file-discovery.mjs').ConfigFile} file
|
||||
* @returns {boolean} true if the file is part of the user's authored config
|
||||
*/
|
||||
function isAuthoredConfig(file) {
|
||||
if (file.absPath.includes(PLUGIN_TREE_MARKER)) return false;
|
||||
const segs = (file.relPath || '').split(sep);
|
||||
if (segs.includes('examples')) return false;
|
||||
const ti = segs.indexOf('tests');
|
||||
if (ti !== -1 && segs[ti + 1] === 'fixtures') return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the user→project→local settings cascade directly from the filesystem.
|
||||
* The settings-key gap checks ask "does the USER's resolved config set X?" — a
|
||||
* question the includeGlobal discovery answers unreliably on a real machine: the
|
||||
* top-level ~/.claude/settings.json is missed (its relPath carries no `.claude`
|
||||
* segment when the walk root IS ~/.claude) and, when many vendored plugins flood
|
||||
* the walk, dropped by the discovery file cap. Reading the canonical cascade
|
||||
* paths directly is immune to both. Merged INTO (not replacing) the discovery
|
||||
* settings so any non-canonical project settings still count and the frozen
|
||||
* snapshots stay byte-stable. (M-BUG-13)
|
||||
* @param {string} targetPath
|
||||
* @returns {Promise<Array<{ key: string, parsed: object }>>}
|
||||
*/
|
||||
async function readSettingsCascade(targetPath) {
|
||||
const home = process.env.HOME || process.env.USERPROFILE || '';
|
||||
const paths = [];
|
||||
if (home) {
|
||||
paths.push(['user', join(home, '.claude', 'settings.json')]);
|
||||
paths.push(['user-local', join(home, '.claude', 'settings.local.json')]);
|
||||
}
|
||||
paths.push(['project', join(targetPath, '.claude', 'settings.json')]);
|
||||
paths.push(['local', join(targetPath, '.claude', 'settings.local.json')]);
|
||||
|
||||
const out = [];
|
||||
for (const [scope, p] of paths) {
|
||||
const content = await readTextFile(p);
|
||||
if (!content) continue;
|
||||
const parsed = parseJson(content);
|
||||
if (parsed && typeof parsed === 'object') out.push({ key: `cascade:${scope}:${p}`, parsed });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const TIER_SEVERITY = {
|
||||
t1: SEVERITY.medium,
|
||||
t2: SEVERITY.low,
|
||||
|
|
@ -479,18 +541,30 @@ export async function scan(targetPath, sharedDiscovery) {
|
|||
? sharedDiscovery
|
||||
: await discoverConfigFiles(resolve(targetPath), { includeGlobal: true });
|
||||
|
||||
// Parse all settings files upfront
|
||||
// Presence checks ("does the user have feature X?") must see only the user's
|
||||
// authored cascade — not bundled/vendored/demo config, which masks real gaps
|
||||
// (M-BUG-13, see isAuthoredConfig).
|
||||
const authoredFiles = discovery.files.filter(isAuthoredConfig);
|
||||
|
||||
// Parse all settings files upfront (authored discovery files) ...
|
||||
const parsedSettings = new Map();
|
||||
for (const file of discovery.files.filter(f => f.type === 'settings-json')) {
|
||||
for (const file of authoredFiles.filter(f => f.type === 'settings-json')) {
|
||||
const content = await readTextFile(file.absPath);
|
||||
if (content) {
|
||||
const parsed = parseJson(content);
|
||||
parsedSettings.set(`${file.scope}:${file.relPath}`, parsed);
|
||||
}
|
||||
}
|
||||
// ... plus the real user→project→local cascade read directly, so settings-key
|
||||
// checks see the true resolved config regardless of the discovery cap/gotcha
|
||||
// (M-BUG-13). Merged, not replacing — keeps non-canonical project settings and
|
||||
// the frozen byte-snapshots unchanged.
|
||||
for (const { key, parsed } of await readSettingsCascade(resolve(targetPath))) {
|
||||
parsedSettings.set(key, parsed);
|
||||
}
|
||||
|
||||
const ctx = {
|
||||
files: discovery.files,
|
||||
files: authoredFiles,
|
||||
targetPath: resolve(targetPath),
|
||||
parsedSettings,
|
||||
fileContents: new Map(),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue