config-audit/scanners/feature-gap-scanner.mjs
Kjell Tore Guttormsen f4bf3ae2cb 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
2026-06-30 10:09:10 +02:00

629 lines
27 KiB
JavaScript

/**
* GAP Scanner — Feature Gap Scanner
* Compares actual configuration against complete Claude Code feature register.
* 25 gap dimensions across 4 tiers, plus a conditional disableBundledSkills
* budget-lever check (remediation companion to SKL CA-SKL-002, fires only under
* measured skill-listing pressure). Always runs with includeGlobal: true.
* Finding IDs: CA-GAP-NNN
*/
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';
import { findImports, parseJson, parseFrontmatter } from './lib/yaml-parser.mjs';
import { measureActiveSkillListing, isBundledSkillsDisabled, BUDGET_CALIBRATION_NOTE } from './lib/skill-listing-budget.mjs';
import { assessMcpDeferralForRepo } from './lib/mcp-deferral.mjs';
import { assessHookContextForRepo } from './lib/hook-additional-context.mjs';
const SCANNER = 'GAP';
/**
* @typedef {object} GapCheck
* @property {string} id - Short identifier (t1_1 through t4_5)
* @property {string} tier - t1|t2|t3|t4
* @property {string} title - Human-readable title
* @property {string} recommendation - What to do
* @property {(ctx: CheckContext) => Promise<boolean>} check - Returns true if feature IS present
*/
/**
* @typedef {object} CheckContext
* @property {import('./lib/file-discovery.mjs').ConfigFile[]} files
* @property {string} targetPath
* @property {Map<string, object>} parsedSettings - scope → parsed JSON
* @property {Map<string, string>} fileContents - absPath → content
*/
/**
* Check if a file belongs to the target project (vs global ~/.claude/).
* Needed because scope classification can be 'plugin' when running inside ~/.claude/plugins/.
* @param {CheckContext} ctx
* @param {import('./lib/file-discovery.mjs').ConfigFile} f
* @returns {boolean}
*/
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,
t3: SEVERITY.info,
t4: SEVERITY.info,
};
/**
* Lazily read and cache file content.
* @param {CheckContext} ctx
* @param {string} absPath
* @returns {Promise<string|null>}
*/
async function getContent(ctx, absPath) {
if (ctx.fileContents.has(absPath)) return ctx.fileContents.get(absPath);
const content = await readTextFile(absPath);
ctx.fileContents.set(absPath, content);
return content;
}
/**
* Check if any settings file has a specific key.
* @param {CheckContext} ctx
* @param {string} key
* @returns {boolean}
*/
function anySettingsHas(ctx, key) {
for (const parsed of ctx.parsedSettings.values()) {
if (parsed && key in parsed) return true;
}
return false;
}
/**
* Get a value from any settings file (first match).
* @param {CheckContext} ctx
* @param {string} key
* @returns {*}
*/
function getSettingsValue(ctx, key) {
for (const parsed of ctx.parsedSettings.values()) {
if (parsed && key in parsed) return parsed[key];
}
return undefined;
}
/**
* Remediation companion to SKL CA-SKL-002: when the active skill listing is over
* its budget and the `disableBundledSkills` lever is un-pulled, recommend it.
*
* Bundled (built-in) skills — /code-review, /batch, /debug, /loop, /claude-api
* and more — live in the Claude Code binary, not on disk, so their exact listing
* cost cannot be measured here. But they draw on the SAME budget the SKL scanner
* measures; when that budget is already exceeded, dropping them is a zero-cost
* lever that does not touch the user's own skills. We fire ONLY under measured
* pressure (SKL's overflow signal) so this stays an opportunity, not noise.
*
* Pure and exported for unit testing.
*
* @param {{ leverPulled: boolean, aggregate: (import('./lib/skill-listing-budget.mjs').BudgetAssessment|null) }} args
* @returns {object|null} a GAP finding, or null when the lever is pulled or the listing is within budget
*/
export function bundledSkillsLeverFinding({ leverPulled, aggregate }) {
if (leverPulled) return null;
if (!aggregate || !aggregate.overBudget) return null;
return finding({
scanner: SCANNER,
severity: SEVERITY.low,
title: 'Bundled skills add to an over-budget skill listing',
description:
`Your ${aggregate.scanned} active skills already carry ~${aggregate.aggregateTokens} tokens of ` +
`description text, over the ${aggregate.budgetTokens}-token listing budget Claude Code allots the ` +
'skill listing on a 200k context window (~2% of context, CC 2.1.32). Claude Code also loads its ' +
'bundled (built-in) skills — /code-review, /batch, /debug, /loop, /claude-api and more — into that ' +
'same listing. They are not on disk, so their exact cost cannot be measured here, but they draw on ' +
'the same budget. `disableBundledSkills: true` drops them from the listing, reclaiming space without ' +
'touching your own skills.',
evidence:
`description_tokens~${aggregate.aggregateTokens}; budget@200k=${aggregate.budgetTokens} tok; over_by~` +
`${aggregate.overBy} tok; lever=disableBundledSkills (unset) - ${BUDGET_CALIBRATION_NOTE}`,
recommendation:
'Set `disableBundledSkills: true` in settings.json (or the CLAUDE_CODE_DISABLE_BUNDLED_SKILLS env var) ' +
'to hide built-in skills and slash commands from the model and reclaim skill-listing budget (CC 2.1.169+). ' +
'Keep it off if you rely on bundled skills like /code-review — in that case trim your own skill ' +
'descriptions or use `skillOverrides` instead.',
category: 'token-efficiency',
});
}
/**
* CLI-over-MCP lever — remediation companion to CA-TOK-006 (v5.10 B4).
*
* Fires ONLY when MCP tool schemas are forced into the always-loaded prefix
* (tool search disabled, or a per-server alwaysLoad), i.e. when MCP is actually
* costing always-loaded tokens. When schemas are deferred (the default), MCP is
* effectively free until used, so there is nothing to recommend and we stay
* silent — opportunity, not noise (mirrors the bundledSkills lever's "fire only
* under measured pressure" contract). CLI tools (gh / aws / gcloud) add zero
* context tokens until invoked, so they are the lever the deferral mechanism
* cannot reach for the forced-upfront servers.
*
* Pure and exported for unit testing.
*
* @param {{ assessment: (import('./lib/mcp-deferral.mjs').assessMcpDeferral)|null }} args
* @returns {object|null} a GAP finding, or null when nothing is forced upfront
*/
export function cliOverMcpLeverFinding({ assessment } = {}) {
if (!assessment || !assessment.forcedUpfront) return null;
const names = (assessment.affectedServers || []).map((m) => m.name).join(', ');
return finding({
scanner: SCANNER,
severity: SEVERITY.low,
title: 'Prefer CLI over MCP for common operations',
description:
`Your active project MCP tool schemas (~${assessment.aggregateTokens} tokens) are forced into the ` +
'always-loaded prefix every turn rather than deferred (see CA-TOK-006). CLI tools (gh, aws, gcloud, …) ' +
'add ZERO context tokens until you actually call them, so moving common operations off MCP and onto a ' +
'CLI reclaims always-loaded budget the deferral mechanism cannot.',
evidence:
`forced_schema_tokens~${assessment.aggregateTokens}; servers=${names}; ` +
`reason=${assessment.reason || 'alwaysLoad'} (companion to CA-TOK-006)`,
recommendation:
'For operations a CLI already covers (GitHub → gh, AWS → aws, GCP → gcloud), prefer the CLI over an ' +
'MCP server — CLI output enters context only when invoked. Keep MCP for capabilities with no CLI ' +
'equivalent, disable unused servers via /mcp, and re-enable tool-search deferral so the rest stay names-only.',
category: 'token-efficiency',
});
}
/**
* filter-before-Claude-reads lever — remediation companion to HKV's B5 advisory
* (v5.10). Fires ONLY when ≥1 active hook was detected injecting unfiltered
* command output into additionalContext, i.e. when there is a measured chatty
* hook to fix. When no such hook exists there is nothing to recommend and we
* stay silent — opportunity, not noise (same "fire only under measured pressure"
* contract as the cliOverMcp / bundledSkills levers).
*
* Pure and exported for unit testing.
*
* @param {{ flaggedHooks: Array<{event:string, scriptPath:string}> }} args
* @returns {object|null} a GAP finding, or null when no chatty hook was detected
*/
export function filterHookLeverFinding({ flaggedHooks } = {}) {
const hooks = Array.isArray(flaggedHooks) ? flaggedHooks : [];
if (hooks.length === 0) return null;
const scripts = hooks.map((h) => h.scriptPath.split('/').slice(-1)[0]).join(', ');
return finding({
scanner: SCANNER,
severity: SEVERITY.info,
title: 'Filter hook output before it enters context',
description:
`${hooks.length} active hook${hooks.length === 1 ? '' : 's'} build hookSpecificOutput.additionalContext ` +
"from un-grepped command output (see HKV advisory). That field enters Claude's context on every fire, " +
'so filtering verbose output down to what matters BEFORE Claude reads it reclaims per-turn tokens — the ' +
'documented filter-test-output.sh pattern (grep ERROR and return only matches instead of a 10,000-line log).',
evidence: `chatty_hooks=${hooks.length}; scripts=${scripts} (companion to HKV additionalContext advisory)`,
recommendation:
'In each flagged hook, pipe the command output through grep/head/jq to keep only the actionable lines ' +
'before assigning additionalContext. Reserve additionalContext for concise signals; leave bulk diagnostics ' +
'on plain stdout (exit 0) so they go to the debug log, not context.',
category: 'token-efficiency',
});
}
/** @type {GapCheck[]} */
const GAP_CHECKS = [
// --- Tier 1: Foundation ---
{
id: 't1_1', tier: 't1',
title: 'No CLAUDE.md file',
recommendation: 'Create a CLAUDE.md at the project root with project-specific instructions, commands, and architecture.',
check: async (ctx) => ctx.files.some(f => f.type === 'claude-md' && isTargetLocal(ctx, f)),
},
{
id: 't1_2', tier: 't1',
title: 'No permissions configured',
recommendation: 'Add permissions.allow and permissions.deny in .claude/settings.json to control tool access.',
check: async (ctx) => {
for (const parsed of ctx.parsedSettings.values()) {
if (parsed?.permissions && (parsed.permissions.allow?.length > 0 || parsed.permissions.deny?.length > 0)) {
return true;
}
}
return false;
},
},
{
id: 't1_3', tier: 't1',
title: 'No hooks configured',
recommendation: 'Add at least one hook (e.g., PreToolUse for security, Stop for session summaries). See knowledge/hook-events-reference.md.',
check: async (ctx) => {
if (ctx.files.some(f => f.type === 'hooks-json')) return true;
for (const parsed of ctx.parsedSettings.values()) {
if (parsed?.hooks && typeof parsed.hooks === 'object' && !Array.isArray(parsed.hooks)) {
return Object.keys(parsed.hooks).length > 0;
}
}
return false;
},
},
{
id: 't1_4', tier: 't1',
title: 'No custom skills or commands',
recommendation: 'Create project-specific skills in .claude/skills/ or commands in .claude/commands/ to automate repetitive workflows.',
check: async (ctx) => ctx.files.some(f => f.type === 'skill-md' || f.type === 'command-md'),
},
{
id: 't1_5', tier: 't1',
title: 'No MCP servers configured',
recommendation: 'Add a .mcp.json at the project root to configure MCP servers for enhanced tool access.',
check: async (ctx) => ctx.files.some(f => f.type === 'mcp-json'),
},
// --- Tier 2: Configuration Depth ---
{
id: 't2_1', tier: 't2',
title: 'Settings only at one scope',
recommendation: 'Use all 3 settings scopes: ~/.claude/settings.json (user), .claude/settings.json (project), .claude/settings.local.json (local/personal).',
check: async (ctx) => {
const localSettings = ctx.files.filter(f => f.type === 'settings-json' && isTargetLocal(ctx, f));
const hasGlobal = ctx.files.some(f => f.type === 'settings-json' && !isTargetLocal(ctx, f));
return (localSettings.length >= 2) || (localSettings.length >= 1 && hasGlobal);
},
},
{
id: 't2_2', tier: 't2',
title: 'CLAUDE.md not modular',
recommendation: 'Use @imports or .claude/rules/ to split large CLAUDE.md files into focused modules.',
check: async (ctx) => {
// Has rules files OR has @imports in any CLAUDE.md
if (ctx.files.some(f => f.type === 'rule')) return true;
for (const file of ctx.files.filter(f => f.type === 'claude-md')) {
const content = await getContent(ctx, file.absPath);
if (content && findImports(content).length > 0) return true;
}
return false;
},
},
{
id: 't2_3', tier: 't2',
title: 'No path-scoped rules',
recommendation: 'Create .claude/rules/*.md with paths: frontmatter to apply rules only to matching files.',
check: async (ctx) => {
for (const file of ctx.files.filter(f => f.type === 'rule')) {
const content = await getContent(ctx, file.absPath);
if (content) {
const { frontmatter } = parseFrontmatter(content);
if (frontmatter && (frontmatter.paths || frontmatter.globs)) return true;
}
}
return false;
},
},
{
id: 't2_4', tier: 't2',
title: 'Auto-memory explicitly disabled',
recommendation: 'Enable auto-memory by removing autoMemoryEnabled: false from settings.',
check: async (ctx) => {
// Present (gap) only if explicitly disabled
const val = getSettingsValue(ctx, 'autoMemoryEnabled');
return val !== false;
},
},
{
id: 't2_5', tier: 't2',
title: 'Low hook diversity',
recommendation: 'Use hooks across 3+ events (e.g., SessionStart, PreToolUse, Stop) for comprehensive automation.',
check: async (ctx) => {
const events = new Set();
for (const parsed of ctx.parsedSettings.values()) {
if (parsed?.hooks && typeof parsed.hooks === 'object' && !Array.isArray(parsed.hooks)) {
for (const event of Object.keys(parsed.hooks)) events.add(event);
}
}
for (const file of ctx.files.filter(f => f.type === 'hooks-json')) {
const content = await getContent(ctx, file.absPath);
if (content) {
const parsed = parseJson(content);
const hookData = parsed?.hooks || parsed;
if (hookData && typeof hookData === 'object' && !Array.isArray(hookData)) {
for (const event of Object.keys(hookData)) events.add(event);
}
}
}
return events.size >= 3;
},
},
{
id: 't2_6', tier: 't2',
title: 'No custom subagents',
recommendation: 'Create custom agents in .claude/agents/ or ~/.claude/agents/ with specialized tools and model selection.',
check: async (ctx) => ctx.files.some(f => f.type === 'agent-md'),
},
{
id: 't2_7', tier: 't2',
title: 'No model configuration',
recommendation: 'Set model preferences in settings.json (model, modelOverrides) for cost/quality optimization.',
check: async (ctx) => anySettingsHas(ctx, 'model') || anySettingsHas(ctx, 'modelOverrides'),
},
// --- Tier 3: Advanced Features ---
{
id: 't3_1', tier: 't3',
title: 'No status line configured',
recommendation: 'Configure statusLine in settings.json to show context window usage, cost, and model info.',
check: async (ctx) => anySettingsHas(ctx, 'statusLine'),
},
{
id: 't3_2', tier: 't3',
title: 'No custom keybindings',
recommendation: 'Create ~/.claude/keybindings.json to customize keyboard shortcuts (e.g., bind chat:newline to Shift+Enter).',
check: async (ctx) => ctx.files.some(f => f.type === 'keybindings-json'),
},
{
id: 't3_3', tier: 't3',
title: 'Using default output style',
recommendation: 'Try "Explanatory" or "Learning" output styles, or create custom styles in .claude/output-styles/.',
check: async (ctx) => anySettingsHas(ctx, 'outputStyle'),
},
{
id: 't3_4', tier: 't3',
title: 'No worktree workflow',
recommendation: 'Use --worktree for parallel feature development. Configure worktree.symlinkDirectories for node_modules.',
check: async (ctx) => anySettingsHas(ctx, 'worktree'),
},
{
id: 't3_5', tier: 't3',
title: 'No advanced skill frontmatter',
recommendation: 'Use disable-model-invocation, context:fork, or argument-hint in skill frontmatter for better control.',
check: async (ctx) => {
for (const file of ctx.files.filter(f => f.type === 'skill-md')) {
const content = await getContent(ctx, file.absPath);
if (content) {
const { frontmatter } = parseFrontmatter(content);
if (frontmatter && (
frontmatter.disable_model_invocation ||
frontmatter.context === 'fork' ||
frontmatter.argument_hint
)) return true;
}
}
return false;
},
},
{
id: 't3_6', tier: 't3',
title: 'No subagent isolation',
recommendation: 'Use isolation: worktree in agent frontmatter for safe parallel development.',
check: async (ctx) => {
for (const file of ctx.files.filter(f => f.type === 'agent-md')) {
const content = await getContent(ctx, file.absPath);
if (content) {
const { frontmatter } = parseFrontmatter(content);
if (frontmatter && frontmatter.isolation === 'worktree') return true;
}
}
return false;
},
},
{
id: 't3_7', tier: 't3',
title: 'No dynamic skill context',
recommendation: 'Use !`command` syntax in skills to inject dynamic context (e.g., !`git branch --show-current`).',
check: async (ctx) => {
for (const file of ctx.files.filter(f => f.type === 'skill-md' || f.type === 'command-md')) {
const content = await getContent(ctx, file.absPath);
if (content && /!`[^`]+`/.test(content)) return true;
}
return false;
},
},
{
id: 't3_8', tier: 't3',
title: 'No autoMode classifier',
recommendation: 'Configure autoMode in user/local settings with environment context and allow/deny rules.',
check: async (ctx) => anySettingsHas(ctx, 'autoMode'),
},
// --- Tier 4: Team/Enterprise ---
{
id: 't4_1', tier: 't4',
title: 'No project .mcp.json in git',
recommendation: 'Add .mcp.json to git so the team shares MCP server configuration.',
check: async (ctx) => ctx.files.some(f => f.type === 'mcp-json' && isTargetLocal(ctx, f)),
},
{
id: 't4_2', tier: 't4',
title: 'No custom plugin',
recommendation: 'Package reusable skills, agents, and hooks as a Claude Code plugin with .claude-plugin/plugin.json.',
check: async (ctx) => ctx.files.some(f => f.type === 'plugin-json'),
},
{
id: 't4_3', tier: 't4',
title: 'Agent teams not enabled',
recommendation: 'Enable agent teams with CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1 for parallel multi-agent workflows.',
check: async (ctx) => {
for (const parsed of ctx.parsedSettings.values()) {
const env = parsed?.env;
if (env && env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS === '1') return true;
}
return !!process.env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS;
},
},
{
id: 't4_4', tier: 't4',
title: 'No managed settings',
recommendation: 'Use managed-settings.json for organization-wide policy enforcement.',
check: async (ctx) => ctx.files.some(f => f.scope === 'managed'),
},
{
id: 't4_5', tier: 't4',
title: 'No LSP plugins',
recommendation: 'Add .lsp.json for real-time code intelligence from language servers.',
check: async (ctx) => ctx.files.some(f => f.relPath.endsWith('.lsp.json')),
},
];
/**
* Scan for feature gaps against Claude Code feature register.
* @param {string} targetPath
* @param {{ files: import('./lib/file-discovery.mjs').ConfigFile[] }} sharedDiscovery - Used when provided with files; otherwise runs own discovery with includeGlobal
* @returns {Promise<object>}
*/
export async function scan(targetPath, sharedDiscovery) {
const start = Date.now();
const findings = [];
// Use shared discovery if it has files (e.g. from full-machine mode), otherwise run own
const discovery = (sharedDiscovery && sharedDiscovery.files && sharedDiscovery.files.length > 0)
? sharedDiscovery
: await discoverConfigFiles(resolve(targetPath), { includeGlobal: true });
// 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 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: authoredFiles,
targetPath: resolve(targetPath),
parsedSettings,
fileContents: new Map(),
};
for (const gap of GAP_CHECKS) {
const present = await gap.check(ctx);
if (!present) {
findings.push(finding({
scanner: SCANNER,
severity: TIER_SEVERITY[gap.tier],
title: gap.title,
description: `Feature gap: ${gap.title}. ${gap.recommendation}`,
recommendation: gap.recommendation,
category: gap.tier,
}));
}
}
// disableBundledSkills lever — fires only under measured skill-listing pressure
// (SKL's CA-SKL-002 overflow signal). HOME-scoped: the listing and the lever
// cascade both resolve via process.env.HOME, independent of project discovery.
const leverPulled = await isBundledSkillsDisabled(ctx.targetPath);
const { aggregate } = await measureActiveSkillListing();
const leverFinding = bundledSkillsLeverFinding({ leverPulled, aggregate });
if (leverFinding) findings.push(leverFinding);
// CLI-over-MCP lever — companion to CA-TOK-006: fires only when project-local
// MCP tool schemas are forced into the always-loaded prefix (tool search
// disabled or a per-server alwaysLoad). Reuses the same static assessment.
const mcpAssessment = await assessMcpDeferralForRepo(ctx.targetPath);
const cliLever = cliOverMcpLeverFinding({ assessment: mcpAssessment });
if (cliLever) findings.push(cliLever);
// filter-before-Claude-reads lever — companion to HKV's B5 advisory: fires
// only when an active hook injects unfiltered output into additionalContext.
const flaggedHooks = await assessHookContextForRepo(discovery);
const hookLever = filterHookLeverFinding({ flaggedHooks });
if (hookLever) findings.push(hookLever);
const filesScanned = discovery.files.length;
return scannerResult(SCANNER, 'ok', findings, filesScanned, Date.now() - start);
}
/**
* Group GAP findings into impact categories for opportunity-based display.
* @param {object[]} findings - GAP scanner findings (each has .category = t1|t2|t3|t4)
* @returns {{ highImpact: object[], mediumImpact: object[], explore: object[] }}
*/
export function opportunitySummary(findings) {
const highImpact = [];
const mediumImpact = [];
const explore = [];
for (const f of findings) {
if (f.category === 't1') highImpact.push(f);
else if (f.category === 't2') mediumImpact.push(f);
else explore.push(f);
}
return { highImpact, mediumImpact, explore };
}