gitnexus 1.6.2 → 1.6.3-rc.10

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 (117) hide show
  1. package/dist/_shared/index.d.ts +45 -0
  2. package/dist/_shared/index.d.ts.map +1 -1
  3. package/dist/_shared/index.js +33 -0
  4. package/dist/_shared/index.js.map +1 -1
  5. package/dist/_shared/scope-resolution/def-index.d.ts +36 -0
  6. package/dist/_shared/scope-resolution/def-index.d.ts.map +1 -0
  7. package/dist/_shared/scope-resolution/def-index.js +51 -0
  8. package/dist/_shared/scope-resolution/def-index.js.map +1 -0
  9. package/dist/_shared/scope-resolution/evidence-weights.d.ts +69 -0
  10. package/dist/_shared/scope-resolution/evidence-weights.d.ts.map +1 -0
  11. package/dist/_shared/scope-resolution/evidence-weights.js +84 -0
  12. package/dist/_shared/scope-resolution/evidence-weights.js.map +1 -0
  13. package/dist/_shared/scope-resolution/finalize-algorithm.d.ts +139 -0
  14. package/dist/_shared/scope-resolution/finalize-algorithm.d.ts.map +1 -0
  15. package/dist/_shared/scope-resolution/finalize-algorithm.js +479 -0
  16. package/dist/_shared/scope-resolution/finalize-algorithm.js.map +1 -0
  17. package/dist/_shared/scope-resolution/language-classification.d.ts +26 -0
  18. package/dist/_shared/scope-resolution/language-classification.d.ts.map +1 -0
  19. package/dist/_shared/scope-resolution/language-classification.js +44 -0
  20. package/dist/_shared/scope-resolution/language-classification.js.map +1 -0
  21. package/dist/_shared/scope-resolution/method-dispatch-index.d.ts +80 -0
  22. package/dist/_shared/scope-resolution/method-dispatch-index.d.ts.map +1 -0
  23. package/dist/_shared/scope-resolution/method-dispatch-index.js +79 -0
  24. package/dist/_shared/scope-resolution/method-dispatch-index.js.map +1 -0
  25. package/dist/_shared/scope-resolution/module-scope-index.d.ts +46 -0
  26. package/dist/_shared/scope-resolution/module-scope-index.d.ts.map +1 -0
  27. package/dist/_shared/scope-resolution/module-scope-index.js +58 -0
  28. package/dist/_shared/scope-resolution/module-scope-index.js.map +1 -0
  29. package/dist/_shared/scope-resolution/origin-priority.d.ts +14 -0
  30. package/dist/_shared/scope-resolution/origin-priority.d.ts.map +1 -0
  31. package/dist/_shared/scope-resolution/origin-priority.js +21 -0
  32. package/dist/_shared/scope-resolution/origin-priority.js.map +1 -0
  33. package/dist/_shared/scope-resolution/position-index.d.ts +62 -0
  34. package/dist/_shared/scope-resolution/position-index.d.ts.map +1 -0
  35. package/dist/_shared/scope-resolution/position-index.js +134 -0
  36. package/dist/_shared/scope-resolution/position-index.js.map +1 -0
  37. package/dist/_shared/scope-resolution/qualified-name-index.d.ts +44 -0
  38. package/dist/_shared/scope-resolution/qualified-name-index.d.ts.map +1 -0
  39. package/dist/_shared/scope-resolution/qualified-name-index.js +75 -0
  40. package/dist/_shared/scope-resolution/qualified-name-index.js.map +1 -0
  41. package/dist/_shared/scope-resolution/registries/class-registry.d.ts +27 -0
  42. package/dist/_shared/scope-resolution/registries/class-registry.d.ts.map +1 -0
  43. package/dist/_shared/scope-resolution/registries/class-registry.js +30 -0
  44. package/dist/_shared/scope-resolution/registries/class-registry.js.map +1 -0
  45. package/dist/_shared/scope-resolution/registries/context.d.ts +69 -0
  46. package/dist/_shared/scope-resolution/registries/context.d.ts.map +1 -0
  47. package/dist/_shared/scope-resolution/registries/context.js +44 -0
  48. package/dist/_shared/scope-resolution/registries/context.js.map +1 -0
  49. package/dist/_shared/scope-resolution/registries/evidence.d.ts +56 -0
  50. package/dist/_shared/scope-resolution/registries/evidence.d.ts.map +1 -0
  51. package/dist/_shared/scope-resolution/registries/evidence.js +150 -0
  52. package/dist/_shared/scope-resolution/registries/evidence.js.map +1 -0
  53. package/dist/_shared/scope-resolution/registries/field-registry.d.ts +26 -0
  54. package/dist/_shared/scope-resolution/registries/field-registry.d.ts.map +1 -0
  55. package/dist/_shared/scope-resolution/registries/field-registry.js +31 -0
  56. package/dist/_shared/scope-resolution/registries/field-registry.js.map +1 -0
  57. package/dist/_shared/scope-resolution/registries/lookup-core.d.ts +81 -0
  58. package/dist/_shared/scope-resolution/registries/lookup-core.d.ts.map +1 -0
  59. package/dist/_shared/scope-resolution/registries/lookup-core.js +332 -0
  60. package/dist/_shared/scope-resolution/registries/lookup-core.js.map +1 -0
  61. package/dist/_shared/scope-resolution/registries/lookup-qualified.d.ts +33 -0
  62. package/dist/_shared/scope-resolution/registries/lookup-qualified.d.ts.map +1 -0
  63. package/dist/_shared/scope-resolution/registries/lookup-qualified.js +56 -0
  64. package/dist/_shared/scope-resolution/registries/lookup-qualified.js.map +1 -0
  65. package/dist/_shared/scope-resolution/registries/method-registry.d.ts +36 -0
  66. package/dist/_shared/scope-resolution/registries/method-registry.d.ts.map +1 -0
  67. package/dist/_shared/scope-resolution/registries/method-registry.js +32 -0
  68. package/dist/_shared/scope-resolution/registries/method-registry.js.map +1 -0
  69. package/dist/_shared/scope-resolution/registries/tie-breaks.d.ts +43 -0
  70. package/dist/_shared/scope-resolution/registries/tie-breaks.d.ts.map +1 -0
  71. package/dist/_shared/scope-resolution/registries/tie-breaks.js +60 -0
  72. package/dist/_shared/scope-resolution/registries/tie-breaks.js.map +1 -0
  73. package/dist/_shared/scope-resolution/resolve-type-ref.d.ts +53 -0
  74. package/dist/_shared/scope-resolution/resolve-type-ref.d.ts.map +1 -0
  75. package/dist/_shared/scope-resolution/resolve-type-ref.js +126 -0
  76. package/dist/_shared/scope-resolution/resolve-type-ref.js.map +1 -0
  77. package/dist/_shared/scope-resolution/scope-id.d.ts +43 -0
  78. package/dist/_shared/scope-resolution/scope-id.d.ts.map +1 -0
  79. package/dist/_shared/scope-resolution/scope-id.js +46 -0
  80. package/dist/_shared/scope-resolution/scope-id.js.map +1 -0
  81. package/dist/_shared/scope-resolution/scope-tree.d.ts +61 -0
  82. package/dist/_shared/scope-resolution/scope-tree.d.ts.map +1 -0
  83. package/dist/_shared/scope-resolution/scope-tree.js +186 -0
  84. package/dist/_shared/scope-resolution/scope-tree.js.map +1 -0
  85. package/dist/_shared/scope-resolution/shadow/aggregate.d.ts +63 -0
  86. package/dist/_shared/scope-resolution/shadow/aggregate.d.ts.map +1 -0
  87. package/dist/_shared/scope-resolution/shadow/aggregate.js +122 -0
  88. package/dist/_shared/scope-resolution/shadow/aggregate.js.map +1 -0
  89. package/dist/_shared/scope-resolution/shadow/diff.d.ts +59 -0
  90. package/dist/_shared/scope-resolution/shadow/diff.d.ts.map +1 -0
  91. package/dist/_shared/scope-resolution/shadow/diff.js +79 -0
  92. package/dist/_shared/scope-resolution/shadow/diff.js.map +1 -0
  93. package/dist/_shared/scope-resolution/symbol-definition.d.ts +34 -0
  94. package/dist/_shared/scope-resolution/symbol-definition.d.ts.map +1 -0
  95. package/dist/_shared/scope-resolution/symbol-definition.js +12 -0
  96. package/dist/_shared/scope-resolution/symbol-definition.js.map +1 -0
  97. package/dist/_shared/scope-resolution/types.d.ts +356 -0
  98. package/dist/_shared/scope-resolution/types.d.ts.map +1 -0
  99. package/dist/_shared/scope-resolution/types.js +17 -0
  100. package/dist/_shared/scope-resolution/types.js.map +1 -0
  101. package/dist/core/ingestion/call-processor.d.ts +2 -1
  102. package/dist/core/ingestion/language-provider.d.ts +172 -1
  103. package/dist/core/ingestion/model/field-registry.d.ts +1 -1
  104. package/dist/core/ingestion/model/index.d.ts +1 -1
  105. package/dist/core/ingestion/model/index.js +2 -0
  106. package/dist/core/ingestion/model/method-registry.d.ts +1 -1
  107. package/dist/core/ingestion/model/registration-table.d.ts +1 -2
  108. package/dist/core/ingestion/model/resolution-context.d.ts +1 -1
  109. package/dist/core/ingestion/model/resolve.d.ts +1 -1
  110. package/dist/core/ingestion/model/symbol-table.d.ts +1 -23
  111. package/dist/core/ingestion/model/type-registry.d.ts +1 -1
  112. package/dist/core/search/phase-timer.d.ts +72 -0
  113. package/dist/core/search/phase-timer.js +106 -0
  114. package/dist/mcp/local/local-backend.d.ts +48 -1
  115. package/dist/mcp/local/local-backend.js +345 -135
  116. package/dist/mcp/tools.js +19 -1
  117. 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();
