GitNexus/gitnexus/src/cli/analyze.ts
abhigyanpatwari 302c7ba7f0 fix: disable embeddings by default, fix segfault on macOS/Linux
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
2026-02-23 03:47:34 +05:30

317 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 (060%) ─────────────────────────────────
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 (6085%) ──────────────────────────────────────
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 (8590%) ─────────────────────────────────────────
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 (9098%) ──────────────────────────────────
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 (98100%) ───────────────────────────────────
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);
}
};