mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-04 02:31:36 +00:00
* Initial plan * fix: prevent stack overflow and memory issues on large repo analysis - Convert c3Linearize from recursive to iterative (explicit work stack) to handle deep class hierarchies without stack overflow - Replace push(...arr) spread patterns with safe loops in parse-worker.ts and lbug-adapter.ts to prevent stack overflow on large arrays - Stream relationship CSV lines directly to per-pair temp files in lbug-adapter.ts instead of accumulating millions of lines in memory - Add test for deep 500-level inheritance chain Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/9cb2eed2-adc7-4fa4-9216-e7ac3facb9b5 Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * fix: add stack size flag and enhanced error messages for large repos - Auto-set --stack-size=4096 alongside --max-old-space-size in analyze command to prevent stack overflow on deep class hierarchies - Add helpful error guidance for known large-repo failure modes (stack overflow, heap OOM, Map size limits) Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/9cb2eed2-adc7-4fa4-9216-e7ac3facb9b5 Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * fix: address code review feedback - Add error handling for write stream close in lbug-adapter.ts - Handle backpressure when writing relationship CSV lines to disk - Clarify ENTER/MERGE phase transition comment in resolve.ts - Fix inconsistent stack size in error message (4096 not 8192) Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/9cb2eed2-adc7-4fa4-9216-e7ac3facb9b5 Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * fix: address PR review — resource leak, redundant guard, Set, test depth - Fix write-stream resource leak on readline error by destroying all open WriteStreams before rejecting (lbug-adapter.ts) - Switch failedPairCsvPaths from array to Set for O(1) lookup - Remove redundant MERGE-phase empty-parents guard in resolve.ts (unreachable — ENTER phase already handles that case) - Increase deep inheritance test DEPTH from 500 to 2000 for reliable regression coverage across platforms Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/cf1f3e22-3864-454a-a3a5-2bded9ebfdba Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * style: fix prettier formatting in lbug-adapter.ts Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/b5ca33c4-bb03-402f-a206-21ea7e1e310e Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * chore: revert unintended package.json/lock changes Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/b5ca33c4-bb03-402f-a206-21ea7e1e310e Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * fix: strip NODE_OPTIONS in skip-git-cli test child processes Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/fc59cd11-348b-4e22-b9ea-98787300de48 Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * fix: don't put --stack-size in NODE_OPTIONS (rejected by Node 24) Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/fc59cd11-348b-4e22-b9ea-98787300de48 Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * fix: pass --stack-size as CLI arg only, not in NODE_OPTIONS (Node 24 compat) Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/fc59cd11-348b-4e22-b9ea-98787300de48 Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>
327 lines
11 KiB
TypeScript
327 lines
11 KiB
TypeScript
/**
|
|
* Analyze Command
|
|
*
|
|
* Indexes a repository and stores the knowledge graph in .gitnexus/
|
|
*
|
|
* Delegates core analysis to the shared runFullAnalysis orchestrator.
|
|
* This CLI wrapper handles: heap management, progress bar, SIGINT,
|
|
* skill generation (--skills), summary output, and process.exit().
|
|
*/
|
|
|
|
import path from 'path';
|
|
import { execFileSync } from 'child_process';
|
|
import v8 from 'v8';
|
|
import cliProgress from 'cli-progress';
|
|
import { closeLbug } from '../core/lbug/lbug-adapter.js';
|
|
import { getStoragePaths, getGlobalRegistryPath } from '../storage/repo-manager.js';
|
|
import { getGitRoot, hasGitDir } from '../storage/git.js';
|
|
import { runFullAnalysis } from '../core/run-analyze.js';
|
|
import fs from 'fs/promises';
|
|
|
|
const HEAP_MB = 8192;
|
|
const HEAP_FLAG = `--max-old-space-size=${HEAP_MB}`;
|
|
/** Increase default stack size (KB) to prevent stack overflow on deep class hierarchies. */
|
|
const STACK_KB = 4096;
|
|
const STACK_FLAG = `--stack-size=${STACK_KB}`;
|
|
|
|
/** Re-exec the process with an 8GB heap and larger stack 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;
|
|
|
|
// --stack-size is a V8 flag not allowed in NODE_OPTIONS on Node 24+,
|
|
// so pass it only as a direct CLI argument, not via the environment.
|
|
const cliFlags = [HEAP_FLAG];
|
|
if (!nodeOpts.includes('--stack-size')) cliFlags.push(STACK_FLAG);
|
|
|
|
try {
|
|
execFileSync(process.execPath, [...cliFlags, ...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;
|
|
/** Skip AGENTS.md and CLAUDE.md gitnexus block updates. */
|
|
skipAgentsMd?: boolean;
|
|
/** Omit volatile symbol/relationship counts from AGENTS.md and CLAUDE.md. */
|
|
noStats?: boolean;
|
|
/** Index the folder even when no .git directory is present. */
|
|
skipGit?: boolean;
|
|
}
|
|
|
|
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) {
|
|
if (!options?.skipGit) {
|
|
console.log(
|
|
' Not inside a git repository.\n Tip: pass --skip-git to index any folder without a .git directory.\n',
|
|
);
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
// --skip-git: fall back to cwd as the root
|
|
repoPath = path.resolve(process.cwd());
|
|
} else {
|
|
repoPath = gitRoot;
|
|
}
|
|
}
|
|
|
|
const repoHasGit = hasGitDir(repoPath);
|
|
if (!repoHasGit && !options?.skipGit) {
|
|
console.log(
|
|
' Not a git repository.\n Tip: pass --skip-git to index any folder without a .git directory.\n',
|
|
);
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
if (!repoHasGit) {
|
|
console.log(
|
|
' Warning: no .git directory found \u2014 commit-tracking and incremental updates disabled.\n',
|
|
);
|
|
}
|
|
|
|
// KuzuDB migration cleanup is handled by runFullAnalysis internally.
|
|
// Note: --skills is handled after runFullAnalysis using the returned pipelineResult.
|
|
|
|
if (process.env.GITNEXUS_NO_GITIGNORE) {
|
|
console.log(
|
|
' GITNEXUS_NO_GITIGNORE is set — skipping .gitignore (still reading .gitnexusignore)\n',
|
|
);
|
|
}
|
|
|
|
// ── CLI progress bar setup ─────────────────────────────────────────
|
|
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
|
|
let aborted = false;
|
|
const sigintHandler = () => {
|
|
if (aborted) process.exit(1);
|
|
aborted = true;
|
|
bar.stop();
|
|
console.log('\n Interrupted — cleaning up...');
|
|
closeLbug()
|
|
.catch(() => {})
|
|
.finally(() => process.exit(130));
|
|
};
|
|
process.on('SIGINT', sigintHandler);
|
|
|
|
// Route console output through bar.log() to prevent progress bar corruption
|
|
const origLog = console.log.bind(console);
|
|
const origWarn = console.warn.bind(console);
|
|
const origError = console.error.bind(console);
|
|
const barLog = (...args: any[]) => {
|
|
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
|
|
let lastPhaseLabel = 'Initializing...';
|
|
let phaseStart = Date.now();
|
|
|
|
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 });
|
|
};
|
|
|
|
const elapsedTimer = setInterval(() => {
|
|
const elapsed = Math.round((Date.now() - phaseStart) / 1000);
|
|
if (elapsed >= 3) {
|
|
bar.update({ phase: `${lastPhaseLabel} (${elapsed}s)` });
|
|
}
|
|
}, 1000);
|
|
|
|
const t0 = Date.now();
|
|
|
|
// ── Run shared analysis orchestrator ───────────────────────────────
|
|
try {
|
|
const result = await runFullAnalysis(
|
|
repoPath,
|
|
{
|
|
force: options?.force || options?.skills,
|
|
embeddings: options?.embeddings,
|
|
skipGit: options?.skipGit,
|
|
skipAgentsMd: options?.skipAgentsMd,
|
|
noStats: options?.noStats,
|
|
},
|
|
{
|
|
onProgress: (_phase, percent, message) => {
|
|
updateBar(percent, message);
|
|
},
|
|
onLog: barLog,
|
|
},
|
|
);
|
|
|
|
if (result.alreadyUpToDate) {
|
|
clearInterval(elapsedTimer);
|
|
process.removeListener('SIGINT', sigintHandler);
|
|
console.log = origLog;
|
|
console.warn = origWarn;
|
|
console.error = origError;
|
|
bar.stop();
|
|
console.log(' Already up to date\n');
|
|
// Safe to return without process.exit(0) — the early-return path in
|
|
// runFullAnalysis never opens LadybugDB, so no native handles prevent exit.
|
|
return;
|
|
}
|
|
|
|
// Skill generation (CLI-only, uses pipeline result from analysis)
|
|
if (options?.skills && result.pipelineResult) {
|
|
updateBar(99, 'Generating skill files...');
|
|
try {
|
|
const { generateSkillFiles } = await import('./skill-gen.js');
|
|
const { generateAIContextFiles } = await import('./ai-context.js');
|
|
const skillResult = await generateSkillFiles(
|
|
repoPath,
|
|
result.repoName,
|
|
result.pipelineResult,
|
|
);
|
|
if (skillResult.skills.length > 0) {
|
|
barLog(` Generated ${skillResult.skills.length} skill files`);
|
|
// Re-generate AI context files now that we have skill info
|
|
const s = result.stats;
|
|
const communityResult = result.pipelineResult?.communityResult;
|
|
let aggregatedClusterCount = 0;
|
|
if (communityResult?.communities) {
|
|
const groups = new Map<string, number>();
|
|
for (const c of 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: number) => count >= 5,
|
|
).length;
|
|
}
|
|
const { storagePath: sp } = getStoragePaths(repoPath);
|
|
await generateAIContextFiles(
|
|
repoPath,
|
|
sp,
|
|
result.repoName,
|
|
{
|
|
files: s.files ?? 0,
|
|
nodes: s.nodes ?? 0,
|
|
edges: s.edges ?? 0,
|
|
communities: s.communities,
|
|
clusters: aggregatedClusterCount,
|
|
processes: s.processes,
|
|
},
|
|
skillResult.skills,
|
|
{ skipAgentsMd: options?.skipAgentsMd, noStats: options?.noStats },
|
|
);
|
|
}
|
|
} catch {
|
|
/* best-effort */
|
|
}
|
|
}
|
|
|
|
const totalTime = ((Date.now() - t0) / 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 s = result.stats;
|
|
console.log(`\n Repository indexed successfully (${totalTime}s)\n`);
|
|
console.log(
|
|
` ${(s.nodes ?? 0).toLocaleString()} nodes | ${(s.edges ?? 0).toLocaleString()} edges | ${s.communities ?? 0} clusters | ${s.processes ?? 0} flows`,
|
|
);
|
|
console.log(` ${repoPath}`);
|
|
|
|
try {
|
|
await fs.access(getGlobalRegistryPath());
|
|
} catch {
|
|
console.log('\n Tip: Run `gitnexus setup` to configure MCP for your editor.');
|
|
}
|
|
|
|
console.log('');
|
|
} catch (err: any) {
|
|
clearInterval(elapsedTimer);
|
|
process.removeListener('SIGINT', sigintHandler);
|
|
console.log = origLog;
|
|
console.warn = origWarn;
|
|
console.error = origError;
|
|
bar.stop();
|
|
|
|
const msg = err.message || String(err);
|
|
console.error(`\n Analysis failed: ${msg}\n`);
|
|
|
|
// Provide helpful guidance for known large-repo failure modes
|
|
if (
|
|
msg.includes('Maximum call stack size exceeded') ||
|
|
msg.includes('call stack') ||
|
|
msg.includes('Map maximum size') ||
|
|
msg.includes('Invalid array length') ||
|
|
msg.includes('Invalid string length') ||
|
|
msg.includes('allocation failed') ||
|
|
msg.includes('heap out of memory') ||
|
|
msg.includes('JavaScript heap')
|
|
) {
|
|
console.error(' This error typically occurs on very large repositories.');
|
|
console.error(' Suggestions:');
|
|
console.error(' 1. Add large vendored/generated directories to .gitnexusignore');
|
|
console.error(' 2. Increase Node.js heap: NODE_OPTIONS="--max-old-space-size=16384"');
|
|
console.error(' 3. Increase stack size: NODE_OPTIONS="--stack-size=4096"');
|
|
console.error('');
|
|
}
|
|
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
// 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);
|
|
};
|