gitnexus 1.6.2 → 1.6.3-rc.2

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 (38) hide show
  1. package/dist/_shared/index.d.ts +7 -0
  2. package/dist/_shared/index.d.ts.map +1 -1
  3. package/dist/_shared/index.js +5 -0
  4. package/dist/_shared/index.js.map +1 -1
  5. package/dist/_shared/scope-resolution/evidence-weights.d.ts +69 -0
  6. package/dist/_shared/scope-resolution/evidence-weights.d.ts.map +1 -0
  7. package/dist/_shared/scope-resolution/evidence-weights.js +84 -0
  8. package/dist/_shared/scope-resolution/evidence-weights.js.map +1 -0
  9. package/dist/_shared/scope-resolution/language-classification.d.ts +26 -0
  10. package/dist/_shared/scope-resolution/language-classification.d.ts.map +1 -0
  11. package/dist/_shared/scope-resolution/language-classification.js +44 -0
  12. package/dist/_shared/scope-resolution/language-classification.js.map +1 -0
  13. package/dist/_shared/scope-resolution/origin-priority.d.ts +14 -0
  14. package/dist/_shared/scope-resolution/origin-priority.d.ts.map +1 -0
  15. package/dist/_shared/scope-resolution/origin-priority.js +21 -0
  16. package/dist/_shared/scope-resolution/origin-priority.js.map +1 -0
  17. package/dist/_shared/scope-resolution/symbol-definition.d.ts +34 -0
  18. package/dist/_shared/scope-resolution/symbol-definition.d.ts.map +1 -0
  19. package/dist/_shared/scope-resolution/symbol-definition.js +12 -0
  20. package/dist/_shared/scope-resolution/symbol-definition.js.map +1 -0
  21. package/dist/_shared/scope-resolution/types.d.ts +200 -0
  22. package/dist/_shared/scope-resolution/types.d.ts.map +1 -0
  23. package/dist/_shared/scope-resolution/types.js +17 -0
  24. package/dist/_shared/scope-resolution/types.js.map +1 -0
  25. package/dist/core/ingestion/call-processor.d.ts +2 -1
  26. package/dist/core/ingestion/model/field-registry.d.ts +1 -1
  27. package/dist/core/ingestion/model/index.d.ts +1 -1
  28. package/dist/core/ingestion/model/index.js +2 -0
  29. package/dist/core/ingestion/model/method-registry.d.ts +1 -1
  30. package/dist/core/ingestion/model/registration-table.d.ts +1 -2
  31. package/dist/core/ingestion/model/resolution-context.d.ts +1 -1
  32. package/dist/core/ingestion/model/resolve.d.ts +1 -1
  33. package/dist/core/ingestion/model/symbol-table.d.ts +1 -23
  34. package/dist/core/ingestion/model/type-registry.d.ts +1 -1
  35. package/dist/mcp/local/local-backend.d.ts +48 -1
  36. package/dist/mcp/local/local-backend.js +287 -131
  37. package/dist/mcp/tools.js +19 -1
  38. package/package.json +1 -1
@@ -10,7 +10,7 @@
10
10
  * `SymbolTable` by design.
11
11
  */
12
12
  export { type SemanticModel, type MutableSemanticModel, createSemanticModel, } from './semantic-model.js';
13
- export { type SymbolTableReader, type SymbolTableWriter, createSymbolTable, type SymbolDefinition, type AddMetadata, CLASS_TYPES, CLASS_TYPES_TUPLE, type ClassLikeLabel, FREE_CALLABLE_TYPES, FREE_CALLABLE_TUPLE, type FreeCallableLabel, CALL_TARGET_TYPES, } from './symbol-table.js';
13
+ export { type SymbolTableReader, type SymbolTableWriter, createSymbolTable, type AddMetadata, CLASS_TYPES, CLASS_TYPES_TUPLE, type ClassLikeLabel, FREE_CALLABLE_TYPES, FREE_CALLABLE_TUPLE, type FreeCallableLabel, CALL_TARGET_TYPES, } from './symbol-table.js';
14
14
  export { type TypeRegistry, type MutableTypeRegistry, createTypeRegistry, } from './type-registry.js';
15
15
  export { type MethodRegistry, type MutableMethodRegistry, createMethodRegistry, } from './method-registry.js';
