@veewo/gitnexus 1.3.10 → 1.3.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/README.md +3 -3
  2. package/dist/benchmark/analyze-memory-sampler.d.ts +10 -0
  3. package/dist/benchmark/analyze-memory-sampler.js +12 -0
  4. package/dist/benchmark/analyze-memory-sampler.test.d.ts +1 -0
  5. package/dist/benchmark/analyze-memory-sampler.test.js +12 -0
  6. package/dist/benchmark/io.test.js +48 -5
  7. package/dist/benchmark/u2-e2e/config.d.ts +1 -0
  8. package/dist/benchmark/u2-e2e/retrieval-runner.js +25 -3
  9. package/dist/benchmark/u2-e2e/retrieval-runner.test.js +44 -1
  10. package/dist/benchmark/unity-lazy-context-sampler.d.ts +58 -0
  11. package/dist/benchmark/unity-lazy-context-sampler.js +217 -0
  12. package/dist/benchmark/unity-lazy-context-sampler.test.d.ts +1 -0
  13. package/dist/benchmark/unity-lazy-context-sampler.test.js +32 -0
  14. package/dist/cli/analyze-close-policy.d.ts +5 -0
  15. package/dist/cli/analyze-close-policy.js +9 -0
  16. package/dist/cli/analyze-close-policy.test.d.ts +1 -0
  17. package/dist/cli/analyze-close-policy.test.js +12 -0
  18. package/dist/cli/analyze-runtime-summary.d.ts +2 -0
  19. package/dist/cli/analyze-runtime-summary.js +9 -0
  20. package/dist/cli/analyze-runtime-summary.test.d.ts +1 -0
  21. package/dist/cli/analyze-runtime-summary.test.js +14 -0
  22. package/dist/cli/analyze.js +42 -15
  23. package/dist/cli/eval-server.js +3 -0
  24. package/dist/cli/exit-code.d.ts +13 -0
  25. package/dist/cli/exit-code.js +25 -0
  26. package/dist/cli/exit-code.test.d.ts +1 -0
  27. package/dist/cli/exit-code.test.js +28 -0
  28. package/dist/cli/index.js +8 -2
  29. package/dist/cli/mcp.js +3 -0
  30. package/dist/cli/setup.js +3 -2
  31. package/dist/cli/setup.test.js +67 -0
  32. package/dist/cli/tool.d.ts +3 -1
  33. package/dist/cli/tool.js +2 -0
  34. package/dist/core/graph/types.d.ts +1 -1
  35. package/dist/core/ingestion/filesystem-walker.d.ts +6 -0
  36. package/dist/core/ingestion/filesystem-walker.js +17 -0
  37. package/dist/core/ingestion/filesystem-walker.test.d.ts +1 -0
  38. package/dist/core/ingestion/filesystem-walker.test.js +51 -0
  39. package/dist/core/ingestion/pipeline.js +4 -3
  40. package/dist/core/ingestion/unity-parity-seed.d.ts +9 -0
  41. package/dist/core/ingestion/unity-parity-seed.js +69 -0
  42. package/dist/core/ingestion/unity-parity-seed.test.d.ts +1 -0
  43. package/dist/core/ingestion/unity-parity-seed.test.js +35 -0
  44. package/dist/core/ingestion/unity-resource-processor.d.ts +2 -0
  45. package/dist/core/ingestion/unity-resource-processor.js +87 -53
  46. package/dist/core/ingestion/unity-resource-processor.test.js +37 -39
  47. package/dist/core/kuzu/csv-generator.d.ts +20 -1
  48. package/dist/core/kuzu/csv-generator.js +92 -25
  49. package/dist/core/kuzu/csv-generator.test.d.ts +1 -0
  50. package/dist/core/kuzu/csv-generator.test.js +28 -0
  51. package/dist/core/kuzu/kuzu-adapter.js +35 -54
  52. package/dist/core/kuzu/relationship-pair-buckets.d.ts +17 -0
  53. package/dist/core/kuzu/relationship-pair-buckets.js +79 -0
  54. package/dist/core/kuzu/relationship-pair-buckets.test.d.ts +1 -0
  55. package/dist/core/kuzu/relationship-pair-buckets.test.js +10 -0
  56. package/dist/core/kuzu/schema.d.ts +1 -1
  57. package/dist/core/kuzu/schema.js +1 -0
  58. package/dist/core/unity/options.d.ts +2 -0
  59. package/dist/core/unity/options.js +9 -0
  60. package/dist/core/unity/options.test.js +8 -1
  61. package/dist/core/unity/resolver.d.ts +3 -0
  62. package/dist/core/unity/resolver.js +56 -2
  63. package/dist/core/unity/resolver.test.js +46 -0
  64. package/dist/core/unity/scan-context.d.ts +5 -0
  65. package/dist/core/unity/scan-context.js +133 -44
  66. package/dist/core/unity/scan-context.test.js +41 -2
  67. package/dist/core/unity/serialized-type-index.d.ts +5 -0
  68. package/dist/core/unity/serialized-type-index.js +44 -13
  69. package/dist/core/unity/serialized-type-index.test.js +9 -1
  70. package/dist/mcp/local/local-backend.d.ts +16 -0
  71. package/dist/mcp/local/local-backend.js +320 -4
  72. package/dist/mcp/local/local-backend.unity-merge.test.d.ts +1 -0
  73. package/dist/mcp/local/local-backend.unity-merge.test.js +261 -0
  74. package/dist/mcp/local/unity-enrichment.d.ts +15 -0
  75. package/dist/mcp/local/unity-enrichment.js +69 -5
  76. package/dist/mcp/local/unity-enrichment.test.js +69 -1
  77. package/dist/mcp/local/unity-lazy-config.d.ts +6 -0
  78. package/dist/mcp/local/unity-lazy-config.js +7 -0
  79. package/dist/mcp/local/unity-lazy-config.test.d.ts +1 -0
  80. package/dist/mcp/local/unity-lazy-config.test.js +9 -0
  81. package/dist/mcp/local/unity-lazy-hydrator.d.ts +15 -0
  82. package/dist/mcp/local/unity-lazy-hydrator.js +43 -0
  83. package/dist/mcp/local/unity-lazy-hydrator.test.d.ts +1 -0
  84. package/dist/mcp/local/unity-lazy-hydrator.test.js +66 -0
  85. package/dist/mcp/local/unity-lazy-overlay.d.ts +3 -0
  86. package/dist/mcp/local/unity-lazy-overlay.js +89 -0
  87. package/dist/mcp/local/unity-lazy-overlay.test.d.ts +1 -0
  88. package/dist/mcp/local/unity-lazy-overlay.test.js +83 -0
  89. package/dist/mcp/local/unity-parity-cache.d.ts +7 -0
  90. package/dist/mcp/local/unity-parity-cache.js +88 -0
  91. package/dist/mcp/local/unity-parity-cache.test.d.ts +1 -0
  92. package/dist/mcp/local/unity-parity-cache.test.js +143 -0
  93. package/dist/mcp/local/unity-parity-seed-loader.d.ts +2 -0
  94. package/dist/mcp/local/unity-parity-seed-loader.js +30 -0
  95. package/dist/mcp/local/unity-parity-seed-loader.test.d.ts +1 -0
  96. package/dist/mcp/local/unity-parity-seed-loader.test.js +25 -0
  97. package/dist/mcp/local/unity-parity-warmup-queue.d.ts +6 -0
  98. package/dist/mcp/local/unity-parity-warmup-queue.js +28 -0
  99. package/dist/mcp/local/unity-parity-warmup-queue.test.d.ts +1 -0
  100. package/dist/mcp/local/unity-parity-warmup-queue.test.js +15 -0
  101. package/dist/mcp/tools.js +24 -2
  102. package/dist/types/pipeline.d.ts +7 -0
  103. package/package.json +4 -1
  104. package/skills/gitnexus-cli.md +18 -0
  105. package/skills/gitnexus-debugging.md +16 -2
  106. package/skills/gitnexus-exploring.md +15 -1
  107. package/skills/gitnexus-guide.md +15 -0
  108. package/skills/gitnexus-impact-analysis.md +2 -0
  109. package/skills/gitnexus-refactoring.md +5 -1
