/** * GAP Scanner — Feature Gap Scanner * Compares actual configuration against complete Claude Code feature register. * 25 gap dimensions across 4 tiers. Always runs with includeGlobal: true. * Finding IDs: CA-GAP-NNN */ import { resolve } 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'; 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} check - Returns true if feature IS present */ /** * @typedef {object} CheckContext * @property {import('./lib/file-discovery.mjs').ConfigFile[]} files * @property {string} targetPath * @property {Map} parsedSettings - scope → parsed JSON * @property {Map} 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); } 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} */ 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; } /** @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} */ 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 }); // Parse all settings files upfront const parsedSettings = new Map(); for (const file of discovery.files.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); } } const ctx = { files: discovery.files, 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, })); } } 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 }; }