gitnexus 1.6.8-rc.34 → 1.6.8-rc.35

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.
@@ -28,6 +28,7 @@ const COMMAND_DESCRIPTION_KEYS = {
28
28
  cypher: 'help.command.cypher.description',
29
29
  'detect-changes': 'help.command.detectChanges.description',
30
30
  check: 'help.command.check.description',
31
+ trace: 'help.command.trace.description',
31
32
  'eval-server': 'help.command.evalServer.description',
32
33
  group: 'help.command.group.description',
33
34
  'group create': 'help.command.group.create.description',
@@ -127,6 +128,14 @@ const OPTION_DESCRIPTION_KEYS = {
127
128
  'check|--json': 'help.option.json',
128
129
  'check|-r, --repo <name>': 'help.option.repo.target',
129
130
  'check|--branch <name>': 'help.option.branch',
131
+ 'trace|--from-uid <uid>': 'help.option.trace.fromUid',
132
+ 'trace|--from-file <path>': 'help.option.trace.fromFile',
133
+ 'trace|--to-uid <uid>': 'help.option.trace.toUid',
134
+ 'trace|--to-file <path>': 'help.option.trace.toFile',
135
+ 'trace|--depth <n>': 'help.option.trace.depth',
136
+ 'trace|--include-tests': 'help.option.trace.includeTests',
137
+ 'trace|-r, --repo <name>': 'help.option.repo.target',
138
+ 'trace|--branch <name>': 'help.option.branch',
130
139
  'eval-server|-p, --port <port>': 'help.option.port',
131
140
  'eval-server|--host <host>': 'help.option.evalServer.host',
132
141
  'eval-server|--idle-timeout <seconds>': 'help.option.evalServer.idleTimeout',
@@ -51,6 +51,7 @@ export declare const en: {
51
51
  readonly 'tool.usage.query': "Usage: gitnexus query <search_query>";
52
52
  readonly 'tool.usage.context': "Usage: gitnexus context <symbol_name> [--uid <uid>] [--file <path>]";
53
53
  readonly 'tool.usage.impact': "Usage: gitnexus impact <symbol_name> [--uid <uid>] [--file <path>] [--kind <kind>] [--direction upstream|downstream]";
54
+ readonly 'tool.usage.trace': "Usage: gitnexus trace <from> <to> [--from-uid <uid>] [--to-uid <uid>] [--depth <n>]";
54
55
  readonly 'tool.usage.cypher': "Usage: gitnexus cypher <cypher_query>";
55
56
  readonly 'tool.warn.unknownKind': "--kind '{{kind}}' is not a known symbol kind (e.g. Function, Class, Method); it will not narrow the result.";
56
57
  readonly 'tool.detectChanges.noChanges': "No changes detected.";
@@ -124,6 +125,7 @@ export declare const en: {
124
125
  readonly 'help.command.query.description': "Search the knowledge graph for execution flows related to a concept";
125
126
  readonly 'help.command.context.description': "360-degree view of a code symbol: callers, callees, processes";
126
127
  readonly 'help.command.impact.description': "Blast radius analysis: what breaks if you change a symbol";
128
+ readonly 'help.command.trace.description': "Find the shortest directed path between two symbols (call + class-member edges)";
127
129
  readonly 'help.command.cypher.description': "Execute raw Cypher query against the knowledge graph";
128
130
  readonly 'help.command.detectChanges.description': "Map git diff hunks to indexed symbols and affected execution flows";
129
131
  readonly 'help.command.check.description': "Run structural checks against the indexed graph";
@@ -203,6 +205,12 @@ export declare const en: {
203
205
  readonly 'help.option.impact.limit': "Max symbols per depth level (default: 100)";
204
206
  readonly 'help.option.impact.offset': "Skip N symbols per depth level for pagination";
205
207
  readonly 'help.option.impact.summaryOnly': "Return counts and risk only, omit symbol list";
208
+ readonly 'help.option.trace.fromUid': "Source symbol UID (zero-ambiguity lookup)";
209
+ readonly 'help.option.trace.fromFile': "Source file path to disambiguate common names";
210
+ readonly 'help.option.trace.toUid': "Target symbol UID (zero-ambiguity lookup)";
211
+ readonly 'help.option.trace.toFile': "Target file path to disambiguate common names";
212
+ readonly 'help.option.trace.depth': "Max path length in hops (default: 10)";
213
+ readonly 'help.option.trace.includeTests': "Traverse through test-file symbols (default: false)";
206
214
  readonly 'help.option.detectChanges.scope': "What to analyze: unstaged, staged, all, or compare";
207
215
  readonly 'help.option.detectChanges.baseRef': "Branch/commit for compare scope (e.g. main)";
208
216
  readonly 'help.option.check.cycles': "Detect circular imports and fail when any are found";
@@ -51,6 +51,7 @@ export const en = {
51
51
  'tool.usage.query': 'Usage: gitnexus query <search_query>',
52
52
  'tool.usage.context': 'Usage: gitnexus context <symbol_name> [--uid <uid>] [--file <path>]',
53
53
  'tool.usage.impact': 'Usage: gitnexus impact <symbol_name> [--uid <uid>] [--file <path>] [--kind <kind>] [--direction upstream|downstream]',
54
+ 'tool.usage.trace': 'Usage: gitnexus trace <from> <to> [--from-uid <uid>] [--to-uid <uid>] [--depth <n>]',
54
55
  'tool.usage.cypher': 'Usage: gitnexus cypher <cypher_query>',
55
56
  'tool.warn.unknownKind': "--kind '{{kind}}' is not a known symbol kind (e.g. Function, Class, Method); it will not narrow the result.",
56
57
  'tool.detectChanges.noChanges': 'No changes detected.',
@@ -124,6 +125,7 @@ export const en = {
124
125
  'help.command.query.description': 'Search the knowledge graph for execution flows related to a concept',
125
126
  'help.command.context.description': '360-degree view of a code symbol: callers, callees, processes',
126
127
  'help.command.impact.description': 'Blast radius analysis: what breaks if you change a symbol',
128
+ 'help.command.trace.description': 'Find the shortest directed path between two symbols (call + class-member edges)',
127
129
  'help.command.cypher.description': 'Execute raw Cypher query against the knowledge graph',
128
130
  'help.command.detectChanges.description': 'Map git diff hunks to indexed symbols and affected execution flows',
129
131
  'help.command.check.description': 'Run structural checks against the indexed graph',
@@ -203,6 +205,12 @@ export const en = {
203
205
  'help.option.impact.limit': 'Max symbols per depth level (default: 100)',
204
206
  'help.option.impact.offset': 'Skip N symbols per depth level for pagination',
205
207
  'help.option.impact.summaryOnly': 'Return counts and risk only, omit symbol list',
208
+ 'help.option.trace.fromUid': 'Source symbol UID (zero-ambiguity lookup)',
209
+ 'help.option.trace.fromFile': 'Source file path to disambiguate common names',
210
+ 'help.option.trace.toUid': 'Target symbol UID (zero-ambiguity lookup)',
211
+ 'help.option.trace.toFile': 'Target file path to disambiguate common names',
212
+ 'help.option.trace.depth': 'Max path length in hops (default: 10)',
213
+ 'help.option.trace.includeTests': 'Traverse through test-file symbols (default: false)',
206
214
  'help.option.detectChanges.scope': 'What to analyze: unstaged, staged, all, or compare',
207
215
  'help.option.detectChanges.baseRef': 'Branch/commit for compare scope (e.g. main)',
208
216
  'help.option.check.cycles': 'Detect circular imports and fail when any are found',
@@ -52,6 +52,7 @@ export declare const cliResources: {
52
52
  readonly 'tool.usage.query': "Usage: gitnexus query <search_query>";
53
53
  readonly 'tool.usage.context': "Usage: gitnexus context <symbol_name> [--uid <uid>] [--file <path>]";
54
54
  readonly 'tool.usage.impact': "Usage: gitnexus impact <symbol_name> [--uid <uid>] [--file <path>] [--kind <kind>] [--direction upstream|downstream]";
55
+ readonly 'tool.usage.trace': "Usage: gitnexus trace <from> <to> [--from-uid <uid>] [--to-uid <uid>] [--depth <n>]";
55
56
  readonly 'tool.usage.cypher': "Usage: gitnexus cypher <cypher_query>";
56
57
  readonly 'tool.warn.unknownKind': "--kind '{{kind}}' is not a known symbol kind (e.g. Function, Class, Method); it will not narrow the result.";
57
58
  readonly 'tool.detectChanges.noChanges': "No changes detected.";
@@ -125,6 +126,7 @@ export declare const cliResources: {
125
126
  readonly 'help.command.query.description': "Search the knowledge graph for execution flows related to a concept";
126
127
  readonly 'help.command.context.description': "360-degree view of a code symbol: callers, callees, processes";
127
128
  readonly 'help.command.impact.description': "Blast radius analysis: what breaks if you change a symbol";
129
+ readonly 'help.command.trace.description': "Find the shortest directed path between two symbols (call + class-member edges)";
128
130
  readonly 'help.command.cypher.description': "Execute raw Cypher query against the knowledge graph";
129
131
  readonly 'help.command.detectChanges.description': "Map git diff hunks to indexed symbols and affected execution flows";
130
132
  readonly 'help.command.check.description': "Run structural checks against the indexed graph";
@@ -204,6 +206,12 @@ export declare const cliResources: {
204
206
  readonly 'help.option.impact.limit': "Max symbols per depth level (default: 100)";
205
207
  readonly 'help.option.impact.offset': "Skip N symbols per depth level for pagination";
206
208
  readonly 'help.option.impact.summaryOnly': "Return counts and risk only, omit symbol list";
209
+ readonly 'help.option.trace.fromUid': "Source symbol UID (zero-ambiguity lookup)";
210
+ readonly 'help.option.trace.fromFile': "Source file path to disambiguate common names";
211
+ readonly 'help.option.trace.toUid': "Target symbol UID (zero-ambiguity lookup)";
212
+ readonly 'help.option.trace.toFile': "Target file path to disambiguate common names";
213
+ readonly 'help.option.trace.depth': "Max path length in hops (default: 10)";
214
+ readonly 'help.option.trace.includeTests': "Traverse through test-file symbols (default: false)";
207
215
  readonly 'help.option.detectChanges.scope': "What to analyze: unstaged, staged, all, or compare";
208
216
  readonly 'help.option.detectChanges.baseRef': "Branch/commit for compare scope (e.g. main)";
209
217
  readonly 'help.option.check.cycles': "Detect circular imports and fail when any are found";
@@ -282,6 +290,7 @@ export declare const cliResources: {
282
290
  'tool.usage.query': string;
283
291
  'tool.usage.context': string;
284
292
  'tool.usage.impact': string;
293
+ 'tool.usage.trace': string;
285
294
  'tool.usage.cypher': string;
286
295
  'tool.warn.unknownKind': string;
287
296
  'tool.detectChanges.noChanges': string;
@@ -355,6 +364,7 @@ export declare const cliResources: {
355
364
  'help.command.query.description': string;
356
365
  'help.command.context.description': string;
357
366
  'help.command.impact.description': string;
367
+ 'help.command.trace.description': string;
358
368
  'help.command.cypher.description': string;
359
369
  'help.command.detectChanges.description': string;
360
370
  'help.command.check.description': string;
@@ -434,6 +444,12 @@ export declare const cliResources: {
434
444
  'help.option.impact.limit': string;
435
445
  'help.option.impact.offset': string;
436
446
  'help.option.impact.summaryOnly': string;
447
+ 'help.option.trace.fromUid': string;
448
+ 'help.option.trace.fromFile': string;
449
+ 'help.option.trace.toUid': string;
450
+ 'help.option.trace.toFile': string;
451
+ 'help.option.trace.depth': string;
452
+ 'help.option.trace.includeTests': string;
437
453
  'help.option.detectChanges.scope': string;
438
454
  'help.option.detectChanges.baseRef': string;
439
455
  'help.option.check.cycles': string;
@@ -51,6 +51,7 @@ export declare const zhCN: {
51
51
  'tool.usage.query': string;
52
52
  'tool.usage.context': string;
53
53
  'tool.usage.impact': string;
54
+ 'tool.usage.trace': string;
54
55
  'tool.usage.cypher': string;
55
56
  'tool.warn.unknownKind': string;
56
57
  'tool.detectChanges.noChanges': string;
@@ -124,6 +125,7 @@ export declare const zhCN: {
124
125
  'help.command.query.description': string;
125
126
  'help.command.context.description': string;
126
127
  'help.command.impact.description': string;
128
+ 'help.command.trace.description': string;
127
129
  'help.command.cypher.description': string;
128
130
  'help.command.detectChanges.description': string;
129
131
  'help.command.check.description': string;
@@ -203,6 +205,12 @@ export declare const zhCN: {
203
205
  'help.option.impact.limit': string;
204
206
  'help.option.impact.offset': string;
205
207
  'help.option.impact.summaryOnly': string;
208
+ 'help.option.trace.fromUid': string;
209
+ 'help.option.trace.fromFile': string;
210
+ 'help.option.trace.toUid': string;
211
+ 'help.option.trace.toFile': string;
212
+ 'help.option.trace.depth': string;
213
+ 'help.option.trace.includeTests': string;
206
214
  'help.option.detectChanges.scope': string;
207
215
  'help.option.detectChanges.baseRef': string;
208
216
  'help.option.check.cycles': string;
@@ -51,6 +51,7 @@ export const zhCN = {
51
51
  'tool.usage.query': '用法:gitnexus query <搜索词>',
52
52
  'tool.usage.context': '用法:gitnexus context <符号名> [--uid <uid>] [--file <路径>]',
53
53
  'tool.usage.impact': '用法:gitnexus impact <符号名> [--uid <uid>] [--file <路径>] [--kind <类型>] [--direction upstream|downstream]',
54
+ 'tool.usage.trace': '用法:gitnexus trace <起点> <终点> [--from-uid <uid>] [--to-uid <uid>] [--depth <n>]',
54
55
  'tool.usage.cypher': '用法:gitnexus cypher <Cypher 查询>',
55
56
  'tool.warn.unknownKind': "--kind '{{kind}}' 不是已知的符号类型(如 Function、Class、Method),不会用于缩小结果范围。",
56
57
  'tool.detectChanges.noChanges': '未检测到变更。',
@@ -124,6 +125,7 @@ export const zhCN = {
124
125
  'help.command.query.description': '搜索知识图谱中与概念相关的执行流程',
125
126
  'help.command.context.description': '查看代码符号的 360 度视图:调用者、被调用者、流程',
126
127
  'help.command.impact.description': '影响面分析:修改符号会影响什么',
128
+ 'help.command.trace.description': '查找两个符号之间的最短有向路径(调用与类成员边)',
127
129
  'help.command.cypher.description': '对知识图谱执行原始 Cypher 查询',
128
130
  'help.command.detectChanges.description': '将 git diff hunk 映射到已索引符号和受影响执行流程',
129
131
  'help.command.check.description': '对已索引图谱运行结构检查',
@@ -203,6 +205,12 @@ export const zhCN = {
203
205
  'help.option.impact.limit': '每层深度最大符号数(默认:100)',
204
206
  'help.option.impact.offset': '每层深度跳过 N 个符号(分页用)',
205
207
  'help.option.impact.summaryOnly': '仅返回计数和风险等级,省略符号列表',
208
+ 'help.option.trace.fromUid': '源符号 UID(零歧义查找)',
209
+ 'help.option.trace.fromFile': '源文件路径,用于消除常见名称歧义',
210
+ 'help.option.trace.toUid': '目标符号 UID(零歧义查找)',
211
+ 'help.option.trace.toFile': '目标文件路径,用于消除常见名称歧义',
212
+ 'help.option.trace.depth': '最大路径跳数(默认:10)',
213
+ 'help.option.trace.includeTests': '遍历时包含测试文件中的符号(默认:false)',
206
214
  'help.option.detectChanges.scope': '分析范围:unstaged、staged、all 或 compare',
207
215
  'help.option.detectChanges.baseRef': 'compare 范围的分支/提交(例如 main)',
208
216
  'help.option.check.cycles': '检测循环导入,并在发现循环时失败',
package/dist/cli/index.js CHANGED
@@ -235,6 +235,18 @@ program
235
235
  .option('--offset <n>', 'Skip N symbols per depth level for pagination')
236
236
  .option('--summary-only', 'Return counts and risk only, omit symbol list')
237
237
  .action(createLbugLazyAction(() => import('./tool.js'), 'impactCommand'));
238
+ program
239
+ .command('trace <from> <to>')
240
+ .description('Find the shortest directed path between two symbols (call + class-member edges)')
241
+ .option('--from-uid <uid>', 'Source symbol UID (zero-ambiguity)')
242
+ .option('--from-file <path>', 'Source file path hint')
243
+ .option('--to-uid <uid>', 'Target symbol UID (zero-ambiguity)')
244
+ .option('--to-file <path>', 'Target file path hint')
245
+ .option('--depth <n>', 'Max path length in hops (default: 10)')
246
+ .option('--include-tests', 'Include test files in results')
247
+ .option('-r, --repo <name>', 'Target repository')
248
+ .option('--branch <name>', 'Scope to a specific branch index')
249
+ .action(createLbugLazyAction(() => import('./tool.js'), 'traceCommand'));
238
250
  program
239
251
  .command('cypher <query>')
240
252
  .description('Execute raw Cypher query against the knowledge graph')
@@ -58,3 +58,13 @@ export declare function checkCommand(options?: {
58
58
  repo?: string;
59
59
  branch?: string;
60
60
  }): Promise<void>;
61
+ export declare function traceCommand(from?: string, to?: string, options?: {
62
+ fromUid?: string;
63
+ fromFile?: string;
64
+ toUid?: string;
65
+ toFile?: string;
66
+ depth?: string;
67
+ repo?: string;
68
+ branch?: string;
69
+ includeTests?: boolean;
70
+ }): Promise<void>;
package/dist/cli/tool.js CHANGED
@@ -207,3 +207,48 @@ export async function checkCommand(options) {
207
207
  process.exitCode = 1;
208
208
  }
209
209
  }
210
+ export async function traceCommand(from, to, options) {
211
+ if (options?.fromUid?.startsWith('--') || options?.toUid?.startsWith('--')) {
212
+ cliErrorKey('tool.usage.trace');
213
+ process.exit(1);
214
+ }
215
+ if ((!from?.trim() && !options?.fromUid) || (!to?.trim() && !options?.toUid)) {
216
+ cliErrorKey('tool.usage.trace');
217
+ process.exit(1);
218
+ }
219
+ // Reject a non-numeric / non-positive --depth up front rather than forwarding
220
+ // NaN (which the backend would silently treat as the default).
221
+ if (options?.depth !== undefined) {
222
+ const parsedDepth = Number(options.depth);
223
+ if (!Number.isInteger(parsedDepth) || parsedDepth < 1) {
224
+ cliErrorKey('tool.usage.trace');
225
+ process.exit(1);
226
+ }
227
+ }
228
+ try {
229
+ const backend = await getBackend();
230
+ const result = await backend.callTool('trace', {
231
+ from: from || undefined,
232
+ from_uid: options?.fromUid,
233
+ from_file: options?.fromFile,
234
+ to: to || undefined,
235
+ to_uid: options?.toUid,
236
+ to_file: options?.toFile,
237
+ maxDepth: options?.depth ? parseInt(options.depth, 10) : undefined,
238
+ includeTests: options?.includeTests ?? false,
239
+ repo: options?.repo,
240
+ branch: options?.branch,
241
+ });
242
+ output(result);
243
+ }
244
+ catch (err) {
245
+ output({
246
+ status: 'error',
247
+ error: (err instanceof Error ? err.message : String(err)) || 'Trace analysis failed unexpectedly',
248
+ from: { name: from },
249
+ to: { name: to },
250
+ suggestion: 'Try gitnexus context <symbol> to see connections, or check if an interface bridges them.',
251
+ });
252
+ process.exit(1);
253
+ }
254
+ }
@@ -511,6 +511,8 @@ export declare class LocalBackend {
511
511
  * Additional refs found via text search are tagged "text_search" (lower confidence).
512
512
  */
513
513
  private rename;
514
+ private trace;
515
+ private _traceImpl;
514
516
  private impact;
515
517
  private _impactImpl;
516
518
  /**
@@ -1101,6 +1101,8 @@ export class LocalBackend {
1101
1101
  return this.toolMap(repo, params);
1102
1102
  case 'api_impact':
1103
1103
  return this.apiImpact(repo, params);
1104
+ case 'trace':
1105
+ return this.trace(repo, params);
1104
1106
  default:
1105
1107
  throw new Error(`Unknown tool: ${method}`);
1106
1108
  }
@@ -3261,6 +3263,208 @@ export class LocalBackend {
3261
3263
  applied: !dry_run,
3262
3264
  };
3263
3265
  }
3266
+ async trace(repo, params) {
3267
+ try {
3268
+ return await this._traceImpl(repo, params);
3269
+ }
3270
+ catch (err) {
3271
+ return {
3272
+ status: 'error',
3273
+ error: (err instanceof Error ? err.message : String(err)) || 'Trace analysis failed',
3274
+ from: { name: params.from },
3275
+ to: { name: params.to },
3276
+ suggestion: 'The graph query failed — try gitnexus context <symbol> to see connections, ' +
3277
+ 'or check if an interface bridges them.',
3278
+ ...(isWalCorruptionError(err) ? { recoverySuggestion: WAL_RECOVERY_SUGGESTION } : {}),
3279
+ };
3280
+ }
3281
+ }
3282
+ async _traceImpl(repo, params) {
3283
+ await this.ensureInitialized(repo);
3284
+ // resolveSymbolCandidates feeds `from`/`to` into string operations
3285
+ // (e.g. name.includes), so a non-string param would surface a low-level
3286
+ // "x.includes is not a function". Reject it with a clear message instead.
3287
+ const isStringOrAbsent = (v) => v === undefined || typeof v === 'string';
3288
+ if (!isStringOrAbsent(params.from) ||
3289
+ !isStringOrAbsent(params.to) ||
3290
+ !isStringOrAbsent(params.from_uid) ||
3291
+ !isStringOrAbsent(params.to_uid)) {
3292
+ return {
3293
+ status: 'error',
3294
+ error: "'from', 'to', and their *_uid variants must be strings.",
3295
+ suggestion: 'Pass symbol names or UIDs as strings, e.g. trace from="A" to="B".',
3296
+ };
3297
+ }
3298
+ const fromOutcome = await this.resolveSymbolCandidates(repo, { uid: params.from_uid, name: params.from }, { file_path: params.from_file });
3299
+ if (fromOutcome.kind === 'not_found') {
3300
+ return {
3301
+ status: 'not_found',
3302
+ error: `Source symbol '${params.from_uid ?? params.from}' not found.`,
3303
+ suggestion: 'Check the symbol name or use --from-uid for zero-ambiguity.',
3304
+ };
3305
+ }
3306
+ if (fromOutcome.kind === 'ambiguous') {
3307
+ return {
3308
+ status: 'ambiguous',
3309
+ role: 'from',
3310
+ message: `Found ${fromOutcome.candidates.length} symbols matching '${params.from}'. Disambiguate with --from-uid.`,
3311
+ candidates: fromOutcome.candidates,
3312
+ };
3313
+ }
3314
+ const toOutcome = await this.resolveSymbolCandidates(repo, { uid: params.to_uid, name: params.to }, { file_path: params.to_file });
3315
+ if (toOutcome.kind === 'not_found') {
3316
+ return {
3317
+ status: 'not_found',
3318
+ error: `Target symbol '${params.to_uid ?? params.to}' not found.`,
3319
+ suggestion: 'Check the symbol name or use --to-uid for zero-ambiguity.',
3320
+ };
3321
+ }
3322
+ if (toOutcome.kind === 'ambiguous') {
3323
+ return {
3324
+ status: 'ambiguous',
3325
+ role: 'to',
3326
+ message: `Found ${toOutcome.candidates.length} symbols matching '${params.to}'. Disambiguate with --to-uid.`,
3327
+ candidates: toOutcome.candidates,
3328
+ };
3329
+ }
3330
+ const fromSym = fromOutcome.symbol;
3331
+ const toSym = toOutcome.symbol;
3332
+ if (fromSym.id === toSym.id) {
3333
+ return {
3334
+ status: 'ok',
3335
+ from: { name: fromSym.name, filePath: fromSym.filePath, startLine: fromSym.startLine },
3336
+ to: { name: toSym.name, filePath: toSym.filePath, startLine: toSym.startLine },
3337
+ hopCount: 0,
3338
+ hops: [{ name: fromSym.name, filePath: fromSym.filePath, startLine: fromSym.startLine }],
3339
+ edges: [],
3340
+ };
3341
+ }
3342
+ // Sanitize maxDepth at the real boundary: the MCP inputSchema's
3343
+ // minimum/maximum is advisory only (callTool is reachable directly), so a
3344
+ // caller can pass 0, a negative, NaN, or a non-integer. `??` does NOT
3345
+ // recover 0/NaN, and Math.min has no lower bound — left unguarded, any of
3346
+ // those makes the BFS loop run zero iterations and return a false no_path.
3347
+ const DEFAULT_TRACE_DEPTH = 10;
3348
+ const MAX_TRACE_DEPTH = 30;
3349
+ const requestedDepth = Number.isInteger(params.maxDepth) && params.maxDepth > 0
3350
+ ? params.maxDepth
3351
+ : DEFAULT_TRACE_DEPTH;
3352
+ const maxDepth = Math.min(requestedDepth, MAX_TRACE_DEPTH);
3353
+ const includeTests = params.includeTests ?? false;
3354
+ // Traversal vocabulary: CALLS for actual calls, HAS_METHOD so a class-rooted
3355
+ // trace can descend into its methods. Not "calls only" — per-hop edge type is
3356
+ // surfaced in edges[] so containment hops stay distinguishable.
3357
+ const TRAVERSAL_EDGE_TYPES = ['CALLS', 'HAS_METHOD'];
3358
+ // Bound the traversal so a high-fanout hub (a logger/util reached by many
3359
+ // symbols) can't materialize an unbounded frontier. Per-level rows are
3360
+ // capped and the total visited set is capped; either cap sets `truncated`
3361
+ // so a resulting no_path is never reported as if the graph was exhausted.
3362
+ const PER_NODE_FANOUT_CAP = 200;
3363
+ const ABS_ROW_CAP = 5000;
3364
+ const MAX_VISITED = 50000;
3365
+ let truncated = false;
3366
+ const visited = new Set([fromSym.id]);
3367
+ let frontier = [fromSym.id];
3368
+ const parent = new Map();
3369
+ let found = false;
3370
+ // The last node discovered at the deepest reached level — surfaced as
3371
+ // `furthest` in the no_path response to hint where the chain breaks.
3372
+ let lastReached = null;
3373
+ let reachedDepth = 0;
3374
+ for (let depth = 1; depth <= maxDepth && frontier.length > 0 && !found; depth++) {
3375
+ const nextFrontier = [];
3376
+ // LadybugDB/Kuzu does not support a parameterized LIMIT, so the cap is
3377
+ // interpolated (it is a derived integer, not user input).
3378
+ const rowCap = Math.min(frontier.length * PER_NODE_FANOUT_CAP, ABS_ROW_CAP);
3379
+ const rows = await executeParameterized(repo.lbugPath, `MATCH (n)-[r:CodeRelation]->(m)
3380
+ WHERE n.id IN $frontierIds AND r.type IN $edgeTypes
3381
+ RETURN n.id AS sourceId, m.id AS id, m.name AS name, labels(m)[0] AS type,
3382
+ m.filePath AS filePath, m.startLine AS startLine,
3383
+ r.type AS edgeType, r.confidence AS confidence
3384
+ LIMIT ${rowCap}`, { frontierIds: frontier, edgeTypes: TRAVERSAL_EDGE_TYPES });
3385
+ // A clipped level may have dropped a node that lies on the only shortest
3386
+ // path, so any subsequent no_path is not authoritative.
3387
+ if (rows.length >= rowCap)
3388
+ truncated = true;
3389
+ for (const row of rows) {
3390
+ // Decode once. The `?? row[N]` fallback handles LadybugDB tuple-mode
3391
+ // returns; the positional indices mirror the RETURN column order above.
3392
+ const nodeId = (row.id ?? row[1]);
3393
+ const sourceId = (row.sourceId ?? row[0]);
3394
+ const name = (row.name ?? row[2]);
3395
+ const filePath = (row.filePath ?? row[4]);
3396
+ const startLine = (row.startLine ?? row[5]);
3397
+ const edgeType = (row.edgeType ?? row[6]);
3398
+ const storedConfidence = row.confidence ?? row[7];
3399
+ const confidence = typeof storedConfidence === 'number' && storedConfidence > 0
3400
+ ? storedConfidence
3401
+ : confidenceForRelType(edgeType);
3402
+ // Match the explicitly-requested target before the test-file filter.
3403
+ // resolveSymbolCandidates does not exclude test-file symbols, so a
3404
+ // target (or a required hop) that lives in a test file would otherwise
3405
+ // be dropped by the includeTests guard below and produce a false
3406
+ // no_path even when a direct edge exists.
3407
+ if (nodeId === toSym.id) {
3408
+ parent.set(nodeId, { from: sourceId, name, filePath, startLine, edgeType, confidence });
3409
+ found = true;
3410
+ break;
3411
+ }
3412
+ // Skip non-target nodes that live in test files unless includeTests.
3413
+ if (!includeTests && isTestFilePath(filePath))
3414
+ continue;
3415
+ if (!visited.has(nodeId)) {
3416
+ visited.add(nodeId);
3417
+ parent.set(nodeId, { from: sourceId, name, filePath, startLine, edgeType, confidence });
3418
+ nextFrontier.push(nodeId);
3419
+ lastReached = { name, filePath, startLine };
3420
+ reachedDepth = depth;
3421
+ }
3422
+ }
3423
+ frontier = nextFrontier;
3424
+ if (visited.size >= MAX_VISITED) {
3425
+ truncated = true;
3426
+ break;
3427
+ }
3428
+ }
3429
+ if (found) {
3430
+ const path = [];
3431
+ const edges = [];
3432
+ let current = toSym.id;
3433
+ while (current !== fromSym.id) {
3434
+ const info = parent.get(current);
3435
+ path.unshift({ name: info.name, filePath: info.filePath, startLine: info.startLine });
3436
+ edges.unshift({ relType: info.edgeType, confidence: info.confidence });
3437
+ current = info.from;
3438
+ }
3439
+ path.unshift({
3440
+ name: fromSym.name,
3441
+ filePath: fromSym.filePath,
3442
+ startLine: fromSym.startLine,
3443
+ });
3444
+ return {
3445
+ status: 'ok',
3446
+ from: { name: fromSym.name, filePath: fromSym.filePath, startLine: fromSym.startLine },
3447
+ to: { name: toSym.name, filePath: toSym.filePath, startLine: toSym.startLine },
3448
+ hopCount: edges.length,
3449
+ hops: path,
3450
+ edges,
3451
+ };
3452
+ }
3453
+ return {
3454
+ status: 'no_path',
3455
+ from: { name: fromSym.name, filePath: fromSym.filePath, startLine: fromSym.startLine },
3456
+ to: { name: toSym.name, filePath: toSym.filePath, startLine: toSym.startLine },
3457
+ furthest: lastReached ? { ...lastReached, depth: reachedDepth } : null,
3458
+ ...(truncated ? { truncated: true } : {}),
3459
+ suggestion: truncated
3460
+ ? 'Search was truncated at a traversal cap before exhausting the graph — a path ' +
3461
+ 'may still exist. Narrow the search (a lower --depth, or trace from a more ' +
3462
+ 'specific symbol), or use gitnexus context <symbol> to inspect connections.'
3463
+ : 'No directed path found. The call chain likely breaks at dynamic dispatch, ' +
3464
+ 'reflection, or an external API boundary. Try gitnexus context <symbol> to see ' +
3465
+ "both symbols' connections, or check if an interface/abstraction bridges them.",
3466
+ };
3467
+ }
3264
3468
  async impact(repo, params) {
3265
3469
  try {
3266
3470
  return await this._impactImpl(repo, params);
package/dist/mcp/tools.js CHANGED
@@ -703,6 +703,45 @@ WHEN TO USE: After changing group.yaml or re-indexing member repos.`,
703
703
  required: ['name'],
704
704
  },
705
705
  },
706
+ {
707
+ name: 'trace',
708
+ description: `Find the shortest directed path between two symbols over call and class-member edges.
709
+
710
+ WHEN TO USE: Debugging "how does A reach B?" — answers in one call what would take 3-8 manual context/impact hops. Shows the exact chain with file:line positions plus a per-hop edge type and confidence.
711
+
712
+ Traverses CALLS edges plus HAS_METHOD (class → member) edges, so a trace can descend from a class into its methods. Each hop's edge type is reported in edges[], so call hops and containment hops remain distinguishable.
713
+
714
+ Returns: ordered hops with file:line, and an aligned edges[] of edge type + confidence. When no path exists, reports the furthest reachable node so you know where the chain breaks (and truncated: true if a traversal cap was hit first).`,
715
+ annotations: READ_ONLY_TOOL_ANNOTATIONS,
716
+ inputSchema: {
717
+ type: 'object',
718
+ properties: {
719
+ from: { type: 'string', description: 'Source symbol name' },
720
+ from_uid: { type: 'string', description: 'Source symbol UID (zero-ambiguity)' },
721
+ from_file: { type: 'string', description: 'Source file path hint for disambiguation' },
722
+ to: { type: 'string', description: 'Target symbol name' },
723
+ to_uid: { type: 'string', description: 'Target symbol UID (zero-ambiguity)' },
724
+ to_file: { type: 'string', description: 'Target file path hint for disambiguation' },
725
+ maxDepth: {
726
+ type: 'number',
727
+ description: 'Maximum path length in hops (default: 10)',
728
+ default: 10,
729
+ minimum: 1,
730
+ maximum: 30,
731
+ },
732
+ includeTests: {
733
+ type: 'boolean',
734
+ description: 'Include test-file symbols in traversal (default: false)',
735
+ default: false,
736
+ },
737
+ repo: {
738
+ type: 'string',
739
+ description: 'Repository name or path. Omit if only one repo is indexed.',
740
+ },
741
+ },
742
+ required: [],
743
+ },
744
+ },
706
745
  ];
707
746
  /**
708
747
  * Per-repo tools that accept an optional `branch` scope (#2106). Single source
@@ -725,6 +764,7 @@ const BRANCH_SCOPED_TOOLS = new Set([
725
764
  'tool_map',
726
765
  'shape_check',
727
766
  'api_impact',
767
+ 'trace',
728
768
  ]);
729
769
  for (const tool of GITNEXUS_TOOLS) {
730
770
  if (!BRANCH_SCOPED_TOOLS.has(tool.name))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitnexus",
3
- "version": "1.6.8-rc.34",
3
+ "version": "1.6.8-rc.35",
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",
@@ -45,6 +45,7 @@ description: "Use when the user is debugging a bug, tracing an error, or asking
45
45
  | Intermittent failure | `context` → look for external calls, async deps |
46
46
  | Performance issue | `context` → find symbols with many callers (hot paths) |
47
47
  | Recent regression | `detect_changes` to see what your changes affect |
48
+ | "How does A reach B?" | `trace` between the two symbols — shortest call chain in one call |
48
49
 
49
50
  ## Tools
50
51
 
@@ -72,6 +73,17 @@ MATCH path = (a)-[:CodeRelation {type: 'CALLS'}*1..2]->(b:Function {name: "valid
72
73
  RETURN [n IN nodes(path) | n.name] AS chain
73
74
  ```
74
75
 
76
+ **trace** — shortest call chain between two symbols ("how does A reach B?"), one call instead of chaining `context` hops:
77
+
78
+ ```
79
+ trace({ from: "processCheckout", to: "fetchRates" })
80
+ → status: ok, hopCount: 3
81
+ → hops: processCheckout → validatePayment → verifyCard → fetchRates
82
+ → edges: CALLS (1.0), CALLS (0.95), CALLS (1.0)
83
+ ```
84
+
85
+ When no path exists, `trace` reports the furthest reachable node — exactly where the chain breaks (dynamic dispatch, reflection, or an external boundary).
86
+
75
87
  ## Example: "Payment endpoint returns 500 intermittently"
76
88
 
77
89
  ```
@@ -35,6 +35,7 @@ For any task involving code understanding, debugging, impact analysis, or refact
35
35
  | `query` | Process-grouped code intelligence — execution flows related to a concept |
36
36
  | `context` | 360-degree symbol view — categorized refs, processes it participates in |
37
37
  | `impact` | Symbol blast radius — what breaks at depth 1/2/3 with confidence |
38
+ | `trace` | Shortest path between two symbols — "how does A reach B?" in one call |
38
39
  | `detect_changes` | Git-diff impact — what do your current changes affect |
39
40
  | `rename` | Multi-file coordinated rename with confidence-tagged edits |
40
41
  | `cypher` | Raw graph queries (read `gitnexus://repo/{name}/schema` first) |
@@ -93,6 +94,16 @@ A repo indexed without `--pdg` returns a clear "no taint layer" note. Caveats: f
93
94
 
94
95
  A repo indexed without `--pdg` returns a "no PDG layer" note (or "status unknown" when the layer can't be confirmed). Intra-procedural only — cross-function flow is taint's domain (`explain`). The raw CDG/REACHING_DEF edges are also queryable via `cypher`. See the `gitnexus-pdg-query` skill for the full query surface.
95
96
 
97
+ ### Shortest path between two symbols (`trace`)
98
+
99
+ `trace` answers "how does A reach B?" in one call — the shortest directed path over `CALLS` (plus `HAS_METHOD`, so a class-rooted trace descends into its methods) instead of chaining 3–8 `context`/`impact` hops by hand.
100
+
101
+ - `trace { from: "validateUser", to: "executeQuery" }` — shortest path between two symbols.
102
+ - Disambiguate common names with `from_uid`/`to_uid` (zero-ambiguity) or `from_file`/`to_file`; an ambiguous name returns ranked candidates.
103
+ - `maxDepth` (default 10, max 30) bounds the search; `includeTests` (default false) lets the traversal pass through test-file symbols.
104
+
105
+ Returns ordered `hops` (each `{ name, filePath, startLine }`) and an aligned `edges[]` of `{ relType, confidence }`, so call hops and containment (`HAS_METHOD`) hops stay distinguishable. When no path exists it reports the **furthest** reachable node (where the chain breaks) and sets `truncated: true` if a traversal cap was hit first. Every result carries a `status`: `ok` / `no_path` / `ambiguous` / `not_found` / `error`.
106
+
96
107
  ## Resources Reference
97
108
 
98
109
  Lightweight reads (~100-500 tokens) for navigation: