diff --git a/bin/cli.js b/bin/cli.js index e695430..4be8378 100755 --- a/bin/cli.js +++ b/bin/cli.js @@ -8,6 +8,7 @@ import { fileURLToPath } from 'url'; import { scanCommand } from '../src/commands/scan.js'; import { docInitCommand } from '../src/commands/doc-init.js'; import { docSyncCommand } from '../src/commands/doc-sync.js'; +import { docGraphCommand } from '../src/commands/doc-graph.js'; import { addCommand } from '../src/commands/add.js'; import { customizeCommand } from '../src/commands/customize.js'; import { CliError } from '../src/lib/errors.js'; @@ -158,6 +159,13 @@ doc return docSyncCommand(path, options); }); +doc + .command('graph') + .description('Rebuild the import graph cache (.claude/graph.json)') + .argument('[path]', 'Path to repo', '.') + .option('--verbose', 'Show detailed graph info') + .action(docGraphCommand); + // Add command program .command('add') diff --git a/src/commands/doc-graph.js b/src/commands/doc-graph.js new file mode 100644 index 0000000..e604fbc --- /dev/null +++ b/src/commands/doc-graph.js @@ -0,0 +1,55 @@ +import { resolve } from 'path'; +import pc from 'picocolors'; +import * as p from '@clack/prompts'; +import { scanRepo } from '../lib/scanner.js'; +import { buildRepoGraph } from '../lib/graph-builder.js'; +import { persistGraphArtifacts } from '../lib/graph-persistence.js'; +import { CliError } from '../lib/errors.js'; + +export async function docGraphCommand(path, options) { + const repoPath = resolve(path); + + p.intro(pc.cyan('aspens doc graph')); + + const spinner = p.spinner(); + spinner.start('Scanning repository...'); + + const scan = scanRepo(repoPath); + spinner.message('Building import graph...'); + + let repoGraph; + try { + repoGraph = await buildRepoGraph(repoPath, scan.languages); + } catch (err) { + spinner.stop(pc.red('Graph build failed')); + throw new CliError(`Failed to build import graph: ${err.message}`); + } + + try { + persistGraphArtifacts(repoPath, repoGraph); + } catch (err) { + spinner.stop(pc.red('Failed to save graph')); + throw new CliError(`Failed to persist graph artifacts: ${err.message}`); + } + + spinner.stop(pc.green('Graph saved')); + + // Print stats + console.log(); + console.log(pc.dim(' Files: ') + repoGraph.stats.totalFiles); + console.log(pc.dim(' Edges: ') + repoGraph.stats.totalEdges); + console.log(pc.dim(' Hubs: ') + repoGraph.hubs.length); + console.log(pc.dim(' Clusters: ') + repoGraph.clusters.components.length); + console.log(pc.dim(' Hotspots: ') + repoGraph.hotspots.length); + console.log(); + + if (options.verbose && repoGraph.hubs.length > 0) { + console.log(pc.bold(' Top hubs:')); + for (const hub of repoGraph.hubs.slice(0, 10)) { + console.log(` ${pc.cyan(hub.path)} — ${hub.fanIn} dependents`); + } + console.log(); + } + + p.outro(pc.dim('Saved to .claude/graph.json + .claude/skills/code-map/skill.md')); +} diff --git a/src/commands/doc-init.js b/src/commands/doc-init.js index a470ce7..aa85e4e 100644 --- a/src/commands/doc-init.js +++ b/src/commands/doc-init.js @@ -7,6 +7,7 @@ import { scanRepo } from '../lib/scanner.js'; import { buildRepoGraph } from '../lib/graph-builder.js'; import { runClaude, loadPrompt, parseFileOutput, validateSkillFiles } from '../lib/runner.js'; import { writeSkillFiles, extractRulesFromSkills, generateDomainPatterns, mergeSettings } from '../lib/skill-writer.js'; +import { persistGraphArtifacts } from '../lib/graph-persistence.js'; import { installGitHook } from './doc-sync.js'; import { CliError } from '../lib/errors.js'; @@ -90,6 +91,10 @@ export async function docInitCommand(path, options) { let repoGraph = null; try { repoGraph = await buildRepoGraph(repoPath, scan.languages); + // Persist graph, code-map skill, and index for runtime use + try { + persistGraphArtifacts(repoPath, repoGraph); + } catch { /* graph persistence failed — non-fatal */ } } catch { /* graph building failed — continue without it */ } scanSpinner.stop(`Scanned ${pc.bold(scan.name)} (${scan.repoType})`); @@ -498,6 +503,8 @@ async function installHooks(repoPath, options) { const hookFiles = [ { src: 'hooks/skill-activation-prompt.sh', dest: 'skill-activation-prompt.sh', chmod: true }, { src: 'hooks/skill-activation-prompt.mjs', dest: 'skill-activation-prompt.mjs', chmod: false }, + { src: 'hooks/graph-context-prompt.sh', dest: 'graph-context-prompt.sh', chmod: true }, + { src: 'hooks/graph-context-prompt.mjs', dest: 'graph-context-prompt.mjs', chmod: false }, ]; for (const hf of hookFiles) { @@ -581,6 +588,8 @@ async function installHooks(repoPath, options) { `${pc.green('+')} .claude/skills/skill-rules.json ${pc.dim(`(${skillCount} skills)`)}`, `${pc.green('+')} .claude/hooks/skill-activation-prompt.sh`, `${pc.green('+')} .claude/hooks/skill-activation-prompt.mjs`, + `${pc.green('+')} .claude/hooks/graph-context-prompt.sh`, + `${pc.green('+')} .claude/hooks/graph-context-prompt.mjs`, `${pc.green('+')} .claude/hooks/post-tool-use-tracker.sh ${pc.dim('(with domain patterns)')}`, `${existingSettings ? pc.yellow('~') : pc.green('+')} .claude/settings.json ${pc.dim(existingSettings ? '(merged)' : '(created)')}`, ]; diff --git a/src/commands/doc-sync.js b/src/commands/doc-sync.js index d6d31ac..2d9267d 100644 --- a/src/commands/doc-sync.js +++ b/src/commands/doc-sync.js @@ -6,6 +6,8 @@ import * as p from '@clack/prompts'; import { scanRepo } from '../lib/scanner.js'; import { runClaude, loadPrompt, parseFileOutput } from '../lib/runner.js'; import { writeSkillFiles } from '../lib/skill-writer.js'; +import { buildRepoGraph } from '../lib/graph-builder.js'; +import { persistGraphArtifacts, loadGraph, extractSubgraph, formatNavigationContext } from '../lib/graph-persistence.js'; import { CliError } from '../lib/errors.js'; const READ_ONLY_TOOLS = ['Read', 'Glob', 'Grep']; @@ -67,7 +69,21 @@ export async function docSyncCommand(path, options) { // Step 3: Find affected skills const scan = scanRepo(repoPath); const existingSkills = findExistingSkills(repoPath); - const affectedSkills = mapChangesToSkills(changedFiles, existingSkills, scan); + + // Rebuild graph from current state (keeps graph fresh on every sync) + let repoGraph = null; + let graphContext = ''; + try { + const rawGraph = await buildRepoGraph(repoPath, scan.languages); + persistGraphArtifacts(repoPath, rawGraph); + repoGraph = loadGraph(repoPath); + if (repoGraph) { + const subgraph = extractSubgraph(repoGraph, changedFiles); + graphContext = formatNavigationContext(subgraph); + } + } catch { /* proceed without graph */ } + + const affectedSkills = mapChangesToSkills(changedFiles, existingSkills, scan, repoGraph); if (affectedSkills.length > 0) { p.log.info(`Skills that may need updates: ${affectedSkills.map(s => pc.yellow(s.name)).join(', ')}`); @@ -109,7 +125,7 @@ ${truncateDiff(diff, 15000)} ## Changed Files ${changedFiles.join('\n')} - +${graphContext ? `\n## Import Graph Context\n${graphContext}` : ''} ## Existing Skills ${skillContents} @@ -280,7 +296,7 @@ const GENERIC_PATH_SEGMENTS = new Set([ 'public', 'assets', 'styles', 'scripts', ]); -function mapChangesToSkills(changedFiles, existingSkills, scan) { +function mapChangesToSkills(changedFiles, existingSkills, scan, repoGraph = null) { const affected = []; for (const skill of existingSkills) { @@ -292,7 +308,7 @@ function mapChangesToSkills(changedFiles, existingSkills, scan) { const activationBlock = activationMatch[0].toLowerCase(); - const isAffected = changedFiles.some(file => { + let isAffected = changedFiles.some(file => { const fileLower = file.toLowerCase(); // Check the filename itself (e.g., billing_service.py) const fileName = fileLower.split('/').pop(); @@ -303,6 +319,22 @@ function mapChangesToSkills(changedFiles, existingSkills, scan) { return parts.some(part => activationBlock.includes(part)); }); + // Graph-aware: check if changed files are imported by files in this skill's domain + if (!isAffected && repoGraph) { + isAffected = changedFiles.some(file => { + const info = repoGraph.files[file]; + if (!info) return false; + // Check if any file that imports the changed file matches the activation block + return (info.importedBy || []).some(dep => { + const depLower = dep.toLowerCase(); + const depName = depLower.split('/').pop(); + if (activationBlock.includes(depName)) return true; + const parts = depLower.split('/').filter(p => !GENERIC_PATH_SEGMENTS.has(p) && p.length > 2); + return parts.some(part => activationBlock.includes(part)); + }); + }); + } + if (isAffected) { affected.push(skill); } diff --git a/src/lib/graph-persistence.js b/src/lib/graph-persistence.js new file mode 100644 index 0000000..815db01 --- /dev/null +++ b/src/lib/graph-persistence.js @@ -0,0 +1,499 @@ +import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs'; +import { join } from 'path'; +import { execSync } from 'child_process'; + +const GRAPH_PATH = '.claude/graph.json'; +const GRAPH_VERSION = '1.0'; + +/** + * Convert raw buildRepoGraph() output to an indexed format optimized for + * O(1) lookups. Adds meta block, per-file cluster field, cluster index. + * Drops redundant edges/ranked/entryPoints arrays. + */ +export function serializeGraph(rawGraph, repoPath) { + // Get git hash for cache metadata + let gitHash = ''; + try { + gitHash = execSync('git rev-parse --short HEAD', { + cwd: repoPath, + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + }).trim(); + } catch { /* not a git repo or git unavailable */ } + + // Build file → cluster label mapping from clusters + const fileToCluster = {}; + if (rawGraph.clusters?.components) { + for (const comp of rawGraph.clusters.components) { + for (const filePath of comp.files) { + fileToCluster[filePath] = comp.label; + } + } + } + + // Build indexed files map (drop externalImports to save space, add cluster) + const files = {}; + for (const [path, info] of Object.entries(rawGraph.files)) { + files[path] = { + imports: info.imports, + importedBy: info.importedBy, + exports: info.exports, + lines: info.lines, + fanIn: info.fanIn, + fanOut: info.fanOut, + churn: info.churn, + priority: Math.round(info.priority * 10) / 10, + cluster: fileToCluster[path] || null, + }; + } + + // Build cluster index for O(1) lookup + const clusters = rawGraph.clusters?.components || []; + const clusterIndex = {}; + for (let i = 0; i < clusters.length; i++) { + clusterIndex[clusters[i].label] = i; + } + + return { + version: GRAPH_VERSION, + meta: { + generatedAt: new Date().toISOString(), + gitHash, + totalFiles: rawGraph.stats.totalFiles, + totalEdges: rawGraph.stats.totalEdges, + }, + files, + hubs: rawGraph.hubs.map(h => ({ + path: h.path, + fanIn: h.fanIn, + exports: h.exports, + })), + clusters: clusters.map(c => ({ + label: c.label, + size: c.size, + files: c.files, + })), + coupling: rawGraph.clusters?.coupling || [], + hotspots: rawGraph.hotspots, + clusterIndex, + }; +} + +/** + * Write serialized graph to .claude/graph.json. + */ +export function saveGraph(repoPath, serializedGraph) { + const graphDir = join(repoPath, '.claude'); + mkdirSync(graphDir, { recursive: true }); + writeFileSync( + join(repoPath, GRAPH_PATH), + JSON.stringify(serializedGraph, null, 2) + '\n', + ); +} + +/** + * Load graph.json from disk. Returns null if missing or unparseable. + */ +export function loadGraph(repoPath) { + const fullPath = join(repoPath, GRAPH_PATH); + if (!existsSync(fullPath)) return null; + try { + const parsed = JSON.parse(readFileSync(fullPath, 'utf-8')); + // Minimal structure validation + if (!parsed || typeof parsed !== 'object') return null; + if (!parsed.files || typeof parsed.files !== 'object') return null; + if (!Array.isArray(parsed.hubs)) return null; + if (!Array.isArray(parsed.clusters)) return null; + return parsed; + } catch { + return null; + } +} + +// --------------------------------------------------------------------------- +// Prompt → file reference extraction +// --------------------------------------------------------------------------- + +/** + * Extract file references from a user prompt, validated against graph keys. + * Returns repo-relative file paths found in the graph. + * + * Tiered extraction: + * 1. Explicit paths (src/lib/scanner.js) + * 2. Bare filenames (scanner.js) — validated against graph keys + * 3. Cluster/directory name keywords + */ +export function extractFileReferences(prompt, graph) { + const graphFiles = Object.keys(graph.files); + const matches = new Set(); + + // Tier 1: Explicit repo-relative paths + const pathRe = /(?:^|\s|['"`(])(([\w@.~-]+\/)+[\w.-]+\.\w{1,5})(?:\s|['"`),:]|$)/g; + let m; + while ((m = pathRe.exec(prompt)) !== null) { + const candidate = m[1]; + // Direct match + if (graph.files[candidate]) { + matches.add(candidate); + continue; + } + // Try without leading ./ + const stripped = candidate.replace(/^\.\//, ''); + if (graph.files[stripped]) { + matches.add(stripped); + } + } + + // Tier 2: Bare filenames (e.g. "scanner.js") — match against graph keys + const bareRe = /\b([\w.-]+\.(js|ts|tsx|jsx|py|go|rs|rb))\b/g; + while ((m = bareRe.exec(prompt)) !== null) { + const filename = m[1]; + for (const gf of graphFiles) { + if (gf.endsWith('/' + filename) || gf === filename) { + matches.add(gf); + } + } + } + + // Tier 3: Cluster/directory keywords — only if no files matched yet + if (matches.size === 0 && graph.clusterIndex) { + const words = prompt.toLowerCase().split(/\s+/); + for (const label of Object.keys(graph.clusterIndex)) { + if (words.includes(label.toLowerCase())) { + // Add top hub files from this cluster (up to 3) + const clusterIdx = graph.clusterIndex[label]; + const cluster = graph.clusters[clusterIdx]; + if (cluster) { + const clusterFiles = cluster.files + .filter(f => graph.files[f]) + .sort((a, b) => (graph.files[b].priority || 0) - (graph.files[a].priority || 0)) + .slice(0, 3); + for (const cf of clusterFiles) { + matches.add(cf); + } + } + } + } + } + + return [...matches]; +} + +// --------------------------------------------------------------------------- +// Subgraph extraction +// --------------------------------------------------------------------------- + +const MAX_NEIGHBORS_PER_FILE = 10; +const MAX_HUBS = 5; +const MAX_HOTSPOTS = 3; + +/** + * Extract the neighborhood of mentioned files from the graph. + * Returns: mentioned files + 1-hop neighbors, relevant hubs, hotspots, cluster info. + * + * Note: this logic is mirrored in graph-context-prompt.mjs::buildNeighborhood + * (the hook is standalone with no aspens imports). Keep both in sync. + */ +export function extractSubgraph(graph, filePaths) { + if (!filePaths || filePaths.length === 0) { + return { mentionedFiles: [], neighbors: [], hubs: [], hotspots: [], clusters: [] }; + } + + const mentioned = new Set(filePaths); + const neighborSet = new Set(); + + // Collect 1-hop neighbors (imports + importedBy) + for (const fp of filePaths) { + const info = graph.files[fp]; + if (!info) continue; + + const allNeighbors = [...(info.imports || []), ...(info.importedBy || [])]; + // Sort by priority (highest first), cap at MAX_NEIGHBORS_PER_FILE + const sorted = allNeighbors + .filter(n => graph.files[n] && !mentioned.has(n)) + .sort((a, b) => (graph.files[b].priority || 0) - (graph.files[a].priority || 0)) + .slice(0, MAX_NEIGHBORS_PER_FILE); + + for (const n of sorted) { + neighborSet.add(n); + } + } + + // Find hubs relevant to mentioned files (same cluster or direct neighbor) + const mentionedClusters = new Set(); + for (const fp of filePaths) { + const info = graph.files[fp]; + if (info?.cluster) mentionedClusters.add(info.cluster); + } + + const relevantHubs = (graph.hubs || []) + .filter(h => { + const info = graph.files[h.path]; + if (!info) return false; + // Hub is in same cluster as a mentioned file, or is a direct neighbor + return mentionedClusters.has(info.cluster) || mentioned.has(h.path) || neighborSet.has(h.path); + }) + .slice(0, MAX_HUBS); + + // Find hotspots overlapping with mentioned files or their cluster + const relevantHotspots = (graph.hotspots || []) + .filter(h => { + const info = graph.files[h.path]; + if (!info) return false; + return mentioned.has(h.path) || mentionedClusters.has(info.cluster); + }) + .slice(0, MAX_HOTSPOTS); + + // Cluster context + const clusterContext = []; + for (const label of mentionedClusters) { + const idx = graph.clusterIndex?.[label]; + if (idx !== undefined && graph.clusters[idx]) { + const cluster = graph.clusters[idx]; + clusterContext.push({ label: cluster.label, size: cluster.size }); + } + } + + // Coupling for mentioned clusters + const clusterCoupling = (graph.coupling || []) + .filter(c => mentionedClusters.has(c.from) || mentionedClusters.has(c.to)) + .slice(0, 5); + + return { + mentionedFiles: filePaths.map(fp => ({ + path: fp, + ...graph.files[fp], + })).filter(f => f.fanIn !== undefined), + neighbors: [...neighborSet].map(fp => ({ + path: fp, + ...graph.files[fp], + })), + hubs: relevantHubs, + hotspots: relevantHotspots, + clusters: clusterContext, + coupling: clusterCoupling, + }; +} + +// --------------------------------------------------------------------------- +// Formatting +// --------------------------------------------------------------------------- + +/** + * Format a subgraph extraction as compact markdown for context injection. + * Respects a ~50 line budget. + */ +export function formatNavigationContext(subgraph) { + if (!subgraph || subgraph.mentionedFiles.length === 0) return ''; + + const lines = ['## Code Navigation\n']; + + // Referenced files with relationships + lines.push('**Referenced files:**'); + for (const f of subgraph.mentionedFiles.slice(0, 10)) { + const hubTag = f.fanIn >= 3 ? `, hub: ${f.fanIn} dependents` : ''; + const imports = (f.imports || []).slice(0, 5).map(shortPath).join(', '); + const importedBy = (f.importedBy || []).slice(0, 5).map(shortPath).join(', '); + let detail = ''; + if (imports) detail += `imports: ${imports}`; + if (importedBy) detail += `${detail ? '; ' : ''}imported by: ${importedBy}`; + lines.push(`- \`${f.path}\` (${f.lines} lines${hubTag})${detail ? ' — ' + detail : ''}`); + } + lines.push(''); + + // Hubs in this area + const nonMentionedHubs = subgraph.hubs.filter( + h => !subgraph.mentionedFiles.some(mf => mf.path === h.path) + ); + if (nonMentionedHubs.length > 0) { + lines.push('**Hubs (read first):**'); + for (const h of nonMentionedHubs) { + const exports = (h.exports || []).slice(0, 5).join(', '); + lines.push(`- \`${h.path}\` — ${h.fanIn} dependents${exports ? ', exports: ' + exports : ''}`); + } + lines.push(''); + } + + // Cluster context + if (subgraph.clusters.length > 0) { + const clusterStr = subgraph.clusters.map(c => `${c.label} (${c.size} files)`).join(', '); + let line = `**Cluster:** ${clusterStr}`; + if (subgraph.coupling && subgraph.coupling.length > 0) { + const couplingStr = subgraph.coupling + .slice(0, 3) + .map(c => `${c.from} → ${c.to} (${c.edges})`) + .join(', '); + line += ` | Cross-dep: ${couplingStr}`; + } + lines.push(line); + lines.push(''); + } + + // Hotspots + if (subgraph.hotspots.length > 0) { + lines.push('**Hotspots (high churn):**'); + for (const h of subgraph.hotspots) { + lines.push(`- \`${h.path}\` — ${h.churn} changes, ${h.lines} lines`); + } + lines.push(''); + } + + return lines.join('\n'); +} + +function shortPath(p) { + // Return just filename if path has multiple segments + const parts = p.split('/'); + return parts.length > 2 ? parts.slice(-2).join('/') : p; +} + +// --------------------------------------------------------------------------- +// Code-map generation — standalone overview, independent of skills +// --------------------------------------------------------------------------- + +const CODE_MAP_PATH = '.claude/code-map.md'; +const MAX_MAP_HUBS = 10; +const MAX_MAP_HOTSPOTS = 5; + +/** + * Generate a standalone code-map overview from the serialized graph. + * Written to .claude/code-map.md — loaded by the graph hook when it fires, + * independent of the skill activation system. + * + * @param {Object} serializedGraph - Output of serializeGraph() + * @returns {string} Markdown content for code-map.md + */ +export function generateCodeMap(serializedGraph) { + const lines = ['## Codebase Structure\n']; + + // Hub files + if (serializedGraph.hubs?.length > 0) { + lines.push('**Hub files (most depended-on — prioritize reading these):**'); + for (const h of serializedGraph.hubs.slice(0, MAX_MAP_HUBS)) { + const exports = (h.exports || []).slice(0, 6).join(', '); + lines.push(`- \`${h.path}\` — ${h.fanIn} dependents${exports ? ' | exports: ' + exports : ''}`); + } + lines.push(''); + } + + // Domain clusters + if (serializedGraph.clusters?.length > 0) { + const multiFileClusters = serializedGraph.clusters.filter(c => c.size > 1); + if (multiFileClusters.length > 0) { + lines.push('**Domain clusters:**'); + for (const c of multiFileClusters) { + const topFiles = c.files + .filter(f => serializedGraph.files[f]) + .sort((a, b) => (serializedGraph.files[b].priority || 0) - (serializedGraph.files[a].priority || 0)) + .slice(0, 5) + .map(f => `\`${shortPath(f)}\``) + .join(', '); + lines.push(`- **${c.label}** (${c.size} files): ${topFiles}`); + } + lines.push(''); + } + } + + // Cross-domain coupling + if (serializedGraph.coupling?.length > 0) { + lines.push('**Cross-domain dependencies:**'); + for (const c of serializedGraph.coupling.slice(0, 5)) { + lines.push(`- ${c.from} \u2192 ${c.to} (${c.edges} imports)`); + } + lines.push(''); + } + + // Hotspots + if (serializedGraph.hotspots?.length > 0) { + lines.push('**Hotspots (high churn):**'); + for (const h of serializedGraph.hotspots.slice(0, MAX_MAP_HOTSPOTS)) { + lines.push(`- \`${h.path}\` — ${h.churn} changes, ${h.lines} lines`); + } + lines.push(''); + } + + lines.push(`*${serializedGraph.meta.totalFiles} files, ${serializedGraph.meta.totalEdges} edges — updated ${serializedGraph.meta.generatedAt.split('T')[0]}*`); + lines.push(''); + + return lines.join('\n'); +} + +/** + * Write code-map to .claude/code-map.md. + */ +export function writeCodeMap(repoPath, serializedGraph) { + const content = generateCodeMap(serializedGraph); + const dir = join(repoPath, '.claude'); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(repoPath, CODE_MAP_PATH), content); +} + +// --------------------------------------------------------------------------- +// Graph index — tiny pre-computed lookup for fast hook matching +// --------------------------------------------------------------------------- + +const INDEX_PATH = '.claude/graph-index.json'; + +/** + * Generate a tiny index (~1-3KB) for fast prompt matching in the hook. + * Contains export names → file path, hub basenames, cluster labels. + * + * @param {Object} serializedGraph - Output of serializeGraph() + * @returns {Object} The index object + */ +export function generateGraphIndex(serializedGraph) { + // Export name → file paths (inverted index, array to handle duplicates) + const exports = {}; + for (const [path, info] of Object.entries(serializedGraph.files)) { + for (const exp of (info.exports || [])) { + // Skip very short or generic exports (1-2 chars like 'x', 'a') + if (exp.length > 2) { + if (!exports[exp]) { + exports[exp] = [path]; + } else { + exports[exp].push(path); + } + } + } + } + + // Hub basenames → full paths (array to handle duplicates like src/utils.js + lib/utils.js) + const hubBasenames = {}; + for (const h of (serializedGraph.hubs || [])) { + const basename = h.path.split('/').pop(); + if (!hubBasenames[basename]) { + hubBasenames[basename] = [h.path]; + } else { + hubBasenames[basename].push(h.path); + } + } + + // Cluster labels + const clusterLabels = Object.keys(serializedGraph.clusterIndex || {}); + + return { exports, hubBasenames, clusterLabels }; +} + +/** + * Write graph-index.json to .claude/graph-index.json. + */ +export function saveGraphIndex(repoPath, index) { + const dir = join(repoPath, '.claude'); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(repoPath, INDEX_PATH), + JSON.stringify(index) + '\n', // compact — no pretty-print for speed + ); +} + +/** + * Convenience: persist graph, code-map, and index in one call. + */ +export function persistGraphArtifacts(repoPath, rawGraph) { + const serialized = serializeGraph(rawGraph, repoPath); + saveGraph(repoPath, serialized); + writeCodeMap(repoPath, serialized); + const index = generateGraphIndex(serialized); + saveGraphIndex(repoPath, index); + return serialized; +} diff --git a/src/templates/hooks/graph-context-prompt.mjs b/src/templates/hooks/graph-context-prompt.mjs new file mode 100644 index 0000000..33651c3 --- /dev/null +++ b/src/templates/hooks/graph-context-prompt.mjs @@ -0,0 +1,409 @@ +#!/usr/bin/env node +/** + * Graph Context Prompt Hook — Smart Matching Engine + * + * Standalone ESM module. Copied into target repo's .claude/hooks/ directory. + * No imports from aspens — uses only Node.js builtins. + * + * Called by graph-context-prompt.sh on every UserPromptSubmit. + * Uses a tiny pre-computed index (.claude/graph-index.json, ~1-3KB) for + * fast matching against export names, hub filenames, and cluster labels. + * Only loads the full graph (.claude/graph.json) when a match is found. + * + * Exports functions for testability (vitest can import them). + */ + +import { readFileSync, existsSync } from 'fs'; +import { join } from 'path'; +import { fileURLToPath } from 'url'; + +// --------------------------------------------------------------------------- +// Index loading — tiny file (~1-3KB), safe to load on every prompt +// --------------------------------------------------------------------------- + +/** + * Load .claude/graph-index.json from the project directory. + * @param {string} projectDir - Absolute path to the project root + * @returns {Object|null} { exports, hubBasenames, clusterLabels } or null + */ +export function loadGraphIndex(projectDir) { + const indexPath = join(projectDir, '.claude', 'graph-index.json'); + if (!existsSync(indexPath)) return null; + try { + return JSON.parse(readFileSync(indexPath, 'utf-8')); + } catch { + return null; + } +} + +// --------------------------------------------------------------------------- +// Index-based prompt matching — fast, no full graph needed +// --------------------------------------------------------------------------- + +/** + * Match prompt against the pre-computed index. + * Returns file paths that should be looked up in the full graph. + * + * Matching tiers: + * 1. Explicit file paths (src/lib/scanner.js) + * 2. Bare filenames matching hub basenames (scanner.js) + * 3. Export/function names (scanRepo, buildRepoGraph) + * 4. Cluster/directory label keywords + * + * @param {string} prompt - User prompt text + * @param {Object} index - Loaded graph-index.json + * @returns {string[]} File paths to look up in full graph (empty = no match) + */ +export function matchPromptAgainstIndex(prompt, index) { + const matches = new Set(); + + // Tier 1: Explicit repo-relative paths — check against hub basenames for validation + const pathRe = /(?:^|\s|['"`(])(([\w@.~-]+\/)+[\w.-]+\.\w{1,5})(?:\s|['"`),:]|$)/g; + let m; + while ((m = pathRe.exec(prompt)) !== null) { + const candidate = m[1].replace(/^\.\//, ''); + // We can't fully validate without the full graph, but any path-like string is worth looking up + matches.add(candidate); + } + + // Tier 2: Bare filenames matching hub basenames (index values are arrays) + const bareRe = /\b([\w.-]+\.(js|ts|tsx|jsx|py|go|rs|rb))\b/g; + while ((m = bareRe.exec(prompt)) !== null) { + const filename = m[1]; + const hubPaths = index.hubBasenames[filename]; + if (hubPaths) { + for (const p of (Array.isArray(hubPaths) ? hubPaths : [hubPaths])) matches.add(p); + } + } + + // Tier 3: Export/function names — only match code-shaped identifiers + // Must look like code: camelCase, PascalCase, snake_case, or backtick-wrapped + const codeIdentRe = /`(\w{3,})`|\b([a-z]+[A-Z]\w*|[A-Z][a-z]+[A-Z]\w*|\w+_\w+)\b/g; + while ((m = codeIdentRe.exec(prompt)) !== null) { + const word = m[1] || m[2]; // m[1] = backtick-wrapped, m[2] = code-shaped + const exportPaths = word && index.exports[word]; + if (exportPaths) { + for (const p of (Array.isArray(exportPaths) ? exportPaths : [exportPaths])) matches.add(p); + } + } + + // Tier 4: Cluster labels — only if no matches yet + if (matches.size === 0 && index.clusterLabels) { + const words = prompt.toLowerCase().split(/\s+/); + for (const label of index.clusterLabels) { + // Only match cluster labels that are specific enough (3+ chars, not generic) + if (label.length >= 3 && words.includes(label.toLowerCase())) { + matches.add(`__cluster__:${label}`); + } + } + } + + return [...matches]; +} + +// --------------------------------------------------------------------------- +// Full graph loading (only when index match found) +// --------------------------------------------------------------------------- + +/** + * Load .claude/graph.json from the project directory. + * @param {string} projectDir + * @returns {Object|null} + */ +export function loadGraphJson(projectDir) { + const graphPath = join(projectDir, '.claude', 'graph.json'); + if (!existsSync(graphPath)) return null; + try { + const graph = JSON.parse(readFileSync(graphPath, 'utf-8')); + if (!graph.files || typeof graph.files !== 'object') return null; + return graph; + } catch { + return null; + } +} + +// --------------------------------------------------------------------------- +// Resolve index matches to actual file paths in the full graph +// --------------------------------------------------------------------------- + +/** + * Resolve index match results against the full graph. + * Handles cluster matches (expand to top files) and validates paths. + * + * @param {string[]} indexMatches - Output of matchPromptAgainstIndex + * @param {Object} graph - Full parsed graph.json + * @returns {string[]} Validated file paths + */ +export function resolveMatches(indexMatches, graph) { + const resolved = new Set(); + + for (const match of indexMatches) { + // Cluster match — expand to top files in that cluster + if (match.startsWith('__cluster__:')) { + const label = match.slice('__cluster__:'.length); + const idx = graph.clusterIndex?.[label]; + if (idx !== undefined && graph.clusters?.[idx]) { + const cluster = graph.clusters[idx]; + const topFiles = cluster.files + .filter(f => graph.files[f]) + .sort((a, b) => (graph.files[b].priority || 0) - (graph.files[a].priority || 0)) + .slice(0, 3); + for (const f of topFiles) resolved.add(f); + } + continue; + } + + // Direct file path — validate against graph + if (graph.files[match]) { + resolved.add(match); + continue; + } + + // Try matching as a suffix (user wrote partial path) + const graphFiles = Object.keys(graph.files); + for (const gf of graphFiles) { + if (gf.endsWith('/' + match) || gf === match) { + resolved.add(gf); + break; + } + } + } + + return [...resolved]; +} + +// --------------------------------------------------------------------------- +// Subgraph extraction — 1-hop neighborhood +// --------------------------------------------------------------------------- + +const MAX_NEIGHBORS = 10; +const MAX_HUBS = 5; +const MAX_HOTSPOTS = 3; + +/** + * Extract neighborhood of mentioned files from the graph. + */ +export function buildNeighborhood(graph, filePaths) { + const mentioned = new Set(filePaths); + const neighborSet = new Set(); + + for (const fp of filePaths) { + const info = graph.files[fp]; + if (!info) continue; + const allNeighbors = [...(info.imports || []), ...(info.importedBy || [])]; + const sorted = allNeighbors + .filter(n => graph.files[n] && !mentioned.has(n)) + .sort((a, b) => (graph.files[b].priority || 0) - (graph.files[a].priority || 0)) + .slice(0, MAX_NEIGHBORS); + for (const n of sorted) neighborSet.add(n); + } + + const mentionedClusters = new Set(); + for (const fp of filePaths) { + const info = graph.files[fp]; + if (info?.cluster) mentionedClusters.add(info.cluster); + } + + const hubs = (graph.hubs || []) + .filter(h => { + const info = graph.files[h.path]; + if (!info) return false; + return mentionedClusters.has(info.cluster) || mentioned.has(h.path) || neighborSet.has(h.path); + }) + .slice(0, MAX_HUBS); + + const hotspots = (graph.hotspots || []) + .filter(h => { + const info = graph.files[h.path]; + if (!info) return false; + return mentioned.has(h.path) || mentionedClusters.has(info.cluster); + }) + .slice(0, MAX_HOTSPOTS); + + const clusters = []; + for (const label of mentionedClusters) { + const idx = graph.clusterIndex?.[label]; + if (idx !== undefined && graph.clusters?.[idx]) { + clusters.push({ label: graph.clusters[idx].label, size: graph.clusters[idx].size }); + } + } + + const coupling = (graph.coupling || []) + .filter(c => mentionedClusters.has(c.from) || mentionedClusters.has(c.to)) + .slice(0, 5); + + return { + mentionedFiles: filePaths + .filter(fp => graph.files[fp]) + .map(fp => ({ path: fp, ...graph.files[fp] })), + neighbors: [...neighborSet].map(fp => ({ path: fp, ...graph.files[fp] })), + hubs, + hotspots, + clusters, + coupling, + }; +} + +// --------------------------------------------------------------------------- +// Format navigation context as compact markdown +// --------------------------------------------------------------------------- + +function shortPath(p) { + const parts = p.split('/'); + return parts.length > 2 ? parts.slice(-2).join('/') : p; +} + +/** + * Format neighborhood as compact markdown for context injection. + */ +export function formatNavContext(neighborhood) { + if (!neighborhood || neighborhood.mentionedFiles.length === 0) return ''; + + const lines = ['## Code Navigation\n']; + + lines.push('**Referenced files:**'); + for (const f of neighborhood.mentionedFiles.slice(0, 10)) { + const hubTag = f.fanIn >= 3 ? `, hub: ${f.fanIn} dependents` : ''; + const imports = (f.imports || []).slice(0, 5).map(shortPath).join(', '); + const importedBy = (f.importedBy || []).slice(0, 5).map(shortPath).join(', '); + let detail = ''; + if (imports) detail += `imports: ${imports}`; + if (importedBy) detail += `${detail ? '; ' : ''}imported by: ${importedBy}`; + lines.push(`- \`${f.path}\` (${f.lines} lines${hubTag})${detail ? ' \u2014 ' + detail : ''}`); + } + lines.push(''); + + const nonMentionedHubs = neighborhood.hubs.filter( + h => !neighborhood.mentionedFiles.some(mf => mf.path === h.path) + ); + if (nonMentionedHubs.length > 0) { + lines.push('**Hubs (read first):**'); + for (const h of nonMentionedHubs) { + const exports = (h.exports || []).slice(0, 5).join(', '); + lines.push(`- \`${h.path}\` \u2014 ${h.fanIn} dependents${exports ? ', exports: ' + exports : ''}`); + } + lines.push(''); + } + + if (neighborhood.clusters.length > 0) { + const clusterStr = neighborhood.clusters.map(c => `${c.label} (${c.size} files)`).join(', '); + let line = `**Cluster:** ${clusterStr}`; + if (neighborhood.coupling && neighborhood.coupling.length > 0) { + const couplingStr = neighborhood.coupling + .slice(0, 3) + .map(c => `${c.from} \u2192 ${c.to} (${c.edges})`) + .join(', '); + line += ` | Cross-dep: ${couplingStr}`; + } + lines.push(line); + lines.push(''); + } + + if (neighborhood.hotspots.length > 0) { + lines.push('**Hotspots (high churn):**'); + for (const h of neighborhood.hotspots) { + lines.push(`- \`${h.path}\` \u2014 ${h.churn} changes, ${h.lines} lines`); + } + lines.push(''); + } + + return lines.join('\n'); +} + +// --------------------------------------------------------------------------- +// CLI entry point +// --------------------------------------------------------------------------- + +async function main() { + try { + const input = readFileSync(0, 'utf-8'); + + let data; + try { + data = JSON.parse(input); + } catch { + process.exit(0); + } + + const prompt = data.prompt || ''; + if (!prompt) { + process.exit(0); + } + + const projectDir = process.env.CLAUDE_PROJECT_DIR; + if (!projectDir) { + process.exit(0); + } + + // Step 1: Always load code-map overview (~1ms) + const codeMapPath = join(projectDir, '.claude', 'code-map.md'); + let codeMap = ''; + if (existsSync(codeMapPath)) { + try { + codeMap = readFileSync(codeMapPath, 'utf-8'); + } catch { /* ignore */ } + } + + // If no code-map exists, nothing to do + if (!codeMap) { + process.exit(0); + } + + // Step 2: Try to enrich with detailed neighborhood (best-effort) + let detailedContext = ''; + let debugInfo = null; + try { + const index = loadGraphIndex(projectDir); + if (index) { + const indexMatches = matchPromptAgainstIndex(prompt, index); + if (indexMatches.length > 0) { + const graph = loadGraphJson(projectDir); + if (graph) { + const filePaths = resolveMatches(indexMatches, graph); + if (filePaths.length > 0) { + const neighborhood = buildNeighborhood(graph, filePaths); + detailedContext = formatNavContext(neighborhood); + debugInfo = { indexMatches, filePaths, neighborhoodSize: neighborhood.mentionedFiles.length + neighborhood.neighbors.length }; + } + } + } + } + } catch { /* matching failed — still emit code-map */ } + + // Debug output + if (process.env.ASPENS_DEBUG === '1' && debugInfo) { + try { + const { writeFileSync: wfs } = await import('fs'); + wfs('/tmp/aspens-debug-graph-context.json', JSON.stringify({ + timestamp: new Date().toISOString(), + projectDir, + prompt: prompt.substring(0, 500), + ...debugInfo, + }, null, 2)); + } catch { /* ignore */ } + } + + // Emit: always code-map, optionally detailed neighborhood + let output = '\n'; + output += codeMap; + if (detailedContext) output += '\n' + detailedContext; + output += '\n'; + process.stdout.write(output); + + if (detailedContext) { + process.stderr.write(`[Graph] Code map + ${debugInfo.filePaths.length} matched files\n`); + } else { + process.stderr.write('[Graph] Code map loaded\n'); + } + + process.exit(0); + } catch (err) { + // NEVER block the user's prompt + process.stderr.write(`[Graph] Error: ${err.message}\n`); + process.exit(0); + } +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + main(); +} diff --git a/src/templates/hooks/graph-context-prompt.sh b/src/templates/hooks/graph-context-prompt.sh new file mode 100644 index 0000000..cb09ec5 --- /dev/null +++ b/src/templates/hooks/graph-context-prompt.sh @@ -0,0 +1,76 @@ +#!/bin/bash +# Graph Context Prompt Hook — Shell Wrapper +# Called by Claude Code on every UserPromptSubmit. +# Loads the persisted import graph, extracts a relevant subgraph based on +# file references in the prompt, and injects navigation context into Claude. +# Always exits 0 — NEVER blocks the user's prompt. +# +# Note: No set -e — hook failures must not block prompts. + +# --------------------------------------------------------------------------- +# Debug logging (opt-in via ASPENS_DEBUG=1) +# --------------------------------------------------------------------------- +log_debug() { + if [ "$ASPENS_DEBUG" = "1" ]; then + echo "[$(date '+%Y-%m-%d %H:%M:%S')] [graph] $1" >> "${TMPDIR:-/tmp}/claude-graph-hook-debug-$(id -u).log" + fi +} + +log_debug "HOOK SCRIPT STARTED - PID $$" + +# --------------------------------------------------------------------------- +# Resolve script directory (handles symlinks — essential for hub support) +# --------------------------------------------------------------------------- +get_script_dir() { + local source="${BASH_SOURCE[0]}" + while [ -h "$source" ]; do + local dir="$(cd -P "$(dirname "$source")" && pwd)" + source="$(readlink "$source")" + [[ $source != /* ]] && source="$dir/$source" + done + cd -P "$(dirname "$source")" && pwd +} + +SCRIPT_DIR="$(get_script_dir)" +log_debug "SCRIPT_DIR=$SCRIPT_DIR" + +cd "$SCRIPT_DIR" || { echo "[Graph] Failed to cd to $SCRIPT_DIR" >&2; exit 0; } + +# --------------------------------------------------------------------------- +# Capture stdin +# --------------------------------------------------------------------------- +INPUT=$(cat) +log_debug "Input received: ${INPUT:0:200}..." + +# --------------------------------------------------------------------------- +# Run graph context engine with clean stdout/stderr separation +# --------------------------------------------------------------------------- +STDOUT_FILE=$(mktemp) +STDERR_FILE=$(mktemp) +trap 'rm -f "$STDOUT_FILE" "$STDERR_FILE"' EXIT + +printf '%s' "$INPUT" | NODE_NO_WARNINGS=1 node graph-context-prompt.mjs \ + >"$STDOUT_FILE" 2>"$STDERR_FILE" +EXIT_CODE=$? + +log_debug "Exit code: $EXIT_CODE" +log_debug "Stderr: $(cat "$STDERR_FILE" 2>/dev/null | head -5)" + +# --------------------------------------------------------------------------- +# Terminal status output (stderr) +# --------------------------------------------------------------------------- +if [ $EXIT_CODE -ne 0 ]; then + log_debug "ERROR: Hook failed with exit code $EXIT_CODE" +fi + +GRAPH_LINE=$(grep -o '\[Graph\] [^"]*' "$STDERR_FILE" | head -1) +if [ -n "$GRAPH_LINE" ]; then + echo "$GRAPH_LINE" >&2 +fi + +# --------------------------------------------------------------------------- +# Emit pristine stdout (injected into Claude's context) +# --------------------------------------------------------------------------- +cat "$STDOUT_FILE" + +exit 0 diff --git a/src/templates/settings/settings.json b/src/templates/settings/settings.json index 0ef7711..84db302 100644 --- a/src/templates/settings/settings.json +++ b/src/templates/settings/settings.json @@ -8,6 +8,14 @@ "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/skill-activation-prompt.sh" } ] + }, + { + "hooks": [ + { + "type": "command", + "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/graph-context-prompt.sh" + } + ] } ], "PostToolUse": [ diff --git a/tests/graph-persistence.test.js b/tests/graph-persistence.test.js new file mode 100644 index 0000000..516ec5c --- /dev/null +++ b/tests/graph-persistence.test.js @@ -0,0 +1,483 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { mkdirSync, writeFileSync, readFileSync, rmSync, existsSync } from 'fs'; +import { join } from 'path'; +import { + serializeGraph, + saveGraph, + loadGraph, + extractFileReferences, + extractSubgraph, + formatNavigationContext, + generateCodeMap, + writeCodeMap, + generateGraphIndex, + saveGraphIndex, + persistGraphArtifacts, +} from '../src/lib/graph-persistence.js'; + +const FIXTURES_DIR = join(import.meta.dirname, 'fixtures', 'graph-persistence'); + +beforeAll(() => { + mkdirSync(FIXTURES_DIR, { recursive: true }); +}); + +afterAll(() => { + try { + if (existsSync(FIXTURES_DIR)) { + rmSync(FIXTURES_DIR, { recursive: true, force: true }); + } + } catch { /* ignore cleanup race */ } +}); + +// --------------------------------------------------------------------------- +// Helper: build a minimal raw graph (as buildRepoGraph would return) +// --------------------------------------------------------------------------- +function makeRawGraph(overrides = {}) { + const files = { + 'src/lib/scanner.js': { + imports: ['src/lib/utils.js'], + importedBy: ['src/commands/scan.js', 'src/commands/doc-init.js', 'src/lib/graph-builder.js'], + exports: ['scanRepo', 'detectEntryPoints'], + externalImports: ['fs', 'path'], + lines: 350, + fanIn: 3, + fanOut: 1, + exportCount: 2, + churn: 8, + priority: 22.5, + }, + 'src/lib/utils.js': { + imports: [], + importedBy: ['src/lib/scanner.js'], + exports: ['formatPath'], + externalImports: ['path'], + lines: 50, + fanIn: 1, + fanOut: 0, + exportCount: 1, + churn: 2, + priority: 5.0, + }, + 'src/lib/graph-builder.js': { + imports: ['src/lib/scanner.js'], + importedBy: ['src/commands/doc-init.js'], + exports: ['buildRepoGraph'], + externalImports: ['es-module-lexer'], + lines: 690, + fanIn: 1, + fanOut: 1, + exportCount: 1, + churn: 5, + priority: 15.0, + }, + 'src/commands/scan.js': { + imports: ['src/lib/scanner.js'], + importedBy: [], + exports: ['scanCommand'], + externalImports: ['commander'], + lines: 120, + fanIn: 0, + fanOut: 1, + exportCount: 1, + churn: 3, + priority: 8.0, + }, + 'src/commands/doc-init.js': { + imports: ['src/lib/scanner.js', 'src/lib/graph-builder.js'], + importedBy: [], + exports: ['docInitCommand'], + externalImports: ['commander', 'picocolors'], + lines: 891, + fanIn: 0, + fanOut: 2, + exportCount: 1, + churn: 10, + priority: 25.0, + }, + 'tests/scanner.test.js': { + imports: [], + importedBy: [], + exports: [], + externalImports: ['vitest'], + lines: 200, + fanIn: 0, + fanOut: 0, + exportCount: 0, + churn: 4, + priority: 9.0, + }, + }; + + return { + files, + edges: [ + { from: 'src/lib/scanner.js', to: 'src/lib/utils.js' }, + { from: 'src/commands/scan.js', to: 'src/lib/scanner.js' }, + { from: 'src/commands/doc-init.js', to: 'src/lib/scanner.js' }, + { from: 'src/commands/doc-init.js', to: 'src/lib/graph-builder.js' }, + { from: 'src/lib/graph-builder.js', to: 'src/lib/scanner.js' }, + ], + ranked: Object.entries(files) + .map(([path, info]) => ({ path, ...info })) + .sort((a, b) => b.priority - a.priority), + hubs: [ + { path: 'src/lib/scanner.js', fanIn: 3, fanOut: 1, exports: ['scanRepo', 'detectEntryPoints'] }, + { path: 'src/lib/graph-builder.js', fanIn: 1, fanOut: 1, exports: ['buildRepoGraph'] }, + ], + clusters: { + components: [ + { label: 'src', files: ['src/lib/scanner.js', 'src/lib/utils.js', 'src/lib/graph-builder.js', 'src/commands/scan.js', 'src/commands/doc-init.js'], size: 5 }, + { label: 'tests', files: ['tests/scanner.test.js'], size: 1 }, + ], + coupling: [ + { from: 'src', to: 'tests', edges: 0 }, + ], + }, + hotspots: [ + { path: 'src/commands/doc-init.js', churn: 10, lines: 891 }, + { path: 'src/lib/scanner.js', churn: 8, lines: 350 }, + ], + entryPoints: ['src/commands/scan.js'], + stats: { + totalFiles: 6, + totalEdges: 5, + totalExternalImports: 6, + }, + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// serializeGraph +// --------------------------------------------------------------------------- +describe('serializeGraph', () => { + it('produces correct version and meta', () => { + const raw = makeRawGraph(); + const serialized = serializeGraph(raw, FIXTURES_DIR); + + expect(serialized.version).toBe('1.0'); + expect(serialized.meta.totalFiles).toBe(6); + expect(serialized.meta.totalEdges).toBe(5); + expect(serialized.meta.generatedAt).toBeTruthy(); + }); + + it('adds cluster field to each file', () => { + const raw = makeRawGraph(); + const serialized = serializeGraph(raw, FIXTURES_DIR); + + expect(serialized.files['src/lib/scanner.js'].cluster).toBe('src'); + expect(serialized.files['tests/scanner.test.js'].cluster).toBe('tests'); + }); + + it('builds clusterIndex for O(1) lookup', () => { + const raw = makeRawGraph(); + const serialized = serializeGraph(raw, FIXTURES_DIR); + + expect(serialized.clusterIndex).toHaveProperty('src'); + expect(serialized.clusterIndex).toHaveProperty('tests'); + expect(serialized.clusters[serialized.clusterIndex.src].label).toBe('src'); + }); + + it('preserves hubs and hotspots', () => { + const raw = makeRawGraph(); + const serialized = serializeGraph(raw, FIXTURES_DIR); + + expect(serialized.hubs).toHaveLength(2); + expect(serialized.hubs[0].path).toBe('src/lib/scanner.js'); + expect(serialized.hotspots).toHaveLength(2); + }); + + it('drops externalImports from files', () => { + const raw = makeRawGraph(); + const serialized = serializeGraph(raw, FIXTURES_DIR); + + expect(serialized.files['src/lib/scanner.js']).not.toHaveProperty('externalImports'); + }); + + it('rounds priority to 1 decimal place', () => { + const raw = makeRawGraph(); + const serialized = serializeGraph(raw, FIXTURES_DIR); + + for (const info of Object.values(serialized.files)) { + const str = String(info.priority); + const decimals = str.includes('.') ? str.split('.')[1].length : 0; + expect(decimals).toBeLessThanOrEqual(1); + } + }); +}); + +// --------------------------------------------------------------------------- +// saveGraph / loadGraph — round-trip +// --------------------------------------------------------------------------- +describe('saveGraph / loadGraph', () => { + it('round-trips correctly', () => { + const dir = join(FIXTURES_DIR, 'roundtrip'); + mkdirSync(dir, { recursive: true }); + + const raw = makeRawGraph(); + const serialized = serializeGraph(raw, dir); + saveGraph(dir, serialized); + + const loaded = loadGraph(dir); + expect(loaded).toEqual(serialized); + }); + + it('returns null for missing file', () => { + const result = loadGraph(join(FIXTURES_DIR, 'nonexistent')); + expect(result).toBeNull(); + }); + + it('returns null for invalid JSON', () => { + const dir = join(FIXTURES_DIR, 'invalid-json'); + mkdirSync(join(dir, '.claude'), { recursive: true }); + writeFileSync(join(dir, '.claude', 'graph.json'), '{ invalid json }}}'); + + const result = loadGraph(dir); + expect(result).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// extractFileReferences +// --------------------------------------------------------------------------- +describe('extractFileReferences', () => { + const graph = serializeGraph(makeRawGraph(), FIXTURES_DIR); + + it('matches explicit paths', () => { + const refs = extractFileReferences('look at src/lib/scanner.js please', graph); + expect(refs).toContain('src/lib/scanner.js'); + }); + + it('matches bare filenames', () => { + const refs = extractFileReferences('the scanner.js file has a bug', graph); + expect(refs).toContain('src/lib/scanner.js'); + }); + + it('does not match non-existent files', () => { + const refs = extractFileReferences('check nonexistent.js for issues', graph); + expect(refs).toHaveLength(0); + }); + + it('matches multiple files', () => { + const refs = extractFileReferences('compare scanner.js and graph-builder.js', graph); + expect(refs).toContain('src/lib/scanner.js'); + expect(refs).toContain('src/lib/graph-builder.js'); + }); + + it('falls back to cluster keywords when no files match', () => { + const refs = extractFileReferences('how does the tests module work', graph); + expect(refs.length).toBeGreaterThan(0); + expect(refs.some(r => r.startsWith('tests/'))).toBe(true); + }); + + it('deduplicates results', () => { + // Both explicit path and bare name should only appear once + const refs = extractFileReferences('fix src/lib/scanner.js (scanner.js)', graph); + const unique = new Set(refs); + expect(refs.length).toBe(unique.size); + }); +}); + +// --------------------------------------------------------------------------- +// extractSubgraph +// --------------------------------------------------------------------------- +describe('extractSubgraph', () => { + const graph = serializeGraph(makeRawGraph(), FIXTURES_DIR); + + it('returns empty for no files', () => { + const sub = extractSubgraph(graph, []); + expect(sub.mentionedFiles).toHaveLength(0); + }); + + it('includes mentioned files with full info', () => { + const sub = extractSubgraph(graph, ['src/lib/scanner.js']); + expect(sub.mentionedFiles).toHaveLength(1); + expect(sub.mentionedFiles[0].path).toBe('src/lib/scanner.js'); + expect(sub.mentionedFiles[0].fanIn).toBe(3); + }); + + it('includes 1-hop neighbors', () => { + const sub = extractSubgraph(graph, ['src/lib/scanner.js']); + const neighborPaths = sub.neighbors.map(n => n.path); + // scanner imports utils and is imported by scan.js, doc-init.js, graph-builder.js + expect(neighborPaths).toContain('src/lib/utils.js'); + expect(neighborPaths.length).toBeGreaterThan(0); + }); + + it('includes relevant hubs', () => { + const sub = extractSubgraph(graph, ['src/commands/scan.js']); + // scan.js is in the 'src' cluster, scanner.js is a hub in that cluster + expect(sub.hubs.length).toBeGreaterThan(0); + }); + + it('includes cluster context', () => { + const sub = extractSubgraph(graph, ['src/lib/scanner.js']); + expect(sub.clusters.length).toBeGreaterThan(0); + expect(sub.clusters[0].label).toBe('src'); + }); + + it('handles missing files gracefully', () => { + const sub = extractSubgraph(graph, ['nonexistent/file.js']); + expect(sub.mentionedFiles).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// formatNavigationContext +// --------------------------------------------------------------------------- +describe('formatNavigationContext', () => { + const graph = serializeGraph(makeRawGraph(), FIXTURES_DIR); + + it('returns empty string for empty subgraph', () => { + const sub = extractSubgraph(graph, []); + expect(formatNavigationContext(sub)).toBe(''); + }); + + it('returns markdown with referenced files section', () => { + const sub = extractSubgraph(graph, ['src/lib/scanner.js']); + const md = formatNavigationContext(sub); + expect(md).toContain('## Code Navigation'); + expect(md).toContain('**Referenced files:**'); + expect(md).toContain('src/lib/scanner.js'); + }); + + it('shows hub tag for high-fanIn files', () => { + const sub = extractSubgraph(graph, ['src/lib/scanner.js']); + const md = formatNavigationContext(sub); + expect(md).toContain('hub: 3 dependents'); + }); + + it('shows cluster info', () => { + const sub = extractSubgraph(graph, ['src/lib/scanner.js']); + const md = formatNavigationContext(sub); + expect(md).toContain('**Cluster:**'); + expect(md).toContain('src'); + }); + + it('stays within line budget', () => { + const sub = extractSubgraph(graph, Object.keys(graph.files)); + const md = formatNavigationContext(sub); + const lineCount = md.split('\n').length; + expect(lineCount).toBeLessThan(60); + }); +}); + +// --------------------------------------------------------------------------- +// generateCodeMap +// --------------------------------------------------------------------------- +describe('generateCodeMap', () => { + const graph = serializeGraph(makeRawGraph(), FIXTURES_DIR); + + it('produces codebase structure header', () => { + const map = generateCodeMap(graph); + expect(map).toContain('## Codebase Structure'); + }); + + it('includes hub files', () => { + const map = generateCodeMap(graph); + expect(map).toContain('Hub files'); + expect(map).toContain('src/lib/scanner.js'); + }); + + it('includes hotspots', () => { + const map = generateCodeMap(graph); + expect(map).toContain('Hotspots'); + }); + + it('includes graph stats', () => { + const map = generateCodeMap(graph); + expect(map).toContain('6 files'); + expect(map).toContain('5 edges'); + }); +}); + +// --------------------------------------------------------------------------- +// writeCodeMap +// --------------------------------------------------------------------------- +describe('writeCodeMap', () => { + it('writes code-map.md to .claude/', () => { + const dir = join(FIXTURES_DIR, 'code-map-write'); + mkdirSync(dir, { recursive: true }); + + const graph = serializeGraph(makeRawGraph(), dir); + writeCodeMap(dir, graph); + + const mapPath = join(dir, '.claude', 'code-map.md'); + expect(existsSync(mapPath)).toBe(true); + + const content = readFileSync(mapPath, 'utf-8'); + expect(content).toContain('## Codebase Structure'); + }); +}); + +// --------------------------------------------------------------------------- +// generateGraphIndex +// --------------------------------------------------------------------------- +describe('generateGraphIndex', () => { + const graph = serializeGraph(makeRawGraph(), FIXTURES_DIR); + + it('builds export name index as arrays', () => { + const index = generateGraphIndex(graph); + expect(index.exports.scanRepo).toEqual(['src/lib/scanner.js']); + expect(index.exports.buildRepoGraph).toEqual(['src/lib/graph-builder.js']); + }); + + it('builds hub basenames index as arrays', () => { + const index = generateGraphIndex(graph); + expect(index.hubBasenames['scanner.js']).toEqual(['src/lib/scanner.js']); + }); + + it('includes cluster labels', () => { + const index = generateGraphIndex(graph); + expect(index.clusterLabels).toContain('src'); + expect(index.clusterLabels).toContain('tests'); + }); + + it('skips very short export names', () => { + const index = generateGraphIndex(graph); + // All exports in our fixture are > 2 chars, so nothing should be skipped + // But if we had a 2-char export, it should be excluded + for (const key of Object.keys(index.exports)) { + expect(key.length).toBeGreaterThan(2); + } + }); +}); + +// --------------------------------------------------------------------------- +// saveGraphIndex / round-trip +// --------------------------------------------------------------------------- +describe('saveGraphIndex', () => { + it('writes and is loadable', () => { + const dir = join(FIXTURES_DIR, 'index-write'); + mkdirSync(dir, { recursive: true }); + + const graph = serializeGraph(makeRawGraph(), dir); + const index = generateGraphIndex(graph); + saveGraphIndex(dir, index); + + const indexPath = join(dir, '.claude', 'graph-index.json'); + expect(existsSync(indexPath)).toBe(true); + + const loaded = JSON.parse(readFileSync(indexPath, 'utf-8')); + expect(loaded.exports.scanRepo).toEqual(['src/lib/scanner.js']); + expect(loaded.hubBasenames).toBeDefined(); + expect(loaded.clusterLabels).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// persistGraphArtifacts +// --------------------------------------------------------------------------- +describe('persistGraphArtifacts', () => { + it('writes graph.json, code-map.md, and index in one call', () => { + const dir = join(FIXTURES_DIR, 'persist-all'); + mkdirSync(dir, { recursive: true }); + + const raw = makeRawGraph(); + persistGraphArtifacts(dir, raw); + + expect(existsSync(join(dir, '.claude', 'graph.json'))).toBe(true); + expect(existsSync(join(dir, '.claude', 'graph-index.json'))).toBe(true); + expect(existsSync(join(dir, '.claude', 'code-map.md'))).toBe(true); + }); +});