gitnexus 1.6.3-rc.2 → 1.6.3-rc.21

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 (130) hide show
  1. package/dist/_shared/graph/types.d.ts +16 -0
  2. package/dist/_shared/graph/types.d.ts.map +1 -1
  3. package/dist/_shared/index.d.ts +41 -1
  4. package/dist/_shared/index.d.ts.map +1 -1
  5. package/dist/_shared/index.js +28 -0
  6. package/dist/_shared/index.js.map +1 -1
  7. package/dist/_shared/scope-resolution/def-index.d.ts +36 -0
  8. package/dist/_shared/scope-resolution/def-index.d.ts.map +1 -0
  9. package/dist/_shared/scope-resolution/def-index.js +51 -0
  10. package/dist/_shared/scope-resolution/def-index.js.map +1 -0
  11. package/dist/_shared/scope-resolution/finalize-algorithm.d.ts +139 -0
  12. package/dist/_shared/scope-resolution/finalize-algorithm.d.ts.map +1 -0
  13. package/dist/_shared/scope-resolution/finalize-algorithm.js +479 -0
  14. package/dist/_shared/scope-resolution/finalize-algorithm.js.map +1 -0
  15. package/dist/_shared/scope-resolution/method-dispatch-index.d.ts +80 -0
  16. package/dist/_shared/scope-resolution/method-dispatch-index.d.ts.map +1 -0
  17. package/dist/_shared/scope-resolution/method-dispatch-index.js +79 -0
  18. package/dist/_shared/scope-resolution/method-dispatch-index.js.map +1 -0
  19. package/dist/_shared/scope-resolution/module-scope-index.d.ts +46 -0
  20. package/dist/_shared/scope-resolution/module-scope-index.d.ts.map +1 -0
  21. package/dist/_shared/scope-resolution/module-scope-index.js +58 -0
  22. package/dist/_shared/scope-resolution/module-scope-index.js.map +1 -0
  23. package/dist/_shared/scope-resolution/parsed-file.d.ts +64 -0
  24. package/dist/_shared/scope-resolution/parsed-file.d.ts.map +1 -0
  25. package/dist/_shared/scope-resolution/parsed-file.js +42 -0
  26. package/dist/_shared/scope-resolution/parsed-file.js.map +1 -0
  27. package/dist/_shared/scope-resolution/position-index.d.ts +62 -0
  28. package/dist/_shared/scope-resolution/position-index.d.ts.map +1 -0
  29. package/dist/_shared/scope-resolution/position-index.js +134 -0
  30. package/dist/_shared/scope-resolution/position-index.js.map +1 -0
  31. package/dist/_shared/scope-resolution/qualified-name-index.d.ts +44 -0
  32. package/dist/_shared/scope-resolution/qualified-name-index.d.ts.map +1 -0
  33. package/dist/_shared/scope-resolution/qualified-name-index.js +75 -0
  34. package/dist/_shared/scope-resolution/qualified-name-index.js.map +1 -0
  35. package/dist/_shared/scope-resolution/reference-site.d.ts +67 -0
  36. package/dist/_shared/scope-resolution/reference-site.d.ts.map +1 -0
  37. package/dist/_shared/scope-resolution/reference-site.js +24 -0
  38. package/dist/_shared/scope-resolution/reference-site.js.map +1 -0
  39. package/dist/_shared/scope-resolution/registries/class-registry.d.ts +27 -0
  40. package/dist/_shared/scope-resolution/registries/class-registry.d.ts.map +1 -0
  41. package/dist/_shared/scope-resolution/registries/class-registry.js +30 -0
  42. package/dist/_shared/scope-resolution/registries/class-registry.js.map +1 -0
  43. package/dist/_shared/scope-resolution/registries/context.d.ts +69 -0
  44. package/dist/_shared/scope-resolution/registries/context.d.ts.map +1 -0
  45. package/dist/_shared/scope-resolution/registries/context.js +44 -0
  46. package/dist/_shared/scope-resolution/registries/context.js.map +1 -0
  47. package/dist/_shared/scope-resolution/registries/evidence.d.ts +56 -0
  48. package/dist/_shared/scope-resolution/registries/evidence.d.ts.map +1 -0
  49. package/dist/_shared/scope-resolution/registries/evidence.js +150 -0
  50. package/dist/_shared/scope-resolution/registries/evidence.js.map +1 -0
  51. package/dist/_shared/scope-resolution/registries/field-registry.d.ts +26 -0
  52. package/dist/_shared/scope-resolution/registries/field-registry.d.ts.map +1 -0
  53. package/dist/_shared/scope-resolution/registries/field-registry.js +31 -0
  54. package/dist/_shared/scope-resolution/registries/field-registry.js.map +1 -0
  55. package/dist/_shared/scope-resolution/registries/lookup-core.d.ts +81 -0
  56. package/dist/_shared/scope-resolution/registries/lookup-core.d.ts.map +1 -0
  57. package/dist/_shared/scope-resolution/registries/lookup-core.js +332 -0
  58. package/dist/_shared/scope-resolution/registries/lookup-core.js.map +1 -0
  59. package/dist/_shared/scope-resolution/registries/lookup-qualified.d.ts +33 -0
  60. package/dist/_shared/scope-resolution/registries/lookup-qualified.d.ts.map +1 -0
  61. package/dist/_shared/scope-resolution/registries/lookup-qualified.js +56 -0
  62. package/dist/_shared/scope-resolution/registries/lookup-qualified.js.map +1 -0
  63. package/dist/_shared/scope-resolution/registries/method-registry.d.ts +36 -0
  64. package/dist/_shared/scope-resolution/registries/method-registry.d.ts.map +1 -0
  65. package/dist/_shared/scope-resolution/registries/method-registry.js +32 -0
  66. package/dist/_shared/scope-resolution/registries/method-registry.js.map +1 -0
  67. package/dist/_shared/scope-resolution/registries/tie-breaks.d.ts +43 -0
  68. package/dist/_shared/scope-resolution/registries/tie-breaks.d.ts.map +1 -0
  69. package/dist/_shared/scope-resolution/registries/tie-breaks.js +60 -0
  70. package/dist/_shared/scope-resolution/registries/tie-breaks.js.map +1 -0
  71. package/dist/_shared/scope-resolution/resolve-type-ref.d.ts +53 -0
  72. package/dist/_shared/scope-resolution/resolve-type-ref.d.ts.map +1 -0
  73. package/dist/_shared/scope-resolution/resolve-type-ref.js +126 -0
  74. package/dist/_shared/scope-resolution/resolve-type-ref.js.map +1 -0
  75. package/dist/_shared/scope-resolution/scope-id.d.ts +43 -0
  76. package/dist/_shared/scope-resolution/scope-id.d.ts.map +1 -0
  77. package/dist/_shared/scope-resolution/scope-id.js +46 -0
  78. package/dist/_shared/scope-resolution/scope-id.js.map +1 -0
  79. package/dist/_shared/scope-resolution/scope-tree.d.ts +61 -0
  80. package/dist/_shared/scope-resolution/scope-tree.d.ts.map +1 -0
  81. package/dist/_shared/scope-resolution/scope-tree.js +186 -0
  82. package/dist/_shared/scope-resolution/scope-tree.js.map +1 -0
  83. package/dist/_shared/scope-resolution/shadow/aggregate.d.ts +63 -0
  84. package/dist/_shared/scope-resolution/shadow/aggregate.d.ts.map +1 -0
  85. package/dist/_shared/scope-resolution/shadow/aggregate.js +122 -0
  86. package/dist/_shared/scope-resolution/shadow/aggregate.js.map +1 -0
  87. package/dist/_shared/scope-resolution/shadow/diff.d.ts +59 -0
  88. package/dist/_shared/scope-resolution/shadow/diff.d.ts.map +1 -0
  89. package/dist/_shared/scope-resolution/shadow/diff.js +79 -0
  90. package/dist/_shared/scope-resolution/shadow/diff.js.map +1 -0
  91. package/dist/_shared/scope-resolution/types.d.ts +156 -0
  92. package/dist/_shared/scope-resolution/types.d.ts.map +1 -1
  93. package/dist/cli/analyze.d.ts +15 -0
  94. package/dist/cli/analyze.js +22 -1
  95. package/dist/cli/index.js +4 -0
  96. package/dist/cli/list.js +11 -1
  97. package/dist/core/ingestion/emit-references.d.ts +88 -0
  98. package/dist/core/ingestion/emit-references.js +229 -0
  99. package/dist/core/ingestion/finalize-orchestrator.d.ts +63 -0
  100. package/dist/core/ingestion/finalize-orchestrator.js +139 -0
  101. package/dist/core/ingestion/framework-detection.js +6 -2
  102. package/dist/core/ingestion/import-target-adapter.d.ts +73 -0
  103. package/dist/core/ingestion/import-target-adapter.js +95 -0
  104. package/dist/core/ingestion/language-provider.d.ts +187 -1
  105. package/dist/core/ingestion/model/scope-resolution-indexes.d.ts +59 -0
  106. package/dist/core/ingestion/model/scope-resolution-indexes.js +42 -0
  107. package/dist/core/ingestion/model/semantic-model.d.ts +25 -0
  108. package/dist/core/ingestion/model/semantic-model.js +16 -0
  109. package/dist/core/ingestion/parsing-processor.d.ts +9 -0
  110. package/dist/core/ingestion/parsing-processor.js +10 -0
  111. package/dist/core/ingestion/registry-primary-flag.d.ts +59 -0
  112. package/dist/core/ingestion/registry-primary-flag.js +78 -0
  113. package/dist/core/ingestion/scope-extractor-bridge.d.ts +32 -0
  114. package/dist/core/ingestion/scope-extractor-bridge.js +44 -0
  115. package/dist/core/ingestion/scope-extractor.d.ts +87 -0
  116. package/dist/core/ingestion/scope-extractor.js +603 -0
  117. package/dist/core/ingestion/shadow-harness.d.ts +113 -0
  118. package/dist/core/ingestion/shadow-harness.js +148 -0
  119. package/dist/core/ingestion/workers/parse-worker.d.ts +9 -0
  120. package/dist/core/ingestion/workers/parse-worker.js +20 -1
  121. package/dist/core/run-analyze.d.ts +21 -0
  122. package/dist/core/run-analyze.js +15 -4
  123. package/dist/core/search/phase-timer.d.ts +72 -0
  124. package/dist/core/search/phase-timer.js +106 -0
  125. package/dist/mcp/local/local-backend.js +70 -8
  126. package/dist/storage/git.d.ts +25 -0
  127. package/dist/storage/git.js +52 -0
  128. package/dist/storage/repo-manager.d.ts +70 -1
  129. package/dist/storage/repo-manager.js +107 -5
  130. package/package.json +1 -1
