fix(trace): add file disambiguator alias (#2705)

This commit is contained in:
azizur100389 2026-07-27 05:05:14 +01:00 • committed by GitHub
parent 9a351fb319
commit 1f500fed02
9 changed files with 117 additions and 3 deletions

View file

@ -60,7 +60,7 @@ export const en = {
'tool.usage.impact':
'Usage: gitnexus impact <symbol_name> [--uid <uid>] [--file <path>] [--kind <kind>] [--direction upstream|downstream]',
'tool.usage.trace':
'Usage: gitnexus trace <from> <to> [--from-uid <uid>] [--to-uid <uid>] [--depth <n>]',
'Usage: gitnexus trace <from> <to> [-f|--file <path>] [--from-file <path>] [--to-file <path>] [--from-uid <uid>] [--to-uid <uid>] [--depth <n>]',
'tool.usage.cypher': 'Usage: gitnexus cypher <cypher_query>',
'tool.warn.unknownKind':
"--kind '{{kind}}' is not a known symbol kind (e.g. Function, Class, Method); it will not narrow the result.",

View file

@ -64,7 +64,7 @@ export const zhCN = {
'tool.usage.impact':
'用法:gitnexus impact <符号名> [--uid <uid>] [--file <路径>] [--kind <类型>] [--direction upstream|downstream]',
'tool.usage.trace':
'用法:gitnexus trace <起点> <终点> [--from-uid <uid>] [--to-uid <uid>] [--depth <n>]',
'用法:gitnexus trace <起点> <终点> [-f|--file <路径>] [--from-file <路径>] [--to-file <路径>] [--from-uid <uid>] [--to-uid <uid>] [--depth <n>]',
'tool.usage.cypher': '用法:gitnexus cypher <Cypher 查询>',
'tool.warn.unknownKind':
"--kind '{{kind}}' 不是已知的符号类型(如 Function、Class、Method),不会用于缩小结果范围。",

View file

@ -414,6 +414,7 @@ program
.command('trace <from> <to>')
.description('Find the shortest directed path between two symbols (call + class-member edges)')
.option('--from-uid <uid>', 'Source symbol UID (zero-ambiguity)')
.option('-f, --file <path>', 'Source file path hint (alias for --from-file)')
.option('--from-file <path>', 'Source file path hint')
.option('--to-uid <uid>', 'Target symbol UID (zero-ambiguity)')
.option('--to-file <path>', 'Target file path hint')

View file

@ -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,

View file

@ -161,6 +161,7 @@ interface StringAliasDefinition {
const TOOL_STRING_ALIASES: Readonly<Record<string, readonly StringAliasDefinition[]>> = {
impact: [{ canonical: 'target', aliases: ['name', 'symbol'] }],
context: [{ canonical: 'file_path', aliases: ['file'] }],
trace: [{ canonical: 'from_file', aliases: ['file'] }],
};
function normalizeToolParams(

View file

@ -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',

View file

@ -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');

View file

@ -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 ──────────────────────────────────────────────

View file

@ -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;
}
});
});