gitnexus 1.6.13-rc.66 → 1.6.13-rc.68

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.
@@ -26,7 +26,7 @@ import type { ExtractedRoute } from './route-extractors/laravel.js';
26
26
  import type { SharedSpringType } from './route-extractors/spring-shared.js';
27
27
  import type { ModuleConstants, Operand, RepoConstants } from './route-extractors/constant-resolver.js';
28
28
  import type Parser from 'tree-sitter';
29
- import type { ExtractedDecoratorRoute } from './workers/parse-worker.js';
29
+ import type { ExtractedDecoratorRoute, ExtractedToolDef } from './workers/parse-worker.js';
30
30
  import type { SemanticModel } from './model/semantic-model.js';
31
31
  /** What a provider's {@link LanguageProviderConfig.resolveRouteHandler} can see. */
32
32
  export interface RouteHandlerResolutionHookContext {
@@ -430,6 +430,9 @@ interface LanguageProviderConfig {
430
430
  * Default: undefined (no text-route extraction).
431
431
  */
432
432
  readonly extractTextRoutes?: (filePath: string, content: string) => ExtractedRoute[];
433
+ /** Extract tool registrations after captures, using only emitted callable identities.
434
+ * The map keys are declaration-name AST node IDs, local to this parsed tree. */
435
+ readonly extractToolDefinitions?: (tree: Parser.Tree, filePath: string, lineOffset: number, callableBindings: ReadonlyMap<number, string>) => ExtractedToolDef[];
433
436
  /**
434
437
  * Extract routes that a parsed file declares in its own AST.
435
438
  *
@@ -0,0 +1,4 @@
1
+ import type Parser from 'tree-sitter';
2
+ import type { ExtractedToolDef } from '../../workers/parse-worker.js';
3
+ /** Direct SDK registrations only; no wrapper, alias-chain or runtime-value inference. */
4
+ export declare function extractToolDefinitions(tree: Parser.Tree, filePath: string, lineOffset?: number, callableBindings?: ReadonlyMap<number, string>): ExtractedToolDef[];
@@ -0,0 +1,355 @@
1
+ import { plainString, propertyName } from '../../route-extractors/data-route-table.js';
2
+ const FUNCTIONS = new Set([
3
+ 'function_declaration',
4
+ 'generator_function_declaration',
5
+ 'function_expression',
6
+ 'generator_function',
7
+ 'arrow_function',
8
+ 'method_definition',
9
+ ]);
10
+ const BLOCKS = new Set([
11
+ 'statement_block',
12
+ 'for_statement',
13
+ 'for_in_statement',
14
+ 'switch_body',
15
+ 'catch_clause',
16
+ 'class_body',
17
+ ]);
18
+ const SDK_MODULES = new Set([
19
+ '@modelcontextprotocol/sdk/server/mcp.js',
20
+ '@modelcontextprotocol/sdk/server/mcp',
21
+ ]);
22
+ function lookup(scope, name) {
23
+ for (let current = scope; current; current = current.parent) {
24
+ const binding = current.bindings.get(name);
25
+ if (binding)
26
+ return binding;
27
+ }
28
+ }
29
+ /** Only binding/assignment patterns: never descend into keys, types or defaults.
30
+ * Member assignment targets are reported separately from binding names. */
31
+ function patternNames(pattern, onMember) {
32
+ const names = [];
33
+ const pending = [pattern];
34
+ while (pending.length) {
35
+ const node = pending.pop();
36
+ if (node.type === 'identifier' || node.type === 'shorthand_property_identifier_pattern') {
37
+ names.push(node);
38
+ }
39
+ else if (node.type === 'member_expression' || node.type === 'subscript_expression') {
40
+ onMember?.(node);
41
+ }
42
+ else if (node.type === 'pair_pattern') {
43
+ const value = node.childForFieldName('value');
44
+ if (value)
45
+ pending.push(value);
46
+ }
47
+ else if (node.type === 'assignment_pattern' || node.type === 'object_assignment_pattern') {
48
+ const left = node.childForFieldName('left');
49
+ if (left)
50
+ pending.push(left);
51
+ }
52
+ else if (node.type === 'array_pattern' ||
53
+ node.type === 'object_pattern' ||
54
+ node.type === 'rest_pattern') {
55
+ pending.push(...node.namedChildren);
56
+ }
57
+ }
58
+ return names;
59
+ }
60
+ function declare(scope, name, details = {}) {
61
+ const previous = scope.bindings.get(name.text);
62
+ if (previous) {
63
+ previous.invalid = true;
64
+ }
65
+ else {
66
+ scope.bindings.set(name.text, { name, scope, kind: 'unknown', ...details });
67
+ }
68
+ }
69
+ function variableScope(scope) {
70
+ while (!scope.functionScope && scope.parent)
71
+ scope = scope.parent;
72
+ return scope;
73
+ }
74
+ function collectBindings(root) {
75
+ const moduleScope = { functionScope: true, bindings: new Map() };
76
+ const scopes = new Map();
77
+ const calls = [];
78
+ const writes = [];
79
+ const pending = [{ node: root, scope: moduleScope }];
80
+ while (pending.length) {
81
+ const entry = pending.pop();
82
+ const node = entry.node;
83
+ let scope = entry.scope;
84
+ const isFunction = FUNCTIONS.has(node.type);
85
+ const name = node.childForFieldName('name');
86
+ if (node.type === 'function_declaration' || node.type === 'generator_function_declaration') {
87
+ if (name)
88
+ declare(scope, name, { kind: 'function', value: node, immutable: true });
89
+ }
90
+ else if ([
91
+ 'class_declaration',
92
+ 'interface_declaration',
93
+ 'type_alias_declaration',
94
+ 'enum_declaration',
95
+ ].includes(node.type)) {
96
+ if (name)
97
+ declare(scope, name);
98
+ }
99
+ if (isFunction ||
100
+ BLOCKS.has(node.type) ||
101
+ node.type === 'class' ||
102
+ node.type === 'class_declaration') {
103
+ scope = { parent: scope, functionScope: isFunction, bindings: new Map() };
104
+ }
105
+ scopes.set(node.id, scope);
106
+ if (node.type === 'class' && name)
107
+ declare(scope, name);
108
+ if (isFunction) {
109
+ if (name && (node.type === 'function_expression' || node.type === 'generator_function')) {
110
+ declare(scope, name, { kind: 'function', value: node, immutable: true });
111
+ }
112
+ const parameters = node.childForFieldName('parameters');
113
+ const single = node.childForFieldName('parameter');
114
+ for (const parameter of parameters?.namedChildren ?? (single ? [single] : [])) {
115
+ const pattern = parameter.childForFieldName('pattern') ?? parameter;
116
+ const type = parameter.childForFieldName('type')?.namedChildren[0];
117
+ for (const bindingName of patternNames(pattern)) {
118
+ declare(scope, bindingName, {
119
+ kind: 'parameter',
120
+ ...(pattern.type === 'identifier' && type ? { type } : {}),
121
+ });
122
+ }
123
+ }
124
+ }
125
+ else if (node.type === 'import_statement') {
126
+ const source = node.childForFieldName('source');
127
+ const sdk = source !== null && SDK_MODULES.has(plainString(source) ?? '');
128
+ const typeOnly = node.children.some((child) => child.type === 'type');
129
+ const clause = node.namedChildren.find((child) => child.type === 'import_clause');
130
+ for (const child of clause?.namedChildren ?? []) {
131
+ if (child.type === 'identifier')
132
+ declare(scope, child);
133
+ else if (child.type === 'namespace_import') {
134
+ const local = child.namedChildren[0];
135
+ if (local)
136
+ declare(scope, local, { kind: sdk ? 'sdk-namespace' : 'unknown', typeOnly });
137
+ }
138
+ else if (child.type === 'named_imports') {
139
+ for (const specifier of child.namedChildren) {
140
+ const imported = specifier.childForFieldName('name');
141
+ const local = specifier.childForFieldName('alias') ?? imported;
142
+ if (local)
143
+ declare(scope, local, {
144
+ kind: sdk && imported?.text === 'McpServer' ? 'sdk' : 'unknown',
145
+ typeOnly: typeOnly || specifier.children.some((part) => part.type === 'type'),
146
+ });
147
+ }
148
+ }
149
+ }
150
+ }
151
+ else if (node.type === 'variable_declarator') {
152
+ if (name) {
153
+ const target = node.parent?.type === 'variable_declaration' ? variableScope(scope) : scope;
154
+ for (const bindingName of patternNames(name)) {
155
+ declare(target, bindingName, {
156
+ kind: 'variable',
157
+ immutable: node.parent?.childForFieldName('kind')?.type === 'const',
158
+ ...(name.type === 'identifier'
159
+ ? { value: node.childForFieldName('value') ?? undefined }
160
+ : {}),
161
+ });
162
+ }
163
+ }
164
+ }
165
+ else if (node.type === 'catch_clause') {
166
+ const parameter = node.childForFieldName('parameter');
167
+ if (parameter)
168
+ for (const bindingName of patternNames(parameter))
169
+ declare(scope, bindingName);
170
+ }
171
+ else if (node.type === 'for_in_statement') {
172
+ const left = node.childForFieldName('left');
173
+ const kind = node.childForFieldName('kind');
174
+ if (left && kind) {
175
+ const target = kind.type === 'var' ? variableScope(scope) : scope;
176
+ for (const bindingName of patternNames(left))
177
+ declare(target, bindingName);
178
+ }
179
+ else if (left)
180
+ writes.push(left);
181
+ }
182
+ else if (node.type === 'type_parameter') {
183
+ if (name)
184
+ declare(scope, name);
185
+ }
186
+ if (node.type === 'call_expression')
187
+ calls.push(node);
188
+ if (node.type === 'assignment_expression' || node.type === 'augmented_assignment_expression') {
189
+ const left = node.childForFieldName('left');
190
+ if (left)
191
+ writes.push(left);
192
+ }
193
+ else if (node.type === 'update_expression' ||
194
+ (node.type === 'unary_expression' && node.children.some((child) => child.type === 'delete'))) {
195
+ const argument = node.childForFieldName('argument');
196
+ if (argument)
197
+ writes.push(argument);
198
+ }
199
+ for (let index = node.namedChildCount - 1; index >= 0; index--) {
200
+ pending.push({ node: node.namedChild(index), scope });
201
+ }
202
+ }
203
+ // Resolve writes after declarations so later declarations also shadow outer names.
204
+ while (writes.length) {
205
+ let target = writes.pop();
206
+ const scope = scopes.get(target.id);
207
+ const members = [];
208
+ while (target.type === 'member_expression' || target.type === 'subscript_expression') {
209
+ const object = target.childForFieldName('object');
210
+ if (!object)
211
+ break;
212
+ const property = target.childForFieldName('property');
213
+ const index = target.childForFieldName('index');
214
+ members.push(property ? propertyName(property) : index ? plainString(index) : null);
215
+ target = object;
216
+ }
217
+ // Nested member targets must pass the same guard as direct property writes.
218
+ for (const name of patternNames(target, (member) => writes.push(member))) {
219
+ const binding = lookup(scope, name.text);
220
+ // Lifecycle callbacks and other known properties do not replace the receiver
221
+ // or its registration methods. Unknown keys and constructor mutations remain unsafe.
222
+ if (binding?.kind !== 'sdk' &&
223
+ binding?.kind !== 'sdk-namespace' &&
224
+ members.length > 0 &&
225
+ members.every((member) => member !== null) &&
226
+ !['tool', 'registerTool', '__proto__'].includes(members[members.length - 1]))
227
+ continue;
228
+ if (binding)
229
+ binding.invalid = true;
230
+ }
231
+ }
232
+ return { scopes, calls };
233
+ }
234
+ function sdkBinding(node, scope, forType = false) {
235
+ let kind = 'sdk';
236
+ if (node.type === (forType ? 'nested_type_identifier' : 'member_expression')) {
237
+ const namespace = node.childForFieldName(forType ? 'module' : 'object');
238
+ const member = node.childForFieldName(forType ? 'name' : 'property');
239
+ if (namespace?.type !== 'identifier' || member?.text !== 'McpServer')
240
+ return false;
241
+ node = namespace;
242
+ kind = 'sdk-namespace';
243
+ }
244
+ if (node.type !== 'identifier' && node.type !== 'type_identifier')
245
+ return false;
246
+ const binding = lookup(scope, node.text);
247
+ return binding?.kind === kind && !binding.invalid && (forType || !binding.typeOnly);
248
+ }
249
+ function sdkReceiver(node, scope, scopes) {
250
+ if (node.type !== 'identifier')
251
+ return false;
252
+ const binding = lookup(scope, node.text);
253
+ if (!binding || binding.invalid)
254
+ return false;
255
+ if (binding.kind === 'parameter' && binding.type) {
256
+ return sdkBinding(binding.type, binding.scope, true);
257
+ }
258
+ const value = binding.value;
259
+ if (binding.kind !== 'variable' || value?.type !== 'new_expression')
260
+ return false;
261
+ if (value.endIndex > node.startIndex && variableScope(binding.scope) === variableScope(scope))
262
+ return false;
263
+ const constructor = value.childForFieldName('constructor');
264
+ return constructor !== null && sdkBinding(constructor, scopes.get(value.id));
265
+ }
266
+ /** An unknown later property can replace description; a later explicit property restores proof. */
267
+ function descriptionFromConfig(config) {
268
+ let description = '';
269
+ if (config.type !== 'object')
270
+ return description;
271
+ for (const child of config.namedChildren) {
272
+ if (child.type === 'comment')
273
+ continue;
274
+ const key = child.childForFieldName('key') ?? child.childForFieldName('name');
275
+ const name = child.type === 'shorthand_property_identifier' ? child.text : key && propertyName(key);
276
+ if (child.type === 'pair' && name === 'description') {
277
+ const value = child.childForFieldName('value');
278
+ description = value ? (plainString(value) ?? '') : '';
279
+ }
280
+ else if (!name || name === 'description') {
281
+ description = '';
282
+ }
283
+ }
284
+ return description;
285
+ }
286
+ function handlerNodeId(node, scope, callableBindings) {
287
+ if (node.type !== 'identifier')
288
+ return undefined;
289
+ const binding = lookup(scope, node.text);
290
+ if (!binding || binding.invalid)
291
+ return undefined;
292
+ if (binding.kind === 'variable') {
293
+ const value = binding.value;
294
+ if (!binding.immutable ||
295
+ !value ||
296
+ (value.type !== 'arrow_function' && value.type !== 'function_expression'))
297
+ return undefined;
298
+ if (value.endIndex > node.startIndex && variableScope(binding.scope) === variableScope(scope))
299
+ return undefined;
300
+ }
301
+ else if (binding.kind !== 'function')
302
+ return undefined;
303
+ return callableBindings?.get(binding.name.id);
304
+ }
305
+ /** Direct SDK registrations only; no wrapper, alias-chain or runtime-value inference. */
306
+ export function extractToolDefinitions(tree, filePath, lineOffset = 0, callableBindings) {
307
+ // Ordinary files need no lexical walk; every supported receiver originates here.
308
+ const importsSdk = tree.rootNode.namedChildren.some((node) => {
309
+ if (node.type !== 'import_statement')
310
+ return false;
311
+ const source = node.childForFieldName('source');
312
+ return source !== null && SDK_MODULES.has(plainString(source) ?? '');
313
+ });
314
+ if (!importsSdk)
315
+ return [];
316
+ const { scopes, calls } = collectBindings(tree.rootNode);
317
+ const definitions = [];
318
+ for (const call of calls) {
319
+ const callee = call.childForFieldName('function');
320
+ if (call.hasError || callee?.type !== 'member_expression')
321
+ continue;
322
+ const receiver = callee.childForFieldName('object');
323
+ const method = callee.childForFieldName('property');
324
+ if (!receiver ||
325
+ method?.type !== 'property_identifier' ||
326
+ (method.text !== 'registerTool' && method.text !== 'tool') ||
327
+ !sdkReceiver(receiver, scopes.get(call.id), scopes))
328
+ continue;
329
+ const args = call
330
+ .childForFieldName('arguments')
331
+ ?.namedChildren.filter((child) => child.type !== 'comment') ?? [];
332
+ if (args.some((arg) => arg.type === 'spread_element'))
333
+ continue;
334
+ if (method.text === 'registerTool' ? args.length !== 3 : args.length < 2 || args.length > 5)
335
+ continue;
336
+ const toolName = plainString(args[0]);
337
+ if (toolName === null)
338
+ continue;
339
+ const description = method.text === 'registerTool'
340
+ ? descriptionFromConfig(args[1])
341
+ : args.length > 2
342
+ ? (plainString(args[1]) ?? '')
343
+ : '';
344
+ const handler = handlerNodeId(args[args.length - 1], scopes.get(call.id), callableBindings);
345
+ definitions.push({
346
+ filePath,
347
+ toolName,
348
+ description,
349
+ lineNumber: call.startPosition.row + 1 + lineOffset,
350
+ ...(handler !== undefined ? { handlerNodeId: handler } : {}),
351
+ allowFileFallback: false,
352
+ });
353
+ }
354
+ return definitions;
355
+ }
@@ -77,6 +77,7 @@ import { extractDataRouteTableRoutes } from '../route-extractors/data-route-tabl
77
77
  import { extractNestRoutes } from '../route-extractors/nest.js';
78
78
  import { extractTrpcRoutes, shouldScanForTrpcRoutes } from '../route-extractors/trpc.js';
79
79
  import { extractConvexEndpointProperties } from './typescript/convex-endpoint-metadata.js';
80
+ import { extractToolDefinitions } from './typescript/tool-definitions.js';
80
81
  const extractJsTsRoutes = (...args) => [
81
82
  ...extractDispatchGuardRoutes(...args),
82
83
  ...extractDataRouteTableRoutes(...args),
@@ -415,6 +416,7 @@ export const typescriptProvider = defineLanguage({
415
416
  // Content-based (not AST): tRPC procedure routers are scanned from source text.
416
417
  // Path-gate lives here (language provider), not in the shared parse worker.
417
418
  extractTextRoutes: extractJsTsTextRoutes,
419
+ extractToolDefinitions,
418
420
  });
419
421
  export const javascriptProvider = defineLanguage({
420
422
  id: SupportedLanguages.JavaScript,
@@ -494,4 +496,5 @@ export const javascriptProvider = defineLanguage({
494
496
  extractDecoratorRoutes: extractJsTsRoutes,
495
497
  // Content-based (not AST): tRPC procedure routers are scanned from source text.
496
498
  extractTextRoutes: extractJsTsTextRoutes,
499
+ extractToolDefinitions,
497
500
  });
@@ -261,6 +261,8 @@ export const processesPhase = {
261
261
  const toolsByHandlerId = new Map();
262
262
  const toolsWithoutHandlerByFile = new Map();
263
263
  for (const td of toolDefs) {
264
+ if (!td.handlerNodeId && td.allowFileFallback === false)
265
+ continue;
264
266
  const key = td.handlerNodeId ?? td.filePath;
265
267
  const targetMap = td.handlerNodeId ? toolsByHandlerId : toolsWithoutHandlerByFile;
266
268
  let list = targetMap.get(key);
@@ -14,6 +14,7 @@ export interface ToolDef {
14
14
  filePath: string;
15
15
  description: string;
16
16
  handlerNodeId?: string;
17
+ allowFileFallback?: false;
17
18
  }
18
19
  export interface ToolsOutput {
19
20
  toolDefs: ToolDef[];
@@ -30,6 +30,7 @@ export const toolsPhase = {
30
30
  filePath: td.filePath,
31
31
  description: td.description,
32
32
  ...(handlerNodeId !== undefined ? { handlerNodeId } : {}),
33
+ ...(td.allowFileFallback === false ? { allowFileFallback: false } : {}),
33
34
  });
34
35
  }
35
36
  // TS tool definition arrays — require inputSchema nearby
@@ -196,6 +196,8 @@ export interface ExtractedToolDef {
196
196
  description: string;
197
197
  lineNumber: number;
198
198
  handlerNodeId?: string;
199
+ /** Unresolved registrations must not inherit unrelated same-file flows. */
200
+ allowFileFallback?: false;
199
201
  }
200
202
  export interface ExtractedORMQuery {
201
203
  filePath: string;
@@ -1087,6 +1087,7 @@ const processFileGroup = (files, language, queryString, result, onFileProcessed)
1087
1087
  // node id → graph node id for classes THIS file's capture loop materialized.
1088
1088
  // Keyed by in-memory AST identity (never persisted); filled below.
1089
1089
  const classOwnersByNodeId = new Map();
1090
+ const callableBindings = new Map();
1090
1091
  // #2687: ONE pass over `matches` yields both suppression sets — the
1091
1092
  // definition-name claims by rank (callable > Property > value), so the dedup
1092
1093
  // below cannot depend on tree-sitter's match order, and the concrete-typedef
@@ -2328,6 +2329,10 @@ const processFileGroup = (files, language, queryString, result, onFileProcessed)
2328
2329
  ...(returnShapeProperty ? { fromReturnShape: true, isDetail: true } : {}),
2329
2330
  }),
2330
2331
  });
2332
+ // Keep actual emitted identities; providers must not reconstruct graph IDs.
2333
+ if (nameNode && (nodeLabel === 'Function' || nodeLabel === 'Method')) {
2334
+ callableBindings.set(nameNode.id, nodeId);
2335
+ }
2331
2336
  // enclosingClassId already computed above (before nodeId generation)
2332
2337
  const ownerId = enclosingClassId ?? objectLiteralOwnerInfo?.ownerId;
2333
2338
  result.symbols.push({
@@ -2419,6 +2424,22 @@ const processFileGroup = (files, language, queryString, result, onFileProcessed)
2419
2424
  }
2420
2425
  }
2421
2426
  }
2427
+ if (provider.extractToolDefinitions) {
2428
+ // Distinct lexical declarations can share a graph ID (for example, sibling
2429
+ // block-scoped functions). Such IDs cannot prove which handler owns a tool.
2430
+ const seenCallableIds = new Set();
2431
+ const ambiguousCallableIds = new Set();
2432
+ for (const nodeId of callableBindings.values()) {
2433
+ if (seenCallableIds.has(nodeId))
2434
+ ambiguousCallableIds.add(nodeId);
2435
+ seenCallableIds.add(nodeId);
2436
+ }
2437
+ for (const [bindingId, nodeId] of callableBindings) {
2438
+ if (ambiguousCallableIds.has(nodeId))
2439
+ callableBindings.delete(bindingId);
2440
+ }
2441
+ result.toolDefs.push(...provider.extractToolDefinitions(tree, file.path, lineOffset, callableBindings));
2442
+ }
2422
2443
  // Extract framework routes via provider detection (e.g., Laravel routes.php)
2423
2444
  if (provider.isRouteFile?.(file.path)) {
2424
2445
  const extractedRoutes = extractLaravelRoutes(tree, file.path);
@@ -893,9 +893,10 @@ export declare const buildFtsQueryCypher: (tableName: string, indexName: string,
893
893
  * @param query - Search query string
894
894
  * @param limit - Maximum results
895
895
  * @param conjunctive - If true, all terms must match (AND); if false, any term matches (OR)
896
+ * @param missingIndex - Preserve the empty-result default, or propagate missing indexes for diagnostics
896
897
  * @returns Array of { node properties, score }
897
898
  */
898
- export declare const queryFTS: (tableName: string, indexName: string, query: string, limit?: number, conjunctive?: boolean) => Promise<Array<{
899
+ export declare const queryFTS: (tableName: string, indexName: string, query: string, limit?: number, conjunctive?: boolean, missingIndex?: 'empty' | 'throw') => Promise<Array<{
899
900
  nodeId: string;
900
901
  name: string;
901
902
  filePath: string;
@@ -3504,9 +3504,10 @@ export const buildFtsQueryCypher = (tableName, indexName, limit, conjunctive = f
3504
3504
  * @param query - Search query string
3505
3505
  * @param limit - Maximum results
3506
3506
  * @param conjunctive - If true, all terms must match (AND); if false, any term matches (OR)
3507
+ * @param missingIndex - Preserve the empty-result default, or propagate missing indexes for diagnostics
3507
3508
  * @returns Array of { node properties, score }
3508
3509
  */
3509
- export const queryFTS = async (tableName, indexName, query, limit = 20, conjunctive = false) => {
3510
+ export const queryFTS = async (tableName, indexName, query, limit = 20, conjunctive = false, missingIndex = 'empty') => {
3510
3511
  if (!conn) {
3511
3512
  throw new Error('LadybugDB not initialized. Call initLbug first.');
3512
3513
  }
@@ -3532,7 +3533,7 @@ export const queryFTS = async (tableName, indexName, query, limit = 20, conjunct
3532
3533
  // NEW-6 — this used to be a bare `.includes('does not exist')` check
3533
3534
  // that could not tell the two apart).
3534
3535
  const message = e instanceof Error ? e.message : String(e);
3535
- if (classifyFtsQueryError(message) === 'missing-index') {
3536
+ if (missingIndex === 'empty' && classifyFtsQueryError(message) === 'missing-index') {
3536
3537
  return [];
3537
3538
  }
3538
3539
  throw e;
@@ -29,6 +29,8 @@ export interface FTSSearchResponse {
29
29
  * which only does so when every table failed).
30
30
  */
31
31
  nonBenignErrors?: string[];
32
+ /** Configured table.index names that could not be queried because their index is missing. */
33
+ missingIndexes?: string[];
32
34
  }
33
35
  /**
34
36
  * Search using LadybugDB's built-in FTS (fresh, reads from disk)
@@ -86,6 +86,7 @@ export const searchFTSFromLbug = async (query, limit = 20, repoId, disabledReaso
86
86
  const resultsByIndex = [];
87
87
  let queriesSucceeded = 0;
88
88
  const nonBenignErrors = [];
89
+ const missingIndexes = [];
89
90
  const ftsExtension = getExtensionCapabilities().find((c) => c.name === 'fts');
90
91
  if (ftsExtension && !ftsExtension.loaded) {
91
92
  // tri-review NEW-4 (applies to BOTH the MCP pool path and the CLI/pipeline
@@ -114,26 +115,32 @@ export const searchFTSFromLbug = async (query, limit = 20, repoId, disabledReaso
114
115
  queriesSucceeded++;
115
116
  resultsByIndex.push(outcome.rows);
116
117
  }
117
- else if (!outcome.benign) {
118
+ else if (outcome.benign) {
119
+ missingIndexes.push(`${table}.${indexName}`);
120
+ }
121
+ else {
118
122
  nonBenignErrors.push(redactPaths(outcome.message ?? 'Unknown FTS query error'));
119
123
  }
120
124
  }
121
125
  }
122
126
  else {
123
127
  // Use core lbug adapter (CLI / pipeline context) — also sequential for safety.
124
- // tri-review Residual-1: `queryFTS` itself only swallows a genuinely-missing
125
- // index (via the SAME classifyFtsQueryError this module re-exports); a
126
- // missing-table or real query error rethrows here — track it the same way
127
- // the MCP pool path does instead of a bare `catch {}` that dropped it.
128
+ // Opt into missing-index propagation so absent indexes cannot masquerade
129
+ // as successful zero-match queries. Keep core/pool classification identical.
128
130
  for (const { table, indexName } of FTS_INDEXES) {
129
131
  try {
130
- const result = await queryFTS(table, indexName, searchQuery, limit, false);
132
+ const result = await queryFTS(table, indexName, searchQuery, limit, false, 'throw');
131
133
  queriesSucceeded++;
132
134
  resultsByIndex.push(result);
133
135
  }
134
136
  catch (e) {
135
137
  const message = e instanceof Error ? e.message : String(e);
136
- nonBenignErrors.push(redactPaths(message));
138
+ if (classifyFtsQueryError(message) === 'missing-index') {
139
+ missingIndexes.push(`${table}.${indexName}`);
140
+ }
141
+ else {
142
+ nonBenignErrors.push(redactPaths(message));
143
+ }
137
144
  }
138
145
  }
139
146
  }
@@ -174,5 +181,6 @@ export const searchFTSFromLbug = async (query, limit = 20, repoId, disabledReaso
174
181
  })),
175
182
  ftsAvailable,
176
183
  ...(nonBenignErrors.length > 0 && { nonBenignErrors }),
184
+ ...(missingIndexes.length > 0 && { missingIndexes }),
177
185
  };
178
186
  };
@@ -48,15 +48,12 @@ export interface FtsWarningContext {
48
48
  */
49
49
  export declare const ftsDegradedWarning: (context?: FtsWarningContext, disabledReason?: FtsDisabledReason) => string;
50
50
  /**
51
- * Warning for when the FTS extension is loaded and indexes exist, but every
52
- * configured table's query failed for a real, non-benign reason (timeout,
53
- * connection reset, native fault) — as opposed to `ftsDegradedWarning`'s
54
- * missing-index case. `--repair-fts` will not fix a query/connection error,
55
- * so this deliberately does NOT suggest it: reusing the missing-index
56
- * message here would reproduce, for this cause, the exact misleading
57
- * "run --repair-fts" guidance #2767 itself was about (tri-review NEW-1).
51
+ * Warning when no FTS query succeeded and at least one failed for a real,
52
+ * non-benign reason (timeout, connection reset, native fault). `--repair-fts`
53
+ * will not fix those errors. If indexes are also missing, the caller composes
54
+ * their repair guidance separately; do not deny that additional failure cause.
58
55
  */
59
- export declare const ftsQueryFailedWarning: (context: FtsWarningContext) => string;
56
+ export declare const ftsQueryFailedWarning: (context: FtsWarningContext, hasMissingIndexes?: boolean) => string;
60
57
  export declare const SUPPORTED_FTS_STEMMERS: ReadonlySet<string>;
61
58
  export interface CreateSearchFTSIndexesOptions {
62
59
  indexes?: readonly FTSIndexDefinition[];
@@ -72,18 +72,17 @@ export const ftsDegradedWarning = (context, disabledReason) => {
72
72
  suffix);
73
73
  };
74
74
  /**
75
- * Warning for when the FTS extension is loaded and indexes exist, but every
76
- * configured table's query failed for a real, non-benign reason (timeout,
77
- * connection reset, native fault) — as opposed to `ftsDegradedWarning`'s
78
- * missing-index case. `--repair-fts` will not fix a query/connection error,
79
- * so this deliberately does NOT suggest it: reusing the missing-index
80
- * message here would reproduce, for this cause, the exact misleading
81
- * "run --repair-fts" guidance #2767 itself was about (tri-review NEW-1).
75
+ * Warning when no FTS query succeeded and at least one failed for a real,
76
+ * non-benign reason (timeout, connection reset, native fault). `--repair-fts`
77
+ * will not fix those errors. If indexes are also missing, the caller composes
78
+ * their repair guidance separately; do not deny that additional failure cause.
82
79
  */
83
- export const ftsQueryFailedWarning = (context) => 'FTS keyword search failed — every configured index query returned an error' +
80
+ export const ftsQueryFailedWarning = (context, hasMissingIndexes = false) => 'FTS keyword search failed — every configured index query returned an error' +
84
81
  (context.lastErrorRedacted ? ` (${context.lastErrorRedacted})` : '') +
85
- '; results do not include keyword matches. This is not a missing-index ' +
86
- 'condition — see server logs for details.' +
82
+ '; results do not include keyword matches. ' +
83
+ (hasMissingIndexes
84
+ ? 'See server logs for query error details.'
85
+ : 'This is not a missing-index condition — see server logs for details.') +
87
86
  ` (resolved: ${formatResolvedSuffix(context)})`;
88
87
  // Stemmers shipped by the LadybugDB FTS extension. Mirrors the lowercase token
89
88
  // set in the extension bundled with @ladybugdb/core 0.18.x (see package.json).
@@ -2413,6 +2413,7 @@ export class LocalBackend {
2413
2413
  // regardless of whether OTHER tables succeeded — previously a real error
2414
2414
  // on N-1 of N tables while one succeeded left zero diagnostic trail.
2415
2415
  const ftsQueryErrors = bm25SearchResult?.nonBenignErrors;
2416
+ const ftsMissingIndexes = bm25SearchResult?.missingIndexes;
2416
2417
  if (ftsQueryErrors) {
2417
2418
  // tri-review NEW-5: these strings are already classified non-benign by
2418
2419
  // classifyFtsQueryError — do NOT route them through logQueryError,
@@ -2847,12 +2848,11 @@ export class LocalBackend {
2847
2848
  branch: repo.branch,
2848
2849
  indexedAt: this.lastObservedPoolState.get(repo.lbugPath)?.indexedAt ?? repo.indexedAt,
2849
2850
  };
2850
- // tri-review NEW-1: every table failing for a REAL error (timeout,
2851
- // connection reset) is not a missing-index condition — `ftsDegradedWarning`'s
2852
- // "run --repair-fts" headline won't fix it. Route to a dedicated message
2853
- // instead of burying the real cause as a trailing suffix on bad advice.
2851
+ // Real errors (timeout, connection reset) need their own diagnosis.
2852
+ // When some indexes are also missing, preserve both causes and append
2853
+ // their repair guidance below even though no FTS query succeeded.
2854
2854
  warnings.push(ftsQueryErrors
2855
- ? ftsQueryFailedWarning({ ...warningContext, lastErrorRedacted: ftsQueryErrors[0] })
2855
+ ? ftsQueryFailedWarning({ ...warningContext, lastErrorRedacted: ftsQueryErrors[0] }, !!ftsMissingIndexes?.length)
2856
2856
  : ftsDegradedWarning(warningContext, ftsDisabledReason));
2857
2857
  }
2858
2858
  else if (ftsQueryErrors) {
@@ -2863,6 +2863,10 @@ export class LocalBackend {
2863
2863
  // that convention instead of only logging server-side.
2864
2864
  warnings.push(`FTS keyword search partially failed — ${ftsQueryErrors.length} of the configured indexes hit a query error and were skipped; results may be missing matches from those node types (see server logs).`);
2865
2865
  }
2866
+ if (ftsMissingIndexes?.length && (ftsUsed || ftsQueryErrors)) {
2867
+ warnings.push(`FTS keyword search is incomplete: missing configured indexes (${ftsMissingIndexes.join(', ')}). ` +
2868
+ 'Results may be missing matches from those node types. Run `gitnexus analyze --repair-fts`.');
2869
+ }
2866
2870
  // #2331: a CJK query against a server process resolving
2867
2871
  // GITNEXUS_FTS_CJK_SEGMENTATION to 'none' silently misses sub-phrase
2868
2872
  // matches with no other signal — this is the only place an agent driving
@@ -2979,7 +2983,7 @@ export class LocalBackend {
2979
2983
  // #2767: a partial FTS failure (some tables ok, one or more real errors)
2980
2984
  // is as much a "results may be incomplete" signal as enrichmentDegraded —
2981
2985
  // flag it the same way rather than only via the warning string.
2982
- const ftsPartial = ftsUsed && !!ftsQueryErrors;
2986
+ const ftsPartial = ftsUsed && (!!ftsQueryErrors || !!ftsMissingIndexes?.length);
2983
2987
  return {
2984
2988
  processes,
2985
2989
  process_symbols,
@@ -3023,6 +3027,7 @@ export class LocalBackend {
3023
3027
  const bm25Results = ftsResponse?.results ?? [];
3024
3028
  const ftsUsed = ftsResponse?.ftsAvailable ?? false;
3025
3029
  const nonBenignErrors = ftsResponse?.nonBenignErrors;
3030
+ const missingIndexes = ftsResponse?.missingIndexes;
3026
3031
  const results = [];
3027
3032
  for (const bm25Result of bm25Results) {
3028
3033
  const fullPath = bm25Result.filePath;
@@ -3087,7 +3092,12 @@ export class LocalBackend {
3087
3092
  });
3088
3093
  }
3089
3094
  }
3090
- return { results, ftsUsed, ...(nonBenignErrors && { nonBenignErrors }) };
3095
+ return {
3096
+ results,
3097
+ ftsUsed,
3098
+ ...(nonBenignErrors && { nonBenignErrors }),
3099
+ ...(missingIndexes && { missingIndexes }),
3100
+ };
3091
3101
  }
3092
3102
  /**
3093
3103
  * Keyword-only indexes are the default. Do not put this on `query.warning`
@@ -817,7 +817,14 @@ import { copyV8CacheIfPresent, tryLoadV8Cache, writeV8CacheFile } from './v8-sid
817
817
  // `handlerReceiver` hint. Warm v123 Go worker results carry no routes.
818
818
  // v125 (#3402): Go route hints now honor lexical declarations and captured writes;
819
819
  // namespace imports retain whether their local name comes from the package clause.
820
- const SCHEMA_BUMP = 125;
820
+ // v126 (#3446): SDK positional tool registrations now emit tool definitions,
821
+ // exact handler identities, and an opt-out from unrelated file-level flows.
822
+ // Warm v125 worker results omit these definitions and must be re-extracted.
823
+ // v127 (#3450): Destructured member writes invalidate SDK registration evidence.
824
+ // Warm v126 worker results can retain false tools after a method replacement.
825
+ // v128 (#3450): SDK namespace imports now prove positional tool receivers.
826
+ // Warm v127 worker results omit these definitions and must be re-extracted.
827
+ const SCHEMA_BUMP = 128;
821
828
  const GITNEXUS_PKG_VERSION = (() => {
822
829
  try {
823
830
  // package.json sits at gitnexus/package.json — two levels up from
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitnexus",
3
- "version": "1.6.13-rc.66",
3
+ "version": "1.6.13-rc.68",
4
4
  "description": "Graph-powered code intelligence for AI agents. Index any codebase, query via MCP or CLI.",
5
5
  "author": "Abhigyan Patwari",
6
6
  "license": "PolyForm-Noncommercial-1.0.0",