16
16
  export { type FieldRegistry, type MutableFieldRegistry, createFieldRegistry, } from './field-registry.js';
@@ -17,6 +17,8 @@ export { createSemanticModel, } from './semantic-model.js';
17
17
  // for the rare caller that needs the file/callable interface in
18
18
  // isolation (e.g. tests).
19
19
  export { createSymbolTable, CLASS_TYPES, CLASS_TYPES_TUPLE, FREE_CALLABLE_TYPES, FREE_CALLABLE_TUPLE, CALL_TARGET_TYPES, } from './symbol-table.js';
20
+ // `SymbolDefinition` moved to `gitnexus-shared` (RFC #909 Ring 1 #910).
21
+ // Consumers should import it directly from `gitnexus-shared`, not via this barrel.
20
22
  // Type registry (classes, structs, interfaces, enums, records, impls)
21
23
  export { createTypeRegistry, } from './type-registry.js';
22
24
  // Method registry (owner-scoped methods with arity-aware overload lookup)
@@ -6,7 +6,7 @@
6
6
  * `ownerNodeId\0methodName` for O(1) lookup. Supports overloads
7
7
  * (array values) and arity-based filtering.
8
8
  */
9
- import type { SymbolDefinition } from './symbol-table.js';
9
+ import type { SymbolDefinition } from '../../../_shared/index.js';
10
10
  export interface MethodRegistry {
11
11
  /**
12
12
  * Look up a method by owner class + name, optionally filtered by arity.
@@ -48,8 +48,7 @@
48
48
  * The runtime exhaustiveness guard in `symbol-table.ts` will warn if a
49
49
  * `NodeLabel` is missing from all three sets.
50
50
  */
51
- import type { NodeLabel } from '../../../_shared/index.js';
52
- import type { SymbolDefinition } from './symbol-table.js';
51
+ import type { NodeLabel, SymbolDefinition } from '../../../_shared/index.js';
53
52
  import type { MutableTypeRegistry } from './type-registry.js';
54
53
  import type { MutableMethodRegistry } from './method-registry.js';
55
54
  import type { MutableFieldRegistry } from './field-registry.js';
@@ -17,7 +17,7 @@
17
17
  * - Tier 3 combines lookupClassByName + lookupImplByName + lookupCallableByName
18
18
  * (three O(1) index lookups with a narrow, type-specific result set).
19
19
  */
20
- import type { SymbolDefinition } from './symbol-table.js';
20
+ import type { SymbolDefinition } from '../../../_shared/index.js';
21
21
  import type { MutableSemanticModel } from './semantic-model.js';
22
22
  /**
23
23
  * A single named binding in a source file (e.g. `import { User as U }`).
@@ -5,7 +5,7 @@
5
5
  * using only the SemanticModel registries and HeritageMap — NO dependency
6
6
  * on resolution-context.ts (circular dependency risk).
7
7
  */
8
- import type { SymbolDefinition } from './symbol-table.js';
8
+ import type { SymbolDefinition } from '../../../_shared/index.js';
9
9
  import type { SemanticModel } from './semantic-model.js';
10
10
  import type { HeritageMap } from './heritage-map.js';
11
11
  import type { MroStrategy } from '../../../_shared/index.js';
@@ -33,7 +33,7 @@
33
33
  * import from `./model/` here, you are going the wrong way — move the
34
34
  * logic up the dependency chain instead.
35
35
  */
36
- import type { NodeLabel } from '../../../_shared/index.js';
36
+ import type { NodeLabel, SymbolDefinition } from '../../../_shared/index.js';
37
37
  /**
38
38
  * Class-like NodeLabels — used for qualifiedName fallback inside
39
39
  * `SymbolTable.add()` and (via import into `model/registration-table.ts`)
@@ -89,28 +89,6 @@ export declare const FREE_CALLABLE_TYPES: ReadonlySet<NodeLabel>;
89
89
  * `filterCallableCandidates` / `countCallableCandidates`.
90
90
  */
91
91
  export declare const CALL_TARGET_TYPES: ReadonlySet<NodeLabel>;
92
- export interface SymbolDefinition {
93
- nodeId: string;
94
- filePath: string;
95
- type: NodeLabel;
96
- /** Canonical dot-separated qualified type name for class-like symbols
97
- * (e.g. `App.Models.User`). Falls back to the simple symbol name when no
98
- * package/namespace/module scope exists or no explicit qualified metadata is provided. */
99
- qualifiedName?: string;
100
- parameterCount?: number;
101
- /** Number of required (non-optional, non-default) parameters.
102
- * Enables range-based arity filtering: argCount >= requiredParameterCount && argCount <= parameterCount. */
103
- requiredParameterCount?: number;
104
- /** Per-parameter type names for overload disambiguation (e.g. ['int', 'String']).
105
- * Populated when parameter types are resolvable from AST (any typed language). */
106
- parameterTypes?: string[];
107
- /** Raw return type text extracted from AST (e.g. 'User', 'Promise<User>') */
108
- returnType?: string;
109
- /** Declared type for non-callable symbols — fields/properties (e.g. 'Address', 'List<User>') */
110
- declaredType?: string;
111
- /** Links Method/Constructor/Property to owning Class/Struct/Trait nodeId */
112
- ownerId?: string;
113
- }
114
92
  /**
115
93
  * Optional metadata accepted by {@link SymbolTable.add}. Kept as a separate
116
94
  * type alias so callers and wrappers can share the same shape.
@@ -5,7 +5,7 @@
5
5
  * Eagerly-populated indexes keyed by symbol name and qualified name.
6
6
  * Also includes a separate index for Rust Impl blocks.
7
7
  */
8
- import type { SymbolDefinition } from './symbol-table.js';
8
+ import type { SymbolDefinition } from '../../../_shared/index.js';
9
9
  export interface TypeRegistry {
10
10
  /**
11
11
  * Look up class-like definitions (Class, Struct, Interface, Enum, Record, Trait)
@@ -150,9 +150,56 @@ export declare class LocalBackend {
150
150
  */
151
151
  private aggregateClusters;
152
152
  private overview;
153
+ /**
154
+ * Patch the `type` field on candidates whose `labels(n)[0]` projection
155
+ * came back empty — a known LadybugDB behaviour for several node types.
156
+ *
157
+ * Uses one scoped UNION query across the five priority labels rather
158
+ * than per-candidate round-trips, so cost is a single DB call regardless
159
+ * of how many candidates need enrichment. No-op when every candidate
160
+ * already has a non-empty type.
161
+ *
162
+ * Failures are swallowed: label enrichment is an optimisation for
163
+ * downstream scoring and #480 Class/Interface BFS seeding; if it fails
164
+ * the symbol still resolves, just without the kind-priority bonus.
165
+ */
166
+ private enrichCandidateLabels;
167
+ /**
168
+ * Score a symbol candidate for disambiguation ranking.
169
+ *
170
+ * Deterministic, no DB round-trip:
171
+ * - base 0.50
172
+ * - +0.40 when file_path hint matches (substring, case-insensitive)
173
+ * - +0.20 when kind hint exactly matches the candidate's kind
174
+ * - when no kind hint, a small priority bonus (Class > Interface >
175
+ * Function > Method > Constructor) to preserve the intuition that
176
+ * class-level names are usually what the user wanted.
177
+ *
178
+ * Capped at 1.0. Intentionally simple and inspectable — a future v2 can
179
+ * plug in BM25/embedding signals here without changing the surrounding
180
+ * resolver shape.
181
+ */
182
+ private scoreCandidate;
183
+ /**
184
+ * Shared symbol resolver used by `context` and `impact`.
185
+ *
186
+ * Returns one of:
187
+ * - `{ kind: 'ok', symbol, resolvedLabel }` — single confident match
188
+ * (either direct UID, only one candidate after filtering, Class/
189
+ * Constructor collapse, or a top-scoring candidate with a clear gap
190
+ * to the runner-up).
191
+ * - `{ kind: 'ambiguous', candidates }` — multiple viable matches,
192
+ * sorted by score desc. Each candidate carries a relevance score.
193
+ * - `{ kind: 'not_found' }` — no matches at all.
194
+ *
195
+ * Preserves the #480 Class/Constructor preference: when the only
196
+ * ambiguity is between a Class and its own Constructor (same name,
197
+ * same filePath), the Class wins silently.
198
+ */
199
+ private resolveSymbolCandidates;
153
200
  /**
154
201
  * Context tool — 360-degree symbol view with categorized refs.
155
- * Disambiguation when multiple symbols share a name.
202
+ * Disambiguation (ranked) when multiple symbols share a name.
156
203
  * UID-based direct lookup. No cluster in output.
157
204
  */
158
205
  private context;
@@ -908,108 +908,268 @@ export class LocalBackend {
908
908
  return result;
909
909
  }
910
910
  /**
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.
911
+ * Patch the `type` field on candidates whose `labels(n)[0]` projection
912
+ * came back empty a known LadybugDB behaviour for several node types.
913
+ *
914
+ * Uses one scoped UNION query across the five priority labels rather
915
+ * than per-candidate round-trips, so cost is a single DB call regardless
916
+ * of how many candidates need enrichment. No-op when every candidate
917
+ * already has a non-empty type.
918
+ *
919
+ * Failures are swallowed: label enrichment is an optimisation for
920
+ * downstream scoring and #480 Class/Interface BFS seeding; if it fails
921
+ * the symbol still resolves, just without the kind-priority bonus.
914
922
  */
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 };
923
+ async enrichCandidateLabels(repo, candidates) {
924
+ const ids = candidates.filter((c) => c.type === '' && c.id).map((c) => c.id);
925
+ if (ids.length === 0)
926
+ return;
927
+ try {
928
+ const rows = await executeParameterized(repo.id, `
929
+ MATCH (n:\`Class\`) WHERE n.id IN $ids RETURN n.id AS id, 'Class' AS label
930
+ UNION ALL
931
+ MATCH (n:\`Interface\`) WHERE n.id IN $ids RETURN n.id AS id, 'Interface' AS label
932
+ UNION ALL
933
+ MATCH (n:\`Function\`) WHERE n.id IN $ids RETURN n.id AS id, 'Function' AS label
934
+ UNION ALL
935
+ MATCH (n:\`Method\`) WHERE n.id IN $ids RETURN n.id AS id, 'Method' AS label
936
+ UNION ALL
937
+ MATCH (n:\`Constructor\`) WHERE n.id IN $ids RETURN n.id AS id, 'Constructor' AS label
938
+ `, { ids });
939
+ const labelById = new Map();
940
+ for (const r of rows) {
941
+ const id = (r.id ?? r[0]);
942
+ const label = (r.label ?? r[1]);
943
+ if (id && label && !labelById.has(id))
944
+ labelById.set(id, label);
941
945
  }
942
- else {
943
- whereClause = `WHERE n.name = $symName`;
944
- queryParams = { symName: name };
946
+ for (const c of candidates) {
947
+ if (c.type === '' && labelById.has(c.id))
948
+ c.type = labelById.get(c.id);
945
949
  }
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
950
  }
952
- if (symbols.length === 0) {
953
- return { error: `Symbol '${name || uid}' not found` };
951
+ catch {
952
+ /* best-effort downstream resolvers still work without the label */
954
953
  }
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]));
954
+ }
955
+ /**
956
+ * Score a symbol candidate for disambiguation ranking.
957
+ *
958
+ * Deterministic, no DB round-trip:
959
+ * - base 0.50
960
+ * - +0.40 when file_path hint matches (substring, case-insensitive)
961
+ * - +0.20 when kind hint exactly matches the candidate's kind
962
+ * - when no kind hint, a small priority bonus (Class > Interface >
963
+ * Function > Method > Constructor) to preserve the intuition that
964
+ * class-level names are usually what the user wanted.
965
+ *
966
+ * Capped at 1.0. Intentionally simple and inspectable — a future v2 can
967
+ * plug in BM25/embedding signals here without changing the surrounding
968
+ * resolver shape.
969
+ */
970
+ scoreCandidate(c, hints) {
971
+ let s = 0.5;
972
+ if (hints.file_path && c.filePath && typeof c.filePath === 'string') {
973
+ if (c.filePath.toLowerCase().includes(hints.file_path.toLowerCase())) {
974
+ s += 0.4;
975
+ }
976
+ }
977
+ if (hints.kind && c.kind === hints.kind) {
978
+ s += 0.2;
979
+ }
980
+ if (!hints.kind) {
981
+ const priority = {
982
+ Class: 5,
983
+ Interface: 4,
984
+ Function: 3,
985
+ Method: 2,
986
+ Constructor: 1,
987
+ };
988
+ s += (priority[c.kind] ?? 0) * 0.02;
989
+ }
990
+ return Math.min(1.0, s);
991
+ }
992
+ /**
993
+ * Shared symbol resolver used by `context` and `impact`.
994
+ *
995
+ * Returns one of:
996
+ * - `{ kind: 'ok', symbol, resolvedLabel }` — single confident match
997
+ * (either direct UID, only one candidate after filtering, Class/
998
+ * Constructor collapse, or a top-scoring candidate with a clear gap
999
+ * to the runner-up).
1000
+ * - `{ kind: 'ambiguous', candidates }` — multiple viable matches,
1001
+ * sorted by score desc. Each candidate carries a relevance score.
1002
+ * - `{ kind: 'not_found' }` — no matches at all.
1003
+ *
1004
+ * Preserves the #480 Class/Constructor preference: when the only
1005
+ * ambiguity is between a Class and its own Constructor (same name,
1006
+ * same filePath), the Class wins silently.
1007
+ */
1008
+ async resolveSymbolCandidates(repo, query, hints) {
1009
+ const { uid, name, include_content } = query;
1010
+ 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' : ''}`;
1011
+ // Direct UID — zero-ambiguity path.
1012
+ if (uid) {
1013
+ const rows = await executeParameterized(repo.id, `MATCH (n {id: $uid}) RETURN ${selectClause} LIMIT 1`, { uid });
1014
+ if (rows.length === 0)
1015
+ return { kind: 'not_found' };
1016
+ const r = rows[0];
1017
+ const symbol = {
1018
+ id: (r.id ?? r[0]),
1019
+ name: (r.name ?? r[1]),
1020
+ type: (r.type ?? r[2] ?? ''),
1021
+ filePath: (r.filePath ?? r[3]),
1022
+ startLine: (r.startLine ?? r[4]),
1023
+ endLine: (r.endLine ?? r[5]),
1024
+ ...(include_content ? { content: (r.content ?? r[6]) } : {}),
1025
+ };
1026
+ // Same LadybugDB label-enrichment as the name-based path: a UID
1027
+ // pointing at a Class must still surface `type: 'Class'` so impact's
1028
+ // Class/Interface BFS seed fires. No-op when type is already set.
1029
+ await this.enrichCandidateLabels(repo, [symbol]);
1030
+ return { kind: 'ok', symbol, resolvedLabel: symbol.type };
1031
+ }
1032
+ if (!name)
1033
+ return { kind: 'not_found' };
1034
+ const isQualified = name.includes('/') || name.includes(':');
1035
+ let whereClause;
1036
+ const queryParams = { symName: name };
1037
+ if (hints.file_path) {
1038
+ whereClause = `WHERE n.name = $symName AND n.filePath CONTAINS $filePath`;
1039
+ queryParams.filePath = hints.file_path;
1040
+ }
1041
+ else if (isQualified) {
1042
+ whereClause = `WHERE n.id = $symName OR n.name = $symName`;
1043
+ }
1044
+ else {
1045
+ whereClause = `WHERE n.name = $symName`;
1046
+ }
1047
+ // LIMIT 20 (was 10) — scoring is the point now, so give the ranker
1048
+ // headroom instead of arbitrary truncation.
1049
+ const rows = await executeParameterized(repo.id, `MATCH (n) ${whereClause} RETURN ${selectClause} LIMIT 20`, queryParams);
1050
+ if (rows.length === 0)
1051
+ return { kind: 'not_found' };
1052
+ // Normalise row shape across object / tuple returns from LadybugDB.
1053
+ const normalized = rows.map((r) => ({
1054
+ id: (r.id ?? r[0]),
1055
+ name: (r.name ?? r[1]),
1056
+ type: (r.type ?? r[2] ?? ''),
1057
+ filePath: (r.filePath ?? r[3]),
1058
+ startLine: (r.startLine ?? r[4]),
1059
+ endLine: (r.endLine ?? r[5]),
1060
+ ...(include_content ? { content: (r.content ?? r[6]) } : {}),
1061
+ }));
1062
+ // Enrich labels for any candidates where `labels(n)[0]` came back empty.
1063
+ // LadybugDB returns an empty string for that projection on certain node
1064
+ // types (notably Class), which left downstream consumers (impact's
1065
+ // Class/Interface BFS seed, the kind-priority scoring bonus) unable to
1066
+ // distinguish a Class target from "unknown kind". One scoped UNION
1067
+ // across the five priority labels patches the type in-place without
1068
+ // per-candidate round-trips.
1069
+ await this.enrichCandidateLabels(repo, normalized);
1070
+ // Preserve #480 Class/Constructor collapse: if we have exactly one
1071
+ // Class (or Interface) candidate and one Constructor sharing name +
1072
+ // filePath, fold into the Class. This used to require a follow-up
1073
+ // label query because LadybugDB sometimes returns an empty labels()[0]
1074
+ // for Class nodes — enrichment above handles the empty-type case, but
1075
+ // the `type === 'Constructor'` gate still correctly triggers when a
1076
+ // Class and its Constructor share the name.
1077
+ if (!hints.kind && normalized.length > 1) {
1078
+ const ambiguousType = normalized.some((s) => s.type === '' || s.type === 'Constructor');
1079
+ if (ambiguousType) {
1080
+ const candidateIds = normalized.map((s) => s.id).filter(Boolean);
1081
+ for (const label of ['Class', 'Interface']) {
1082
+ const labelRows = await executeParameterized(repo.id, `MATCH (n:\`${label}\`) WHERE n.id IN $candidateIds RETURN n.id AS id LIMIT 1`, { candidateIds }).catch(() => []);
1083
+ if (labelRows.length > 0) {
1084
+ const preferredId = labelRows[0].id ?? labelRows[0][0];
1085
+ const preferred = normalized.find((s) => s.id === preferredId);
987
1086
  if (preferred) {
988
- resolvedLabel = label;
989
- break;
1087
+ return {
1088
+ kind: 'ok',
1089
+ symbol: preferred,
1090
+ resolvedLabel: label,
1091
+ };
990
1092
  }
991
1093
  }
992
1094
  }
993
- if (preferred)
994
- symbols = [preferred];
995
1095
  }
996
1096
  }
997
- if (symbols.length > 1 && !uid) {
1097
+ if (normalized.length === 1) {
1098
+ return {
1099
+ kind: 'ok',
1100
+ symbol: normalized[0],
1101
+ resolvedLabel: '',
1102
+ };
1103
+ }
1104
+ // Score, sort desc, stable tiebreak on shorter filePath then lex uid.
1105
+ const scored = normalized.map((s) => ({
1106
+ ...s,
1107
+ score: this.scoreCandidate({ kind: s.type, filePath: s.filePath || '' }, hints),
1108
+ }));
1109
+ scored.sort((a, b) => {
1110
+ if (b.score !== a.score)
1111
+ return b.score - a.score;
1112
+ const fpA = (a.filePath || '').length;
1113
+ const fpB = (b.filePath || '').length;
1114
+ if (fpA !== fpB)
1115
+ return fpA - fpB;
1116
+ return String(a.id).localeCompare(String(b.id));
1117
+ });
1118
+ // Confident single-result: top score ≥ 0.95 AND beats runner-up by a
1119
+ // clear margin. This lets a very strong file_path/kind hint resolve
1120
+ // cleanly instead of forcing the caller through a disambiguation
1121
+ // round-trip.
1122
+ //
1123
+ // The gap threshold uses `> 0.09` rather than `>= 0.10` on purpose:
1124
+ // IEEE754 addition of the scoring terms (0.50 + 0.40 + 0.20 - 0.90
1125
+ // yields 0.09999999999999998, not exactly 0.10) would otherwise break
1126
+ // the comparison for legitimate "top is 1.00, runner is 0.90" cases.
1127
+ // The intent is a clearly-dominant winner; 0.09 is a large enough
1128
+ // margin to mean that unambiguously.
1129
+ //
1130
+ // The `scored.length >= 2` guard is defensive. The `normalized.length === 1`
1131
+ // early return above already handles the single-candidate path, so in
1132
+ // practice `scored` always has at least two elements by the time we get
1133
+ // here — keeping the guard means changes to the upstream early-return
1134
+ // logic cannot accidentally index out of bounds at `scored[1]`.
1135
+ if (scored.length >= 2 && scored[0].score >= 0.95 && scored[0].score - scored[1].score > 0.09) {
1136
+ return { kind: 'ok', symbol: scored[0], resolvedLabel: scored[0].type };
1137
+ }
1138
+ return { kind: 'ambiguous', candidates: scored };
1139
+ }
1140
+ /**
1141
+ * Context tool — 360-degree symbol view with categorized refs.
1142
+ * Disambiguation (ranked) when multiple symbols share a name.
1143
+ * UID-based direct lookup. No cluster in output.
1144
+ */
1145
+ async context(repo, params) {
1146
+ await this.ensureInitialized(repo.id);
1147
+ const { name, uid, file_path, kind, include_content } = params;
1148
+ if (!name && !uid) {
1149
+ return { error: 'Either "name" or "uid" parameter is required.' };
1150
+ }
1151
+ const outcome = await this.resolveSymbolCandidates(repo, { uid, name, include_content }, { file_path, kind });
1152
+ if (outcome.kind === 'not_found') {
1153
+ return { error: `Symbol '${name || uid}' not found` };
1154
+ }
1155
+ if (outcome.kind === 'ambiguous') {
998
1156
  return {
999
1157
  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],
1158
+ message: `Found ${outcome.candidates.length} symbols matching '${name}'. Use uid, file_path, or kind to disambiguate.`,
1159
+ candidates: outcome.candidates.map((c) => ({
1160
+ uid: c.id,
1161
+ name: c.name,
1162
+ kind: c.type,
1163
+ filePath: c.filePath,
1164
+ line: c.startLine,
1165
+ score: Number(c.score.toFixed(2)),
1007
1166
  })),
1008
1167
  };
1009
1168
  }
1010
1169
  // Step 3: Build full context
1011
- const sym = symbols[0];
1012
- const symId = sym.id || sym[0];
1170
+ const sym = outcome.symbol;
1171
+ const resolvedLabel = outcome.resolvedLabel;
1172
+ const symId = sym.id;
1013
1173
  // Categorized incoming refs
1014
1174
  const incomingRows = await executeParameterized(repo.id, `
1015
1175
  MATCH (caller)-[r:CodeRelation]->(n {id: $symId})
@@ -1608,54 +1768,50 @@ export class LocalBackend {
1608
1768
  ];
1609
1769
  const includeTests = params.includeTests ?? false;
1610
1770
  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 */
1771
+ // Resolve target via the shared symbol resolver. When the caller passes
1772
+ // target_uid we skip the name lookup entirely (zero-ambiguity). Otherwise
1773
+ // we rank candidates (#470) and either proceed with a confident single
1774
+ // match, or return a structured ambiguous response instead of silently
1775
+ // picking the wrong symbol.
1776
+ //
1777
+ // The resolver preserves the #480 Class/Constructor preference heuristic:
1778
+ // when a Class and its Constructor share name + filePath, the Class is
1779
+ // selected silently.
1780
+ const outcome = await this.resolveSymbolCandidates(repo, { uid: params.target_uid, name: target }, { file_path: params.file_path, kind: params.kind });
1781
+ if (outcome.kind === 'not_found') {
1782
+ const missing = params.target_uid ?? target;
1783
+ return {
1784
+ error: `Target '${missing}' not found`,
1785
+ target: { name: target },
1786
+ direction,
1787
+ impactedCount: 0,
1788
+ risk: 'UNKNOWN',
1789
+ };
1645
1790
  }
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];
1791
+ if (outcome.kind === 'ambiguous') {
1792
+ return {
1793
+ status: 'ambiguous',
1794
+ message: `Found ${outcome.candidates.length} symbols matching '${target}'. Use target_uid, file_path, or kind to disambiguate.`,
1795
+ target: { name: target },
1796
+ direction,
1797
+ impactedCount: 0,
1798
+ risk: 'UNKNOWN',
1799
+ candidates: outcome.candidates.map((c) => ({
1800
+ uid: c.id,
1801
+ name: c.name,
1802
+ kind: c.type,
1803
+ filePath: c.filePath,
1804
+ line: c.startLine,
1805
+ score: Number(c.score.toFixed(2)),
1806
+ })),
1807
+ };
1656
1808
  }
1657
- if (!sym)
1658
- return { error: `Target '${target}' not found` };
1809
+ const sym = {
1810
+ id: outcome.symbol.id,
1811
+ name: outcome.symbol.name,
1812
+ filePath: outcome.symbol.filePath,
1813
+ };
1814
+ const symType = outcome.resolvedLabel || outcome.symbol.type || '';
1659
1815
  return this._runImpactBFS(repo, sym, symType, direction, {
1660
1816
  maxDepth,
1661
1817
  relationTypes,