config-audit/scanners/feature-gap-scanner.mjs
Kjell Tore Guttormsen 7a794b47eb fix(scanners)!: a finding ID names the check, not the emission (M-BUG-28)
BREAKING CHANGE: the {NNN} in CA-{SCANNER}-{NNN} identifies the check that
produced the finding. It used to be the finding's position in that scanner's
output for that run, which made it unstable across CONFIGURATIONS, not just
across releases as STATE framed it. Measured on two fixtures: "No custom
subagents" was CA-GAP-007 on minimal-project and CA-GAP-004 on healthy-project.
A user who fixed an unrelated earlier gap silently renumbered every later one,
so a .config-audit-ignore pin retargeted to a neighbouring finding with no
version change at all.

Second measured arm: README already documented the opposite scheme. It and the
scanner headers describe ~20 numbers as check codes (CA-SKL-003 = oversized
body, CA-PLH-015 = folder shadowing, CA-TOK-006 = schema deferral), and the
counter could only produce those in the all-fire case -- source-order positions
are 4, 3 and 8. The documentation described the scheme; the implementation was
what was wrong. Every published number is preserved by construction and pinned
exhaustively in tests/lib/finding-codes.test.mjs.

scanners/lib/finding-codes.mjs is the single authority. Every finding() call
passes a `code`; an undeclared or missing one THROWS. No counter fallback --
that would reproduce D1's findGapId -> 'unknown' silent degradation and let a
half-converted scanner ship IDs that look valid. findingCounter/resetCounter
are deleted outright, not left as no-ops. Retirement is now a mechanism:
RETIRED_CODES tombstones a withdrawn key so its number is never reissued,
seeded with GAP t3_8 -- the D1 removal that opened this chunk.

IDs are consequently NOT unique per finding: one check failing in three files
emits three findings sharing an ID. That inverts which consumer is correct, so
every f.id/findingId site was classified before the change. diff-engine and
most of fix-engine already keyed on scanner+title+file (drift was never lying);
fix-engine's verification did not, and keyed on the ID alone -- fixing one of
two sibling instances marked both fixed, and the untouched one, still present
in the re-scan, was reported as a REGRESSION. Red test first, then keyed on
(findingId, file), which both planFixes and applyFixes already carry.
plugin-health's crossIds Set was measured and is a clean negative: cross
findings are allFindings.slice(crossPluginStart) and codes 18/19 are emitted
only in that tail, so the partition holds by construction.

unknownSuppressions() reports a pin that names no declared check, in the
--output-file payload (ux-rules rule 2 -- a stderr-only warning is invisible to
the commands) and only when one exists, so a clean config is byte-identical.
That is what makes the break safe: a stale pin goes loud instead of dying quiet.

Frozen tests/snapshots/v5.0.0/ untouched on disk. IDs are masked out of that
comparison (mask-finding-ids.mjs) rather than re-derived -- re-deriving
positional IDs would assert the retired scheme against itself, and #58's
isGapEntry off-by-one is the measured example of that misfiring. The dead
re-derivation is removed from strip-retired-gap.mjs. default-output snapshots
re-approved after confirming the diff is IDs and nothing else.

Guards, each seen red against its own defect: a missing code (scanner errors
out mid-sweep), an orphan declaration, a resurrected retired key, and a
documented ID naming no check. The sweep asserts the union across all 16
scanners, never per scanner -- a per-scanner assertion goes green on a partial
conversion.

Fasit written before implementation: docs/mbug28-id-semantics-fasit.local.md,
including one correction made before running (CML has 12 checks over 13 call
sites -- the anchored and calibrated char-budget arms are one check, which a
repeated-title sweep found and my call-site count had missed).

Suite 1535 -> 1573, 0 failing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MyqCQKK2ornJ1jFWwqx17E
2026-08-09 23:26:36 +02:00

632 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,
code: 'bundled-skills-lever',
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,
code: 'cli-over-mcp-lever',
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,
code: 'filter-hook-output-lever',
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[]} */
export 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;
},
},
// t3_8 ('No autoMode classifier') retired in v5.14: CC 2.1.226's /doctor
// Check 8 covers auto mode with usage-weighted judgement, so an "adopt this
// feature" nudge is a pure duplicate under the binding /doctor positioning.
// The DETERMINISTIC side stays: SET still validates autoMode structure and
// flags it as dead config in shared project settings.
// --- 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,
code: gap.id,
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 };
}