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.
- package/dist/_shared/index.d.ts +45 -0
- package/dist/_shared/index.d.ts.map +1 -1
- package/dist/_shared/index.js +33 -0
- package/dist/_shared/index.js.map +1 -1
- package/dist/_shared/scope-resolution/def-index.d.ts +36 -0
- package/dist/_shared/scope-resolution/def-index.d.ts.map +1 -0
- package/dist/_shared/scope-resolution/def-index.js +51 -0
- package/dist/_shared/scope-resolution/def-index.js.map +1 -0
- package/dist/_shared/scope-resolution/evidence-weights.d.ts +69 -0
- package/dist/_shared/scope-resolution/evidence-weights.d.ts.map +1 -0
- package/dist/_shared/scope-resolution/evidence-weights.js +84 -0
- package/dist/_shared/scope-resolution/evidence-weights.js.map +1 -0
- package/dist/_shared/scope-resolution/finalize-algorithm.d.ts +139 -0
- package/dist/_shared/scope-resolution/finalize-algorithm.d.ts.map +1 -0
- package/dist/_shared/scope-resolution/finalize-algorithm.js +479 -0
- package/dist/_shared/scope-resolution/finalize-algorithm.js.map +1 -0
- package/dist/_shared/scope-resolution/language-classification.d.ts +26 -0
- package/dist/_shared/scope-resolution/language-classification.d.ts.map +1 -0
- package/dist/_shared/scope-resolution/language-classification.js +44 -0
- package/dist/_shared/scope-resolution/language-classification.js.map +1 -0
- package/dist/_shared/scope-resolution/method-dispatch-index.d.ts +80 -0
- package/dist/_shared/scope-resolution/method-dispatch-index.d.ts.map +1 -0
- package/dist/_shared/scope-resolution/method-dispatch-index.js +79 -0
- package/dist/_shared/scope-resolution/method-dispatch-index.js.map +1 -0
- package/dist/_shared/scope-resolution/module-scope-index.d.ts +46 -0
- package/dist/_shared/scope-resolution/module-scope-index.d.ts.map +1 -0
- package/dist/_shared/scope-resolution/module-scope-index.js +58 -0
- package/dist/_shared/scope-resolution/module-scope-index.js.map +1 -0
- package/dist/_shared/scope-resolution/origin-priority.d.ts +14 -0
- package/dist/_shared/scope-resolution/origin-priority.d.ts.map +1 -0
- package/dist/_shared/scope-resolution/origin-priority.js +21 -0
- package/dist/_shared/scope-resolution/origin-priority.js.map +1 -0
- package/dist/_shared/scope-resolution/position-index.d.ts +62 -0
- package/dist/_shared/scope-resolution/position-index.d.ts.map +1 -0
- package/dist/_shared/scope-resolution/position-index.js +134 -0
- package/dist/_shared/scope-resolution/position-index.js.map +1 -0
- package/dist/_shared/scope-resolution/qualified-name-index.d.ts +44 -0
- package/dist/_shared/scope-resolution/qualified-name-index.d.ts.map +1 -0
- package/dist/_shared/scope-resolution/qualified-name-index.js +75 -0
- package/dist/_shared/scope-resolution/qualified-name-index.js.map +1 -0
- package/dist/_shared/scope-resolution/registries/class-registry.d.ts +27 -0
- package/dist/_shared/scope-resolution/registries/class-registry.d.ts.map +1 -0
- package/dist/_shared/scope-resolution/registries/class-registry.js +30 -0
- package/dist/_shared/scope-resolution/registries/class-registry.js.map +1 -0
- package/dist/_shared/scope-resolution/registries/context.d.ts +69 -0
- package/dist/_shared/scope-resolution/registries/context.d.ts.map +1 -0
- package/dist/_shared/scope-resolution/registries/context.js +44 -0
- package/dist/_shared/scope-resolution/registries/context.js.map +1 -0
- package/dist/_shared/scope-resolution/registries/evidence.d.ts +56 -0
- package/dist/_shared/scope-resolution/registries/evidence.d.ts.map +1 -0
- package/dist/_shared/scope-resolution/registries/evidence.js +150 -0
- package/dist/_shared/scope-resolution/registries/evidence.js.map +1 -0
- package/dist/_shared/scope-resolution/registries/field-registry.d.ts +26 -0
- package/dist/_shared/scope-resolution/registries/field-registry.d.ts.map +1 -0
- package/dist/_shared/scope-resolution/registries/field-registry.js +31 -0
- package/dist/_shared/scope-resolution/registries/field-registry.js.map +1 -0
- package/dist/_shared/scope-resolution/registries/lookup-core.d.ts +81 -0
- package/dist/_shared/scope-resolution/registries/lookup-core.d.ts.map +1 -0
- package/dist/_shared/scope-resolution/registries/lookup-core.js +332 -0
- package/dist/_shared/scope-resolution/registries/lookup-core.js.map +1 -0
- package/dist/_shared/scope-resolution/registries/lookup-qualified.d.ts +33 -0
- package/dist/_shared/scope-resolution/registries/lookup-qualified.d.ts.map +1 -0
- package/dist/_shared/scope-resolution/registries/lookup-qualified.js +56 -0
- package/dist/_shared/scope-resolution/registries/lookup-qualified.js.map +1 -0
- package/dist/_shared/scope-resolution/registries/method-registry.d.ts +36 -0
- package/dist/_shared/scope-resolution/registries/method-registry.d.ts.map +1 -0
- package/dist/_shared/scope-resolution/registries/method-registry.js +32 -0
- package/dist/_shared/scope-resolution/registries/method-registry.js.map +1 -0
- package/dist/_shared/scope-resolution/registries/tie-breaks.d.ts +43 -0
- package/dist/_shared/scope-resolution/registries/tie-breaks.d.ts.map +1 -0
- package/dist/_shared/scope-resolution/registries/tie-breaks.js +60 -0
- package/dist/_shared/scope-resolution/registries/tie-breaks.js.map +1 -0
- package/dist/_shared/scope-resolution/resolve-type-ref.d.ts +53 -0
- package/dist/_shared/scope-resolution/resolve-type-ref.d.ts.map +1 -0
- package/dist/_shared/scope-resolution/resolve-type-ref.js +126 -0
- package/dist/_shared/scope-resolution/resolve-type-ref.js.map +1 -0
- package/dist/_shared/scope-resolution/scope-id.d.ts +43 -0
- package/dist/_shared/scope-resolution/scope-id.d.ts.map +1 -0
- package/dist/_shared/scope-resolution/scope-id.js +46 -0
- package/dist/_shared/scope-resolution/scope-id.js.map +1 -0
- package/dist/_shared/scope-resolution/scope-tree.d.ts +61 -0
- package/dist/_shared/scope-resolution/scope-tree.d.ts.map +1 -0
- package/dist/_shared/scope-resolution/scope-tree.js +186 -0
- package/dist/_shared/scope-resolution/scope-tree.js.map +1 -0
- package/dist/_shared/scope-resolution/shadow/aggregate.d.ts +63 -0
- package/dist/_shared/scope-resolution/shadow/aggregate.d.ts.map +1 -0
- package/dist/_shared/scope-resolution/shadow/aggregate.js +122 -0
- package/dist/_shared/scope-resolution/shadow/aggregate.js.map +1 -0
- package/dist/_shared/scope-resolution/shadow/diff.d.ts +59 -0
- package/dist/_shared/scope-resolution/shadow/diff.d.ts.map +1 -0
- package/dist/_shared/scope-resolution/shadow/diff.js +79 -0
- package/dist/_shared/scope-resolution/shadow/diff.js.map +1 -0
- package/dist/_shared/scope-resolution/symbol-definition.d.ts +34 -0
- package/dist/_shared/scope-resolution/symbol-definition.d.ts.map +1 -0
- package/dist/_shared/scope-resolution/symbol-definition.js +12 -0
- package/dist/_shared/scope-resolution/symbol-definition.js.map +1 -0
- package/dist/_shared/scope-resolution/types.d.ts +356 -0
- package/dist/_shared/scope-resolution/types.d.ts.map +1 -0
- package/dist/_shared/scope-resolution/types.js +17 -0
- package/dist/_shared/scope-resolution/types.js.map +1 -0
- package/dist/core/ingestion/call-processor.d.ts +2 -1
- package/dist/core/ingestion/language-provider.d.ts +172 -1
- package/dist/core/ingestion/model/field-registry.d.ts +1 -1
- package/dist/core/ingestion/model/index.d.ts +1 -1
- package/dist/core/ingestion/model/index.js +2 -0
- package/dist/core/ingestion/model/method-registry.d.ts +1 -1
- package/dist/core/ingestion/model/registration-table.d.ts +1 -2
- package/dist/core/ingestion/model/resolution-context.d.ts +1 -1
- package/dist/core/ingestion/model/resolve.d.ts +1 -1
- package/dist/core/ingestion/model/symbol-table.d.ts +1 -23
- package/dist/core/ingestion/model/type-registry.d.ts +1 -1
- package/dist/core/search/phase-timer.d.ts +72 -0
- package/dist/core/search/phase-timer.js +106 -0
- package/dist/mcp/local/local-backend.d.ts +48 -1
- package/dist/mcp/local/local-backend.js +345 -135
- package/dist/mcp/tools.js +19 -1
- 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
|
-
//
|
|
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
|
-
*
|
|
912
|
-
*
|
|
913
|
-
*
|
|
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
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
MATCH (n
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
const
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
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
|
-
|
|
943
|
-
|
|
944
|
-
|
|
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
|
-
|
|
953
|
-
|
|
996
|
+
catch {
|
|
997
|
+
/* best-effort — downstream resolvers still work without the label */
|
|
954
998
|
}
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
}
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
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
|
-
|
|
989
|
-
|
|
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 (
|
|
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 ${
|
|
1001
|
-
candidates:
|
|
1002
|
-
uid:
|
|
1003
|
-
name:
|
|
1004
|
-
kind:
|
|
1005
|
-
filePath:
|
|
1006
|
-
line:
|
|
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 =
|
|
1012
|
-
const
|
|
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
|
-
|
|
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
|
|
1612
|
-
//
|
|
1613
|
-
//
|
|
1614
|
-
//
|
|
1615
|
-
//
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
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
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
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
|
-
|
|
1658
|
-
|
|
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
|
|
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)',
|