@@ -0,0 +1,14 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { toPipelineRuntimeSummary } from './analyze-runtime-summary.js';
4
+ test('toPipelineRuntimeSummary drops graph reference and preserves reporting fields', () => {
5
+ const out = toPipelineRuntimeSummary({
6
+ totalFileCount: 12,
7
+ communityResult: { stats: { totalCommunities: 3 } },
8
+ processResult: { stats: { totalProcesses: 2 } },
9
+ unityResult: { diagnostics: ['scanContext: scripts=1'] },
10
+ });
11
+ assert.equal('graph' in out, false);
12
+ assert.equal(out.totalFileCount, 12);
13
+ assert.equal(out.communityResult?.stats.totalCommunities, 3);
14
+ });
@@ -20,6 +20,9 @@ import fs from 'fs/promises';
20
20
  import { registerClaudeHook } from './claude-hooks.js';
21
21
  import { resolveEffectiveAnalyzeOptions } from './analyze-options.js';
22
22
  import { formatFallbackSummary, formatUnityDiagnosticsSummary } from './analyze-summary.js';
23
+ import { resolveChildProcessExit } from './exit-code.js';
24
+ import { shouldCloseKuzuOnAnalyzeExit } from './analyze-close-policy.js';
25
+ import { toPipelineRuntimeSummary } from './analyze-runtime-summary.js';
23
26
  const HEAP_MB = 8192;
24
27
  const HEAP_FLAG = `--max-old-space-size=${HEAP_MB}`;
25
28
  /** Re-exec the process with an 8GB heap if we're currently below that. */
@@ -37,7 +40,11 @@ function ensureHeap() {
37
40
  });
38
41
  }
39
42
  catch (e) {
40
- process.exitCode = e.status ?? 1;
43
+ const resolved = resolveChildProcessExit(e, 1);
44
+ if (resolved.bySignal && resolved.signal) {
45
+ console.error(` analyze subprocess terminated by signal ${resolved.signal}`);
46
+ }
47
+ process.exitCode = resolved.code;
41
48
  }
42
49
  return true;
43
50
  }
@@ -149,6 +156,10 @@ export const analyzeCommand = async (inputPath, options) => {
149
156
  aborted = true;
150
157
  bar.stop();
151
158
  console.log('\n Interrupted — cleaning up...');
159
+ if (!shouldCloseKuzuOnAnalyzeExit()) {
160
+ process.exit(130);
161
+ return;
162
+ }
152
163
  closeKuzu().catch(() => { }).finally(() => process.exit(130));
153
164
  };
154
165
  process.on('SIGINT', sigintHandler);
@@ -246,6 +257,8 @@ export const analyzeCommand = async (inputPath, options) => {
246
257
  const progress = Math.min(84, 60 + Math.round((kuzuMsgCount / (kuzuMsgCount + 10)) * 24));
247
258
  updateBar(progress, msg);
248
259
  });
260
+ const pipelineRuntime = toPipelineRuntimeSummary(pipelineResult);
261
+ pipelineResult = undefined;
249
262
  const kuzuTime = ((Date.now() - t0Kuzu) / 1000).toFixed(1);
250
263
  const kuzuWarnings = kuzuResult.warnings;