@@ -449,15 +469,26 @@ export class LocalBackend {
449
469
  const maxSymbolsPerProcess = params.max_symbols || 10;
450
470
  const includeContent = params.include_content ?? false;
451
471
  const searchQuery = params.query.trim();
452
- // Step 1: Run hybrid search to get matching symbols
472
+ // Per-phase timing instrumentation (#553). Records wall time for each
473
+ // observable sub-step of the search pipeline so production latency can
474
+ // be aggregated offline for Pareto analysis and bottleneck detection.
475
+ // Overhead is <0.1 ms per phase; the timer is passive and never alters
476
+ // query behaviour.
477
+ const timer = new PhaseTimer();
478
+ const wallStart = performance.now();
479
+ // Step 1: Run hybrid search to get matching symbols. BM25 and vector
480
+ // search run concurrently via Promise.all — use `timer.time()` for
481
+ // each so both get independent wall-time records without fighting
482
+ // over a single `current` phase slot.
453
483
  const searchLimit = processLimit * maxSymbolsPerProcess; // fetch enough raw results
454
484
  const [bm25SearchResult, semanticResults] = await Promise.all([
455
- this.bm25Search(repo, searchQuery, searchLimit),
456
- this.semanticSearch(repo, searchQuery, searchLimit),
485
+ timer.time('bm25', this.bm25Search(repo, searchQuery, searchLimit)),
486
+ timer.time('vector', this.semanticSearch(repo, searchQuery, searchLimit)),
457
487
  ]);
458
488
  const bm25Results = bm25SearchResult.results;
459
489
  const ftsUsed = bm25SearchResult.ftsUsed;
460
490
  // Merge via reciprocal rank fusion
491
+ timer.start('merge');
461
492
  const scoreMap = new Map();
462
493
  for (let i = 0; i < bm25Results.length; i++) {
463
494
  const result = bm25Results[i];
@@ -486,7 +517,9 @@ export class LocalBackend {
486
517
  const merged = Array.from(scoreMap.entries())
487
518
  .sort((a, b) => b[1].score - a[1].score)
488
519
  .slice(0, searchLimit);
520
+ timer.stop(); // merge
489
521
  // Step 2: For each match with a nodeId, trace to process(es)
522
+ timer.start('symbol_lookup');
490
523
  const processMap = new Map();
491
524
  const definitions = []; // standalone symbols not in any process
492
525
  for (const [_, item] of merged) {
@@ -590,7 +623,9 @@ export class LocalBackend {
590
623
  }
591
624
  }
592
625
  }
626
+ timer.stop(); // symbol_lookup
593
627
  // Step 3: Rank processes by aggregate score + internal cohesion boost
628
+ timer.start('ranking');
594
629
  const rankedProcesses = Array.from(processMap.values())
595
630
  .map((p) => ({
596
631
  ...p,
@@ -598,7 +633,9 @@ export class LocalBackend {
598
633
  }))
599
634
  .sort((a, b) => b.priority - a.priority)
600
635
  .slice(0, processLimit);
636
+ timer.stop(); // ranking
601
637
  // Step 4: Build response
638
+ timer.start('formatting');
602
639
  const processes = rankedProcesses.map((p) => ({
603
640
  id: p.id,
604
641
  summary: p.heuristicLabel || p.label,
@@ -619,10 +656,18 @@ export class LocalBackend {
619
656
  seen.add(s.id);
620
657
  return true;
621
658
  });
659
+ timer.stop(); // formatting
660
+ // End-to-end wall time — deliberately a separate mark so callers can
661
+ // compare sum(phases) vs wall to see how much Promise.all concurrency
662
+ // saved. Must come before summary() so it's included.
663
+ timer.mark('wall', performance.now() - wallStart);
664
+ const timing = timer.summary();
665
+ logQueryTiming(searchQuery, timing);
622
666
  return {
623
667
  processes,
624
668
  process_symbols: dedupedSymbols,
625
669
  definitions: definitions.slice(0, 20), // cap standalone definitions
670
+ timing,
626
671
  ...(!ftsUsed && {
627
672
  warning: 'FTS extension unavailable - keyword search degraded. Run: gitnexus analyze --force to rebuild indexes.',
628
673
  }),
@@ -908,108 +953,268 @@ export class LocalBackend {
908
953
  return result;
909
954
  }
910
955
  /**
911
- * Context tool 360-degree symbol view with categorized refs.
912
- * Disambiguation when multiple symbols share a name.
913
- * UID-based direct lookup. No cluster in output.
956
+ * Patch the `type` field on candidates whose `labels(n)[0]` projection
957
+ * came back empty a known LadybugDB behaviour for several node types.
958
+ *
959
+ * Uses one scoped UNION query across the five priority labels rather
960
+ * than per-candidate round-trips, so cost is a single DB call regardless
961
+ * of how many candidates need enrichment. No-op when every candidate
962
+ * already has a non-empty type.
963
+ *
964
+ * Failures are swallowed: label enrichment is an optimisation for
965
+ * downstream scoring and #480 Class/Interface BFS seeding; if it fails
966
+ * the symbol still resolves, just without the kind-priority bonus.
914
967
  */
915
- async context(repo, params) {
916
- await this.ensureInitialized(repo.id);
917
- const { name, uid, file_path, include_content } = params;
918
- if (!name && !uid) {
919
- return { error: 'Either "name" or "uid" parameter is required.' };
920
- }
921
- // Step 1: Find the symbol
922
- let symbols;
923
- if (uid) {
924
- symbols = await executeParameterized(repo.id, `
925
- MATCH (n {id: $uid})
926
- RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine${include_content ? ', n.content AS content' : ''}
927
- LIMIT 1
928
- `, { uid });
929
- }
930
- else {
931
- const isQualified = name.includes('/') || name.includes(':');
932
- let whereClause;
933
- let queryParams;
934
- if (file_path) {
935
- whereClause = `WHERE n.name = $symName AND n.filePath CONTAINS $filePath`;
936
- queryParams = { symName: name, filePath: file_path };
937
- }
938
- else if (isQualified) {
939
- whereClause = `WHERE n.id = $symName OR n.name = $symName`;
940
- queryParams = { symName: name };
968
+ async enrichCandidateLabels(repo, candidates) {
969
+ const ids = candidates.filter((c) => c.type === '' && c.id).map((c) => c.id);
970
+ if (ids.length === 0)
971
+ return;
972
+ try {
973
+ const rows = await executeParameterized(repo.id, `
974
+ MATCH (n:\`Class\`) WHERE n.id IN $ids RETURN n.id AS id, 'Class' AS label
975
+ UNION ALL
976
+ MATCH (n:\`Interface\`) WHERE n.id IN $ids RETURN n.id AS id, 'Interface' AS label
977
+ UNION ALL
978
+ MATCH (n:\`Function\`) WHERE n.id IN $ids RETURN n.id AS id, 'Function' AS label
979
+ UNION ALL
980
+ MATCH (n:\`Method\`) WHERE n.id IN $ids RETURN n.id AS id, 'Method' AS label
981
+ UNION ALL
982
+ MATCH (n:\`Constructor\`) WHERE n.id IN $ids RETURN n.id AS id, 'Constructor' AS label
983
+ `, { ids });
984
+ const labelById = new Map();
985
+ for (const r of rows) {
986
+ const id = (r.id ?? r[0]);
987
+ const label = (r.label ?? r[1]);
988
+ if (id && label && !labelById.has(id))
989
+ labelById.set(id, label);
941
990
  }
942
- else {
943
- whereClause = `WHERE n.name = $symName`;
944
- queryParams = { symName: name };
991
+ for (const c of candidates) {
992
+ if (c.type === '' && labelById.has(c.id))
993
+ c.type = labelById.get(c.id);
945
994
  }
946
- symbols = await executeParameterized(repo.id, `
947
- MATCH (n) ${whereClause}
948
- RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine${include_content ? ', n.content AS content' : ''}
949
- LIMIT 10
950
- `, queryParams);
951
995
  }
952
- if (symbols.length === 0) {
953
- return { error: `Symbol '${name || uid}' not found` };
996
+ catch {
997
+ /* best-effort downstream resolvers still work without the label */
954
998
  }
955
- // Step 2: Disambiguation
956
- // When multiple nodes share the same name (e.g. a Java Class and its
957
- // Constructor both named 'SessionTracker'), prefer the Class node so
958
- // context() returns the semantically meaningful result rather than
959
- // triggering ambiguous disambiguation (#480).
960
- // labels(n)[0] returns empty string in LadybugDB, so we resolve the
961
- // preferred node by re-querying with explicit label filters, scoped to
962
- // the candidate IDs already in symbols.
963
- //
964
- // Guard: only attempt Class-preference when at least one candidate has an
965
- // empty/unknown type (LadybugDB limitation) or is a Constructor — meaning
966
- // the ambiguity may be a Class/Constructor name collision rather than two
967
- // genuinely distinct symbols (e.g. two Functions in different files).
968
- //
969
- // resolvedLabel is set here and threaded to Step 3 to avoid a redundant
970
- // classCheck round-trip later.
971
- let resolvedLabel = '';
972
- if (symbols.length > 1 && !uid) {
973
- const hasAmbiguousType = symbols.some((s) => {
974
- const t = s.type || s[2] || '';
975
- return t === '' || t === 'Constructor';
976
- });
977
- if (hasAmbiguousType) {
978
- const candidateIds = symbols.map((s) => s.id || s[0]).filter(Boolean);
979
- const PREFER_LABELS = ['Class', 'Interface'];
980
- let preferred = null;
981
- for (const label of PREFER_LABELS) {
982
- const match = await executeParameterized(repo.id, `
983
- MATCH (n:\`${label}\`) WHERE n.id IN $candidateIds RETURN n.id AS id LIMIT 1
984
- `, { candidateIds }).catch(() => []);
985
- if (match.length > 0) {
986
- preferred = symbols.find((s) => (s.id || s[0]) === (match[0].id || match[0][0]));
999
+ }
1000
+ /**
1001
+ * Score a symbol candidate for disambiguation ranking.
1002
+ *
1003
+ * Deterministic, no DB round-trip:
1004
+ * - base 0.50
1005
+ * - +0.40 when file_path hint matches (substring, case-insensitive)
1006
+ * - +0.20 when kind hint exactly matches the candidate's kind
1007
+ * - when no kind hint, a small priority bonus (Class > Interface >
1008
+ * Function > Method > Constructor) to preserve the intuition that
1009
+ * class-level names are usually what the user wanted.
1010
+ *
1011
+ * Capped at 1.0. Intentionally simple and inspectable — a future v2 can
1012
+ * plug in BM25/embedding signals here without changing the surrounding
1013
+ * resolver shape.
1014
+ */
1015
+ scoreCandidate(c, hints) {
1016
+ let s = 0.5;
1017
+ if (hints.file_path && c.filePath && typeof c.filePath === 'string') {
1018
+ if (c.filePath.toLowerCase().includes(hints.file_path.toLowerCase())) {
1019
+ s += 0.4;
1020
+ }
1021
+ }
1022
+ if (hints.kind && c.kind === hints.kind) {
1023
+ s += 0.2;
1024
+ }
1025
+ if (!hints.kind) {
1026
+ const priority = {
1027
+ Class: 5,
1028
+ Interface: 4,
1029
+ Function: 3,
1030
+ Method: 2,
1031
+ Constructor: 1,
1032
+ };
1033
+ s += (priority[c.kind] ?? 0) * 0.02;
1034
+ }
1035
+ return Math.min(1.0, s);
1036
+ }
1037
+ /**
1038
+ * Shared symbol resolver used by `context` and `impact`.
1039
+ *
1040
+ * Returns one of:
1041
+ * - `{ kind: 'ok', symbol, resolvedLabel }` — single confident match
1042
+ * (either direct UID, only one candidate after filtering, Class/
1043
+ * Constructor collapse, or a top-scoring candidate with a clear gap
1044
+ * to the runner-up).
1045
+ * - `{ kind: 'ambiguous', candidates }` — multiple viable matches,
1046
+ * sorted by score desc. Each candidate carries a relevance score.
1047
+ * - `{ kind: 'not_found' }` — no matches at all.
1048
+ *
1049
+ * Preserves the #480 Class/Constructor preference: when the only
1050
+ * ambiguity is between a Class and its own Constructor (same name,
1051
+ * same filePath), the Class wins silently.
1052
+ */
1053
+ async resolveSymbolCandidates(repo, query, hints) {
1054
+ const { uid, name, include_content } = query;
1055
+ const selectClause = `n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine${include_content ? ', n.content AS content' : ''}`;
1056
+ // Direct UID — zero-ambiguity path.
1057
+ if (uid) {
1058
+ const rows = await executeParameterized(repo.id, `MATCH (n {id: $uid}) RETURN ${selectClause} LIMIT 1`, { uid });
1059
+ if (rows.length === 0)
1060
+ return { kind: 'not_found' };
1061
+ const r = rows[0];
1062
+ const symbol = {
1063
+ id: (r.id ?? r[0]),
1064
+ name: (r.name ?? r[1]),
1065
+ type: (r.type ?? r[2] ?? ''),
1066
+ filePath: (r.filePath ?? r[3]),
1067
+ startLine: (r.startLine ?? r[4]),
1068
+ endLine: (r.endLine ?? r[5]),
1069
+ ...(include_content ? { content: (r.content ?? r[6]) } : {}),
1070
+ };
1071
+ // Same LadybugDB label-enrichment as the name-based path: a UID
1072
+ // pointing at a Class must still surface `type: 'Class'` so impact's
1073
+ // Class/Interface BFS seed fires. No-op when type is already set.
1074
+ await this.enrichCandidateLabels(repo, [symbol]);
1075
+ return { kind: 'ok', symbol, resolvedLabel: symbol.type };
1076
+ }
1077
+ if (!name)
1078
+ return { kind: 'not_found' };
1079
+ const isQualified = name.includes('/') || name.includes(':');
1080
+ let whereClause;
1081
+ const queryParams = { symName: name };
1082
+ if (hints.file_path) {
1083
+ whereClause = `WHERE n.name = $symName AND n.filePath CONTAINS $filePath`;
1084
+ queryParams.filePath = hints.file_path;
1085
+ }
1086
+ else if (isQualified) {
1087
+ whereClause = `WHERE n.id = $symName OR n.name = $symName`;
1088
+ }
1089
+ else {
1090
+ whereClause = `WHERE n.name = $symName`;
1091
+ }
1092
+ // LIMIT 20 (was 10) — scoring is the point now, so give the ranker
1093
+ // headroom instead of arbitrary truncation.
1094
+ const rows = await executeParameterized(repo.id, `MATCH (n) ${whereClause} RETURN ${selectClause} LIMIT 20`, queryParams);
1095
+ if (rows.length === 0)
1096
+ return { kind: 'not_found' };
1097
+ // Normalise row shape across object / tuple returns from LadybugDB.
1098
+ const normalized = rows.map((r) => ({
1099
+ id: (r.id ?? r[0]),
1100
+ name: (r.name ?? r[1]),
1101
+ type: (r.type ?? r[2] ?? ''),
1102
+ filePath: (r.filePath ?? r[3]),
1103
+ startLine: (r.startLine ?? r[4]),
1104
+ endLine: (r.endLine ?? r[5]),
1105
+ ...(include_content ? { content: (r.content ?? r[6]) } : {}),
1106
+ }));
1107
+ // Enrich labels for any candidates where `labels(n)[0]` came back empty.
1108
+ // LadybugDB returns an empty string for that projection on certain node
1109
+ // types (notably Class), which left downstream consumers (impact's
1110
+ // Class/Interface BFS seed, the kind-priority scoring bonus) unable to
1111
+ // distinguish a Class target from "unknown kind". One scoped UNION
1112
+ // across the five priority labels patches the type in-place without
1113
+ // per-candidate round-trips.
1114
+ await this.enrichCandidateLabels(repo, normalized);
1115
+ // Preserve #480 Class/Constructor collapse: if we have exactly one
1116
+ // Class (or Interface) candidate and one Constructor sharing name +
1117
+ // filePath, fold into the Class. This used to require a follow-up
1118
+ // label query because LadybugDB sometimes returns an empty labels()[0]
1119
+ // for Class nodes — enrichment above handles the empty-type case, but
1120
+ // the `type === 'Constructor'` gate still correctly triggers when a
1121
+ // Class and its Constructor share the name.
1122
+ if (!hints.kind && normalized.length > 1) {
1123
+ const ambiguousType = normalized.some((s) => s.type === '' || s.type === 'Constructor');
1124
+ if (ambiguousType) {
1125
+ const candidateIds = normalized.map((s) => s.id).filter(Boolean);
1126
+ for (const label of ['Class', 'Interface']) {
1127
+ const labelRows = await executeParameterized(repo.id, `MATCH (n:\`${label}\`) WHERE n.id IN $candidateIds RETURN n.id AS id LIMIT 1`, { candidateIds }).catch(() => []);
1128
+ if (labelRows.length > 0) {
1129
+ const preferredId = labelRows[0].id ?? labelRows[0][0];
1130
+ const preferred = normalized.find((s) => s.id === preferredId);
987
1131
  if (preferred) {
988
- resolvedLabel = label;
989
- break;
1132
+ return {
1133
+ kind: 'ok',
1134
+ symbol: preferred,
1135
+ resolvedLabel: label,
1136
+ };
990
1137
  }
991
1138
  }
992
1139
  }
993
- if (preferred)
994
- symbols = [preferred];
995
1140
  }
996
1141
  }
997
- if (symbols.length > 1 && !uid) {
1142
+ if (normalized.length === 1) {
1143
+ return {
1144
+ kind: 'ok',
1145
+ symbol: normalized[0],
1146
+ resolvedLabel: '',
1147
+ };
1148
+ }
1149
+ // Score, sort desc, stable tiebreak on shorter filePath then lex uid.
1150
+ const scored = normalized.map((s) => ({
1151
+ ...s,
1152
+ score: this.scoreCandidate({ kind: s.type, filePath: s.filePath || '' }, hints),
1153
+ }));
1154
+ scored.sort((a, b) => {
1155
+ if (b.score !== a.score)
1156
+ return b.score - a.score;
1157
+ const fpA = (a.filePath || '').length;
1158
+ const fpB = (b.filePath || '').length;
1159
+ if (fpA !== fpB)
1160
+ return fpA - fpB;
1161
+ return String(a.id).localeCompare(String(b.id));
1162
+ });
1163
+ // Confident single-result: top score ≥ 0.95 AND beats runner-up by a
1164
+ // clear margin. This lets a very strong file_path/kind hint resolve
1165
+ // cleanly instead of forcing the caller through a disambiguation
1166
+ // round-trip.
1167
+ //
1168
+ // The gap threshold uses `> 0.09` rather than `>= 0.10` on purpose:
1169
+ // IEEE754 addition of the scoring terms (0.50 + 0.40 + 0.20 - 0.90
1170
+ // yields 0.09999999999999998, not exactly 0.10) would otherwise break
1171
+ // the comparison for legitimate "top is 1.00, runner is 0.90" cases.
1172
+ // The intent is a clearly-dominant winner; 0.09 is a large enough
1173
+ // margin to mean that unambiguously.
1174
+ //
1175
+ // The `scored.length >= 2` guard is defensive. The `normalized.length === 1`
1176
+ // early return above already handles the single-candidate path, so in
1177
+ // practice `scored` always has at least two elements by the time we get
1178
+ // here — keeping the guard means changes to the upstream early-return
1179
+ // logic cannot accidentally index out of bounds at `scored[1]`.
1180
+ if (scored.length >= 2 && scored[0].score >= 0.95 && scored[0].score - scored[1].score > 0.09) {
1181
+ return { kind: 'ok', symbol: scored[0], resolvedLabel: scored[0].type };
1182
+ }
1183
+ return { kind: 'ambiguous', candidates: scored };
1184
+ }
1185
+ /**
1186
+ * Context tool — 360-degree symbol view with categorized refs.
1187
+ * Disambiguation (ranked) when multiple symbols share a name.
1188
+ * UID-based direct lookup. No cluster in output.
1189
+ */
1190
+ async context(repo, params) {
1191
+ await this.ensureInitialized(repo.id);
1192
+ const { name, uid, file_path, kind, include_content } = params;
1193
+ if (!name && !uid) {
1194
+ return { error: 'Either "name" or "uid" parameter is required.' };
1195
+ }
1196
+ const outcome = await this.resolveSymbolCandidates(repo, { uid, name, include_content }, { file_path, kind });
1197
+ if (outcome.kind === 'not_found') {
1198
+ return { error: `Symbol '${name || uid}' not found` };
1199
+ }
1200
+ if (outcome.kind === 'ambiguous') {
998
1201
  return {
999
1202
  status: 'ambiguous',
1000
- message: `Found ${symbols.length} symbols matching '${name}'. Use uid or file_path to disambiguate.`,
1001
- candidates: symbols.map((s) => ({
1002
- uid: s.id || s[0],
1003
- name: s.name || s[1],
1004
- kind: s.type || s[2],
1005
- filePath: s.filePath || s[3],
1006
- line: s.startLine || s[4],
1203
+ message: `Found ${outcome.candidates.length} symbols matching '${name}'. Use uid, file_path, or kind to disambiguate.`,
1204
+ candidates: outcome.candidates.map((c) => ({
1205
+ uid: c.id,
1206
+ name: c.name,
1207
+ kind: c.type,
1208
+ filePath: c.filePath,
1209
+ line: c.startLine,
1210
+ score: Number(c.score.toFixed(2)),
1007
1211
  })),
1008
1212
  };
1009
1213
  }
1010
1214
  // Step 3: Build full context
1011
- const sym = symbols[0];
1012
- const symId = sym.id || sym[0];
1215
+ const sym = outcome.symbol;
1216
+ const resolvedLabel = outcome.resolvedLabel;
1217
+ const symId = sym.id;
1013
1218
  // Categorized incoming refs
1014
1219
  const incomingRows = await executeParameterized(repo.id, `
1015
1220
  MATCH (caller)-[r:CodeRelation]->(n {id: $symId})
@@ -1287,7 +1492,14 @@ export class LocalBackend {
1287
1492
  }
1288
1493
  let diffOutput;
1289
1494
  try {
1290
- diffOutput = execFileSync('git', diffArgs, { cwd: repo.repoPath, encoding: 'utf-8' });
1495
+ // maxBuffer raised from Node's 1MB default to 256MB to avoid ENOBUFS on
1496
+ // repos with large unstaged/untracked diffs (e.g. unignored build folders).
1497
+ // See issue: spawnSync git ENOBUFS in detect_changes(scope="unstaged").
1498
+ diffOutput = execFileSync('git', diffArgs, {
1499
+ cwd: repo.repoPath,
1500
+ encoding: 'utf-8',
1501
+ maxBuffer: 256 * 1024 * 1024,
1502
+ });
1291
1503
  }
1292
1504
  catch (err) {
1293
1505
  return { error: `Git diff failed: ${err.message}` };
@@ -1502,6 +1714,8 @@ export class LocalBackend {
1502
1714
  cwd: repo.repoPath,
1503
1715
  encoding: 'utf-8',
1504
1716
  timeout: 5000,
1717
+ // Avoid ENOBUFS on large repos: rg -l can list many files.
1718
+ maxBuffer: 256 * 1024 * 1024,
1505
1719
  });
1506
1720
  const files = output
1507
1721
  .trim()
@@ -1608,54 +1822,50 @@ export class LocalBackend {
1608
1822
  ];
1609
1823
  const includeTests = params.includeTests ?? false;
1610
1824
  const minConfidence = params.minConfidence ?? 0;
1611
- // Resolve target by name, preferring Class/Interface over Constructor
1612
- // (fix #480: Java class and constructor share the same name).
1613
- // labels(n)[0] returns empty string in LadybugDB, so we use explicit
1614
- // label-typed sub-queries in a single UNION ordered by priority to avoid
1615
- // up to 6 serial round-trips for non-Class targets.
1616
- let sym = null;
1617
- let symType = '';
1618
- try {
1619
- const rows = await executeParameterized(repo.id, `
1620
- MATCH (n:\`Class\`) WHERE n.name = $targetName
1621
- RETURN n.id AS id, n.name AS name, n.filePath AS filePath, 0 AS priority LIMIT 1
1622
- UNION ALL
1623
- MATCH (n:\`Interface\`) WHERE n.name = $targetName
1624
- RETURN n.id AS id, n.name AS name, n.filePath AS filePath, 1 AS priority LIMIT 1
1625
- UNION ALL
1626
- MATCH (n:\`Function\`) WHERE n.name = $targetName
1627
- RETURN n.id AS id, n.name AS name, n.filePath AS filePath, 2 AS priority LIMIT 1
1628
- UNION ALL
1629
- MATCH (n:\`Method\`) WHERE n.name = $targetName
1630
- RETURN n.id AS id, n.name AS name, n.filePath AS filePath, 3 AS priority LIMIT 1
1631
- UNION ALL
1632
- MATCH (n:\`Constructor\`) WHERE n.name = $targetName
1633
- RETURN n.id AS id, n.name AS name, n.filePath AS filePath, 4 AS priority LIMIT 1
1634
- `, { targetName: target }).catch(() => []);
1635
- if (rows.length > 0) {
1636
- // Pick the row with the lowest priority value (Class wins over Constructor)
1637
- const best = rows.reduce((a, b) => (a.priority ?? a[3] ?? 99) <= (b.priority ?? b[3] ?? 99) ? a : b);
1638
- sym = best;
1639
- const priorityToLabel = ['Class', 'Interface', 'Function', 'Method', 'Constructor'];
1640
- symType = priorityToLabel[best.priority ?? best[3]] ?? '';
1641
- }
1642
- }
1643
- catch {
1644
- /* fall through to unlabeled match */
1825
+ // Resolve target via the shared symbol resolver. When the caller passes
1826
+ // target_uid we skip the name lookup entirely (zero-ambiguity). Otherwise
1827
+ // we rank candidates (#470) and either proceed with a confident single
1828
+ // match, or return a structured ambiguous response instead of silently
1829
+ // picking the wrong symbol.
1830
+ //
1831
+ // The resolver preserves the #480 Class/Constructor preference heuristic:
1832
+ // when a Class and its Constructor share name + filePath, the Class is
1833
+ // selected silently.
1834
+ const outcome = await this.resolveSymbolCandidates(repo, { uid: params.target_uid, name: target }, { file_path: params.file_path, kind: params.kind });
1835
+ if (outcome.kind === 'not_found') {
1836
+ const missing = params.target_uid ?? target;
1837
+ return {
1838
+ error: `Target '${missing}' not found`,
1839
+ target: { name: target },
1840
+ direction,
1841
+ impactedCount: 0,
1842
+ risk: 'UNKNOWN',
1843
+ };
1645
1844
  }
1646
- // Fall back to unlabeled match for any other node type
1647
- if (!sym) {
1648
- const rows = await executeParameterized(repo.id, `
1649
- MATCH (n)
1650
- WHERE n.name = $targetName
1651
- RETURN n.id AS id, n.name AS name, n.filePath AS filePath
1652
- LIMIT 1
1653
- `, { targetName: target });
1654
- if (rows.length > 0)
1655
- sym = rows[0];
1845
+ if (outcome.kind === 'ambiguous') {
1846
+ return {
1847
+ status: 'ambiguous',
1848
+ message: `Found ${outcome.candidates.length} symbols matching '${target}'. Use target_uid, file_path, or kind to disambiguate.`,
1849
+ target: { name: target },
1850
+ direction,
1851
+ impactedCount: 0,
1852
+ risk: 'UNKNOWN',
1853
+ candidates: outcome.candidates.map((c) => ({
1854
+ uid: c.id,
1855
+ name: c.name,
1856
+ kind: c.type,
1857
+ filePath: c.filePath,
1858
+ line: c.startLine,
1859
+ score: Number(c.score.toFixed(2)),
1860
+ })),
1861
+ };
1656
1862
  }
1657
- if (!sym)
1658
- return { error: `Target '${target}' not found` };
1863
+ const sym = {
1864
+ id: outcome.symbol.id,
1865
+ name: outcome.symbol.name,
1866
+ filePath: outcome.symbol.filePath,
1867
+ };
1868
+ const symType = outcome.resolvedLabel || outcome.symbol.type || '';
1659
1869
  return this._runImpactBFS(repo, sym, symType, direction, {
1660
1870
  maxDepth,
1661
1871
  relationTypes,
package/dist/mcp/tools.js CHANGED
@@ -133,7 +133,7 @@ Shows categorized incoming/outgoing references (calls, imports, extends, impleme
133
133
  WHEN TO USE: After query() to understand a specific symbol in depth. When you need to know all callers, callees, and what execution flows a symbol participates in.
134
134
  AFTER THIS: Use impact() if planning changes, or READ gitnexus://repo/{name}/process/{processName} for full execution trace.
135
135
 
136
- Handles disambiguation: if multiple symbols share the same name, returns candidates for you to pick from. Use uid param for zero-ambiguity lookup from prior results.
136
+ Handles disambiguation: if multiple symbols share the same name, returns ranked candidates (each with a relevance score) for you to pick from. Use uid for zero-ambiguity lookup, or narrow the search with file_path and/or kind hints.
137
137
 
138
138
  NOTE: ACCESSES edges (field read/write tracking) are included in context results with reason 'read' or 'write'. CALLS edges resolve through field access chains and method-call chains (e.g., user.address.getCity().save() produces CALLS edges at each step).`,
139
139
  inputSchema: {
@@ -145,6 +145,10 @@ NOTE: ACCESSES edges (field read/write tracking) are included in context results
145
145
  description: 'Direct symbol UID from prior tool results (zero-ambiguity lookup)',
146
146
  },
147
147
  file_path: { type: 'string', description: 'File path to disambiguate common names' },
148
+ kind: {
149
+ type: 'string',
150
+ description: "Kind filter to disambiguate common names (e.g. 'Function', 'Class', 'Method', 'Interface', 'Constructor')",
151
+ },
148
152
  include_content: {
149
153
  type: 'boolean',
150
154
  description: 'Include full symbol source code (default: false)',
@@ -244,16 +248,30 @@ Depth groups:
244
248
 
245
249
  TIP: Default traversal uses CALLS/IMPORTS/EXTENDS/IMPLEMENTS. For class members, include HAS_METHOD and HAS_PROPERTY in relationTypes. For field access analysis, include ACCESSES in relationTypes.
246
250
 
251
+ Handles disambiguation: when multiple symbols share the target name, returns ranked candidates (each with a relevance score) instead of silently picking one. Use target_uid for zero-ambiguity lookup, or narrow with file_path and/or kind hints.
252
+
247
253
  EdgeType: CALLS, IMPORTS, EXTENDS, IMPLEMENTS, HAS_METHOD, HAS_PROPERTY, METHOD_OVERRIDES, METHOD_IMPLEMENTS, ACCESSES
248
254
  Confidence: 1.0 = certain, <0.8 = fuzzy match`,
249
255
  inputSchema: {
250
256
  type: 'object',
251
257
  properties: {
252
258
  target: { type: 'string', description: 'Name of function, class, or file to analyze' },
259
+ target_uid: {
260
+ type: 'string',
261
+ description: 'Direct symbol UID from prior tool results (zero-ambiguity lookup, skips target resolution)',
262
+ },
253
263
  direction: {
254
264
  type: 'string',
255
265
  description: 'upstream (what depends on this) or downstream (what this depends on)',
256
266
  },
267
+ file_path: {
268
+ type: 'string',
269
+ description: 'File path hint to disambiguate common names',
270
+ },
271
+ kind: {
272
+ type: 'string',
273
+ description: "Kind filter to disambiguate common names (e.g. 'Function', 'Class', 'Method', 'Interface', 'Constructor')",
274
+ },
257
275
  maxDepth: {
258
276
  type: 'number',
259
277
  description: 'Max relationship depth (default: 3)',