@@ -18,6 +18,7 @@ import { listRegisteredRepos, cleanupOldKuzuFiles, } from '../../storage/repo-ma
18
18
  import { GroupService } from '../../core/group/service.js';
19
19
  import { collectBestChunks } from '../../core/embeddings/types.js';
20
20
  import { EMBEDDING_TABLE_NAME, EMBEDDING_INDEX_NAME } from '../../core/lbug/schema.js';
21
+ import { PhaseTimer } from '../../core/search/phase-timer.js';
21
22
  // AI context generation is CLI-only (gitnexus analyze)
22
23
  // import { generateAIContextFiles } from '../../cli/ai-context.js';
23
24
  /**
@@ -134,6 +135,25 @@ function logQueryError(context, err) {
134
135
  const msg = err instanceof Error ? err.message : String(err);
135
136
  console.error(`GitNexus [${context}]: ${msg}`);
136
137
  }
138
+ /**
139
+ * Structured per-query latency log for production aggregation (#553).
140
+ *
141
+ * Emitted on stderr — NOT stdout — because the MCP stdio transport uses
142
+ * stdout exclusively for JSON-RPC responses (#324), and the CLI e2e test
143
+ * `tool output goes to stdout via fd 1` asserts that stdout parses cleanly
144
+ * as JSON. Any `console.log` from inside a tool handler would corrupt the
145
+ * protocol. Matches the existing `logQueryError` convention above, which
146
+ * uses stderr for the same reason.
147
+ *
148
+ * The `GitNexus [query:timing] …` prefix keeps lines greppable; the
149
+ * `phases` payload is JSON so log-scraping pipelines can parse it
150
+ * without custom format knowledge.
151
+ */
152
+ function logQueryTiming(query, phases) {
153
+ const totalMs = phases.wall ?? Object.values(phases).reduce((a, b) => a + b, 0);
154
+ const truncated = query.length > 80 ? `${query.slice(0, 80)}…` : query;
155
+ console.error(`GitNexus [query:timing] query=${JSON.stringify(truncated)} totalMs=${totalMs} phases=${JSON.stringify(phases)}`);
156
+ }
137
157
  export class LocalBackend {
138
158
  repos = new Map();
139
159
  contextCache = new Map();
@@ -259,12 +279,20 @@ export class LocalBackend {
259
279
  if (this.repos.size === 0) {
260
280
  throw new Error('No indexed repositories. Run: gitnexus analyze');
261
281
  }
282
+ // Build a disambiguated "Available: …" list (#829). When two handles
283
+ // share a name, annotate each colliding label with its path so the
284
+ // caller can actually pick the right one. Single-name entries render
285
+ // identically to pre-#829 output.
286
+ const nameCounts = new Map();
287
+ for (const h of this.repos.values()) {
288
+ const key = h.name.toLowerCase();
289
+ nameCounts.set(key, (nameCounts.get(key) ?? 0) + 1);
290
+ }
291
+ const labels = [...this.repos.values()].map((h) => (nameCounts.get(h.name.toLowerCase()) ?? 0) > 1 ? `${h.name} (${h.repoPath})` : h.name);
262
292
  if (repoParam) {
263
- const names = [...this.repos.values()].map((h) => h.name);
264
- throw new Error(`Repository "${repoParam}" not found. Available: ${names.join(', ')}`);
293
+ throw new Error(`Repository "${repoParam}" not found. Available: ${labels.join(', ')}`);
265
294
  }
266
- const names = [...this.repos.values()].map((h) => h.name);
267
- throw new Error(`Multiple repositories indexed. Specify which one with the "repo" parameter. Available: ${names.join(', ')}`);
295
+ throw new Error(`Multiple repositories indexed. Specify which one with the "repo" parameter. Available: ${labels.join(', ')}`);
268
296
  }
269
297
  /**
270
298
  * Try to resolve a repo from the in-memory cache. Returns null on miss.
@@ -449,15 +477,26 @@ export class LocalBackend {
449
477
  const maxSymbolsPerProcess = params.max_symbols || 10;
450
478
  const includeContent = params.include_content ?? false;
451
479
  const searchQuery = params.query.trim();
452
- // Step 1: Run hybrid search to get matching symbols
480
+ // Per-phase timing instrumentation (#553). Records wall time for each
481
+ // observable sub-step of the search pipeline so production latency can
482
+ // be aggregated offline for Pareto analysis and bottleneck detection.
483
+ // Overhead is <0.1 ms per phase; the timer is passive and never alters
484
+ // query behaviour.
485
+ const timer = new PhaseTimer();
486
+ const wallStart = performance.now();
487
+ // Step 1: Run hybrid search to get matching symbols. BM25 and vector
488
+ // search run concurrently via Promise.all — use `timer.time()` for
489
+ // each so both get independent wall-time records without fighting
490
+ // over a single `current` phase slot.
453
491
  const searchLimit = processLimit * maxSymbolsPerProcess; // fetch enough raw results
454
492
  const [bm25SearchResult, semanticResults] = await Promise.all([
455
- this.bm25Search(repo, searchQuery, searchLimit),
456
- this.semanticSearch(repo, searchQuery, searchLimit),
493
+ timer.time('bm25', this.bm25Search(repo, searchQuery, searchLimit)),
494
+ timer.time('vector', this.semanticSearch(repo, searchQuery, searchLimit)),
457
495
  ]);
458
496
  const bm25Results = bm25SearchResult.results;
459
497
  const ftsUsed = bm25SearchResult.ftsUsed;
460
498
  // Merge via reciprocal rank fusion
499
+ timer.start('merge');
461
500
  const scoreMap = new Map();
462
501
  for (let i = 0; i < bm25Results.length; i++) {
463
502
  const result = bm25Results[i];
@@ -486,7 +525,9 @@ export class LocalBackend {
486
525
  const merged = Array.from(scoreMap.entries())
487
526
  .sort((a, b) => b[1].score - a[1].score)
488
527
  .slice(0, searchLimit);
528
+ timer.stop(); // merge
489
529
  // Step 2: For each match with a nodeId, trace to process(es)
530
+ timer.start('symbol_lookup');
490
531
  const processMap = new Map();
491
532
  const definitions = []; // standalone symbols not in any process
492
533
  for (const [_, item] of merged) {
@@ -590,7 +631,9 @@ export class LocalBackend {
590
631
  }
591
632
  }
592
633
  }
634
+ timer.stop(); // symbol_lookup
593
635
  // Step 3: Rank processes by aggregate score + internal cohesion boost
636
+ timer.start('ranking');
594
637
  const rankedProcesses = Array.from(processMap.values())
595
638
  .map((p) => ({
596
639
  ...p,
@@ -598,7 +641,9 @@ export class LocalBackend {
598
641
  }))
599
642
  .sort((a, b) => b.priority - a.priority)
600
643
  .slice(0, processLimit);
644
+ timer.stop(); // ranking
601
645
  // Step 4: Build response
646
+ timer.start('formatting');
602
647
  const processes = rankedProcesses.map((p) => ({
603
648
  id: p.id,
604
649
  summary: p.heuristicLabel || p.label,
@@ -619,10 +664,18 @@ export class LocalBackend {
619
664
  seen.add(s.id);
620
665
  return true;
621
666
  });
667
+ timer.stop(); // formatting
668
+ // End-to-end wall time — deliberately a separate mark so callers can
669
+ // compare sum(phases) vs wall to see how much Promise.all concurrency
670
+ // saved. Must come before summary() so it's included.
671
+ timer.mark('wall', performance.now() - wallStart);
672
+ const timing = timer.summary();
673
+ logQueryTiming(searchQuery, timing);
622
674
  return {
623
675
  processes,
624
676
  process_symbols: dedupedSymbols,
625
677
  definitions: definitions.slice(0, 20), // cap standalone definitions
678
+ timing,
626
679
  ...(!ftsUsed && {
627
680
  warning: 'FTS extension unavailable - keyword search degraded. Run: gitnexus analyze --force to rebuild indexes.',
628
681
  }),
@@ -1447,7 +1500,14 @@ export class LocalBackend {
1447
1500
  }
1448
1501
  let diffOutput;
1449
1502
  try {
1450
- diffOutput = execFileSync('git', diffArgs, { cwd: repo.repoPath, encoding: 'utf-8' });
1503
+ // maxBuffer raised from Node's 1MB default to 256MB to avoid ENOBUFS on
1504
+ // repos with large unstaged/untracked diffs (e.g. unignored build folders).
1505
+ // See issue: spawnSync git ENOBUFS in detect_changes(scope="unstaged").
1506
+ diffOutput = execFileSync('git', diffArgs, {
1507
+ cwd: repo.repoPath,
1508
+ encoding: 'utf-8',
1509
+ maxBuffer: 256 * 1024 * 1024,
1510
+ });
1451
1511
  }
1452
1512
  catch (err) {
1453
1513
  return { error: `Git diff failed: ${err.message}` };
@@ -1662,6 +1722,8 @@ export class LocalBackend {
1662
1722
  cwd: repo.repoPath,
1663
1723
  encoding: 'utf-8',
1664
1724
  timeout: 5000,
1725
+ // Avoid ENOBUFS on large repos: rg -l can list many files.
1726
+ maxBuffer: 256 * 1024 * 1024,
1665
1727
  });
1666
1728
  const files = output
1667
1729
  .trim()
@@ -16,6 +16,31 @@ export declare const getGitRoot: (fromPath: string) => string | null;
16
16
  * @returns `true` when `.git` is present, `false` otherwise.
17
17
  */
18
18
  export declare const hasGitDir: (dirPath: string) => boolean;
19
+ /**
20
+ * Read `remote.origin.url` from a git repository, or `null` if not a
21
+ * git repo, has no `origin` remote, or git is unavailable.
22
+ *
23
+ * Used by the registry-name inference path (#979) to recover a
24
+ * meaningful repo name when `path.basename(repoPath)` is generic
25
+ * (e.g. monorepo subprojects, git worktrees, Gas-Town-style
26
+ * `<rig>/refinery/rig/` layouts).
27
+ */
28
+ export declare const getRemoteOriginUrl: (repoPath: string) => string | null;
29
+ /**
30
+ * Parse a repository name out of a git remote URL. Handles the common
31
+ * SSH (`git@host:owner/repo.git`), HTTPS (`https://host/owner/repo.git`),
32
+ * `git://`, `ssh://`, and `file://` shapes. Returns `null` for empty /
33
+ * unparseable input.
34
+ *
35
+ * The heuristic: strip a trailing `.git` and trailing slashes, then
36
+ * take the segment after the last `/` or `:`.
37
+ */
38
+ export declare const parseRepoNameFromUrl: (url: string | null | undefined) => string | null;
39
+ /**
40
+ * Convenience wrapper: derive a registry-friendly name from the repo's
41
+ * `origin` remote, or `null` when it cannot be inferred.
42
+ */
43
+ export declare const getInferredRepoName: (repoPath: string) => string | null;
19
44
  export interface DiffHunk {
20
45
  startLine: number;
21
46
  endLine: number;
@@ -52,6 +52,58 @@ export const hasGitDir = (dirPath) => {
52
52
  return false;
53
53
  }
54
54
  };
55
+ /**
56
+ * Read `remote.origin.url` from a git repository, or `null` if not a
57
+ * git repo, has no `origin` remote, or git is unavailable.
58
+ *
59
+ * Used by the registry-name inference path (#979) to recover a
60
+ * meaningful repo name when `path.basename(repoPath)` is generic
61
+ * (e.g. monorepo subprojects, git worktrees, Gas-Town-style
62
+ * `<rig>/refinery/rig/` layouts).
63
+ */
64
+ export const getRemoteOriginUrl = (repoPath) => {
65
+ try {
66
+ const url = execSync('git config --get remote.origin.url', {
67
+ cwd: repoPath,
68
+ stdio: ['ignore', 'pipe', 'ignore'],
69
+ })
70
+ .toString()
71
+ .trim();
72
+ return url || null;
73
+ }
74
+ catch {
75
+ return null;
76
+ }
77
+ };
78
+ /**
79
+ * Parse a repository name out of a git remote URL. Handles the common
80
+ * SSH (`git@host:owner/repo.git`), HTTPS (`https://host/owner/repo.git`),
81
+ * `git://`, `ssh://`, and `file://` shapes. Returns `null` for empty /
82
+ * unparseable input.
83
+ *
84
+ * The heuristic: strip a trailing `.git` and trailing slashes, then
85
+ * take the segment after the last `/` or `:`.
86
+ */
87
+ export const parseRepoNameFromUrl = (url) => {
88
+ if (!url)
89
+ return null;
90
+ const trimmed = url.trim();
91
+ if (!trimmed)
92
+ return null;
93
+ // Strip `.git` suffix (case-insensitive) and any trailing slashes.
94
+ const withoutSuffix = trimmed.replace(/\.git\/*$/i, '').replace(/\/+$/, '');
95
+ // Last path segment, splitting on either `/` or `:` (covers SSH form).
96
+ const m = withoutSuffix.match(/[/:]([^/:]+)$/);
97
+ const candidate = m ? m[1] : withoutSuffix;
98
+ return candidate || null;
99
+ };
100
+ /**
101
+ * Convenience wrapper: derive a registry-friendly name from the repo's
102
+ * `origin` remote, or `null` when it cannot be inferred.
103
+ */
104
+ export const getInferredRepoName = (repoPath) => {
105
+ return parseRepoNameFromUrl(getRemoteOriginUrl(repoPath));
106
+ };
55
107
  /**
56
108
  * Parse unified diff output (with -U0) into per-file hunk ranges.
57
109
  * Extracts the new-file line ranges from @@ hunk headers.
@@ -102,11 +102,80 @@ export declare const getGlobalRegistryPath: () => string;
102
102
  * Read the global registry. Returns empty array if not found.
103
103
  */
104
104
  export declare const readRegistry: () => Promise<RegistryEntry[]>;
105
+ /**
106
+ * Options for {@link registerRepo}. All optional — callers without any
107
+ * disambiguation requirement can keep calling `registerRepo(path, meta)`
108
+ * unchanged.
109
+ */
110
+ export interface RegisterRepoOptions {
111
+ /**
112
+ * User-provided alias from `analyze --name <alias>` (#829). Overrides
113
+ * the default basename-derived registry `name`. Persisted — subsequent
114
+ * re-analyses of the same path without `--name` preserve the alias.
115
+ */
116
+ name?: string;
117
+ /**
118
+ * Allow two DIFFERENT repo paths to register under the same alias
119
+ * (#829). Mapped from the `--allow-duplicate-name` CLI flag.
120
+ *
121
+ * Scope: this flag governs cross-path alias sharing only — one repo
122
+ * path always has exactly one registry entry (and therefore exactly
123
+ * one alias). Re-analyzing the same path with `--name Y` overwrites
124
+ * a previous `--name X`; it does NOT create a second entry or a
125
+ * second alias for the same path (see the upsert-by-resolved-path
126
+ * logic in {@link registerRepo} and the
127
+ * `re-registerRepo with a different name overrides the previous
128
+ * alias` test in `test/unit/repo-manager.test.ts`).
129
+ *
130
+ * Distinct from `--force` (which only triggers pipeline re-index);
131
+ * a user accepting a duplicate alias should not be forced to also
132
+ * re-run the full pipeline.
133
+ */
134
+ allowDuplicateName?: boolean;
135
+ }
136
+ /**
137
+ * Thrown by {@link registerRepo} when a requested name is already in
138
+ * use by a DIFFERENT path. The CLI layer surfaces this as an actionable
139
+ * error instead of relying on `.message` string-matching.
140
+ *
141
+ * The colliding alias is exposed as `err.registryName` (not `err.name`).
142
+ * `err.name` keeps its inherited `Error.prototype.name` semantics (the
143
+ * class name) so downstream code can do the usual `err.name ===
144
+ * 'RegistryNameCollisionError'` checks; use the `kind` discriminant or
145
+ * `instanceof RegistryNameCollisionError` for type-safe narrowing.
146
+ */
147
+ export declare class RegistryNameCollisionError extends Error {
148
+ readonly registryName: string;
149
+ readonly existingPath: string;
150
+ readonly requestedPath: string;
151
+ readonly kind: "RegistryNameCollisionError";
152
+ constructor(registryName: string, existingPath: string, requestedPath: string);
153
+ }
105
154
  /**
106
155
  * Register (add or update) a repo in the global registry.
107
156
  * Called after `gitnexus analyze` completes.
157
+ *
158
+ * Name resolution precedence (#829, #979):
159
+ * 1. explicit `opts.name` (from `analyze --name <alias>`)
160
+ * 2. preserved alias on an existing entry for this path
161
+ * 3. `git config --get remote.origin.url` repo name (#979 — recovers
162
+ * a meaningful name for monorepo subprojects, git worktrees, and
163
+ * Gas-Town-style `<rig>/refinery/rig/` layouts where the basename
164
+ * is generic)
165
+ * 4. `path.basename(repoPath)` (the original default)
166
+ *
167
+ * Duplicate-name guard: if another path already uses the resolved
168
+ * `name`, throw {@link RegistryNameCollisionError} unless
169
+ * `opts.allowDuplicateName` is set. The guard ONLY fires when the user explicitly passed a
170
+ * `name`; un-aliased basename collisions continue to register silently
171
+ * so existing users who don't know about `--name` see no behaviour
172
+ * change.
173
+ *
174
+ * Returns the `name` that was actually written to the registry — the
175
+ * caller can re-use it to keep AGENTS.md / skill files aligned with the
176
+ * MCP-visible repo name (#979).
108
177
  */
109
- export declare const registerRepo: (repoPath: string, meta: RepoMeta) => Promise<void>;
178
+ export declare const registerRepo: (repoPath: string, meta: RepoMeta, opts?: RegisterRepoOptions) => Promise<string>;
110
179
  /**
111
180
  * Remove a repo from the global registry.
112
181
  * Called after `gitnexus clean`.
@@ -8,6 +8,7 @@
8
8
  import fs from 'fs/promises';
9
9
  import path from 'path';
10
10
  import os from 'os';
11
+ import { getInferredRepoName } from './git.js';
11
12
  const GITNEXUS_DIR = '.gitnexus';
12
13
  // ─── Local Storage Helpers ─────────────────────────────────────────────
13
14
  /**
@@ -196,20 +197,120 @@ const writeRegistry = async (entries) => {
196
197
  await fs.mkdir(dir, { recursive: true });
197
198
  await fs.writeFile(getGlobalRegistryPath(), JSON.stringify(entries, null, 2), 'utf-8');
198
199
  };
200
+ /**
201
+ * Thrown by {@link registerRepo} when a requested name is already in
202
+ * use by a DIFFERENT path. The CLI layer surfaces this as an actionable
203
+ * error instead of relying on `.message` string-matching.
204
+ *
205
+ * The colliding alias is exposed as `err.registryName` (not `err.name`).
206
+ * `err.name` keeps its inherited `Error.prototype.name` semantics (the
207
+ * class name) so downstream code can do the usual `err.name ===
208
+ * 'RegistryNameCollisionError'` checks; use the `kind` discriminant or
209
+ * `instanceof RegistryNameCollisionError` for type-safe narrowing.
210
+ */
211
+ export class RegistryNameCollisionError extends Error {
212
+ registryName;
213
+ existingPath;
214
+ requestedPath;
215
+ kind = 'RegistryNameCollisionError';
216
+ constructor(registryName, existingPath, requestedPath) {
217
+ super(`Registry name "${registryName}" is already used by "${existingPath}".\n` +
218
+ `Pass --name <alias> to register "${requestedPath}" under a different name, ` +
219
+ `or --allow-duplicate-name to allow both paths under the same name (leaves -r <name> ambiguous for these two).`);
220
+ this.registryName = registryName;
221
+ this.existingPath = existingPath;
222
+ this.requestedPath = requestedPath;
223
+ this.name = 'RegistryNameCollisionError';
224
+ }
225
+ }
226
+ /** Returns true when a previously-registered entry's `name` differs from
227
+ * both `path.basename(entry.path)` and the git-remote-derived name —
228
+ * i.e. a user explicitly aliased it via `analyze --name <alias>` on a
229
+ * prior run. Used to preserve the alias across re-analyses that omit
230
+ * `--name`. The remote-derived name is treated as an inference, not a
231
+ * custom alias, so re-analyses keep tracking remote renames.
232
+ *
233
+ * `inferredName` is passed in (rather than re-derived) so callers can
234
+ * avoid a second `git config` subprocess invocation. */
235
+ const hasCustomAlias = (entry, inferredName) => {
236
+ const resolved = path.resolve(entry.path);
237
+ if (entry.name === path.basename(resolved))
238
+ return false;
239
+ if (inferredName && entry.name === inferredName)
240
+ return false;
241
+ return true;
242
+ };
199
243
  /**
200
244
  * Register (add or update) a repo in the global registry.
201
245
  * Called after `gitnexus analyze` completes.
246
+ *
247
+ * Name resolution precedence (#829, #979):
248
+ * 1. explicit `opts.name` (from `analyze --name <alias>`)
249
+ * 2. preserved alias on an existing entry for this path
250
+ * 3. `git config --get remote.origin.url` repo name (#979 — recovers
251
+ * a meaningful name for monorepo subprojects, git worktrees, and
252
+ * Gas-Town-style `<rig>/refinery/rig/` layouts where the basename
253
+ * is generic)
254
+ * 4. `path.basename(repoPath)` (the original default)
255
+ *
256
+ * Duplicate-name guard: if another path already uses the resolved
257
+ * `name`, throw {@link RegistryNameCollisionError} unless
258
+ * `opts.allowDuplicateName` is set. The guard ONLY fires when the user explicitly passed a
259
+ * `name`; un-aliased basename collisions continue to register silently
260
+ * so existing users who don't know about `--name` see no behaviour
261
+ * change.
262
+ *
263
+ * Returns the `name` that was actually written to the registry — the
264
+ * caller can re-use it to keep AGENTS.md / skill files aligned with the
265
+ * MCP-visible repo name (#979).
202
266
  */
203
- export const registerRepo = async (repoPath, meta) => {
267
+ export const registerRepo = async (repoPath, meta, opts) => {
204
268
  const resolved = path.resolve(repoPath);
205
- const name = path.basename(resolved);
206
269
  const { storagePath } = getStoragePaths(resolved);
207
270
  const entries = await readRegistry();
208
- const existing = entries.findIndex((e) => {
271
+ const existingIdx = entries.findIndex((e) => {
209
272
  const a = path.resolve(e.path);
210
273
  const b = resolved;
211
274
  return process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b;
212
275
  });
276
+ const existing = existingIdx >= 0 ? entries[existingIdx] : null;
277
+ // Precedence: explicit --name > preserved alias > remote-inferred > basename.
278
+ // Skip the `git config` subprocess entirely when --name was passed —
279
+ // the remote isn't consulted in that case.
280
+ let name;
281
+ let isPreservedAlias = false;
282
+ if (opts?.name !== undefined) {
283
+ name = opts.name;
284
+ }
285
+ else {
286
+ // Compute the remote-derived name at most once. It feeds both the
287
+ // alias-preservation check (`hasCustomAlias` needs it to distinguish
288
+ // a sticky user alias from a previously-stored remote inference) and
289
+ // the fallback name when neither --name nor a preserved alias apply.
290
+ const inferred = getInferredRepoName(resolved);
291
+ if (existing && hasCustomAlias(existing, inferred)) {
292
+ name = existing.name;
293
+ isPreservedAlias = true;
294
+ }
295
+ else {
296
+ name = inferred ?? path.basename(resolved);
297
+ }
298
+ }
299
+ // Duplicate-name guard: only fire when the user EXPLICITLY asked for
300
+ // this name (via opts.name or a preserved alias). Unqualified basename
301
+ // and remote-inferred collisions are preserved for backward-compat —
302
+ // they still register, and the user sees the ambiguity at `-r` / `list`
303
+ // resolution time (which is already improved by the disambiguated error
304
+ // messages and list output #829 ships).
305
+ const explicitName = opts?.name !== undefined || isPreservedAlias;
306
+ if (explicitName && !opts?.allowDuplicateName) {
307
+ const collidingEntry = entries.find((e, i) => i !== existingIdx &&
308
+ e.name.toLowerCase() === name.toLowerCase() &&
309
+ path.resolve(e.path) !== resolved);
310
+ if (collidingEntry) {
311
+ throw new RegistryNameCollisionError(name, collidingEntry.path, resolved);
312
+ }
313
+ }
213
314
  const entry = {
214
315
  name,
215
316
  path: resolved,
@@ -218,13 +319,14 @@ export const registerRepo = async (repoPath, meta) => {
218
319
  lastCommit: meta.lastCommit,
219
320
  stats: meta.stats,
220
321
  };
221
- if (existing >= 0) {
222
- entries[existing] = entry;
322
+ if (existingIdx >= 0) {
323
+ entries[existingIdx] = entry;
223
324
  }
224
325
  else {
225
326
  entries.push(entry);
226
327
  }
227
328
  await writeRegistry(entries);
329
+ return name;
228
330
  };
229
331
  /**
230
332
  * Remove a repo from the global registry.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitnexus",
3
- "version": "1.6.3-rc.2",
3
+ "version": "1.6.3-rc.21",
4
4
  "description": "Graph-powered code intelligence for AI agents. Index any codebase, query via MCP or CLI.",
5
5
  "author": "Abhigyan Patwari",
6
6
  "license": "PolyForm-Noncommercial-1.0.0",