251
264
  // ── Phase 3: FTS (85–90%) ─────────────────────────────────────────
@@ -312,38 +325,41 @@ export const analyzeCommand = async (inputPath, options) => {
312
325
  embeddings: embeddingsEnabled,
313
326
  },
314
327
  stats: {
315
- files: pipelineResult.totalFileCount,
328
+ files: pipelineRuntime.totalFileCount,
316
329
  nodes: stats.nodes,
317
330
  edges: stats.edges,
318
- communities: pipelineResult.communityResult?.stats.totalCommunities,
319
- processes: pipelineResult.processResult?.stats.totalProcesses,
331
+ communities: pipelineRuntime.communityResult?.stats.totalCommunities,
332
+ processes: pipelineRuntime.processResult?.stats.totalProcesses,
320
333
  },
321
334
  };
322
335
  await saveMeta(storagePath, meta);
336
+ await persistUnityParitySeed(storagePath, pipelineRuntime.unityResult?.paritySeed);
323
337
  const registeredRepo = await registerRepo(repoPath, meta, { repoAlias });
324
338
  await addToGitignore(repoPath);
325
339
  const hookResult = await registerClaudeHook();
326
340
  const projectName = path.basename(repoPath);
327
341
  let aggregatedClusterCount = 0;
328
- if (pipelineResult.communityResult?.communities) {
342
+ if (pipelineRuntime.communityResult?.communities) {
329
343
  const groups = new Map();
330
- for (const c of pipelineResult.communityResult.communities) {
344
+ for (const c of pipelineRuntime.communityResult.communities) {
331
345
  const label = c.heuristicLabel || c.label || 'Unknown';
332
346
  groups.set(label, (groups.get(label) || 0) + c.symbolCount);
333
347
  }
334
348
  aggregatedClusterCount = Array.from(groups.values()).filter(count => count >= 5).length;
335
349
  }
336
350
  const aiContext = await generateAIContextFiles(repoPath, storagePath, projectName, {
337
- files: pipelineResult.totalFileCount,
351
+ files: pipelineRuntime.totalFileCount,
338
352
  nodes: stats.nodes,
339
353
  edges: stats.edges,
340
- communities: pipelineResult.communityResult?.stats.totalCommunities,
354
+ communities: pipelineRuntime.communityResult?.stats.totalCommunities,
341
355
  clusters: aggregatedClusterCount,
342
- processes: pipelineResult.processResult?.stats.totalProcesses,
356
+ processes: pipelineRuntime.processResult?.stats.totalProcesses,
343
357
  }, {
344
358
  skillScope: ((await loadCLIConfig()).setupScope === 'global') ? 'global' : 'project',
345
359
  });
346
- await closeKuzu();
360
+ if (shouldCloseKuzuOnAnalyzeExit()) {
361
+ await closeKuzu();
362
+ }
347
363
  // Note: we intentionally do NOT call disposeEmbedder() here.
348
364
  // ONNX Runtime's native cleanup segfaults on macOS and some Linux configs.
349
365
  // Since the process exits immediately after, Node.js reclaims everything.
@@ -361,9 +377,9 @@ export const analyzeCommand = async (inputPath, options) => {
361
377
  console.log(` Repo Name: ${registeredRepo.name}`);
362
378
  console.log(` Repo Alias: ${registeredRepo.alias || 'none'}`);
363
379
  console.log(` Scope Rules: ${scopeRules.length}`);
364
- console.log(` Scoped Files: ${pipelineResult.totalFileCount}`);
365
- if (scopeRules.length > 0 && pipelineResult.scopeDiagnostics) {
366
- const diagnostics = pipelineResult.scopeDiagnostics;
380
+ console.log(` Scoped Files: ${pipelineRuntime.totalFileCount}`);
381
+ if (scopeRules.length > 0 && pipelineRuntime.scopeDiagnostics) {
382
+ const diagnostics = pipelineRuntime.scopeDiagnostics;
367
383
  console.log(` Scope Overlap Files: ${diagnostics.overlapFiles} (${diagnostics.dedupedMatchCount} duplicate matches removed)`);
368
384
  if (diagnostics.normalizedCollisions.length === 0) {
369
385
  console.log(' Scope Collisions: none');
@@ -378,11 +394,11 @@ export const analyzeCommand = async (inputPath, options) => {
378
394
  }
379
395
  }
380
396
  }
381
- const unitySummaryLines = formatUnityDiagnosticsSummary(pipelineResult.unityResult?.diagnostics);
397
+ const unitySummaryLines = formatUnityDiagnosticsSummary(pipelineRuntime.unityResult?.diagnostics);
382
398
  for (const line of unitySummaryLines) {
383
399
  console.log(` ${line}`);
384
400
  }
385
- console.log(` ${stats.nodes.toLocaleString()} nodes | ${stats.edges.toLocaleString()} edges | ${pipelineResult.communityResult?.stats.totalCommunities || 0} clusters | ${pipelineResult.processResult?.stats.totalProcesses || 0} flows`);
401
+ console.log(` ${stats.nodes.toLocaleString()} nodes | ${stats.edges.toLocaleString()} edges | ${pipelineRuntime.communityResult?.stats.totalCommunities || 0} clusters | ${pipelineRuntime.processResult?.stats.totalProcesses || 0} flows`);
386
402
  console.log(` KuzuDB ${kuzuTime}s | FTS ${ftsTime}s | Embeddings ${embeddingSkipped ? embeddingSkipReason : embeddingTime + 's'}`);
387
403
  if (includeExtensions.length > 0) {
388
404
  console.log(` File filter: ${includeExtensions.join(', ')}`);
@@ -416,3 +432,14 @@ export const analyzeCommand = async (inputPath, options) => {
416
432
  process.exit(0);
417
433
  }
418
434
  };
435
+ async function persistUnityParitySeed(storagePath, seed) {
436
+ const seedPath = path.join(storagePath, 'unity-parity-seed.json');
437
+ if (!seed) {
438
+ try {
439
+ await fs.rm(seedPath, { force: true });
440
+ }
441
+ catch { }
442
+ return;
443
+ }
444
+ await fs.writeFile(seedPath, JSON.stringify(seed), 'utf-8');
445
+ }
@@ -253,6 +253,9 @@ function getNextStepHint(toolName) {
253
253
  }
254
254
  // ─── Server ───────────────────────────────────────────────────────────
255
255
  export async function evalServerCommand(options) {
256
+ if (!process.env.GITNEXUS_UNITY_PARITY_WARMUP) {
257
+ process.env.GITNEXUS_UNITY_PARITY_WARMUP = '1';
258
+ }
256
259
  const port = parseInt(options?.port || '4848');
257
260
  const idleTimeoutSec = parseInt(options?.idleTimeout || '0');
258
261
  const backend = new LocalBackend();
@@ -0,0 +1,13 @@
1
+ export interface ChildExitResolution {
2
+ code: number;
3
+ signal?: string;
4
+ bySignal: boolean;
5
+ }
6
+ /**
7
+ * Convert a signal name to a conventional shell exit code (128 + signal number).
8
+ */
9
+ export declare function getSignalExitCode(signal?: string | null): number | null;
10
+ /**
11
+ * Normalize child-process termination details into a stable numeric exit code.
12
+ */
13
+ export declare function resolveChildProcessExit(error: any, fallbackCode?: number): ChildExitResolution;
@@ -0,0 +1,25 @@
1
+ import { constants as osConstants } from 'node:os';
2
+ /**
3
+ * Convert a signal name to a conventional shell exit code (128 + signal number).
4
+ */
5
+ export function getSignalExitCode(signal) {
6
+ if (!signal)
7
+ return null;
8
+ const signalNumber = osConstants.signals[signal];
9
+ if (typeof signalNumber !== 'number')
10
+ return null;
11
+ return 128 + signalNumber;
12
+ }
13
+ /**
14
+ * Normalize child-process termination details into a stable numeric exit code.
15
+ */
16
+ export function resolveChildProcessExit(error, fallbackCode = 1) {
17
+ if (typeof error?.status === 'number') {
18
+ return { code: error.status, bySignal: false };
19
+ }
20
+ if (typeof error?.signal === 'string') {
21
+ const code = getSignalExitCode(error.signal) ?? fallbackCode;
22
+ return { code, signal: error.signal, bySignal: true };
23
+ }
24
+ return { code: fallbackCode, bySignal: false };
25
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,28 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { constants as osConstants } from 'node:os';
4
+ import { getSignalExitCode, resolveChildProcessExit } from './exit-code.js';
5
+ test('getSignalExitCode maps signal number using 128+N convention', () => {
6
+ const signalNumber = osConstants.signals.SIGSEGV;
7
+ const expected = typeof signalNumber === 'number' ? 128 + signalNumber : null;
8
+ assert.equal(getSignalExitCode('SIGSEGV'), expected);
9
+ });
10
+ test('resolveChildProcessExit prefers explicit status when present', () => {
11
+ const resolved = resolveChildProcessExit({ status: 42, signal: 'SIGSEGV' }, 1);
12
+ assert.equal(resolved.code, 42);
13
+ assert.equal(resolved.bySignal, false);
14
+ assert.equal(resolved.signal, undefined);
15
+ });
16
+ test('resolveChildProcessExit maps signal-based termination', () => {
17
+ const signalNumber = osConstants.signals.SIGSEGV;
18
+ const expected = typeof signalNumber === 'number' ? 128 + signalNumber : 1;
19
+ const resolved = resolveChildProcessExit({ signal: 'SIGSEGV' }, 1);
20
+ assert.equal(resolved.code, expected);
21
+ assert.equal(resolved.bySignal, true);
22
+ assert.equal(resolved.signal, 'SIGSEGV');
23
+ });
24
+ test('resolveChildProcessExit falls back to default code for unknown errors', () => {
25
+ const resolved = resolveChildProcessExit({ message: 'boom' }, 7);
26
+ assert.equal(resolved.code, 7);
27
+ assert.equal(resolved.bySignal, false);
28
+ });
package/dist/cli/index.js CHANGED
@@ -14,8 +14,11 @@ if (!process.env.NODE_OPTIONS?.includes('--max-old-space-size')) {
14
14
  process.exit(0);
15
15
  }
16
16
  catch (e) {
17
- // If the child exited with an error code, propagate it
18
- process.exit(e.status ?? 1);
17
+ const resolved = resolveChildProcessExit(e, 1);
18
+ if (resolved.bySignal && resolved.signal) {
19
+ process.stderr.write(`gitnexus: child process terminated by signal ${resolved.signal}\n`);
20
+ }
21
+ process.exit(resolved.code);
19
22
  }
20
23
  }
21
24
  }
@@ -38,6 +41,7 @@ import { benchmarkUnityCommand } from './benchmark-unity.js';
38
41
  import { benchmarkAgentContextCommand } from './benchmark-agent-context.js';
39
42
  import { unityBindingsCommand } from './unity-bindings.js';
40
43
  import { benchmarkU2E2ECommand } from './benchmark-u2-e2e.js';
44
+ import { resolveChildProcessExit } from './exit-code.js';
41
45
  function resolveCliVersion() {
42
46
  try {
43
47
  const currentFile = fileURLToPath(import.meta.url);
@@ -125,6 +129,7 @@ program
125
129
  .option('-l, --limit <n>', 'Max processes to return (default: 5)')
126
130
  .option('--content', 'Include full symbol source code')
127
131
  .option('--unity-resources <mode>', 'Unity resource retrieval mode: off|on|auto', 'off')
132
+ .option('--unity-hydration <mode>', 'Unity hydration mode when resources are enabled: parity|compact', 'compact')
128
133
  .action(queryCommand);
129
134
  program
130
135
  .command('context [name]')
@@ -134,6 +139,7 @@ program
134
139
  .option('-f, --file <path>', 'File path to disambiguate common names')
135
140
  .option('--content', 'Include full symbol source code')
136
141
  .option('--unity-resources <mode>', 'Unity resource retrieval mode: off|on|auto', 'off')
142
+ .option('--unity-hydration <mode>', 'Unity hydration mode when resources are enabled: parity|compact', 'compact')
137
143
  .action(contextCommand);
138
144
  program
139
145
  .command('unity-bindings <symbol>')
package/dist/cli/mcp.js CHANGED
@@ -8,6 +8,9 @@
8
8
  import { startMCPServer } from '../mcp/server.js';
9
9
  import { LocalBackend } from '../mcp/local/local-backend.js';
10
10
  export const mcpCommand = async () => {
11
+ if (!process.env.GITNEXUS_UNITY_PARITY_WARMUP) {
12
+ process.env.GITNEXUS_UNITY_PARITY_WARMUP = '1';
13
+ }
11
14
  // Prevent unhandled errors from crashing the MCP server process.
12
15
  // KuzuDB lock conflicts and transient errors should degrade gracefully.
13
16
  process.on('uncaughtException', (err) => {
package/dist/cli/setup.js CHANGED
@@ -164,9 +164,10 @@ function buildCodexMcpTable() {
164
164
  function mergeCodexConfig(existingRaw) {
165
165
  const table = buildCodexMcpTable();
166
166
  const normalized = existingRaw.replace(/\r\n/g, '\n');
167
- const tablePattern = /\[mcp_servers\.gitnexus\][\s\S]*?(?=\n\[[^\]]+\]|\s*$)/m;
167
+ const tablePattern = /^\[mcp_servers\.gitnexus\][\s\S]*?(?=^\[[^\]]+\]|(?![\s\S]))/m;
168
168
  if (tablePattern.test(normalized)) {
169
- return normalized.replace(tablePattern, table).trimEnd() + '\n';
169
+ // Keep exactly one table by replacing the whole previous section block.
170
+ return normalized.replace(tablePattern, `${table}\n\n`).trimEnd() + '\n';
170
171
  }
171
172
  const trimmed = normalized.trimEnd();
172
173
  if (trimmed.length === 0)
@@ -12,6 +12,7 @@ const packageRoot = path.resolve(here, '..', '..');
12
12
  const cliPath = path.join(packageRoot, 'dist', 'cli', 'index.js');
13
13
  const packageName = JSON.parse(await fs.readFile(path.join(packageRoot, 'package.json'), 'utf-8'));
14
14
  const expectedMcpPackage = `${packageName.name || 'gitnexus'}@latest`;
15
+ const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
15
16
  async function runSetup(args, env, cwd = packageRoot) {
16
17
  return execFileAsync(process.execPath, [cliPath, 'setup', ...args], { cwd, env });
17
18
  }
@@ -235,6 +236,72 @@ test('setup --scope project --agent codex writes only .codex/config.toml', async
235
236
  await fs.rm(fakeRepo, { recursive: true, force: true });
236
237
  }
237
238
  });
239
+ test('setup --scope project --agent codex replaces existing gitnexus table without duplicate keys', async () => {
240
+ const fakeHome = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-setup-home-'));
241
+ const fakeRepo = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-setup-repo-'));
242
+ try {
243
+ await execFileAsync('git', ['init'], { cwd: fakeRepo });
244
+ const codexConfigPath = path.join(fakeRepo, '.codex', 'config.toml');
245
+ await fs.mkdir(path.dirname(codexConfigPath), { recursive: true });
246
+ await fs.writeFile(codexConfigPath, [
247
+ '[mcp_servers.gitnexus]',
248
+ 'command = "npx"',
249
+ 'args = ["-y", "oldpkg@latest", "mcp"]',
250
+ '',
251
+ '[profiles.default]',
252
+ 'model = "gpt-5"',
253
+ '',
254
+ ].join('\n'), 'utf-8');
255
+ await runSetup(['--scope', 'project', '--agent', 'codex'], {
256
+ ...process.env,
257
+ HOME: fakeHome,
258
+ USERPROFILE: fakeHome,
259
+ }, fakeRepo);
260
+ const codexConfigRaw = await fs.readFile(codexConfigPath, 'utf-8');
261
+ const tableMatches = codexConfigRaw.match(/^\[mcp_servers\.gitnexus\]$/gm) || [];
262
+ assert.equal(tableMatches.length, 1);
263
+ const gitnexusTableMatch = codexConfigRaw.match(/^\[mcp_servers\.gitnexus\][\s\S]*?(?=^\[[^\]]+\]|(?![\s\S]))/m);
264
+ assert.ok(gitnexusTableMatch, 'expected [mcp_servers.gitnexus] table');
265
+ const gitnexusTable = gitnexusTableMatch[0];
266
+ assert.equal((gitnexusTable.match(/^command\s*=/gm) || []).length, 1);
267
+ assert.equal((gitnexusTable.match(/^args\s*=/gm) || []).length, 1);
268
+ assert.match(gitnexusTable, new RegExp(escapeRegExp(expectedMcpPackage)));
269
+ assert.doesNotMatch(gitnexusTable, /oldpkg@latest/);
270
+ assert.match(codexConfigRaw, /^\[profiles\.default\]$/m);
271
+ }
272
+ finally {
273
+ await fs.rm(fakeHome, { recursive: true, force: true });
274
+ await fs.rm(fakeRepo, { recursive: true, force: true });
275
+ }
276
+ });
277
+ test('setup --scope project --agent codex is idempotent across repeated runs', async () => {
278
+ const fakeHome = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-setup-home-'));
279
+ const fakeRepo = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-setup-repo-'));
280
+ try {
281
+ await execFileAsync('git', ['init'], { cwd: fakeRepo });
282
+ const env = {
283
+ ...process.env,
284
+ HOME: fakeHome,
285
+ USERPROFILE: fakeHome,
286
+ };
287
+ await runSetup(['--scope', 'project', '--agent', 'codex'], env, fakeRepo);
288
+ await runSetup(['--scope', 'project', '--agent', 'codex'], env, fakeRepo);
289
+ const codexConfigPath = path.join(fakeRepo, '.codex', 'config.toml');
290
+ const codexConfigRaw = await fs.readFile(codexConfigPath, 'utf-8');
291
+ const tableMatches = codexConfigRaw.match(/^\[mcp_servers\.gitnexus\]$/gm) || [];
292
+ assert.equal(tableMatches.length, 1);
293
+ const gitnexusTableMatch = codexConfigRaw.match(/^\[mcp_servers\.gitnexus\][\s\S]*?(?=^\[[^\]]+\]|(?![\s\S]))/m);
294
+ assert.ok(gitnexusTableMatch, 'expected [mcp_servers.gitnexus] table');
295
+ const gitnexusTable = gitnexusTableMatch[0];
296
+ assert.equal((gitnexusTable.match(/^command\s*=/gm) || []).length, 1);
297
+ assert.equal((gitnexusTable.match(/^args\s*=/gm) || []).length, 1);
298
+ assert.match(gitnexusTable, new RegExp(escapeRegExp(expectedMcpPackage)));
299
+ }
300
+ finally {
301
+ await fs.rm(fakeHome, { recursive: true, force: true });
302
+ await fs.rm(fakeRepo, { recursive: true, force: true });
303
+ }
304
+ });
238
305
  test('setup --scope project --agent opencode writes only opencode.json', async () => {
239
306
  const fakeHome = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-setup-home-'));
240
307
  const fakeRepo = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-setup-repo-'));
@@ -13,7 +13,7 @@
13
13
  * Note: Output goes to stderr because KuzuDB's native module captures stdout
14
14
  * at the OS level during init. This is consistent with augment.ts.
15
15
  */
16
- import type { UnityResourcesMode } from '../core/unity/options.js';
16
+ import type { UnityHydrationMode, UnityResourcesMode } from '../core/unity/options.js';
17
17
  export declare function queryCommand(queryText: string, options?: {
18
18
  repo?: string;
19
19
  context?: string;
@@ -21,6 +21,7 @@ export declare function queryCommand(queryText: string, options?: {
21
21
  limit?: string;
22
22
  content?: boolean;
23
23
  unityResources?: UnityResourcesMode;
24
+ unityHydration?: UnityHydrationMode;
24
25
  }): Promise<void>;
25
26
  export declare function contextCommand(name: string, options?: {
26
27
  repo?: string;
@@ -28,6 +29,7 @@ export declare function contextCommand(name: string, options?: {
28
29
  uid?: string;
29
30
  content?: boolean;
30
31
  unityResources?: UnityResourcesMode;
32
+ unityHydration?: UnityHydrationMode;
31
33
  }): Promise<void>;
32
34
  export declare function impactCommand(target: string, options?: {
33
35
  direction?: string;
package/dist/cli/tool.js CHANGED
@@ -44,6 +44,7 @@ export async function queryCommand(queryText, options) {
44
44
  limit: options?.limit ? parseInt(options.limit) : undefined,
45
45
  include_content: options?.content ?? false,
46
46
  unity_resources: options?.unityResources,
47
+ unity_hydration_mode: options?.unityHydration,
47
48
  repo: options?.repo,
48
49
  });
49
50
  output(result);
@@ -60,6 +61,7 @@ export async function contextCommand(name, options) {
60
61
  file_path: options?.file,
61
62
  include_content: options?.content ?? false,
62
63
  unity_resources: options?.unityResources,
64
+ unity_hydration_mode: options?.unityHydration,
63
65
  repo: options?.repo,
64
66
  });
65
67
  output(result);
@@ -20,7 +20,7 @@ export type NodeProperties = {
20
20
  entryPointScore?: number;
21
21
  entryPointReason?: string;
22
22
  };
23
- export type RelationshipType = 'CONTAINS' | 'CALLS' | 'INHERITS' | 'OVERRIDES' | 'IMPORTS' | 'USES' | 'DEFINES' | 'DECORATES' | 'IMPLEMENTS' | 'EXTENDS' | 'MEMBER_OF' | 'STEP_IN_PROCESS' | 'UNITY_COMPONENT_IN' | 'UNITY_COMPONENT_INSTANCE' | 'UNITY_SERIALIZED_TYPE_IN';
23
+ export type RelationshipType = 'CONTAINS' | 'CALLS' | 'INHERITS' | 'OVERRIDES' | 'IMPORTS' | 'USES' | 'DEFINES' | 'DECORATES' | 'IMPLEMENTS' | 'EXTENDS' | 'MEMBER_OF' | 'STEP_IN_PROCESS' | 'UNITY_COMPONENT_IN' | 'UNITY_COMPONENT_INSTANCE' | 'UNITY_RESOURCE_SUMMARY' | 'UNITY_SERIALIZED_TYPE_IN';
24
24
  export interface GraphNode {
25
25
  id: string;
26
26
  label: NodeLabel;
@@ -16,6 +16,12 @@ export interface FilePath {
16
16
  * Memory: ~10MB for 100K files vs ~1GB+ with content.
17
17
  */
18
18
  export declare const walkRepositoryPaths: (repoPath: string, onProgress?: (current: number, total: number, filePath: string) => void) => Promise<ScannedFile[]>;
19
+ /**
20
+ * Scan Unity resource files used by binding enrichment.
21
+ * This path scan intentionally does not apply the 512KB source-size cap,
22
+ * because bindings often live in large serialized assets.
23
+ */
24
+ export declare const walkUnityResourcePaths: (repoPath: string) => Promise<string[]>;
19
25
  /**
20
26
  * Phase 2: Read file contents for a specific set of relative paths.
21
27
  * Returns a Map for O(1) lookup. Silently skips files that fail to read.
@@ -5,6 +5,7 @@ import { shouldIgnorePath } from '../../config/ignore-service.js';
5
5
  const READ_CONCURRENCY = 32;
6
6
  /** Skip files larger than 512KB — they're usually generated/vendored and crash tree-sitter */
7
7
  const MAX_FILE_SIZE = 512 * 1024;
8
+ const UNITY_RESOURCE_GLOBS = ['**/*.prefab', '**/*.unity', '**/*.asset'];
8
9
  /**
9
10
  * Phase 1: Scan repository — stat files to get paths + sizes, no content loaded.
10
11
  * Memory: ~10MB for 100K files vs ~1GB+ with content.
@@ -46,6 +47,22 @@ export const walkRepositoryPaths = async (repoPath, onProgress) => {
46
47
  }
47
48
  return entries;
48
49
  };
50
+ /**
51
+ * Scan Unity resource files used by binding enrichment.
52
+ * This path scan intentionally does not apply the 512KB source-size cap,
53
+ * because bindings often live in large serialized assets.
54
+ */
55
+ export const walkUnityResourcePaths = async (repoPath) => {
56
+ const files = await glob(UNITY_RESOURCE_GLOBS, {
57
+ cwd: repoPath,
58
+ nodir: true,
59
+ dot: false,
60
+ });
61
+ return files
62
+ .filter(file => !shouldIgnorePath(file))
63
+ .map(file => file.replace(/\\/g, '/'))
64
+ .sort((left, right) => left.localeCompare(right));
65
+ };
49
66
  /**
50
67
  * Phase 2: Read file contents for a specific set of relative paths.
51
68
  * Returns a Map for O(1) lookup. Silently skips files that fail to read.
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,51 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import path from 'node:path';
4
+ import os from 'node:os';
5
+ import fs from 'node:fs/promises';
6
+ import { walkRepositoryPaths, walkUnityResourcePaths } from './filesystem-walker.js';
7
+ test('walkUnityResourcePaths includes large Unity resources while walkRepositoryPaths skips them', async () => {
8
+ const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-fswalker-'));
9
+ const assetsDir = path.join(repoRoot, 'Assets/Scene');
10
+ await fs.mkdir(assetsDir, { recursive: true });
11
+ const largePrefab = 'Assets/Scene/Large.prefab';
12
+ const smallPrefab = 'Assets/Scene/Small.prefab';
13
+ const scriptFile = 'Assets/Scene/Test.cs';
14
+ try {
15
+ await fs.writeFile(path.join(repoRoot, largePrefab), 'x'.repeat(600 * 1024), 'utf-8');
16
+ await fs.writeFile(path.join(repoRoot, smallPrefab), 'small', 'utf-8');
17
+ await fs.writeFile(path.join(repoRoot, scriptFile), 'public class Test {}', 'utf-8');
18
+ const scanned = await walkRepositoryPaths(repoRoot);
19
+ const scannedPaths = new Set(scanned.map((entry) => entry.path));
20
+ assert.equal(scannedPaths.has(largePrefab), false);
21
+ assert.equal(scannedPaths.has(smallPrefab), true);
22
+ assert.equal(scannedPaths.has(scriptFile), true);
23
+ const unityPaths = await walkUnityResourcePaths(repoRoot);
24
+ assert.equal(unityPaths.includes(largePrefab), true);
25
+ assert.equal(unityPaths.includes(smallPrefab), true);
26
+ }
27
+ finally {
28
+ await fs.rm(repoRoot, { recursive: true, force: true });
29
+ }
30
+ });
31
+ test('walkUnityResourcePaths only returns prefab/unity/asset files and still honors ignore rules', async () => {
32
+ const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-fswalker-'));
33
+ await fs.mkdir(path.join(repoRoot, 'Assets/Scene'), { recursive: true });
34
+ await fs.mkdir(path.join(repoRoot, 'node_modules/pkg'), { recursive: true });
35
+ try {
36
+ await fs.writeFile(path.join(repoRoot, 'Assets/Scene/Keep.prefab'), 'prefab', 'utf-8');
37
+ await fs.writeFile(path.join(repoRoot, 'Assets/Scene/Keep.unity'), 'scene', 'utf-8');
38
+ await fs.writeFile(path.join(repoRoot, 'Assets/Scene/Keep.asset'), 'asset', 'utf-8');
39
+ await fs.writeFile(path.join(repoRoot, 'Assets/Scene/Ignore.cs'), 'public class Ignore {}', 'utf-8');
40
+ await fs.writeFile(path.join(repoRoot, 'node_modules/pkg/Hidden.prefab'), 'hidden', 'utf-8');
41
+ const unityPaths = await walkUnityResourcePaths(repoRoot);
42
+ assert.deepEqual(unityPaths, [
43
+ 'Assets/Scene/Keep.asset',
44
+ 'Assets/Scene/Keep.prefab',
45
+ 'Assets/Scene/Keep.unity',
46
+ ]);
47
+ }
48
+ finally {
49
+ await fs.rm(repoRoot, { recursive: true, force: true });
50
+ }
51
+ });
@@ -9,7 +9,7 @@ import { processProcesses } from './process-processor.js';
9
9
  import { processUnityResources } from './unity-resource-processor.js';
10
10
  import { createSymbolTable } from './symbol-table.js';
11
11
  import { createASTCache } from './ast-cache.js';
12
- import { walkRepositoryPaths, readFileContents } from './filesystem-walker.js';
12
+ import { walkRepositoryPaths, readFileContents, walkUnityResourcePaths } from './filesystem-walker.js';
13
13
  import { getLanguageFromFilename } from './utils.js';
14
14
  import { createWorkerPool } from './workers/worker-pool.js';
15
15
  import { selectEntriesByScopeRules } from './scope-filter.js';
@@ -75,9 +75,10 @@ export const runPipelineFromRepo = async (repoPath, onProgress, options) => {
75
75
  stats: { filesProcessed: 0, totalFiles, nodesCreated: graph.nodeCount },
76
76
  });
77
77
  const allPaths = extensionFiltered.map(f => f.path);
78
+ const unityCandidates = await walkUnityResourcePaths(repoPath);
78
79
  const unityScopedPaths = (options?.scopeRules && options.scopeRules.length > 0)
79
- ? scopedFiles.map(f => f.path)
80
- : allPaths;
80
+ ? selectEntriesByScopeRules(unityCandidates.map(path => ({ path })), options.scopeRules).selected.map(entry => entry.path)
81
+ : unityCandidates;
81
82
  processStructure(graph, allPaths);
82
83
  onProgress({
83
84
  phase: 'structure',
@@ -0,0 +1,9 @@
1
+ import type { UnityScanContext } from '../unity/scan-context.js';
2
+ export interface UnityParitySeed {
3
+ version: 1;
4
+ symbolToScriptPath: Record<string, string>;
5
+ scriptPathToGuid: Record<string, string>;
6
+ guidToResourcePaths: Record<string, string[]>;
7
+ assetGuidToPath?: Record<string, string>;
8
+ }
9
+ export declare function buildUnityParitySeed(scanContext: UnityScanContext): UnityParitySeed;
@@ -0,0 +1,69 @@
1
+ export function buildUnityParitySeed(scanContext) {
2
+ const canonicalMap = scanContext.symbolToCanonicalScriptPath instanceof Map
3
+ ? scanContext.symbolToCanonicalScriptPath
4
+ : new Map();
5
+ const fallbackMap = scanContext.symbolToScriptPath instanceof Map
6
+ ? scanContext.symbolToScriptPath
7
+ : new Map();
8
+ const canonical = canonicalMap.size > 0 ? canonicalMap : fallbackMap;
9
+ const symbolToScriptPath = {};
10
+ for (const [symbol, scriptPath] of canonical.entries()) {
11
+ const key = String(symbol || '').trim();
12
+ const value = normalizePath(scriptPath);
13
+ if (!key || !value)
14
+ continue;
15
+ symbolToScriptPath[key] = value;
16
+ }
17
+ const scriptPathToGuid = {};
18
+ const scriptPathToGuidMap = scanContext.scriptPathToGuid instanceof Map
19
+ ? scanContext.scriptPathToGuid
20
+ : new Map();
21
+ for (const [scriptPath, guid] of scriptPathToGuidMap.entries()) {
22
+ const key = normalizePath(scriptPath);
23
+ const value = String(guid || '').trim();
24
+ if (!key || !value)
25
+ continue;
26
+ scriptPathToGuid[key] = value;
27
+ }
28
+ const guidToResourcePaths = {};
29
+ const guidToResourceHitsMap = scanContext.guidToResourceHits instanceof Map
30
+ ? scanContext.guidToResourceHits
31
+ : new Map();
32
+ for (const [guid, hits] of guidToResourceHitsMap.entries()) {
33
+ const key = String(guid || '').trim();
34
+ if (!key)
35
+ continue;
36
+ const uniquePaths = new Set();
37
+ for (const hit of hits || []) {
38
+ const resourcePath = normalizePath(hit?.resourcePath || '');
39
+ if (resourcePath)
40
+ uniquePaths.add(resourcePath);
41
+ }
42
+ if (uniquePaths.size > 0) {
43
+ guidToResourcePaths[key] = [...uniquePaths].sort((left, right) => left.localeCompare(right));
44
+ }
45
+ }
46
+ const assetGuidToPath = {};
47
+ if (scanContext.assetGuidToPath instanceof Map) {
48
+ for (const [guid, assetPath] of scanContext.assetGuidToPath.entries()) {
49
+ const key = String(guid || '').trim();
50
+ const value = normalizePath(assetPath);
51
+ if (!key || !value)
52
+ continue;
53
+ assetGuidToPath[key] = value;
54
+ }
55
+ }
56
+ return {
57
+ version: 1,
58
+ symbolToScriptPath: sortRecord(symbolToScriptPath),
59
+ scriptPathToGuid: sortRecord(scriptPathToGuid),
60
+ guidToResourcePaths: sortRecord(guidToResourcePaths),
61
+ assetGuidToPath: Object.keys(assetGuidToPath).length > 0 ? sortRecord(assetGuidToPath) : undefined,
62
+ };
63
+ }
64
+ function normalizePath(input) {
65
+ return String(input || '').replace(/\\/g, '/').trim();
66
+ }
67
+ function sortRecord(input) {
68
+ return Object.fromEntries(Object.entries(input).sort(([left], [right]) => left.localeCompare(right)));
69
+ }
@@ -0,0 +1 @@
1
+ export {};