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:
Kjell Tore Guttormsen 2026-06-30 10:09:10 +02:00
commit f4bf3ae2cb
3 changed files with 207 additions and 5 deletions

View file

@ -1,6 +1,6 @@
import { describe, it, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import { resolve, join } from 'node:path';
import { resolve, join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
@ -411,3 +411,98 @@ describe('GAP scanner — filter-before lever wiring (chatty hook fixture)', ()
assert.equal(lever, undefined, `expected no filter-before lever; got id=${lever?.id}`);
});
});
describe('GAP scanner — test/demo data must not mask real gaps (M-BUG-13)', () => {
// Build a throwaway project (+ optional hermetic HOME settings) and run GAP
// exactly as posture --global does: includeGlobal discovery + scan().
async function runGap({ projectFiles = {}, homeSettings = null } = {}) {
const project = await mkdtemp(join(tmpdir(), 'config-audit-gap-mask-proj-'));
const home = await mkdtemp(join(tmpdir(), 'config-audit-gap-mask-home-'));
for (const [rel, content] of Object.entries(projectFiles)) {
const abs = join(project, rel);
await mkdir(dirname(abs), { recursive: true });
await writeFile(abs, content);
}
if (homeSettings) {
await mkdir(join(home, '.claude'), { recursive: true });
await writeFile(join(home, '.claude', 'settings.json'), JSON.stringify(homeSettings));
}
const original = process.env.HOME;
process.env.HOME = home;
try {
resetCounter();
const discovery = await discoverConfigFiles(project, { includeGlobal: true });
const result = await scan(project, discovery);
return result;
} finally {
process.env.HOME = original;
await rm(project, { recursive: true, force: true });
await rm(home, { recursive: true, force: true });
}
}
const hasGap = (result, titleRe) =>
result.findings.some(f => f.scanner === 'GAP' && titleRe.test(f.title || ''));
it('a nested examples/ settings.json does NOT satisfy the outputStyle check (settings-key)', async () => {
const result = await runGap({
projectFiles: {
'.claude/CLAUDE.md': '# proj',
'examples/optimal-setup/.claude/settings.json': JSON.stringify({ outputStyle: 'Explanatory' }),
},
});
assert.ok(
hasGap(result, /output style/i),
`example settings.json must not mask the outputStyle gap; got: ${result.findings.map(f => f.title).join(' | ')}`,
);
});
it('a nested examples/ keybindings.json does NOT satisfy the keybindings check (file-type)', async () => {
const result = await runGap({
projectFiles: {
'.claude/CLAUDE.md': '# proj',
'examples/optimal-setup/.claude/keybindings.json': JSON.stringify({}),
},
});
assert.ok(
hasGap(result, /keybinding/i),
`example keybindings.json must not mask the keybindings gap; got: ${result.findings.map(f => f.title).join(' | ')}`,
);
});
it('a nested tests/fixtures/ settings.json does NOT satisfy the model check', async () => {
const result = await runGap({
projectFiles: {
'.claude/CLAUDE.md': '# proj',
'tests/fixtures/demo/.claude/settings.json': JSON.stringify({ model: 'opus' }),
},
});
assert.ok(
hasGap(result, /model config/i),
`fixture settings.json must not mask the model gap; got: ${result.findings.map(f => f.title).join(' | ')}`,
);
});
it("the project's OWN real .claude/settings.json IS counted (not over-excluded)", async () => {
const result = await runGap({
projectFiles: {
'.claude/CLAUDE.md': '# proj',
'.claude/settings.json': JSON.stringify({ outputStyle: 'Explanatory' }),
},
});
assert.ok(
!hasGap(result, /output style/i),
`real project settings.json must satisfy outputStyle; got: ${result.findings.map(f => f.title).join(' | ')}`,
);
});
it('the real ~/.claude/settings.json IS seen for settings-key checks (gotcha fix end-to-end)', async () => {
const result = await runGap({
homeSettings: { statusLine: { type: 'command', command: 'x' } },
});
assert.ok(
!hasGap(result, /status line/i),
`~/.claude/settings.json statusLine must be discovered; got: ${result.findings.map(f => f.title).join(' | ')}`,
);
});
});