mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-26 01:11:16 +00:00
* feat(ingestion): respect .gitignore and .gitnexusignore during file discovery Add support for excluding files from indexing based on .gitignore and .gitnexusignore patterns. Previously, GitNexus used only a hardcoded ignore list, causing significant index pollution in repositories with git-ignored directories containing code (e.g., Docker-mounted volumes). Changes: - Add `ignore` package for gitignore-spec pattern matching - Add `loadIgnoreRules()` to parse .gitignore + .gitnexusignore - Add `createIgnoreFilter()` returning glob-compatible IgnoreLike object - Integrate filter into glob's `ignore` option for directory-level pruning - Remove post-glob `.filter()` call (now handled during traversal) The hardcoded DEFAULT_IGNORE_LIST remains as fallback for non-git repos. Closes #228 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(ingestion): address review feedback on ignore filtering - Distinguish ENOENT vs EACCES in loadIgnoreRules (warn on permission errors) - Add GITNEXUS_NO_GITIGNORE env var to bypass .gitignore parsing - Fix bare-name pattern matching in childrenIgnored (check both with/without trailing slash) - Rename isIgnoredDirectory to isHardcodedIgnoredDirectory for clarity - Add clarifying comments for design decisions (D2 negation, D3 dot:false redundancy) - Add tests for bare-name patterns, file-glob patterns, EACCES handling, env var Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(ingestion): address second round of review feedback - G1: Document GITNEXUS_NO_GITIGNORE in `analyze --help` and log when active - G2: Add comment clarifying path-scurry POSIX normalization contract - G3: Add IgnoreOptions interface — env var now falls back, callers can pass `noGitignore` explicitly for testability and future CLI flag - G4: Add integration test verifying walkRepositoryPaths respects the env var Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(ingestion): gracefully skip files with unavailable tree-sitter grammars Port unsupported language resilience from PR #301 by @jecanore. - Make Kotlin import optional (like Swift) in parser-loader and parse-worker - Add worker-local isLanguageAvailable() with filePath param for tsx distinction - Track and log skipped files per language in both sequential and worker paths - Add skippedLanguages to ParseWorkerResult for worker→main aggregation - Add isLanguageAvailable unit tests Refs: #301, #155, #228 Co-Authored-By: jecanore <juan@housingbase.io> Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test(e2e): add ignore + language-skip end-to-end test with fixture repo Add a fixture repo (test/fixtures/ignore-and-skip-repo/) with .gitignore, .gitnexusignore, TypeScript source files, and a Swift file to exercise all three features end-to-end: - File discovery: verifies .gitignore excludes data/ and *.log, .gitnexusignore excludes vendor/, source files are discovered - Parsing: verifies TypeScript files produce Function nodes and DEFINES relationships, Swift files are skipped gracefully when grammar is unavailable Add the test to the standalone group in ci-integration.yml and coverage job. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(ci): move ignore-and-skip-e2e test to e2e group per review feedback Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(test): use temp directory instead of fixture for e2e ignore test The fixture's .gitignore prevented data/seed.json and debug.log from being committed — these files would be missing after checkout in CI. Switch to creating the entire test structure in a temp directory via beforeAll (matching filesystem-walker.test.ts pattern). This ensures all files exist regardless of git ignore rules. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(test): correct graph API usage in e2e ignore test Use graph.nodes property getter instead of graph.getNodes(), and check Function node filePath instead of non-existent File nodes (File nodes are created by processStructure, not processParsing). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * ci: add workflows permission to ci-integration.yml Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * ci: change workflows permission to write per review Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * ci: move workflows permission from ci-integration.yml to ci.yml caller Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(ci): fix Claude workflows for fork PRs, remove misplaced workflows perm Three issues prevented Claude from running on fork PRs: 1. claude-code-review.yml lacked workflows:write — push failed when fork PRs modify .github/workflows/ files 2. claude.yml had no fork PR support — checked out main and couldn't fetch the fork's branch from origin 3. Cleanup step unconditionally deleted branches even when push failed, breaking the concurrent claude.yml workflow Also removes workflows:write from ci.yml's integration job — CI tests don't need that permission. The permission belongs on the claude workflows that push fork branches. Changes: - Add workflows:write to both claude workflow permissions blocks - Add fork PR detection + branch push/cleanup to claude.yml - Add step id to push-fork; cleanup only runs if push succeeded - Pass branch names via env vars to prevent shell injection (security) - Add concurrency groups to prevent race conditions between workflows - Remove misplaced workflows:write from ci.yml integration job * fix(ci): use GitHub API for fork branch refs instead of git push GITHUB_TOKEN cannot have 'workflows' permission — it's only valid for PATs and GitHub Apps. This means git push fails whenever a fork PR modifies .github/workflows/ files. Replace git push with the GitHub REST API (POST/PATCH /git/refs) to create temporary branch refs. The API creates a pointer to the already-existing PR head commit without triggering the workflow file push protection. Similarly, cleanup uses DELETE /git/refs instead of git push --delete. Also removes the invalid 'workflows: write' from permissions blocks. --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: jecanore <juan@housingbase.io> Co-authored-by: Gergo Magyar <gergomagyar@icloud.com>
398 lines
15 KiB
TypeScript
398 lines
15 KiB
TypeScript
/**
|
||
* Analyze Command
|
||
*
|
||
* Indexes a repository and stores the knowledge graph in .gitnexus/
|
||
*/
|
||
|
||
import path from 'path';
|
||
import { execFileSync } from 'child_process';
|
||
import v8 from 'v8';
|
||
import cliProgress from 'cli-progress';
|
||
import { runPipelineFromRepo } from '../core/ingestion/pipeline.js';
|
||
import { initLbug, loadGraphToLbug, getLbugStats, executeQuery, executeWithReusedStatement, closeLbug, createFTSIndex, loadCachedEmbeddings } from '../core/lbug/lbug-adapter.js';
|
||
// Embedding imports are lazy (dynamic import) so onnxruntime-node is never
|
||
// loaded when embeddings are not requested. This avoids crashes on Node
|
||
// versions whose ABI is not yet supported by the native binary (#89).
|
||
// disposeEmbedder intentionally not called — ONNX Runtime segfaults on cleanup (see #38)
|
||
import { getStoragePaths, saveMeta, loadMeta, addToGitignore, registerRepo, getGlobalRegistryPath, cleanupOldKuzuFiles } from '../storage/repo-manager.js';
|
||
import { getCurrentCommit, isGitRepo, getGitRoot } from '../storage/git.js';
|
||
import { generateAIContextFiles } from './ai-context.js';
|
||
import { generateSkillFiles, type GeneratedSkillInfo } from './skill-gen.js';
|
||
import fs from 'fs/promises';
|
||
|
||
|
||
const HEAP_MB = 8192;
|
||
const HEAP_FLAG = `--max-old-space-size=${HEAP_MB}`;
|
||
|
||
/** Re-exec the process with an 8GB heap if we're currently below that. */
|
||
function ensureHeap(): boolean {
|
||
const nodeOpts = process.env.NODE_OPTIONS || '';
|
||
if (nodeOpts.includes('--max-old-space-size')) return false;
|
||
|
||
const v8Heap = v8.getHeapStatistics().heap_size_limit;
|
||
if (v8Heap >= HEAP_MB * 1024 * 1024 * 0.9) return false;
|
||
|
||
try {
|
||
execFileSync(process.execPath, [HEAP_FLAG, ...process.argv.slice(1)], {
|
||
stdio: 'inherit',
|
||
env: { ...process.env, NODE_OPTIONS: `${nodeOpts} ${HEAP_FLAG}`.trim() },
|
||
});
|
||
} catch (e: any) {
|
||
process.exitCode = e.status ?? 1;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
export interface AnalyzeOptions {
|
||
force?: boolean;
|
||
embeddings?: boolean;
|
||
skills?: boolean;
|
||
verbose?: 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',
|
||
lbug: 'Loading into LadybugDB',
|
||
fts: 'Creating search indexes',
|
||
embeddings: 'Generating embeddings',
|
||
done: 'Done',
|
||
};
|
||
|
||
export const analyzeCommand = async (
|
||
inputPath?: string,
|
||
options?: AnalyzeOptions
|
||
) => {
|
||
if (ensureHeap()) return;
|
||
|
||
if (options?.verbose) {
|
||
process.env.GITNEXUS_VERBOSE = '1';
|
||
}
|
||
|
||
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, lbugPath } = getStoragePaths(repoPath);
|
||
|
||
// Clean up stale KuzuDB files from before the LadybugDB migration.
|
||
// If kuzu existed but lbug doesn't, we're doing a migration re-index — say so.
|
||
const kuzuResult = await cleanupOldKuzuFiles(storagePath);
|
||
if (kuzuResult.found && kuzuResult.needsReindex) {
|
||
console.log(' Migrating from KuzuDB to LadybugDB — rebuilding index...\n');
|
||
}
|
||
|
||
const currentCommit = getCurrentCommit(repoPath);
|
||
const existingMeta = await loadMeta(storagePath);
|
||
|
||
if (existingMeta && !options?.force && !options?.skills && existingMeta.lastCommit === currentCommit) {
|
||
console.log(' Already up to date\n');
|
||
return;
|
||
}
|
||
|
||
if (process.env.GITNEXUS_NO_GITIGNORE) {
|
||
console.log(' GITNEXUS_NO_GITIGNORE is set — skipping .gitignore (still reading .gitnexusignore)\n');
|
||
}
|
||
|
||
// 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...' });
|
||
|
||
// Graceful SIGINT handling — clean up resources and exit
|
||
let aborted = false;
|
||
const sigintHandler = () => {
|
||
if (aborted) process.exit(1); // Second Ctrl-C: force exit
|
||
aborted = true;
|
||
bar.stop();
|
||
console.log('\n Interrupted — cleaning up...');
|
||
closeLbug().catch(() => {}).finally(() => process.exit(130));
|
||
};
|
||
process.on('SIGINT', sigintHandler);
|
||
|
||
// 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[]) => {
|
||
// Clear the bar line, print the message, then let the next bar.update redraw
|
||
process.stdout.write('\x1b[2K\r');
|
||
origLog(args.map(a => (typeof a === 'string' ? a : String(a))).join(' '));
|
||
};
|
||
console.log = barLog;
|
||
console.warn = barLog;
|
||
console.error = barLog;
|
||
|
||
// Track elapsed time per phase — both updateBar and the interval use the
|
||
// same format so they don't flicker against each other.
|
||
let lastPhaseLabel = 'Initializing...';
|
||
let phaseStart = Date.now();
|
||
|
||
/** Update bar with phase label + elapsed seconds (shown after 3s). */
|
||
const updateBar = (value: number, phaseLabel: string) => {
|
||
if (phaseLabel !== lastPhaseLabel) { lastPhaseLabel = phaseLabel; phaseStart = Date.now(); }
|
||
const elapsed = Math.round((Date.now() - phaseStart) / 1000);
|
||
const display = elapsed >= 3 ? `${phaseLabel} (${elapsed}s)` : phaseLabel;
|
||
bar.update(value, { phase: display });
|
||
};
|
||
|
||
// Tick elapsed seconds for phases with infrequent progress callbacks
|
||
// (e.g. CSV streaming, FTS indexing). Uses the same display format as
|
||
// updateBar so there's no flickering.
|
||
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 {
|
||
updateBar(0, 'Caching embeddings...');
|
||
await initLbug(lbugPath);
|
||
const cached = await loadCachedEmbeddings();
|
||
cachedEmbeddingNodeIds = cached.embeddingNodeIds;
|
||
cachedEmbeddings = cached.embeddings;
|
||
await closeLbug();
|
||
} catch {
|
||
try { await closeLbug(); } 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);
|
||
updateBar(scaled, phaseLabel);
|
||
});
|
||
|
||
// ── Phase 2: LadybugDB (60–85%) ──────────────────────────────────────
|
||
updateBar(60, 'Loading into LadybugDB...');
|
||
|
||
await closeLbug();
|
||
const lbugFiles = [lbugPath, `${lbugPath}.wal`, `${lbugPath}.lock`];
|
||
for (const f of lbugFiles) {
|
||
try { await fs.rm(f, { recursive: true, force: true }); } catch {}
|
||
}
|
||
|
||
const t0Lbug = Date.now();
|
||
await initLbug(lbugPath);
|
||
let lbugMsgCount = 0;
|
||
const lbugResult = await loadGraphToLbug(pipelineResult.graph, pipelineResult.repoPath, storagePath, (msg) => {
|
||
lbugMsgCount++;
|
||
const progress = Math.min(84, 60 + Math.round((lbugMsgCount / (lbugMsgCount + 10)) * 24));
|
||
updateBar(progress, msg);
|
||
});
|
||
const lbugTime = ((Date.now() - t0Lbug) / 1000).toFixed(1);
|
||
const lbugWarnings = lbugResult.warnings;
|
||
|
||
// ── Phase 3: FTS (85–90%) ─────────────────────────────────────────
|
||
updateBar(85, 'Creating search indexes...');
|
||
|
||
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) {
|
||
updateBar(88, `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 getLbugStats();
|
||
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) {
|
||
updateBar(90, 'Loading embedding model...');
|
||
const t0Emb = Date.now();
|
||
const { runEmbeddingPipeline } = await import('../core/embeddings/embedding-pipeline.js');
|
||
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 || '?'}`;
|
||
updateBar(scaled, label);
|
||
},
|
||
{},
|
||
cachedEmbeddingNodeIds.size > 0 ? cachedEmbeddingNodeIds : undefined,
|
||
);
|
||
embeddingTime = ((Date.now() - t0Emb) / 1000).toFixed(1);
|
||
}
|
||
|
||
// ── Phase 5: Finalize (98–100%) ───────────────────────────────────
|
||
updateBar(98, 'Saving metadata...');
|
||
|
||
// Count embeddings in the index (cached + newly generated)
|
||
let embeddingCount = 0;
|
||
try {
|
||
const embResult = await executeQuery(`MATCH (e:CodeEmbedding) RETURN count(e) AS cnt`);
|
||
embeddingCount = embResult?.[0]?.cnt ?? 0;
|
||
} catch { /* table may not exist if embeddings never ran */ }
|
||
|
||
const meta = {
|
||
repoPath,
|
||
lastCommit: currentCommit,
|
||
indexedAt: new Date().toISOString(),
|
||
stats: {
|
||
files: pipelineResult.totalFileCount,
|
||
nodes: stats.nodes,
|
||
edges: stats.edges,
|
||
communities: pipelineResult.communityResult?.stats.totalCommunities,
|
||
processes: pipelineResult.processResult?.stats.totalProcesses,
|
||
embeddings: embeddingCount,
|
||
},
|
||
};
|
||
await saveMeta(storagePath, meta);
|
||
await registerRepo(repoPath, meta);
|
||
await addToGitignore(repoPath);
|
||
|
||
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;
|
||
}
|
||
|
||
let generatedSkills: GeneratedSkillInfo[] = [];
|
||
if (options?.skills && pipelineResult.communityResult) {
|
||
updateBar(99, 'Generating skill files...');
|
||
const skillResult = await generateSkillFiles(repoPath, projectName, pipelineResult);
|
||
generatedSkills = skillResult.skills;
|
||
}
|
||
|
||
const aiContext = await generateAIContextFiles(repoPath, storagePath, projectName, {
|
||
files: pipelineResult.totalFileCount,
|
||
nodes: stats.nodes,
|
||
edges: stats.edges,
|
||
communities: pipelineResult.communityResult?.stats.totalCommunities,
|
||
clusters: aggregatedClusterCount,
|
||
processes: pipelineResult.processResult?.stats.totalProcesses,
|
||
}, generatedSkills);
|
||
|
||
await closeLbug();
|
||
// 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);
|
||
process.removeListener('SIGINT', sigintHandler);
|
||
|
||
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(` LadybugDB ${lbugTime}s | FTS ${ftsTime}s | Embeddings ${embeddingSkipped ? embeddingSkipReason : embeddingTime + 's'}`);
|
||
console.log(` ${repoPath}`);
|
||
|
||
if (aiContext.files.length > 0) {
|
||
console.log(` Context: ${aiContext.files.join(', ')}`);
|
||
}
|
||
|
||
// Show a quiet summary if some edge types needed fallback insertion
|
||
if (lbugWarnings.length > 0) {
|
||
const totalFallback = lbugWarnings.reduce((sum, w) => {
|
||
const m = w.match(/\((\d+) edges\)/);
|
||
return sum + (m ? parseInt(m[1]) : 0);
|
||
}, 0);
|
||
console.log(` Note: ${totalFallback} edges across ${lbugWarnings.length} types inserted via fallback (schema will be updated in next release)`);
|
||
}
|
||
|
||
try {
|
||
await fs.access(getGlobalRegistryPath());
|
||
} catch {
|
||
console.log('\n Tip: Run `gitnexus setup` to configure MCP for your editor.');
|
||
}
|
||
|
||
console.log('');
|
||
|
||
// LadybugDB's native module holds open handles that prevent Node from exiting.
|
||
// ONNX Runtime also registers native atexit hooks that segfault on some
|
||
// platforms (#38, #40). Force-exit to ensure clean termination.
|
||
process.exit(0);
|
||
};
|