New GAP finding CA-GAP-028: authored subagents exist and not one of them names `model:` or `effort:`, so every delegated task runs on the main conversation's model (`model` defaults to `inherit`). Cites BP-MODEL-001/002, landed in C1. `whats-active` and `manifest` now carry `model`/`effort` per agent. Shipped as a conditional LEVER rather than a 25th dimension, and the choice was made by measurement: as a t3 dimension the agent-less marketplace-medium fixture would count it vacuously-present, moving the denominators 41->42 and utilization 44->45 — which flips `segment` "Developing"->"Competent" in the frozen v5.0.0 posture baseline, a field strip-retired-gap.mjs does not mask. A lever never enters those denominators. The general rule is now an invariant in CLAUDE.md. One check across both axes, not one per axis: it fires only when neither is used anywhere, so a deliberate everything-on-one-model policy stays silent. Cost is recall, chosen for precision. Found by dogfooding, fixed red-first: `model: inherit` is the documented default spelled out, so it must not count as routing — otherwise a config opts out of the opportunity without changing anything real. Two pre-existing defects surfaced and closed on the way: - The humanizer guard asserted TRANSLATIONS.GAP.static EQUALS the dimension titles, which forbade humanizing any lever — all three existing levers fell through to the generic "feature opportunity" default, wrong for a budget lever. Guard now requires coverage of every emittable title, seen red against those three before the entries were written. - Two hand-written copies of the lever list (finding-codes guard, humanizer guard) merged into one exported LEVERS registry carrying code AND title. - suppression-validation pinned CA-GAP-028 as an unoccupied number; C4 claimed it. Fixed structurally with a derived first-free id, not by picking a new literal — same class as #60's "bump this again". Suite 1596/0. Frozen v5.0.0 snapshots untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pq3nye21RVYk4pZLeT8pGz
566 lines
23 KiB
JavaScript
566 lines
23 KiB
JavaScript
import { describe, it, beforeEach } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
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';
|
|
import { scan, opportunitySummary, bundledSkillsLeverFinding, cliOverMcpLeverFinding, filterHookLeverFinding, GAP_CHECKS, LEVERS } from '../../scanners/feature-gap-scanner.mjs';
|
|
import { TITLE_TO_ID as GAP_TITLE_TO_ID, TIER_COUNTS, TOTAL_DIMENSIONS } from '../../scanners/lib/scoring.mjs';
|
|
import { TRANSLATIONS } from '../../scanners/lib/humanizer-data.mjs';
|
|
import { discoverConfigFiles } from '../../scanners/lib/file-discovery.mjs';
|
|
import { withHermeticHome } from '../helpers/hermetic-home.mjs';
|
|
|
|
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
|
const FIXTURES = resolve(__dirname, '../fixtures');
|
|
|
|
// Pre-discover fixture files WITHOUT includeGlobal so tests are environment-independent.
|
|
// The GAP scanner uses shared discovery when it has files, avoiding its own includeGlobal scan.
|
|
async function fixtureDiscovery(name) {
|
|
return discoverConfigFiles(resolve(FIXTURES, name));
|
|
}
|
|
|
|
describe('GAP scanner — healthy project', () => {
|
|
let result;
|
|
beforeEach(async () => {
|
|
const discovery = await fixtureDiscovery('healthy-project');
|
|
// Hermetic HOME: scan() now enumerates active skills via process.env.HOME
|
|
// (the disableBundledSkills lever check). An empty HOME keeps these fixture
|
|
// tests environment-independent — no skills, no budget pressure, no lever finding.
|
|
result = await withHermeticHome(() => scan(resolve(FIXTURES, 'healthy-project'), discovery));
|
|
});
|
|
|
|
it('returns status ok', () => {
|
|
assert.equal(result.status, 'ok');
|
|
});
|
|
|
|
it('reports scanner prefix GAP', () => {
|
|
assert.equal(result.scanner, 'GAP');
|
|
});
|
|
|
|
it('scans multiple files', () => {
|
|
assert.ok(result.files_scanned >= 1);
|
|
});
|
|
|
|
it('finding IDs match CA-GAP-NNN pattern', () => {
|
|
for (const f of result.findings) {
|
|
assert.match(f.id, /^CA-GAP-\d{3}$/);
|
|
}
|
|
});
|
|
|
|
it('does NOT report missing CLAUDE.md', () => {
|
|
assert.ok(!result.findings.some(f =>
|
|
f.scanner === 'GAP' && f.category === 't1' && /CLAUDE\.md/.test(f.recommendation || '')
|
|
));
|
|
});
|
|
|
|
it('does NOT report missing MCP', () => {
|
|
assert.ok(!result.findings.some(f =>
|
|
f.scanner === 'GAP' && f.category === 't1' && /\.mcp\.json/.test(f.recommendation || '')
|
|
));
|
|
});
|
|
|
|
it('does NOT report missing hooks', () => {
|
|
assert.ok(!result.findings.some(f =>
|
|
f.scanner === 'GAP' && f.category === 't1' && /hook/i.test(f.recommendation || '')
|
|
));
|
|
});
|
|
|
|
it('has counts object with all severity levels', () => {
|
|
assert.ok('critical' in result.counts);
|
|
assert.ok('high' in result.counts);
|
|
assert.ok('medium' in result.counts);
|
|
assert.ok('low' in result.counts);
|
|
assert.ok('info' in result.counts);
|
|
});
|
|
|
|
it('has no critical or high findings', () => {
|
|
assert.equal(result.counts.critical, 0);
|
|
assert.equal(result.counts.high, 0);
|
|
});
|
|
|
|
it('all findings have recommendations', () => {
|
|
for (const f of result.findings) {
|
|
assert.ok(f.recommendation, `Finding ${f.id} missing recommendation`);
|
|
}
|
|
});
|
|
|
|
it('T3/T4 findings are info severity', () => {
|
|
const infoFindings = result.findings.filter(f => f.category === 't3' || f.category === 't4');
|
|
for (const f of infoFindings) {
|
|
assert.equal(f.severity, 'info', `${f.id} (${f.category}) should be info, got ${f.severity}`);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('GAP scanner — minimal project', () => {
|
|
let result;
|
|
beforeEach(async () => {
|
|
const discovery = await fixtureDiscovery('minimal-project');
|
|
result = await withHermeticHome(() => scan(resolve(FIXTURES, 'minimal-project'), discovery));
|
|
});
|
|
|
|
it('returns status ok', () => {
|
|
assert.equal(result.status, 'ok');
|
|
});
|
|
|
|
it('reports missing hooks', () => {
|
|
// CA-GAP-002 in minimal-project = t1_3 (No hooks configured); see docs/v5.1.0-test-audit.md.
|
|
assert.ok(result.findings.some(f => f.scanner === 'GAP' && f.id === 'CA-GAP-002'));
|
|
});
|
|
|
|
it('reports missing MCP', () => {
|
|
// CA-GAP-004 in minimal-project = t1_5 (No MCP servers configured).
|
|
assert.ok(result.findings.some(f => f.scanner === 'GAP' && f.id === 'CA-GAP-004'));
|
|
});
|
|
|
|
it('T1 gaps are medium severity', () => {
|
|
const t1 = result.findings.filter(f => f.category === 't1');
|
|
for (const f of t1) {
|
|
assert.equal(f.severity, 'medium', `${f.id} should be medium, got ${f.severity}`);
|
|
}
|
|
});
|
|
|
|
it('T2 gaps are low severity', () => {
|
|
const t2 = result.findings.filter(f => f.category === 't2');
|
|
for (const f of t2) {
|
|
assert.equal(f.severity, 'low', `${f.id} should be low, got ${f.severity}`);
|
|
}
|
|
});
|
|
|
|
it('has more findings than healthy project', async () => {
|
|
const discovery = await fixtureDiscovery('healthy-project');
|
|
const healthyResult = await withHermeticHome(() => scan(resolve(FIXTURES, 'healthy-project'), discovery));
|
|
assert.ok(result.findings.length > healthyResult.findings.length);
|
|
});
|
|
});
|
|
|
|
describe('GAP scanner — empty project', () => {
|
|
let result;
|
|
beforeEach(async () => {
|
|
const discovery = await fixtureDiscovery('empty-project');
|
|
result = await withHermeticHome(() => scan(resolve(FIXTURES, 'empty-project'), discovery));
|
|
});
|
|
|
|
it('returns status ok (never skips)', () => {
|
|
assert.equal(result.status, 'ok');
|
|
});
|
|
|
|
it('has multiple medium findings (T1 gaps)', () => {
|
|
const mediums = result.findings.filter(f => f.severity === 'medium');
|
|
assert.ok(mediums.length >= 1);
|
|
});
|
|
|
|
it('all findings have category field', () => {
|
|
for (const f of result.findings) {
|
|
assert.ok(f.category, `Finding ${f.id} missing category`);
|
|
assert.match(f.category, /^t[1-4]$/);
|
|
}
|
|
});
|
|
|
|
it('reports T1 gaps including missing CLAUDE.md', () => {
|
|
// CA-GAP-001 in empty-project = t1_1 (No CLAUDE.md file).
|
|
assert.ok(result.findings.some(f => f.scanner === 'GAP' && f.id === 'CA-GAP-001'));
|
|
});
|
|
});
|
|
|
|
describe('opportunitySummary', () => {
|
|
it('returns empty arrays for no findings', () => {
|
|
const result = opportunitySummary([]);
|
|
assert.deepEqual(result.highImpact, []);
|
|
assert.deepEqual(result.mediumImpact, []);
|
|
assert.deepEqual(result.explore, []);
|
|
});
|
|
|
|
it('routes T1 to highImpact', () => {
|
|
const findings = [{ category: 't1', title: 'No CLAUDE.md' }];
|
|
const result = opportunitySummary(findings);
|
|
assert.equal(result.highImpact.length, 1);
|
|
assert.equal(result.mediumImpact.length, 0);
|
|
assert.equal(result.explore.length, 0);
|
|
});
|
|
|
|
it('routes T2 to mediumImpact', () => {
|
|
const findings = [{ category: 't2', title: 'Low hook diversity' }];
|
|
const result = opportunitySummary(findings);
|
|
assert.equal(result.highImpact.length, 0);
|
|
assert.equal(result.mediumImpact.length, 1);
|
|
});
|
|
|
|
it('routes T3 and T4 to explore', () => {
|
|
const findings = [
|
|
{ category: 't3', title: 'No status line' },
|
|
{ category: 't4', title: 'No custom plugin' },
|
|
];
|
|
const result = opportunitySummary(findings);
|
|
assert.equal(result.explore.length, 2);
|
|
});
|
|
|
|
it('handles mixed tiers', () => {
|
|
const findings = [
|
|
{ category: 't1', title: 'A' },
|
|
{ category: 't2', title: 'B' },
|
|
{ category: 't2', title: 'C' },
|
|
{ category: 't3', title: 'D' },
|
|
{ category: 't4', title: 'E' },
|
|
];
|
|
const result = opportunitySummary(findings);
|
|
assert.equal(result.highImpact.length, 1);
|
|
assert.equal(result.mediumImpact.length, 2);
|
|
assert.equal(result.explore.length, 2);
|
|
});
|
|
});
|
|
|
|
// ── disableBundledSkills lever (CA-GAP) — remediation companion to SKL CA-SKL-002 ──
|
|
|
|
describe('bundledSkillsLeverFinding — pure decision', () => {
|
|
const overBudget = { scanned: 17, aggregateChars: 17000, aggregateTokens: 4250, budgetTokens: 4000, overBudget: true, overBy: 250 };
|
|
const underBudget = { scanned: 3, aggregateChars: 3000, aggregateTokens: 750, budgetTokens: 4000, overBudget: false, overBy: 0 };
|
|
|
|
it('returns null when the lever is already pulled, even over budget', () => {
|
|
assert.equal(bundledSkillsLeverFinding({ leverPulled: true, aggregate: overBudget }), null);
|
|
});
|
|
|
|
it('returns null when the listing is under budget', () => {
|
|
assert.equal(bundledSkillsLeverFinding({ leverPulled: false, aggregate: underBudget }), null);
|
|
});
|
|
|
|
it('returns a finding when the lever is un-pulled AND the listing is over budget', () => {
|
|
const f = bundledSkillsLeverFinding({ leverPulled: false, aggregate: overBudget });
|
|
assert.ok(f, 'expected a finding');
|
|
assert.match(f.id, /^CA-GAP-\d{3}$/);
|
|
assert.equal(f.scanner, 'GAP');
|
|
assert.equal(f.severity, 'low');
|
|
assert.equal(f.category, 'token-efficiency');
|
|
assert.match(f.recommendation, /disableBundledSkills/);
|
|
assert.match(`${f.description} ${f.recommendation}`, /2\.1\.169/);
|
|
});
|
|
|
|
it('handles a null/garbage aggregate without throwing', () => {
|
|
assert.equal(bundledSkillsLeverFinding({ leverPulled: false, aggregate: null }), null);
|
|
});
|
|
});
|
|
|
|
describe('GAP scanner — disableBundledSkills lever wiring (HOME-scoped)', () => {
|
|
async function buildHome(count, descLen, settings) {
|
|
const home = await mkdtemp(join(tmpdir(), 'config-audit-gap-home-'));
|
|
for (let i = 0; i < count; i++) {
|
|
const dir = join(home, '.claude', 'skills', `s${i}`);
|
|
await mkdir(dir, { recursive: true });
|
|
await writeFile(join(dir, 'SKILL.md'), `---\nname: s${i}\ndescription: ${'a'.repeat(descLen)}\n---\nBody.\n`);
|
|
}
|
|
if (settings) {
|
|
await mkdir(join(home, '.claude'), { recursive: true });
|
|
await writeFile(join(home, '.claude', 'settings.json'), JSON.stringify(settings));
|
|
}
|
|
return home;
|
|
}
|
|
|
|
/** Run the GAP scanner with HOME pointed at `home`; an empty throwaway project. */
|
|
async function runGapWithHome(home) {
|
|
const project = await mkdtemp(join(tmpdir(), 'config-audit-gap-proj-'));
|
|
const original = process.env.HOME;
|
|
process.env.HOME = home;
|
|
try {
|
|
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 });
|
|
}
|
|
}
|
|
|
|
const hasLever = (result) =>
|
|
result.findings.some(f => f.scanner === 'GAP' && /disableBundledSkills/.test(f.recommendation || ''));
|
|
|
|
it('fires when the listing is over budget and the lever is un-pulled', async () => {
|
|
const home = await buildHome(17, 1000); // 17000 chars -> 4250 tok > 4000 budget
|
|
try {
|
|
const result = await runGapWithHome(home);
|
|
const f = result.findings.find(x => x.scanner === 'GAP' && /disableBundledSkills/.test(x.recommendation || ''));
|
|
assert.ok(f, `expected a disableBundledSkills finding; got: ${result.findings.map(x => x.title).join(' | ')}`);
|
|
assert.equal(f.severity, 'low');
|
|
assert.equal(f.category, 'token-efficiency');
|
|
} finally {
|
|
await rm(home, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('does NOT fire when the listing is under budget', async () => {
|
|
const home = await buildHome(3, 1000); // 3000 chars -> 750 tok < 4000
|
|
try {
|
|
const result = await runGapWithHome(home);
|
|
assert.equal(hasLever(result), false);
|
|
} finally {
|
|
await rm(home, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('does NOT fire when disableBundledSkills is already true in settings', async () => {
|
|
const home = await buildHome(17, 1000, { disableBundledSkills: true });
|
|
try {
|
|
const result = await runGapWithHome(home);
|
|
assert.equal(hasLever(result), false,
|
|
'lever already pulled in settings — should not recommend it again');
|
|
} finally {
|
|
await rm(home, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('does NOT fire when the CLAUDE_CODE_DISABLE_BUNDLED_SKILLS env var is set', async () => {
|
|
const home = await buildHome(17, 1000);
|
|
const originalEnv = process.env.CLAUDE_CODE_DISABLE_BUNDLED_SKILLS;
|
|
process.env.CLAUDE_CODE_DISABLE_BUNDLED_SKILLS = '1';
|
|
try {
|
|
const result = await runGapWithHome(home);
|
|
assert.equal(hasLever(result), false, 'env lever should suppress the recommendation');
|
|
} finally {
|
|
if (originalEnv === undefined) delete process.env.CLAUDE_CODE_DISABLE_BUNDLED_SKILLS;
|
|
else process.env.CLAUDE_CODE_DISABLE_BUNDLED_SKILLS = originalEnv;
|
|
await rm(home, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('treats CLAUDE_CODE_DISABLE_BUNDLED_SKILLS=0 as un-pulled (still fires)', async () => {
|
|
const home = await buildHome(17, 1000);
|
|
const originalEnv = process.env.CLAUDE_CODE_DISABLE_BUNDLED_SKILLS;
|
|
process.env.CLAUDE_CODE_DISABLE_BUNDLED_SKILLS = '0';
|
|
try {
|
|
const result = await runGapWithHome(home);
|
|
assert.equal(hasLever(result), true, '"0" means off — the lever is NOT pulled, so the finding should fire');
|
|
} finally {
|
|
if (originalEnv === undefined) delete process.env.CLAUDE_CODE_DISABLE_BUNDLED_SKILLS;
|
|
else process.env.CLAUDE_CODE_DISABLE_BUNDLED_SKILLS = originalEnv;
|
|
await rm(home, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('cliOverMcpLeverFinding (CLI-over-MCP lever, v5.10 B4)', () => {
|
|
it('returns null when nothing is forced upfront', () => {
|
|
assert.equal(cliOverMcpLeverFinding({ assessment: { forcedUpfront: false } }), null);
|
|
assert.equal(cliOverMcpLeverFinding({ assessment: null }), null);
|
|
assert.equal(cliOverMcpLeverFinding({}), null);
|
|
});
|
|
|
|
it('fires a low-severity opportunity when MCP schemas are forced upfront', () => {
|
|
const f = cliOverMcpLeverFinding({
|
|
assessment: {
|
|
forcedUpfront: true,
|
|
aggregateTokens: 2500,
|
|
affectedServers: [{ name: 'srv-a' }],
|
|
reason: 'enable-tool-search-false',
|
|
},
|
|
});
|
|
assert.ok(f, 'expected a lever finding');
|
|
assert.equal(f.severity, 'low');
|
|
assert.equal(f.category, 'token-efficiency');
|
|
assert.match(f.recommendation || '', /\bgh\b|\baws\b|\bgcloud\b/);
|
|
});
|
|
});
|
|
|
|
describe('filterHookLeverFinding (filter-before-Claude-reads lever, v5.10 B5)', () => {
|
|
it('returns null when no chatty hook was detected', () => {
|
|
assert.equal(filterHookLeverFinding({ flaggedHooks: [] }), null);
|
|
assert.equal(filterHookLeverFinding({}), null);
|
|
assert.equal(filterHookLeverFinding(), null);
|
|
});
|
|
|
|
it('fires an info opportunity when ≥1 chatty hook is detected', () => {
|
|
const f = filterHookLeverFinding({
|
|
flaggedHooks: [{ event: 'SessionStart', scriptPath: '/x/hooks/scripts/chatty.sh' }],
|
|
});
|
|
assert.ok(f, 'expected a lever finding');
|
|
assert.equal(f.severity, 'info');
|
|
assert.equal(f.category, 'token-efficiency');
|
|
assert.match(f.description || '', /filter-test-output\.sh|filter/i);
|
|
assert.match(f.evidence || '', /chatty_hooks=1/);
|
|
});
|
|
});
|
|
|
|
describe('GAP scanner — filter-before lever wiring (chatty hook fixture)', () => {
|
|
it('emits the filter-before lever when scanning a repo with a chatty hook', async () => {
|
|
const discovery = await fixtureDiscovery('hooks-additional-context');
|
|
const result = await withHermeticHome(() =>
|
|
scan(resolve(FIXTURES, 'hooks-additional-context'), discovery),
|
|
);
|
|
const lever = result.findings.find(
|
|
f => f.scanner === 'GAP' && /chatty_hooks=/.test(f.evidence || ''),
|
|
);
|
|
assert.ok(lever, `expected filter-before lever; got: ${result.findings.map(x => x.title).join(' | ')}`);
|
|
assert.equal(lever.severity, 'info');
|
|
});
|
|
|
|
it('does NOT emit the lever for a repo with only quiet hooks', async () => {
|
|
const discovery = await fixtureDiscovery('hooks-quiet');
|
|
const result = await withHermeticHome(() =>
|
|
scan(resolve(FIXTURES, 'hooks-quiet'), discovery),
|
|
);
|
|
const lever = result.findings.find(
|
|
f => f.scanner === 'GAP' && /chatty_hooks=/.test(f.evidence || ''),
|
|
);
|
|
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 {
|
|
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(' | ')}`,
|
|
);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* D1 — retired GAP dimensions.
|
|
*
|
|
* The binding /doctor positioning (README «config-audit vs. the built-in
|
|
* /doctor») forbids carrying a dimension whose whole value is duplicating a
|
|
* /doctor check. `No autoMode classifier` was retired when CC 2.1.226's
|
|
* /doctor Check 8 was measured to cover auto mode with usage-weighted
|
|
* judgement (re-measured 2026-08-09; the deterministic SET `autoMode`
|
|
* structure/dead-config validation is NOT a duplicate and stays).
|
|
*
|
|
* The title lived in THREE tables, not two: the dimension list, the scoring
|
|
* title→id map, and the humanizer's static translations. `findGapId` falls
|
|
* back to 'unknown' silently, so a partial removal degrades without failing —
|
|
* hence the blanket sync invariant below rather than three separate absence
|
|
* checks.
|
|
*/
|
|
describe('GAP scanner — retired dimensions (D1)', () => {
|
|
const RETIRED_TITLES = ['No autoMode classifier'];
|
|
|
|
it('emits no retired dimension on a fixture that lacks the feature', async () => {
|
|
const discovery = await fixtureDiscovery('healthy-project');
|
|
const result = await withHermeticHome(
|
|
() => scan(resolve(FIXTURES, 'healthy-project'), discovery),
|
|
);
|
|
const titles = result.findings.map(f => f.title);
|
|
for (const t of RETIRED_TITLES) {
|
|
assert.ok(!titles.includes(t), `retired dimension still emitted: ${t}`);
|
|
}
|
|
});
|
|
|
|
it('leaves no orphaned entry in any of the three tables', () => {
|
|
for (const t of RETIRED_TITLES) {
|
|
assert.ok(!GAP_CHECKS.some(g => g.title === t), `still in GAP_CHECKS: ${t}`);
|
|
assert.ok(!(t in GAP_TITLE_TO_ID), `orphan in scoring TITLE_TO_ID: ${t}`);
|
|
assert.ok(!(t in TRANSLATIONS.GAP.static), `orphan in humanizer TRANSLATIONS.GAP: ${t}`);
|
|
}
|
|
});
|
|
|
|
it('keeps the scoring title table carrying exactly the dimension titles', () => {
|
|
const checks = GAP_CHECKS.map(g => g.title).sort();
|
|
const scoring = Object.keys(GAP_TITLE_TO_ID).sort();
|
|
assert.deepEqual(scoring, checks, 'scoring TITLE_TO_ID drifted from GAP_CHECKS');
|
|
});
|
|
|
|
// The humanizer's table is NOT the dimension table: levers are findings too,
|
|
// and a title with no static entry falls through to the generic GAP _default
|
|
// ("You have a feature opportunity worth a look") — wrong for a budget lever.
|
|
// The invariant is therefore coverage of EVERY title GAP can emit, asserted
|
|
// blanket rather than as a relation between the two dimension tables.
|
|
it('humanizes every title the scanner can emit — dimensions AND levers', () => {
|
|
const emittable = [...GAP_CHECKS.map(g => g.title), ...Object.values(LEVERS).map(l => l.title)].sort();
|
|
const humanizer = Object.keys(TRANSLATIONS.GAP.static).sort();
|
|
assert.deepEqual(humanizer, emittable, 'humanizer TRANSLATIONS.GAP drifted from the emittable titles');
|
|
});
|
|
|
|
// The scoring denominators are a FOURTH copy of the dimension inventory, and
|
|
// the one that silently moves every user's utilization score: the score is
|
|
// presentWeight / MAX_WEIGHTED, derived from TIER_COUNTS. Adding or retiring a
|
|
// dimension without updating these skews the score instead of failing.
|
|
it('keeps the scoring denominators in step with the dimension inventory', () => {
|
|
const perTier = { t1: 0, t2: 0, t3: 0, t4: 0 };
|
|
for (const g of GAP_CHECKS) perTier[g.tier]++;
|
|
assert.deepEqual(TIER_COUNTS, perTier, 'TIER_COUNTS drifted from GAP_CHECKS tiers');
|
|
assert.equal(TOTAL_DIMENSIONS, GAP_CHECKS.length,
|
|
'TOTAL_DIMENSIONS drifted from GAP_CHECKS length');
|
|
});
|
|
});
|