From 2c6465106b612937b8308d64a66637e0cc9865d0 Mon Sep 17 00:00:00 2001 From: kzhivotov Date: Sun, 22 Mar 2026 11:27:08 -0700 Subject: [PATCH 1/4] feat: graph --- bin/cli.js | 8 + src/commands/doc-graph.js | 50 ++ src/commands/doc-init.js | 9 + src/commands/doc-sync.js | 40 +- src/lib/graph-persistence.js | 495 +++++++++++++++++++ src/templates/hooks/graph-context-prompt.mjs | 399 +++++++++++++++ src/templates/hooks/graph-context-prompt.sh | 76 +++ src/templates/settings/settings.json | 8 + tests/graph-persistence.test.js | 490 ++++++++++++++++++ 9 files changed, 1571 insertions(+), 4 deletions(-) create mode 100644 src/commands/doc-graph.js create mode 100644 src/lib/graph-persistence.js create mode 100644 src/templates/hooks/graph-context-prompt.mjs create mode 100644 src/templates/hooks/graph-context-prompt.sh create mode 100644 tests/graph-persistence.test.js 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..05ef016 --- /dev/null +++ b/src/commands/doc-graph.js @@ -0,0 +1,50 @@ +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}`); + } + + persistGraphArtifacts(repoPath, repoGraph); + + 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..653fa4d --- /dev/null +++ b/src/lib/graph-persistence.js @@ -0,0 +1,495 @@ +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 { + return JSON.parse(readFileSync(fullPath, 'utf-8')); + } 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. + */ +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 skill generation +// --------------------------------------------------------------------------- + +const MAX_SKILL_HUBS = 10; +const MAX_SKILL_HOTSPOTS = 5; + +/** + * Generate a static code-map skill.md from the serialized graph. + * This skill provides Claude with a structural overview of the codebase + * via the existing skill activation system — no hook needed. + * + * @param {Object} serializedGraph - Output of serializeGraph() + * @returns {string} Markdown content for code-map/skill.md + */ +export function generateCodeMapSkill(serializedGraph) { + const lines = [ + '---', + 'name: code-map', + 'description: Import graph overview — hub files, domain clusters, and codebase structure', + '---', + '', + '## Activation', + '', + 'This skill activates when navigating code, understanding file relationships, debugging, or exploring architecture.', + '', + '---', + '', + ]; + + // 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_SKILL_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 — review carefully)'); + for (const h of serializedGraph.hotspots.slice(0, MAX_SKILL_HOTSPOTS)) { + lines.push(`- \`${h.path}\` — ${h.churn} changes, ${h.lines} lines`); + } + lines.push(''); + } + + // Stats + lines.push(`**Graph:** ${serializedGraph.meta.totalFiles} files, ${serializedGraph.meta.totalEdges} edges`); + lines.push(`**Last Updated:** ${serializedGraph.meta.generatedAt.split('T')[0]}`); + lines.push(''); + + return lines.join('\n'); +} + +/** + * Write the code-map skill to .claude/skills/code-map/skill.md. + */ +export function writeCodeMapSkill(repoPath, serializedGraph) { + const content = generateCodeMapSkill(serializedGraph); + const skillDir = join(repoPath, '.claude', 'skills', 'code-map'); + mkdirSync(skillDir, { recursive: true }); + writeFileSync(join(skillDir, 'skill.md'), 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 path (inverted index) + 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) { + exports[exp] = path; + } + } + } + + // Hub basenames → full path + const hubBasenames = {}; + for (const h of (serializedGraph.hubs || [])) { + const basename = h.path.split('/').pop(); + hubBasenames[basename] = 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 skill, and index in one call. + */ +export function persistGraphArtifacts(repoPath, rawGraph) { + const serialized = serializeGraph(rawGraph, repoPath); + saveGraph(repoPath, serialized); + writeCodeMapSkill(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..f9ab5a0 --- /dev/null +++ b/src/templates/hooks/graph-context-prompt.mjs @@ -0,0 +1,399 @@ +#!/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 + const bareRe = /\b([\w.-]+\.(js|ts|tsx|jsx|py|go|rs|rb))\b/g; + while ((m = bareRe.exec(prompt)) !== null) { + const filename = m[1]; + if (index.hubBasenames[filename]) { + matches.add(index.hubBasenames[filename]); + } + } + + // Tier 3: Export/function names — match words against export index + // Only match word-like tokens that could be identifiers (camelCase, snake_case, PascalCase) + const wordRe = /\b([a-zA-Z_]\w{2,})\b/g; + while ((m = wordRe.exec(prompt)) !== null) { + const word = m[1]; + if (index.exports[word]) { + matches.add(index.exports[word]); + } + } + + // 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: Load tiny index (~1KB, ~1ms) + const index = loadGraphIndex(projectDir); + if (!index) { + process.exit(0); + } + + // Step 2: Fast match against index (~1ms) + const indexMatches = matchPromptAgainstIndex(prompt, index); + if (indexMatches.length === 0) { + process.exit(0); + } + + // Step 3: Match found — load full graph for detailed context + const graph = loadGraphJson(projectDir); + if (!graph) { + process.exit(0); + } + + // Step 4: Resolve index matches to validated file paths + const filePaths = resolveMatches(indexMatches, graph); + if (filePaths.length === 0) { + process.exit(0); + } + + // Step 5: Build neighborhood and format + const neighborhood = buildNeighborhood(graph, filePaths); + const context = formatNavContext(neighborhood); + + if (!context) { + process.exit(0); + } + + // Debug output + if (process.env.ASPENS_DEBUG === '1') { + 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), + indexMatches, + filePaths, + neighborhoodSize: neighborhood.mentionedFiles.length + neighborhood.neighbors.length, + }, null, 2)); + } catch { /* ignore */ } + } + + // Emit graph context (injected into Claude's context via stdout) + const output = `\n${context}\n`; + process.stdout.write(output); + + process.stderr.write(`[Graph] Context: ${filePaths.length} files, ${neighborhood.neighbors.length} neighbors\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..c5853da --- /dev/null +++ b/tests/graph-persistence.test.js @@ -0,0 +1,490 @@ +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, + generateCodeMapSkill, + writeCodeMapSkill, + 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); + }); +}); + +// --------------------------------------------------------------------------- +// generateCodeMapSkill +// --------------------------------------------------------------------------- +describe('generateCodeMapSkill', () => { + const graph = serializeGraph(makeRawGraph(), FIXTURES_DIR); + + it('produces valid skill frontmatter', () => { + const skill = generateCodeMapSkill(graph); + expect(skill).toContain('---'); + expect(skill).toContain('name: code-map'); + expect(skill).toContain('description:'); + }); + + it('includes hub files', () => { + const skill = generateCodeMapSkill(graph); + expect(skill).toContain('## Hub Files'); + expect(skill).toContain('src/lib/scanner.js'); + }); + + it('includes hotspots', () => { + const skill = generateCodeMapSkill(graph); + expect(skill).toContain('## Hotspots'); + }); + + it('includes graph stats', () => { + const skill = generateCodeMapSkill(graph); + expect(skill).toContain('**Graph:**'); + expect(skill).toContain('6 files'); + }); + + it('includes activation section', () => { + const skill = generateCodeMapSkill(graph); + expect(skill).toContain('## Activation'); + }); +}); + +// --------------------------------------------------------------------------- +// writeCodeMapSkill +// --------------------------------------------------------------------------- +describe('writeCodeMapSkill', () => { + it('writes skill.md to correct path', () => { + const dir = join(FIXTURES_DIR, 'code-map-write'); + mkdirSync(dir, { recursive: true }); + + const graph = serializeGraph(makeRawGraph(), dir); + writeCodeMapSkill(dir, graph); + + const skillPath = join(dir, '.claude', 'skills', 'code-map', 'skill.md'); + expect(existsSync(skillPath)).toBe(true); + + const content = readFileSync(skillPath, 'utf-8'); + expect(content).toContain('name: code-map'); + }); +}); + +// --------------------------------------------------------------------------- +// generateGraphIndex +// --------------------------------------------------------------------------- +describe('generateGraphIndex', () => { + const graph = serializeGraph(makeRawGraph(), FIXTURES_DIR); + + it('builds export name index', () => { + const index = generateGraphIndex(graph); + expect(index.exports.scanRepo).toBe('src/lib/scanner.js'); + expect(index.exports.buildRepoGraph).toBe('src/lib/graph-builder.js'); + }); + + it('builds hub basenames index', () => { + const index = generateGraphIndex(graph); + expect(index.hubBasenames['scanner.js']).toBe('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).toBe('src/lib/scanner.js'); + expect(loaded.hubBasenames).toBeDefined(); + expect(loaded.clusterLabels).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// persistGraphArtifacts +// --------------------------------------------------------------------------- +describe('persistGraphArtifacts', () => { + it('writes graph.json, code-map skill, 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', 'skills', 'code-map', 'skill.md'))).toBe(true); + }); +}); From fb5580c1258e505df92d872cbb62cb67617c4a89 Mon Sep 17 00:00:00 2001 From: kzhivotov Date: Sun, 22 Mar 2026 11:37:58 -0700 Subject: [PATCH 2/4] fix: graph hook --- src/lib/graph-persistence.js | 63 ++++++++------------ src/templates/hooks/graph-context-prompt.mjs | 14 ++++- tests/graph-persistence.test.js | 57 ++++++++---------- 3 files changed, 63 insertions(+), 71 deletions(-) diff --git a/src/lib/graph-persistence.js b/src/lib/graph-persistence.js index 653fa4d..61c1e6a 100644 --- a/src/lib/graph-persistence.js +++ b/src/lib/graph-persistence.js @@ -339,39 +339,28 @@ function shortPath(p) { } // --------------------------------------------------------------------------- -// Code-map skill generation +// Code-map generation — standalone overview, independent of skills // --------------------------------------------------------------------------- -const MAX_SKILL_HUBS = 10; -const MAX_SKILL_HOTSPOTS = 5; +const CODE_MAP_PATH = '.claude/code-map.md'; +const MAX_MAP_HUBS = 10; +const MAX_MAP_HOTSPOTS = 5; /** - * Generate a static code-map skill.md from the serialized graph. - * This skill provides Claude with a structural overview of the codebase - * via the existing skill activation system — no hook needed. + * 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/skill.md + * @returns {string} Markdown content for code-map.md */ -export function generateCodeMapSkill(serializedGraph) { - const lines = [ - '---', - 'name: code-map', - 'description: Import graph overview — hub files, domain clusters, and codebase structure', - '---', - '', - '## Activation', - '', - 'This skill activates when navigating code, understanding file relationships, debugging, or exploring architecture.', - '', - '---', - '', - ]; +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_SKILL_HUBS)) { + 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 : ''}`); } @@ -382,7 +371,7 @@ export function generateCodeMapSkill(serializedGraph) { if (serializedGraph.clusters?.length > 0) { const multiFileClusters = serializedGraph.clusters.filter(c => c.size > 1); if (multiFileClusters.length > 0) { - lines.push('## Domain Clusters'); + lines.push('**Domain clusters:**'); for (const c of multiFileClusters) { const topFiles = c.files .filter(f => serializedGraph.files[f]) @@ -398,7 +387,7 @@ export function generateCodeMapSkill(serializedGraph) { // Cross-domain coupling if (serializedGraph.coupling?.length > 0) { - lines.push('## Cross-Domain Dependencies'); + lines.push('**Cross-domain dependencies:**'); for (const c of serializedGraph.coupling.slice(0, 5)) { lines.push(`- ${c.from} \u2192 ${c.to} (${c.edges} imports)`); } @@ -407,29 +396,27 @@ export function generateCodeMapSkill(serializedGraph) { // Hotspots if (serializedGraph.hotspots?.length > 0) { - lines.push('## Hotspots (high churn — review carefully)'); - for (const h of serializedGraph.hotspots.slice(0, MAX_SKILL_HOTSPOTS)) { + 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(''); } - // Stats - lines.push(`**Graph:** ${serializedGraph.meta.totalFiles} files, ${serializedGraph.meta.totalEdges} edges`); - lines.push(`**Last Updated:** ${serializedGraph.meta.generatedAt.split('T')[0]}`); + lines.push(`*${serializedGraph.meta.totalFiles} files, ${serializedGraph.meta.totalEdges} edges — updated ${serializedGraph.meta.generatedAt.split('T')[0]}*`); lines.push(''); return lines.join('\n'); } /** - * Write the code-map skill to .claude/skills/code-map/skill.md. + * Write code-map to .claude/code-map.md. */ -export function writeCodeMapSkill(repoPath, serializedGraph) { - const content = generateCodeMapSkill(serializedGraph); - const skillDir = join(repoPath, '.claude', 'skills', 'code-map'); - mkdirSync(skillDir, { recursive: true }); - writeFileSync(join(skillDir, 'skill.md'), content); +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); } // --------------------------------------------------------------------------- @@ -483,12 +470,12 @@ export function saveGraphIndex(repoPath, index) { } /** - * Convenience: persist graph, code-map skill, and index in one call. + * Convenience: persist graph, code-map, and index in one call. */ export function persistGraphArtifacts(repoPath, rawGraph) { const serialized = serializeGraph(rawGraph, repoPath); saveGraph(repoPath, serialized); - writeCodeMapSkill(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 index f9ab5a0..c8b9165 100644 --- a/src/templates/hooks/graph-context-prompt.mjs +++ b/src/templates/hooks/graph-context-prompt.mjs @@ -380,8 +380,20 @@ async function main() { } catch { /* ignore */ } } + // Load code-map overview (already on disk, only read when we have a match) + let codeMap = ''; + const codeMapPath = join(projectDir, '.claude', 'code-map.md'); + if (existsSync(codeMapPath)) { + try { + codeMap = readFileSync(codeMapPath, 'utf-8'); + } catch { /* ignore */ } + } + // Emit graph context (injected into Claude's context via stdout) - const output = `\n${context}\n`; + let output = '\n'; + if (codeMap) output += codeMap; + output += context; + output += '\n'; process.stdout.write(output); process.stderr.write(`[Graph] Context: ${filePaths.length} files, ${neighborhood.neighbors.length} neighbors\n`); diff --git a/tests/graph-persistence.test.js b/tests/graph-persistence.test.js index c5853da..46e855d 100644 --- a/tests/graph-persistence.test.js +++ b/tests/graph-persistence.test.js @@ -8,8 +8,8 @@ import { extractFileReferences, extractSubgraph, formatNavigationContext, - generateCodeMapSkill, - writeCodeMapSkill, + generateCodeMap, + writeCodeMap, generateGraphIndex, saveGraphIndex, persistGraphArtifacts, @@ -363,57 +363,50 @@ describe('formatNavigationContext', () => { }); // --------------------------------------------------------------------------- -// generateCodeMapSkill +// generateCodeMap // --------------------------------------------------------------------------- -describe('generateCodeMapSkill', () => { +describe('generateCodeMap', () => { const graph = serializeGraph(makeRawGraph(), FIXTURES_DIR); - it('produces valid skill frontmatter', () => { - const skill = generateCodeMapSkill(graph); - expect(skill).toContain('---'); - expect(skill).toContain('name: code-map'); - expect(skill).toContain('description:'); + it('produces codebase structure header', () => { + const map = generateCodeMap(graph); + expect(map).toContain('## Codebase Structure'); }); it('includes hub files', () => { - const skill = generateCodeMapSkill(graph); - expect(skill).toContain('## Hub Files'); - expect(skill).toContain('src/lib/scanner.js'); + const map = generateCodeMap(graph); + expect(map).toContain('Hub files'); + expect(map).toContain('src/lib/scanner.js'); }); it('includes hotspots', () => { - const skill = generateCodeMapSkill(graph); - expect(skill).toContain('## Hotspots'); + const map = generateCodeMap(graph); + expect(map).toContain('Hotspots'); }); it('includes graph stats', () => { - const skill = generateCodeMapSkill(graph); - expect(skill).toContain('**Graph:**'); - expect(skill).toContain('6 files'); - }); - - it('includes activation section', () => { - const skill = generateCodeMapSkill(graph); - expect(skill).toContain('## Activation'); + const map = generateCodeMap(graph); + expect(map).toContain('6 files'); + expect(map).toContain('5 edges'); }); }); // --------------------------------------------------------------------------- -// writeCodeMapSkill +// writeCodeMap // --------------------------------------------------------------------------- -describe('writeCodeMapSkill', () => { - it('writes skill.md to correct path', () => { +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); - writeCodeMapSkill(dir, graph); + writeCodeMap(dir, graph); - const skillPath = join(dir, '.claude', 'skills', 'code-map', 'skill.md'); - expect(existsSync(skillPath)).toBe(true); + const mapPath = join(dir, '.claude', 'code-map.md'); + expect(existsSync(mapPath)).toBe(true); - const content = readFileSync(skillPath, 'utf-8'); - expect(content).toContain('name: code-map'); + const content = readFileSync(mapPath, 'utf-8'); + expect(content).toContain('## Codebase Structure'); }); }); @@ -476,7 +469,7 @@ describe('saveGraphIndex', () => { // persistGraphArtifacts // --------------------------------------------------------------------------- describe('persistGraphArtifacts', () => { - it('writes graph.json, code-map skill, and index in one call', () => { + it('writes graph.json, code-map.md, and index in one call', () => { const dir = join(FIXTURES_DIR, 'persist-all'); mkdirSync(dir, { recursive: true }); @@ -485,6 +478,6 @@ describe('persistGraphArtifacts', () => { expect(existsSync(join(dir, '.claude', 'graph.json'))).toBe(true); expect(existsSync(join(dir, '.claude', 'graph-index.json'))).toBe(true); - expect(existsSync(join(dir, '.claude', 'skills', 'code-map', 'skill.md'))).toBe(true); + expect(existsSync(join(dir, '.claude', 'code-map.md'))).toBe(true); }); }); From 6799f43d70ce242fe80048513b9458d135eb6695 Mon Sep 17 00:00:00 2001 From: kzhivotov Date: Sun, 22 Mar 2026 11:42:30 -0700 Subject: [PATCH 3/4] fix: code map in the context --- src/templates/hooks/graph-context-prompt.mjs | 94 ++++++++++---------- 1 file changed, 45 insertions(+), 49 deletions(-) diff --git a/src/templates/hooks/graph-context-prompt.mjs b/src/templates/hooks/graph-context-prompt.mjs index c8b9165..0450a12 100644 --- a/src/templates/hooks/graph-context-prompt.mjs +++ b/src/templates/hooks/graph-context-prompt.mjs @@ -75,12 +75,12 @@ export function matchPromptAgainstIndex(prompt, index) { } } - // Tier 3: Export/function names — match words against export index - // Only match word-like tokens that could be identifiers (camelCase, snake_case, PascalCase) - const wordRe = /\b([a-zA-Z_]\w{2,})\b/g; - while ((m = wordRe.exec(prompt)) !== null) { - const word = m[1]; - if (index.exports[word]) { + // 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 + if (word && index.exports[word]) { matches.add(index.exports[word]); } } @@ -333,70 +333,66 @@ async function main() { process.exit(0); } - // Step 1: Load tiny index (~1KB, ~1ms) - const index = loadGraphIndex(projectDir); - if (!index) { - process.exit(0); - } - - // Step 2: Fast match against index (~1ms) - const indexMatches = matchPromptAgainstIndex(prompt, index); - if (indexMatches.length === 0) { - process.exit(0); - } - - // Step 3: Match found — load full graph for detailed context - const graph = loadGraphJson(projectDir); - if (!graph) { - 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 */ } } - // Step 4: Resolve index matches to validated file paths - const filePaths = resolveMatches(indexMatches, graph); - if (filePaths.length === 0) { + // If no code-map exists, nothing to do + if (!codeMap) { process.exit(0); } - // Step 5: Build neighborhood and format - const neighborhood = buildNeighborhood(graph, filePaths); - const context = formatNavContext(neighborhood); - - if (!context) { - 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') { + 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), - indexMatches, - filePaths, - neighborhoodSize: neighborhood.mentionedFiles.length + neighborhood.neighbors.length, + ...debugInfo, }, null, 2)); } catch { /* ignore */ } } - // Load code-map overview (already on disk, only read when we have a match) - let codeMap = ''; - const codeMapPath = join(projectDir, '.claude', 'code-map.md'); - if (existsSync(codeMapPath)) { - try { - codeMap = readFileSync(codeMapPath, 'utf-8'); - } catch { /* ignore */ } - } - - // Emit graph context (injected into Claude's context via stdout) + // Emit: always code-map, optionally detailed neighborhood let output = '\n'; - if (codeMap) output += codeMap; - output += context; + output += codeMap; + if (detailedContext) output += '\n' + detailedContext; output += '\n'; process.stdout.write(output); - process.stderr.write(`[Graph] Context: ${filePaths.length} files, ${neighborhood.neighbors.length} neighbors\n`); + 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) { From c6ab8d8297e521f82316a2cdc792f85e20fbb8d0 Mon Sep 17 00:00:00 2001 From: kzhivotov Date: Sun, 22 Mar 2026 11:45:12 -0700 Subject: [PATCH 4/4] fix: cr --- src/commands/doc-graph.js | 7 ++++- src/lib/graph-persistence.js | 27 ++++++++++++++++---- src/templates/hooks/graph-context-prompt.mjs | 12 +++++---- tests/graph-persistence.test.js | 12 ++++----- 4 files changed, 41 insertions(+), 17 deletions(-) diff --git a/src/commands/doc-graph.js b/src/commands/doc-graph.js index 05ef016..e604fbc 100644 --- a/src/commands/doc-graph.js +++ b/src/commands/doc-graph.js @@ -25,7 +25,12 @@ export async function docGraphCommand(path, options) { throw new CliError(`Failed to build import graph: ${err.message}`); } - persistGraphArtifacts(repoPath, repoGraph); + 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')); diff --git a/src/lib/graph-persistence.js b/src/lib/graph-persistence.js index 61c1e6a..815db01 100644 --- a/src/lib/graph-persistence.js +++ b/src/lib/graph-persistence.js @@ -98,7 +98,13 @@ export function loadGraph(repoPath) { const fullPath = join(repoPath, GRAPH_PATH); if (!existsSync(fullPath)) return null; try { - return JSON.parse(readFileSync(fullPath, 'utf-8')); + 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; } @@ -184,6 +190,9 @@ 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) { @@ -433,22 +442,30 @@ const INDEX_PATH = '.claude/graph-index.json'; * @returns {Object} The index object */ export function generateGraphIndex(serializedGraph) { - // Export name → file path (inverted index) + // 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) { - exports[exp] = path; + if (!exports[exp]) { + exports[exp] = [path]; + } else { + exports[exp].push(path); + } } } } - // Hub basenames → full 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(); - hubBasenames[basename] = h.path; + if (!hubBasenames[basename]) { + hubBasenames[basename] = [h.path]; + } else { + hubBasenames[basename].push(h.path); + } } // Cluster labels diff --git a/src/templates/hooks/graph-context-prompt.mjs b/src/templates/hooks/graph-context-prompt.mjs index 0450a12..33651c3 100644 --- a/src/templates/hooks/graph-context-prompt.mjs +++ b/src/templates/hooks/graph-context-prompt.mjs @@ -66,12 +66,13 @@ export function matchPromptAgainstIndex(prompt, index) { matches.add(candidate); } - // Tier 2: Bare filenames matching hub basenames + // 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]; - if (index.hubBasenames[filename]) { - matches.add(index.hubBasenames[filename]); + const hubPaths = index.hubBasenames[filename]; + if (hubPaths) { + for (const p of (Array.isArray(hubPaths) ? hubPaths : [hubPaths])) matches.add(p); } } @@ -80,8 +81,9 @@ export function matchPromptAgainstIndex(prompt, index) { 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 - if (word && index.exports[word]) { - matches.add(index.exports[word]); + const exportPaths = word && index.exports[word]; + if (exportPaths) { + for (const p of (Array.isArray(exportPaths) ? exportPaths : [exportPaths])) matches.add(p); } } diff --git a/tests/graph-persistence.test.js b/tests/graph-persistence.test.js index 46e855d..516ec5c 100644 --- a/tests/graph-persistence.test.js +++ b/tests/graph-persistence.test.js @@ -416,15 +416,15 @@ describe('writeCodeMap', () => { describe('generateGraphIndex', () => { const graph = serializeGraph(makeRawGraph(), FIXTURES_DIR); - it('builds export name index', () => { + it('builds export name index as arrays', () => { const index = generateGraphIndex(graph); - expect(index.exports.scanRepo).toBe('src/lib/scanner.js'); - expect(index.exports.buildRepoGraph).toBe('src/lib/graph-builder.js'); + expect(index.exports.scanRepo).toEqual(['src/lib/scanner.js']); + expect(index.exports.buildRepoGraph).toEqual(['src/lib/graph-builder.js']); }); - it('builds hub basenames index', () => { + it('builds hub basenames index as arrays', () => { const index = generateGraphIndex(graph); - expect(index.hubBasenames['scanner.js']).toBe('src/lib/scanner.js'); + expect(index.hubBasenames['scanner.js']).toEqual(['src/lib/scanner.js']); }); it('includes cluster labels', () => { @@ -459,7 +459,7 @@ describe('saveGraphIndex', () => { expect(existsSync(indexPath)).toBe(true); const loaded = JSON.parse(readFileSync(indexPath, 'utf-8')); - expect(loaded.exports.scanRepo).toBe('src/lib/scanner.js'); + expect(loaded.exports.scanRepo).toEqual(['src/lib/scanner.js']); expect(loaded.hubBasenames).toBeDefined(); expect(loaded.clusterLabels).toBeDefined(); });