From 1f500fed02de4903898004573185da62d64d5e41 Mon Sep 17 00:00:00 2001 From: azizur100389 Date: Mon, 27 Jul 2026 05:05:14 +0100 Subject: [PATCH] fix(trace): add file disambiguator alias (#2705) --- gitnexus/src/cli/i18n/en.ts | 2 +- gitnexus/src/cli/i18n/zh-CN.ts | 2 +- gitnexus/src/cli/index.ts | 1 + gitnexus/src/cli/tool.ts | 12 ++++- gitnexus/src/mcp/local/local-backend.ts | 1 + gitnexus/src/mcp/tools.ts | 4 ++ gitnexus/test/unit/tools.test.ts | 7 +++ gitnexus/test/unit/trace-bfs.test.ts | 27 +++++++++++ gitnexus/test/unit/trace-cli.test.ts | 64 +++++++++++++++++++++++++ 9 files changed, 117 insertions(+), 3 deletions(-) diff --git a/gitnexus/src/cli/i18n/en.ts b/gitnexus/src/cli/i18n/en.ts index 37811cc9e6..566dc6d875 100644 --- a/gitnexus/src/cli/i18n/en.ts +++ b/gitnexus/src/cli/i18n/en.ts @@ -60,7 +60,7 @@ export const en = { 'tool.usage.impact': 'Usage: gitnexus impact [--uid ] [--file ] [--kind ] [--direction upstream|downstream]', 'tool.usage.trace': - 'Usage: gitnexus trace [--from-uid ] [--to-uid ] [--depth ]', + 'Usage: gitnexus trace [-f|--file ] [--from-file ] [--to-file ] [--from-uid ] [--to-uid ] [--depth ]', 'tool.usage.cypher': 'Usage: gitnexus cypher ', 'tool.warn.unknownKind': "--kind '{{kind}}' is not a known symbol kind (e.g. Function, Class, Method); it will not narrow the result.", diff --git a/gitnexus/src/cli/i18n/zh-CN.ts b/gitnexus/src/cli/i18n/zh-CN.ts index d411764074..0c99d37d95 100644 --- a/gitnexus/src/cli/i18n/zh-CN.ts +++ b/gitnexus/src/cli/i18n/zh-CN.ts @@ -64,7 +64,7 @@ export const zhCN = { 'tool.usage.impact': '用法:gitnexus impact <符号名> [--uid ] [--file <路径>] [--kind <类型>] [--direction upstream|downstream]', 'tool.usage.trace': - '用法:gitnexus trace <起点> <终点> [--from-uid ] [--to-uid ] [--depth ]', + '用法:gitnexus trace <起点> <终点> [-f|--file <路径>] [--from-file <路径>] [--to-file <路径>] [--from-uid ] [--to-uid ] [--depth ]', 'tool.usage.cypher': '用法:gitnexus cypher ', 'tool.warn.unknownKind': "--kind '{{kind}}' 不是已知的符号类型(如 Function、Class、Method),不会用于缩小结果范围。", diff --git a/gitnexus/src/cli/index.ts b/gitnexus/src/cli/index.ts index 0ba1c55480..41aad4d6af 100644 --- a/gitnexus/src/cli/index.ts +++ b/gitnexus/src/cli/index.ts @@ -414,6 +414,7 @@ program .command('trace ') .description('Find the shortest directed path between two symbols (call + class-member edges)') .option('--from-uid ', 'Source symbol UID (zero-ambiguity)') + .option('-f, --file ', 'Source file path hint (alias for --from-file)') .option('--from-file ', 'Source file path hint') .option('--to-uid ', 'Target symbol UID (zero-ambiguity)') .option('--to-file ', 'Target file path hint') diff --git a/gitnexus/src/cli/tool.ts b/gitnexus/src/cli/tool.ts index 0605714315..36a45651a7 100644 --- a/gitnexus/src/cli/tool.ts +++ b/gitnexus/src/cli/tool.ts @@ -385,6 +385,7 @@ export async function traceCommand( to?: string, options?: { fromUid?: string; + file?: string; fromFile?: string; toUid?: string; toFile?: string; @@ -398,6 +399,14 @@ export async function traceCommand( cliErrorKey('tool.usage.trace'); process.exit(1); } + if ( + options?.file !== undefined && + options?.fromFile !== undefined && + options.file !== options.fromFile + ) { + cliErrorKey('tool.usage.trace'); + process.exit(1); + } if ((!from?.trim() && !options?.fromUid) || (!to?.trim() && !options?.toUid)) { cliErrorKey('tool.usage.trace'); process.exit(1); @@ -414,10 +423,11 @@ export async function traceCommand( try { const backend = await getBackend(); + const fromFile = options?.fromFile ?? options?.file; const result = await backend.callTool('trace', { from: from || undefined, from_uid: options?.fromUid, - from_file: options?.fromFile, + from_file: fromFile, to: to || undefined, to_uid: options?.toUid, to_file: options?.toFile, diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index a727e5ee36..ad9caeb47e 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -161,6 +161,7 @@ interface StringAliasDefinition { const TOOL_STRING_ALIASES: Readonly> = { impact: [{ canonical: 'target', aliases: ['name', 'symbol'] }], context: [{ canonical: 'file_path', aliases: ['file'] }], + trace: [{ canonical: 'from_file', aliases: ['file'] }], }; function normalizeToolParams( diff --git a/gitnexus/src/mcp/tools.ts b/gitnexus/src/mcp/tools.ts index 1d634d2063..6be6e7776c 100644 --- a/gitnexus/src/mcp/tools.ts +++ b/gitnexus/src/mcp/tools.ts @@ -846,6 +846,10 @@ DESTINATION TRACE (cross-repo): for an "@groupName" trace, OMIT to/to_uid/to_fil properties: { from: { type: 'string', description: 'Source symbol name' }, from_uid: { type: 'string', description: 'Source symbol UID (zero-ambiguity)' }, + file: { + type: 'string', + description: 'Source file path hint for disambiguation (alias for from_file)', + }, from_file: { type: 'string', description: 'Source file path hint for disambiguation' }, to: { type: 'string', diff --git a/gitnexus/test/unit/tools.test.ts b/gitnexus/test/unit/tools.test.ts index 3681016285..8c6c040719 100644 --- a/gitnexus/test/unit/tools.test.ts +++ b/gitnexus/test/unit/tools.test.ts @@ -134,6 +134,13 @@ describe('GITNEXUS_TOOLS', () => { expect(contextTool.inputSchema.properties.file).toMatchObject({ type: 'string' }); }); + it('trace tool advertises file as a compatibility alias for from_file', () => { + const traceTool = GITNEXUS_TOOLS.find((t) => t.name === 'trace')!; + expect(traceTool.inputSchema.properties.from_file).toBeDefined(); + expect(traceTool.inputSchema.properties.file).toMatchObject({ type: 'string' }); + expect(traceTool.inputSchema.properties.file.description).toContain('from_file'); + }); + it('api_impact tool avoids top-level schema combinators for Bedrock compatibility (#2487)', () => { const apiImpactTool = GITNEXUS_TOOLS.find((t) => t.name === 'api_impact')!; expect(apiImpactTool.inputSchema).not.toHaveProperty('anyOf'); diff --git a/gitnexus/test/unit/trace-bfs.test.ts b/gitnexus/test/unit/trace-bfs.test.ts index 1f3a97bafe..6609be4bea 100644 --- a/gitnexus/test/unit/trace-bfs.test.ts +++ b/gitnexus/test/unit/trace-bfs.test.ts @@ -201,6 +201,33 @@ describe('trace: dispatch', () => { expect(result.role).toBe('to'); expect(result.candidates).toHaveLength(2); }); + + it('accepts file as an MCP alias for from_file', async () => { + (executeParameterized as any).mockImplementation( + makeResolveMock([SYMBOL_A], [SYMBOL_B], { [SYMBOL_A.id]: [] }), + ); + + await backend.callTool('trace', { from: 'A', to: 'B', file: 'src/a.ts' }); + + expect((executeParameterized as any).mock.calls[0][2]).toMatchObject({ + symName: 'A', + filePath: 'src/a.ts', + }); + }); + + it('rejects conflicting file and from_file MCP aliases', async () => { + const result = await backend.callTool('trace', { + from: 'A', + to: 'B', + file: 'src/a.ts', + from_file: 'src/other.ts', + }); + + expect(result).toEqual({ + error: 'Conflicting MCP parameters for trace.from_file: from_file, file must agree.', + }); + expect(executeParameterized).not.toHaveBeenCalled(); + }); }); // ─── Group 2: BFS Core ────────────────────────────────────────────── diff --git a/gitnexus/test/unit/trace-cli.test.ts b/gitnexus/test/unit/trace-cli.test.ts index d0fdfe91be..2730f28e0d 100644 --- a/gitnexus/test/unit/trace-cli.test.ts +++ b/gitnexus/test/unit/trace-cli.test.ts @@ -21,6 +21,10 @@ vi.mock('../../src/mcp/local/local-backend.js', () => ({ vi.mock('node:fs', () => ({ writeSync: vi.fn() })); +vi.mock('../../src/core/lbug/native-check.js', () => ({ + checkLbugNative: () => ({ ok: true }), +})); + import { traceCommand } from '../../src/cli/tool.js'; describe('CLI trace command', () => { @@ -72,6 +76,45 @@ describe('CLI trace command', () => { ); }); + it('forwards -f/--file as the source file hint', async () => { + await traceCommand('A', 'B', { + file: 'src/a.ts', + }); + + expect(callTool).toHaveBeenCalledWith( + 'trace', + expect.objectContaining({ + from_file: 'src/a.ts', + }), + ); + }); + + it('accepts matching --file and --from-file values', async () => { + await traceCommand('A', 'B', { + file: 'src/a.ts', + fromFile: 'src/a.ts', + }); + + expect(callTool).toHaveBeenCalledWith( + 'trace', + expect.objectContaining({ + from_file: 'src/a.ts', + }), + ); + }); + + it('exits with usage when --file and --from-file disagree', async () => { + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit'); + }); + await expect( + traceCommand('A', 'B', { file: 'src/a.ts', fromFile: 'src/other.ts' }), + ).rejects.toThrow('process.exit'); + expect(exitSpy).toHaveBeenCalledWith(1); + expect(callTool).not.toHaveBeenCalled(); + exitSpy.mockRestore(); + }); + it('forwards --depth as maxDepth', async () => { await traceCommand('A', 'B', { depth: '5' }); @@ -120,4 +163,25 @@ describe('CLI trace command', () => { expect(callTool).toHaveBeenCalledWith('trace', expect.objectContaining({ includeTests: true })); }); + + it('parses -f through the real CLI dispatcher', async () => { + const originalArgv = process.argv; + process.argv = ['node', 'gitnexus', 'trace', 'A', 'B', '-f', 'src/a.ts']; + try { + vi.resetModules(); + await import('../../src/cli/index.js'); + await vi.waitFor(() => { + expect(callTool).toHaveBeenCalledWith( + 'trace', + expect.objectContaining({ + from: 'A', + to: 'B', + from_file: 'src/a.ts', + }), + ); + }); + } finally { + process.argv = originalArgv; + } + }); });