mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-21 00:21:30 +00:00
Embeddings (ONNX Runtime) caused segfaults on macOS (#38) and CUDA install failures on Linux (#40). Since embeddings only power semantic vector search in query() and BM25 + graph traversal already provide strong retrieval, disable embeddings by default with minimal quality impact. Changes: - Replace --skip-embeddings with --embeddings opt-in flag - Remove disposeEmbedder() call (ONNX native cleanup segfaults) - Add process.exit(0) when embeddings are used to bypass ONNX atexit hooks - Suppress ONNX session warnings via logSeverityLevel - Fix progress bar duplication by routing console output through bar.log() - Add elapsed time indicator for long-running phases Fixes #38 Fixes #40
317 lines
12 KiB
TypeScript
317 lines
12 KiB
TypeScript
/**
|
||
* Analyze Command
|
||
*
|
||
* Indexes a repository and stores the knowledge graph in .gitnexus/
|
||
*/
|
||
|
||
import path from 'path';
|
||
import cliProgress from 'cli-progress';
|
||
import { runPipelineFromRepo } from '../core/ingestion/pipeline.js';
|
||
import { initKuzu, loadGraphToKuzu, getKuzuStats, executeQuery, executeWithReusedStatement, closeKuzu, createFTSIndex, loadCachedEmbeddings } from '../core/kuzu/kuzu-adapter.js';
|
||
import { runEmbeddingPipeline } from '../core/embeddings/embedding-pipeline.js';
|
||
// disposeEmbedder intentionally not called — ONNX Runtime segfaults on cleanup (see #38)
|
||
import { getStoragePaths, saveMeta, loadMeta, addToGitignore, registerRepo, getGlobalRegistryPath } from '../storage/repo-manager.js';
|
||
import { getCurrentCommit, isGitRepo, getGitRoot } from '../storage/git.js';
|
||
import { generateAIContextFiles } from './ai-context.js';
|
||
import fs from 'fs/promises';
|
||
import { registerClaudeHook } from './claude-hooks.js';
|
||
|
||
export interface AnalyzeOptions {
|
||
force?: boolean;
|
||
embeddings?: boolean;
|
||
}
|
||
|
||
/** Threshold: auto-skip embeddings for repos with more nodes than this */
|
||
const EMBEDDING_NODE_LIMIT = 50_000;
|
||
|
||
const PHASE_LABELS: Record<string, string> = {
|
||
extracting: 'Scanning files',
|
||
structure: 'Building structure',
|
||
parsing: 'Parsing code',
|
||
imports: 'Resolving imports',
|
||
calls: 'Tracing calls',
|
||
heritage: 'Extracting inheritance',
|
||
communities: 'Detecting communities',
|
||
processes: 'Detecting processes',
|
||
complete: 'Pipeline complete',
|
||
kuzu: 'Loading into KuzuDB',
|
||
fts: 'Creating search indexes',
|
||
embeddings: 'Generating embeddings',
|
||
done: 'Done',
|
||
};
|
||
|
||
export const analyzeCommand = async (
|
||
inputPath?: string,
|
||
options?: AnalyzeOptions
|
||
) => {
|
||
console.log('\n GitNexus Analyzer\n');
|
||
|
||
let repoPath: string;
|
||
if (inputPath) {
|
||
repoPath = path.resolve(inputPath);
|
||
} else {
|
||
const gitRoot = getGitRoot(process.cwd());
|
||
if (!gitRoot) {
|
||
console.log(' Not inside a git repository\n');
|
||
process.exitCode = 1;
|
||
return;
|
||
}
|
||
repoPath = gitRoot;
|
||
}
|
||
|
||
if (!isGitRepo(repoPath)) {
|
||
console.log(' Not a git repository\n');
|
||
process.exitCode = 1;
|
||
return;
|
||
}
|
||
|
||
const { storagePath, kuzuPath } = getStoragePaths(repoPath);
|
||
const currentCommit = getCurrentCommit(repoPath);
|
||
const existingMeta = await loadMeta(storagePath);
|
||
|
||
if (existingMeta && !options?.force && existingMeta.lastCommit === currentCommit) {
|
||
console.log(' Already up to date\n');
|
||
return;
|
||
}
|
||
|
||
// Single progress bar for entire pipeline
|
||
const bar = new cliProgress.SingleBar({
|
||
format: ' {bar} {percentage}% | {phase}',
|
||
barCompleteChar: '\u2588',
|
||
barIncompleteChar: '\u2591',
|
||
hideCursor: true,
|
||
barGlue: '',
|
||
autopadding: true,
|
||
clearOnComplete: false,
|
||
stopOnComplete: false,
|
||
}, cliProgress.Presets.shades_grey);
|
||
|
||
bar.start(100, 0, { phase: 'Initializing...' });
|
||
|
||
// Route all console output through bar.log() so the bar doesn't stamp itself
|
||
// multiple times when other code writes to stdout/stderr mid-render.
|
||
const origLog = console.log.bind(console);
|
||
const origWarn = console.warn.bind(console);
|
||
const origError = console.error.bind(console);
|
||
const barLog = (...args: any[]) => (bar as any).log(args.map(a => (typeof a === 'string' ? a : String(a))).join(' '));
|
||
console.log = barLog;
|
||
console.warn = barLog;
|
||
console.error = barLog;
|
||
|
||
// Show elapsed seconds for phases that run longer than 3s
|
||
let lastPhaseLabel = 'Initializing...';
|
||
let phaseStart = Date.now();
|
||
const elapsedTimer = setInterval(() => {
|
||
const elapsed = Math.round((Date.now() - phaseStart) / 1000);
|
||
if (elapsed >= 3) {
|
||
bar.update({ phase: `${lastPhaseLabel} (${elapsed}s)` });
|
||
}
|
||
}, 1000);
|
||
|
||
const t0Global = Date.now();
|
||
|
||
// ── Cache embeddings from existing index before rebuild ────────────
|
||
let cachedEmbeddingNodeIds = new Set<string>();
|
||
let cachedEmbeddings: Array<{ nodeId: string; embedding: number[] }> = [];
|
||
|
||
if (options?.embeddings && existingMeta && !options?.force) {
|
||
try {
|
||
bar.update(0, { phase: 'Caching embeddings...' });
|
||
await initKuzu(kuzuPath);
|
||
const cached = await loadCachedEmbeddings();
|
||
cachedEmbeddingNodeIds = cached.embeddingNodeIds;
|
||
cachedEmbeddings = cached.embeddings;
|
||
await closeKuzu();
|
||
} catch {
|
||
try { await closeKuzu(); } catch {}
|
||
}
|
||
}
|
||
|
||
// ── Phase 1: Full Pipeline (0–60%) ─────────────────────────────────
|
||
const pipelineResult = await runPipelineFromRepo(repoPath, (progress) => {
|
||
const phaseLabel = PHASE_LABELS[progress.phase] || progress.phase;
|
||
const scaled = Math.round(progress.percent * 0.6);
|
||
if (phaseLabel !== lastPhaseLabel) { lastPhaseLabel = phaseLabel; phaseStart = Date.now(); }
|
||
bar.update(scaled, { phase: phaseLabel });
|
||
});
|
||
|
||
// ── Phase 2: KuzuDB (60–85%) ──────────────────────────────────────
|
||
lastPhaseLabel = 'Loading into KuzuDB...'; phaseStart = Date.now();
|
||
bar.update(60, { phase: lastPhaseLabel });
|
||
|
||
await closeKuzu();
|
||
const kuzuFiles = [kuzuPath, `${kuzuPath}.wal`, `${kuzuPath}.lock`];
|
||
for (const f of kuzuFiles) {
|
||
try { await fs.rm(f, { recursive: true, force: true }); } catch {}
|
||
}
|
||
|
||
const t0Kuzu = Date.now();
|
||
await initKuzu(kuzuPath);
|
||
let kuzuMsgCount = 0;
|
||
const kuzuResult = await loadGraphToKuzu(pipelineResult.graph, pipelineResult.fileContents, storagePath, (msg) => {
|
||
kuzuMsgCount++;
|
||
const progress = Math.min(84, 60 + Math.round((kuzuMsgCount / (kuzuMsgCount + 10)) * 24));
|
||
bar.update(progress, { phase: msg });
|
||
});
|
||
const kuzuTime = ((Date.now() - t0Kuzu) / 1000).toFixed(1);
|
||
const kuzuWarnings = kuzuResult.warnings;
|
||
|
||
// ── Phase 3: FTS (85–90%) ─────────────────────────────────────────
|
||
lastPhaseLabel = 'Creating search indexes...'; phaseStart = Date.now();
|
||
bar.update(85, { phase: lastPhaseLabel });
|
||
|
||
const t0Fts = Date.now();
|
||
try {
|
||
await createFTSIndex('File', 'file_fts', ['name', 'content']);
|
||
await createFTSIndex('Function', 'function_fts', ['name', 'content']);
|
||
await createFTSIndex('Class', 'class_fts', ['name', 'content']);
|
||
await createFTSIndex('Method', 'method_fts', ['name', 'content']);
|
||
await createFTSIndex('Interface', 'interface_fts', ['name', 'content']);
|
||
} catch (e: any) {
|
||
// Non-fatal — FTS is best-effort
|
||
}
|
||
const ftsTime = ((Date.now() - t0Fts) / 1000).toFixed(1);
|
||
|
||
// ── Phase 3.5: Re-insert cached embeddings ────────────────────────
|
||
if (cachedEmbeddings.length > 0) {
|
||
bar.update(88, { phase: `Restoring ${cachedEmbeddings.length} cached embeddings...` });
|
||
const EMBED_BATCH = 200;
|
||
for (let i = 0; i < cachedEmbeddings.length; i += EMBED_BATCH) {
|
||
const batch = cachedEmbeddings.slice(i, i + EMBED_BATCH);
|
||
const paramsList = batch.map(e => ({ nodeId: e.nodeId, embedding: e.embedding }));
|
||
try {
|
||
await executeWithReusedStatement(
|
||
`CREATE (e:CodeEmbedding {nodeId: $nodeId, embedding: $embedding})`,
|
||
paramsList,
|
||
);
|
||
} catch { /* some may fail if node was removed, that's fine */ }
|
||
}
|
||
}
|
||
|
||
// ── Phase 4: Embeddings (90–98%) ──────────────────────────────────
|
||
const stats = await getKuzuStats();
|
||
let embeddingTime = '0.0';
|
||
let embeddingSkipped = true;
|
||
let embeddingSkipReason = 'off (use --embeddings to enable)';
|
||
|
||
if (options?.embeddings) {
|
||
if (stats.nodes > EMBEDDING_NODE_LIMIT) {
|
||
embeddingSkipReason = `skipped (${stats.nodes.toLocaleString()} nodes > ${EMBEDDING_NODE_LIMIT.toLocaleString()} limit)`;
|
||
} else {
|
||
embeddingSkipped = false;
|
||
}
|
||
}
|
||
|
||
if (!embeddingSkipped) {
|
||
lastPhaseLabel = 'Loading embedding model...'; phaseStart = Date.now();
|
||
bar.update(90, { phase: lastPhaseLabel });
|
||
const t0Emb = Date.now();
|
||
await runEmbeddingPipeline(
|
||
executeQuery,
|
||
executeWithReusedStatement,
|
||
(progress) => {
|
||
const scaled = 90 + Math.round((progress.percent / 100) * 8);
|
||
const label = progress.phase === 'loading-model' ? 'Loading embedding model...' : `Embedding ${progress.nodesProcessed || 0}/${progress.totalNodes || '?'}`;
|
||
if (label !== lastPhaseLabel) { lastPhaseLabel = label; phaseStart = Date.now(); }
|
||
bar.update(scaled, { phase: label });
|
||
},
|
||
{},
|
||
cachedEmbeddingNodeIds.size > 0 ? cachedEmbeddingNodeIds : undefined,
|
||
);
|
||
embeddingTime = ((Date.now() - t0Emb) / 1000).toFixed(1);
|
||
}
|
||
|
||
// ── Phase 5: Finalize (98–100%) ───────────────────────────────────
|
||
bar.update(98, { phase: 'Saving metadata...' });
|
||
|
||
const meta = {
|
||
repoPath,
|
||
lastCommit: currentCommit,
|
||
indexedAt: new Date().toISOString(),
|
||
stats: {
|
||
files: pipelineResult.fileContents.size,
|
||
nodes: stats.nodes,
|
||
edges: stats.edges,
|
||
communities: pipelineResult.communityResult?.stats.totalCommunities,
|
||
processes: pipelineResult.processResult?.stats.totalProcesses,
|
||
},
|
||
};
|
||
await saveMeta(storagePath, meta);
|
||
await registerRepo(repoPath, meta);
|
||
await addToGitignore(repoPath);
|
||
|
||
const hookResult = await registerClaudeHook();
|
||
|
||
const projectName = path.basename(repoPath);
|
||
let aggregatedClusterCount = 0;
|
||
if (pipelineResult.communityResult?.communities) {
|
||
const groups = new Map<string, number>();
|
||
for (const c of pipelineResult.communityResult.communities) {
|
||
const label = c.heuristicLabel || c.label || 'Unknown';
|
||
groups.set(label, (groups.get(label) || 0) + c.symbolCount);
|
||
}
|
||
aggregatedClusterCount = Array.from(groups.values()).filter(count => count >= 5).length;
|
||
}
|
||
|
||
const aiContext = await generateAIContextFiles(repoPath, storagePath, projectName, {
|
||
files: pipelineResult.fileContents.size,
|
||
nodes: stats.nodes,
|
||
edges: stats.edges,
|
||
communities: pipelineResult.communityResult?.stats.totalCommunities,
|
||
clusters: aggregatedClusterCount,
|
||
processes: pipelineResult.processResult?.stats.totalProcesses,
|
||
});
|
||
|
||
await closeKuzu();
|
||
// Note: we intentionally do NOT call disposeEmbedder() here.
|
||
// ONNX Runtime's native cleanup segfaults on macOS and some Linux configs.
|
||
// Since the process exits immediately after, Node.js reclaims everything.
|
||
|
||
const totalTime = ((Date.now() - t0Global) / 1000).toFixed(1);
|
||
|
||
clearInterval(elapsedTimer);
|
||
console.log = origLog;
|
||
console.warn = origWarn;
|
||
console.error = origError;
|
||
|
||
bar.update(100, { phase: 'Done' });
|
||
bar.stop();
|
||
|
||
// ── Summary ───────────────────────────────────────────────────────
|
||
const embeddingsCached = cachedEmbeddings.length > 0;
|
||
console.log(`\n Repository indexed successfully (${totalTime}s)${embeddingsCached ? ` [${cachedEmbeddings.length} embeddings cached]` : ''}\n`);
|
||
console.log(` ${stats.nodes.toLocaleString()} nodes | ${stats.edges.toLocaleString()} edges | ${pipelineResult.communityResult?.stats.totalCommunities || 0} clusters | ${pipelineResult.processResult?.stats.totalProcesses || 0} flows`);
|
||
console.log(` KuzuDB ${kuzuTime}s | FTS ${ftsTime}s | Embeddings ${embeddingSkipped ? embeddingSkipReason : embeddingTime + 's'}`);
|
||
console.log(` ${repoPath}`);
|
||
|
||
if (aiContext.files.length > 0) {
|
||
console.log(` Context: ${aiContext.files.join(', ')}`);
|
||
}
|
||
|
||
if (hookResult.registered) {
|
||
console.log(` Hooks: ${hookResult.message}`);
|
||
}
|
||
|
||
// Show warnings (missing schema pairs, etc.) after the clean output
|
||
if (kuzuWarnings.length > 0) {
|
||
console.log(`\n Warnings (${kuzuWarnings.length}):`);
|
||
for (const w of kuzuWarnings) {
|
||
console.log(` ${w}`);
|
||
}
|
||
}
|
||
|
||
try {
|
||
await fs.access(getGlobalRegistryPath());
|
||
} catch {
|
||
console.log('\n Tip: Run `gitnexus setup` to configure MCP for your editor.');
|
||
}
|
||
|
||
console.log('');
|
||
|
||
// ONNX Runtime registers native atexit hooks that segfault during process
|
||
// shutdown on macOS (#38) and some Linux configs (#40). Force-exit to
|
||
// bypass them when embeddings were loaded.
|
||
if (!embeddingSkipped) {
|
||
process.exit(0);
|
||
}
|
||
};
|