basant307/AI_Governance_Project
048
1/**2 * @license3 * Copyright 2025 Qwen Team4 * SPDX-License-Identifier: Apache-2.05 */6 7import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';8import path from 'node:path';9import { pathToFileURL } from 'node:url';10import type { Config } from '../config/config.js';11import type {12 LspCallHierarchyIncomingCall,13 LspCallHierarchyItem,14 LspCallHierarchyOutgoingCall,15 LspClient,16 LspDefinition,17 LspHoverResult,18 LspLocation,19 LspReference,20 LspSymbolInformation,21} from '../lsp/types.js';22import { LspTool, type LspToolParams, type LspOperation } from './lsp.js';23 24const abortSignal = new AbortController().signal;25const workspaceRoot = '/test/workspace';26 27/**28 * Helper to resolve a path relative to workspace root.29 */30const resolvePath = (...segments: string[]) =>31 path.join(workspaceRoot, ...segments);32 33/**34 * Helper to convert file path to URI.35 */36const toUri = (filePath: string) => pathToFileURL(filePath).toString();37 38/**39 * Helper to create a mock LspLocation.40 */41const createLocation = (42 filePath: string,43 line: number,44 character: number,45): LspLocation => ({46 uri: toUri(filePath),47 range: {48 start: { line, character },49 end: { line, character },50 },51});52 53/**54 * Create a mock LspClient with all methods mocked.55 */56const createMockClient = (): LspClient =>57 ({58 workspaceSymbols: vi.fn().mockResolvedValue([]),59 hover: vi.fn().mockResolvedValue(null),60 documentSymbols: vi.fn().mockResolvedValue([]),61 definitions: vi.fn().mockResolvedValue([]),62 implementations: vi.fn().mockResolvedValue([]),63 references: vi.fn().mockResolvedValue([]),64 prepareCallHierarchy: vi.fn().mockResolvedValue([]),65 incomingCalls: vi.fn().mockResolvedValue([]),66 outgoingCalls: vi.fn().mockResolvedValue([]),67 }) as unknown as LspClient;68 69/**70 * Create a mock Config for testing.71 */72const createMockConfig = (client?: LspClient, enabled = true): Config =>73 ({74 getLspClient: () => client,75 isLspEnabled: () => enabled,76 getProjectRoot: () => workspaceRoot,77 }) as unknown as Config;78 79/**80 * Create a LspTool with mock config.81 */82const createTool = (client?: LspClient, enabled = true) =>83 new LspTool(createMockConfig(client, enabled));84 85describe('LspTool', () => {86 describe('validateToolParams', () => {87 let tool: LspTool;88 89 beforeEach(() => {90 tool = createTool();91 });92 93 describe('location-based operations', () => {94 const locationOperations: LspOperation[] = [95 'goToDefinition',96 'findReferences',97 'hover',98 'goToImplementation',99 'prepareCallHierarchy',100 ];101 102 it.each(locationOperations)(103 'requires filePath for %s operation',104 (operation) => {105 const result = tool.validateToolParams({106 operation,107 } as LspToolParams);108 expect(result).toBe(`filePath is required for ${operation}.`);109 },110 );111 112 it.each(locationOperations)(113 'requires line for %s operation',114 (operation) => {115 const result = tool.validateToolParams({116 operation,117 filePath: 'src/app.ts',118 } as LspToolParams);119 expect(result).toBe(`line is required for ${operation}.`);120 },121 );122 123 it.each(locationOperations)(124 'passes validation with valid params for %s',125 (operation) => {126 const result = tool.validateToolParams({127 operation,128 filePath: 'src/app.ts',129 line: 10,130 character: 5,131 } as LspToolParams);132 expect(result).toBeNull();133 },134 );135 });136 137 describe('documentSymbol operation', () => {138 it('requires filePath for documentSymbol', () => {139 const result = tool.validateToolParams({140 operation: 'documentSymbol',141 } as LspToolParams);142 expect(result).toBe('filePath is required for documentSymbol.');143 });144 145 it('passes validation with filePath', () => {146 const result = tool.validateToolParams({147 operation: 'documentSymbol',148 filePath: 'src/app.ts',149 } as LspToolParams);150 expect(result).toBeNull();151 });152 });153 154 describe('workspaceSymbol operation', () => {155 it('requires query for workspaceSymbol', () => {156 const result = tool.validateToolParams({157 operation: 'workspaceSymbol',158 } as LspToolParams);159 expect(result).toBe('query is required for workspaceSymbol.');160 });161 162 it('rejects empty query', () => {163 const result = tool.validateToolParams({164 operation: 'workspaceSymbol',165 query: ' ',166 } as LspToolParams);167 expect(result).toBe('query is required for workspaceSymbol.');168 });169 170 it('passes validation with query', () => {171 const result = tool.validateToolParams({172 operation: 'workspaceSymbol',173 query: 'Widget',174 } as LspToolParams);175 expect(result).toBeNull();176 });177 });178 179 describe('call hierarchy operations', () => {180 it('requires callHierarchyItem for incomingCalls', () => {181 const result = tool.validateToolParams({182 operation: 'incomingCalls',183 } as LspToolParams);184 expect(result).toBe('callHierarchyItem is required for incomingCalls.');185 });186 187 it('requires callHierarchyItem for outgoingCalls', () => {188 const result = tool.validateToolParams({189 operation: 'outgoingCalls',190 } as LspToolParams);191 expect(result).toBe('callHierarchyItem is required for outgoingCalls.');192 });193 194 it('passes validation with callHierarchyItem', () => {195 const item: LspCallHierarchyItem = {196 name: 'testFunc',197 uri: 'file:///test.ts',198 range: {199 start: { line: 0, character: 0 },200 end: { line: 0, character: 10 },201 },202 selectionRange: {203 start: { line: 0, character: 0 },204 end: { line: 0, character: 10 },205 },206 };207 const result = tool.validateToolParams({208 operation: 'incomingCalls',209 callHierarchyItem: item,210 } as LspToolParams);211 expect(result).toBeNull();212 });213 });214 215 describe('numeric parameter validation', () => {216 it('rejects non-positive line', () => {217 const result = tool.validateToolParams({218 operation: 'goToDefinition',219 filePath: 'src/app.ts',220 line: 0,221 } as LspToolParams);222 expect(result).toBe('line must be a positive number.');223 });224 225 it('rejects negative line', () => {226 const result = tool.validateToolParams({227 operation: 'goToDefinition',228 filePath: 'src/app.ts',229 line: -1,230 } as LspToolParams);231 expect(result).toBe('line must be a positive number.');232 });233 234 it('rejects non-positive character', () => {235 const result = tool.validateToolParams({236 operation: 'goToDefinition',237 filePath: 'src/app.ts',238 line: 1,239 character: 0,240 } as LspToolParams);241 expect(result).toBe('character must be a positive number.');242 });243 244 it('rejects non-positive limit', () => {245 const result = tool.validateToolParams({246 operation: 'documentSymbol',247 filePath: 'src/app.ts',248 limit: 0,249 } as LspToolParams);250 expect(result).toBe('params/limit must be >= 1');251 });252 253 it('rejects negative integer limit', () => {254 const result = tool.validateToolParams({255 operation: 'documentSymbol',256 filePath: 'src/app.ts',257 limit: -1,258 } as LspToolParams);259 expect(result).toBe('params/limit must be >= 1');260 });261 262 it('rejects fractional limit', () => {263 const result = tool.validateToolParams({264 operation: 'documentSymbol',265 filePath: 'src/app.ts',266 limit: 1.5,267 } as LspToolParams);268 expect(result).toBe('params/limit must be integer');269 });270 });271 272 describe('edge case validation', () => {273 it('rejects empty filePath', () => {274 const result = tool.validateToolParams({275 operation: 'goToDefinition',276 filePath: '',277 line: 1,278 } as LspToolParams);279 expect(result).toBe('filePath is required for goToDefinition.');280 });281 282 it('rejects whitespace-only filePath', () => {283 const result = tool.validateToolParams({284 operation: 'goToDefinition',285 filePath: ' ',286 line: 1,287 } as LspToolParams);288 expect(result).toBe('filePath is required for goToDefinition.');289 });290 291 it('rejects whitespace-only query', () => {292 const result = tool.validateToolParams({293 operation: 'workspaceSymbol',294 query: ' \t\n ',295 } as LspToolParams);296 expect(result).toBe('query is required for workspaceSymbol.');297 });298 299 it.skipIf(process.platform === 'win32')(300 'should unescape shell-escaped filePath',301 () => {302 const params: LspToolParams = {303 operation: 'goToDefinition',304 filePath: 'src/app\\ file.ts',305 line: 10,306 character: 5,307 };308 const result = tool.validateToolParams(params);309 expect(result).toBeNull();310 expect(params.filePath).toBe('src/app file.ts');311 },312 );313 });314 });315 316 describe('execute', () => {317 describe('LSP disabled or unavailable', () => {318 it('returns unavailable message when LSP is disabled', async () => {319 const tool = createTool(undefined, false);320 const invocation = tool.build({321 operation: 'hover',322 filePath: 'src/app.ts',323 line: 1,324 character: 1,325 });326 const result = await invocation.execute(abortSignal);327 expect(result.llmContent).toContain('LSP hover is unavailable');328 expect(result.llmContent).toContain('LSP disabled or not initialized');329 });330 331 it('returns unavailable message when no LSP client', async () => {332 const tool = createTool(undefined, true);333 const invocation = tool.build({334 operation: 'goToDefinition',335 filePath: 'src/app.ts',336 line: 1,337 character: 1,338 });339 const result = await invocation.execute(abortSignal);340 // Note: operation labels are formatted (e.g., "go-to-definition")341 expect(result.llmContent).toContain(342 'LSP go-to-definition is unavailable',343 );344 });345 });346 347 describe('goToDefinition operation', () => {348 it('dispatches to definitions and formats results', async () => {349 const client = createMockClient();350 const tool = createTool(client);351 const filePath = resolvePath('src', 'app.ts');352 const definition: LspDefinition = {353 ...createLocation(filePath, 10, 5),354 serverName: 'tsserver',355 };356 (client.definitions as Mock).mockResolvedValue([definition]);357 358 const invocation = tool.build({359 operation: 'goToDefinition',360 filePath: 'src/app.ts',361 line: 5,362 character: 10,363 });364 const result = await invocation.execute(abortSignal);365 366 expect(client.definitions).toHaveBeenCalledWith(367 expect.objectContaining({368 uri: toUri(filePath),369 range: expect.objectContaining({370 start: { line: 4, character: 9 }, // 1-based to 0-based conversion371 }),372 }),373 undefined,374 20,375 );376 expect(result.llmContent).toContain('Definitions for');377 expect(result.llmContent).toContain('1.');378 });379 380 it('handles empty results', async () => {381 const client = createMockClient();382 const tool = createTool(client);383 (client.definitions as Mock).mockResolvedValue([]);384 385 const invocation = tool.build({386 operation: 'goToDefinition',387 filePath: 'src/app.ts',388 line: 5,389 character: 10,390 });391 const result = await invocation.execute(abortSignal);392 393 expect(result.llmContent).toContain('No definitions found');394 });395 });396 397 describe('findReferences operation', () => {398 it('dispatches to references and formats results', async () => {399 const client = createMockClient();400 const tool = createTool(client);401 const filePath = resolvePath('src', 'app.ts');402 const refs: LspReference[] = [403 { ...createLocation(filePath, 10, 5), serverName: 'tsserver' },404 { ...createLocation(filePath, 20, 8) },405 ];406 (client.references as Mock).mockResolvedValue(refs);407 408 const invocation = tool.build({409 operation: 'findReferences',410 filePath: 'src/app.ts',411 line: 5,412 character: 10,413 includeDeclaration: true,414 });415 const result = await invocation.execute(abortSignal);416 417 // Default limit for references is 50418 expect(client.references).toHaveBeenCalledWith(419 expect.objectContaining({ uri: toUri(filePath) }),420 undefined,421 true,422 50,423 );424 expect(result.llmContent).toContain('References for');425 expect(result.llmContent).toContain('1.');426 expect(result.llmContent).toContain('2.');427 });428 });429 430 describe('hover operation', () => {431 it('dispatches to hover and formats results', async () => {432 const client = createMockClient();433 const tool = createTool(client);434 const hoverResult: LspHoverResult = {435 contents: '**Type**: string\n\nA sample variable.',436 };437 (client.hover as Mock).mockResolvedValue(hoverResult);438 439 const invocation = tool.build({440 operation: 'hover',441 filePath: 'src/app.ts',442 line: 10,443 character: 5,444 });445 const result = await invocation.execute(abortSignal);446 447 expect(client.hover).toHaveBeenCalled();448 expect(result.llmContent).toContain('Hover for');449 expect(result.llmContent).toContain('Type');450 });451 452 it('handles null hover result', async () => {453 const client = createMockClient();454 const tool = createTool(client);455 (client.hover as Mock).mockResolvedValue(null);456 457 const invocation = tool.build({458 operation: 'hover',459 filePath: 'src/app.ts',460 line: 10,461 character: 5,462 });463 const result = await invocation.execute(abortSignal);464 465 expect(result.llmContent).toContain('No hover information found');466 });467 });468 469 describe('documentSymbol operation', () => {470 it('dispatches to documentSymbols and formats results', async () => {471 const client = createMockClient();472 const tool = createTool(client);473 const filePath = resolvePath('src', 'app.ts');474 const symbols: LspSymbolInformation[] = [475 {476 name: 'MyClass',477 kind: 'Class',478 containerName: 'app',479 location: createLocation(filePath, 5, 0),480 serverName: 'tsserver',481 },482 {483 name: 'myFunction',484 kind: 'Function',485 location: createLocation(filePath, 20, 0),486 },487 ];488 (client.documentSymbols as Mock).mockResolvedValue(symbols);489 490 const invocation = tool.build({491 operation: 'documentSymbol',492 filePath: 'src/app.ts',493 });494 const result = await invocation.execute(abortSignal);495 496 // Default limit for documentSymbols is 50497 expect(client.documentSymbols).toHaveBeenCalledWith(498 toUri(filePath),499 undefined,500 50,501 );502 expect(result.llmContent).toContain('Document symbols for');503 expect(result.llmContent).toContain('MyClass');504 expect(result.llmContent).toContain('myFunction');505 });506 });507 508 describe('workspaceSymbol operation', () => {509 it('dispatches to workspaceSymbols and formats results', async () => {510 const client = createMockClient();511 const tool = createTool(client);512 const filePath = resolvePath('src', 'app.ts');513 const symbols: LspSymbolInformation[] = [514 {515 name: 'Widget',516 kind: 'Class',517 location: createLocation(filePath, 10, 0),518 },519 ];520 (client.workspaceSymbols as Mock).mockResolvedValue(symbols);521 (client.references as Mock).mockResolvedValue([]);522 523 const invocation = tool.build({524 operation: 'workspaceSymbol',525 query: 'Widget',526 limit: 10,527 });528 const result = await invocation.execute(abortSignal);529 530 expect(client.workspaceSymbols).toHaveBeenCalledWith('Widget', 10);531 expect(result.llmContent).toContain('symbols for query "Widget"');532 expect(result.llmContent).toContain('Widget');533 });534 });535 536 describe('goToImplementation operation', () => {537 it('dispatches to implementations and formats results', async () => {538 const client = createMockClient();539 const tool = createTool(client);540 const filePath = resolvePath('src', 'impl.ts');541 const impl: LspDefinition = {542 ...createLocation(filePath, 15, 2),543 serverName: 'tsserver',544 };545 (client.implementations as Mock).mockResolvedValue([impl]);546 547 const invocation = tool.build({548 operation: 'goToImplementation',549 filePath: 'src/interface.ts',550 line: 5,551 character: 10,552 });553 const result = await invocation.execute(abortSignal);554 555 expect(client.implementations).toHaveBeenCalled();556 expect(result.llmContent).toContain('Implementations for');557 });558 });559 560 describe('prepareCallHierarchy operation', () => {561 it('dispatches to prepareCallHierarchy and formats results with JSON', async () => {562 const client = createMockClient();563 const tool = createTool(client);564 const filePath = resolvePath('src', 'app.ts');565 const item: LspCallHierarchyItem = {566 name: 'myFunction',567 kind: 'Function',568 detail: '(param: string)',569 uri: toUri(filePath),570 range: {571 start: { line: 10, character: 0 },572 end: { line: 20, character: 1 },573 },574 selectionRange: {575 start: { line: 10, character: 9 },576 end: { line: 10, character: 19 },577 },578 serverName: 'tsserver',579 };580 (client.prepareCallHierarchy as Mock).mockResolvedValue([item]);581 582 const invocation = tool.build({583 operation: 'prepareCallHierarchy',584 filePath: 'src/app.ts',585 line: 11,586 character: 15,587 });588 const result = await invocation.execute(abortSignal);589 590 expect(client.prepareCallHierarchy).toHaveBeenCalled();591 expect(result.llmContent).toContain('Call hierarchy items for');592 expect(result.llmContent).toContain('myFunction');593 expect(result.llmContent).toContain('Call hierarchy items (JSON):');594 expect(result.llmContent).toContain('"name": "myFunction"');595 });596 });597 598 describe('incomingCalls operation', () => {599 it('dispatches to incomingCalls and formats results', async () => {600 const client = createMockClient();601 const tool = createTool(client);602 const targetPath = resolvePath('src', 'target.ts');603 const callerPath = resolvePath('src', 'caller.ts');604 605 const targetItem: LspCallHierarchyItem = {606 name: 'targetFunc',607 uri: toUri(targetPath),608 range: {609 start: { line: 5, character: 0 },610 end: { line: 10, character: 1 },611 },612 selectionRange: {613 start: { line: 5, character: 9 },614 end: { line: 5, character: 19 },615 },616 serverName: 'tsserver',617 };618 619 const callerItem: LspCallHierarchyItem = {620 name: 'callerFunc',621 kind: 'Function',622 uri: toUri(callerPath),623 range: {624 start: { line: 20, character: 0 },625 end: { line: 30, character: 1 },626 },627 selectionRange: {628 start: { line: 20, character: 9 },629 end: { line: 20, character: 19 },630 },631 };632 633 const incomingCall: LspCallHierarchyIncomingCall = {634 from: callerItem,635 fromRanges: [636 {637 start: { line: 25, character: 4 },638 end: { line: 25, character: 14 },639 },640 ],641 };642 (client.incomingCalls as Mock).mockResolvedValue([incomingCall]);643 644 const invocation = tool.build({645 operation: 'incomingCalls',646 callHierarchyItem: targetItem,647 });648 const result = await invocation.execute(abortSignal);649 650 expect(client.incomingCalls).toHaveBeenCalledWith(651 targetItem,652 'tsserver',653 20,654 );655 expect(result.llmContent).toContain('Incoming calls for targetFunc');656 expect(result.llmContent).toContain('callerFunc');657 expect(result.llmContent).toContain('Incoming calls (JSON):');658 });659 });660 661 describe('outgoingCalls operation', () => {662 it('dispatches to outgoingCalls and formats results', async () => {663 const client = createMockClient();664 const tool = createTool(client);665 const sourcePath = resolvePath('src', 'source.ts');666 const targetPath = resolvePath('src', 'target.ts');667 668 const sourceItem: LspCallHierarchyItem = {669 name: 'sourceFunc',670 uri: toUri(sourcePath),671 range: {672 start: { line: 5, character: 0 },673 end: { line: 15, character: 1 },674 },675 selectionRange: {676 start: { line: 5, character: 9 },677 end: { line: 5, character: 19 },678 },679 };680 681 const targetItem: LspCallHierarchyItem = {682 name: 'targetFunc',683 kind: 'Function',684 uri: toUri(targetPath),685 range: {686 start: { line: 20, character: 0 },687 end: { line: 30, character: 1 },688 },689 selectionRange: {690 start: { line: 20, character: 9 },691 end: { line: 20, character: 19 },692 },693 serverName: 'tsserver',694 };695 696 const outgoingCall: LspCallHierarchyOutgoingCall = {697 to: targetItem,698 fromRanges: [699 {700 start: { line: 10, character: 4 },701 end: { line: 10, character: 14 },702 },703 ],704 };705 (client.outgoingCalls as Mock).mockResolvedValue([outgoingCall]);706 707 const invocation = tool.build({708 operation: 'outgoingCalls',709 callHierarchyItem: sourceItem,710 });711 const result = await invocation.execute(abortSignal);712 713 expect(client.outgoingCalls).toHaveBeenCalled();714 expect(result.llmContent).toContain('Outgoing calls for sourceFunc');715 expect(result.llmContent).toContain('targetFunc');716 expect(result.llmContent).toContain('Outgoing calls (JSON):');717 });718 });719 720 describe('error handling', () => {721 it('handles LSP client errors gracefully', async () => {722 const client = createMockClient();723 const tool = createTool(client);724 (client.definitions as Mock).mockRejectedValue(725 new Error('Connection refused'),726 );727 728 const invocation = tool.build({729 operation: 'goToDefinition',730 filePath: 'src/app.ts',731 line: 5,732 character: 10,733 });734 const result = await invocation.execute(abortSignal);735 736 expect(result.llmContent).toContain('failed');737 expect(result.llmContent).toContain('Connection refused');738 });739 740 it('handles hover operation errors', async () => {741 const client = createMockClient();742 const tool = createTool(client);743 (client.hover as Mock).mockRejectedValue(new Error('Server timeout'));744 745 const invocation = tool.build({746 operation: 'hover',747 filePath: 'src/app.ts',748 line: 5,749 character: 10,750 });751 const result = await invocation.execute(abortSignal);752 753 expect(result.llmContent).toContain('failed');754 expect(result.llmContent).toContain('Server timeout');755 });756 757 it('handles call hierarchy errors', async () => {758 const client = createMockClient();759 const tool = createTool(client);760 (client.prepareCallHierarchy as Mock).mockRejectedValue(761 new Error('Not supported'),762 );763 764 const invocation = tool.build({765 operation: 'prepareCallHierarchy',766 filePath: 'src/app.ts',767 line: 5,768 character: 10,769 });770 const result = await invocation.execute(abortSignal);771 772 expect(result.llmContent).toContain('failed');773 expect(result.llmContent).toContain('Not supported');774 });775 });776 777 describe('workspaceSymbol with references', () => {778 it('fetches references for top match when available', async () => {779 const client = createMockClient();780 const tool = createTool(client);781 const filePath = resolvePath('src', 'app.ts');782 const refPath = resolvePath('src', 'other.ts');783 const symbols: LspSymbolInformation[] = [784 {785 name: 'TopWidget',786 kind: 'Class',787 location: createLocation(filePath, 10, 0),788 serverName: 'tsserver',789 },790 ];791 const references: LspReference[] = [792 { ...createLocation(refPath, 5, 10), serverName: 'tsserver' },793 { ...createLocation(refPath, 20, 5) },794 ];795 (client.workspaceSymbols as Mock).mockResolvedValue(symbols);796 (client.references as Mock).mockResolvedValue(references);797 798 const invocation = tool.build({799 operation: 'workspaceSymbol',800 query: 'TopWidget',801 });802 const result = await invocation.execute(abortSignal);803 804 // Should fetch references for top match805 expect(client.references).toHaveBeenCalledWith(806 symbols[0].location,807 'tsserver',808 false,809 expect.any(Number),810 );811 expect(result.llmContent).toContain('References for top match');812 expect(result.llmContent).toContain('TopWidget');813 });814 815 it('handles reference lookup failure gracefully', async () => {816 const client = createMockClient();817 const tool = createTool(client);818 const filePath = resolvePath('src', 'app.ts');819 const symbols: LspSymbolInformation[] = [820 {821 name: 'Widget',822 kind: 'Class',823 location: createLocation(filePath, 10, 0),824 },825 ];826 (client.workspaceSymbols as Mock).mockResolvedValue(symbols);827 (client.references as Mock).mockRejectedValue(828 new Error('References not supported'),829 );830 831 const invocation = tool.build({832 operation: 'workspaceSymbol',833 query: 'Widget',834 });835 const result = await invocation.execute(abortSignal);836 837 // Should still return symbols even if references fail838 expect(result.llmContent).toContain('Widget');839 expect(result.llmContent).toContain('References lookup failed');840 });841 });842 843 describe('returnDisplay verification', () => {844 it('returns formatted display for definitions', async () => {845 const client = createMockClient();846 const tool = createTool(client);847 const filePath = resolvePath('src', 'app.ts');848 const definition: LspDefinition = {849 ...createLocation(filePath, 10, 5),850 serverName: 'tsserver',851 };852 (client.definitions as Mock).mockResolvedValue([definition]);853 854 const invocation = tool.build({855 operation: 'goToDefinition',856 filePath: 'src/app.ts',857 line: 5,858 character: 10,859 });860 const result = await invocation.execute(abortSignal);861 862 // returnDisplay should be concise (without heading)863 expect(result.returnDisplay).toBeDefined();864 expect(result.returnDisplay).toContain('1.');865 expect(result.returnDisplay).toContain('[tsserver]');866 });867 868 it('returns formatted display for hover with trimmed content', async () => {869 const client = createMockClient();870 const tool = createTool(client);871 const hoverResult: LspHoverResult = {872 contents: ' \n Type: string \n ',873 };874 (client.hover as Mock).mockResolvedValue(hoverResult);875 876 const invocation = tool.build({877 operation: 'hover',878 filePath: 'src/app.ts',879 line: 10,880 character: 5,881 });882 const result = await invocation.execute(abortSignal);883 884 // returnDisplay should be trimmed885 expect(result.returnDisplay).toBe('Type: string');886 });887 });888 889 describe('serverName and limit parameter passing', () => {890 it('passes serverName to client methods', async () => {891 const client = createMockClient();892 const tool = createTool(client);893 (client.definitions as Mock).mockResolvedValue([]);894 895 const invocation = tool.build({896 operation: 'goToDefinition',897 filePath: 'src/app.ts',898 line: 5,899 character: 10,900 serverName: 'pylsp',901 });902 await invocation.execute(abortSignal);903 904 expect(client.definitions).toHaveBeenCalledWith(905 expect.anything(),906 'pylsp',907 expect.any(Number),908 );909 });910 911 it('passes custom limit to client methods', async () => {912 const client = createMockClient();913 const tool = createTool(client);914 (client.definitions as Mock).mockResolvedValue([]);915 916 const invocation = tool.build({917 operation: 'goToDefinition',918 filePath: 'src/app.ts',919 line: 5,920 character: 10,921 limit: 5,922 });923 await invocation.execute(abortSignal);924 925 expect(client.definitions).toHaveBeenCalledWith(926 expect.anything(),927 undefined,928 5,929 );930 });931 });932 });933 934 describe('schema compatibility with Claude Code', () => {935 /**936 * Claude Code LSP tool schema reference:937 * {938 * "name": "lsp",939 * "input_schema": {940 * "type": "object",941 * "properties": {942 * "operation": { "type": "string", "enum": [...] },943 * "filePath": { "type": "string" },944 * "line": { "type": "number" },945 * "character": { "type": "number" },946 * "includeDeclaration": { "type": "boolean" },947 * "query": { "type": "string" },948 * "callHierarchyItem": { ... }949 * },950 * "required": ["operation"]951 * }952 * }953 */954 955 it('has correct tool name', () => {956 const tool = createTool();957 expect(tool.schema.name).toBe('lsp');958 });959 960 it('has operation as only required field', () => {961 const tool = createTool();962 const schema = tool.schema.parametersJsonSchema as {963 required?: string[];964 };965 expect(schema.required).toEqual(['operation']);966 });967 968 it('operation enum matches Claude Code exactly', () => {969 const tool = createTool();970 const schema = tool.schema.parametersJsonSchema as {971 properties?: {972 operation?: {973 enum?: string[];974 };975 };976 };977 const expectedOperations = [978 'goToDefinition',979 'findReferences',980 'hover',981 'documentSymbol',982 'workspaceSymbol',983 'goToImplementation',984 'prepareCallHierarchy',985 'incomingCalls',986 'outgoingCalls',987 'diagnostics',988 'workspaceDiagnostics',989 'codeActions',990 ];991 expect(schema.properties?.operation?.enum).toEqual(expectedOperations);992 });993 994 it('has all Claude Code core properties', () => {995 const tool = createTool();996 const schema = tool.schema.parametersJsonSchema as {997 properties?: Record<string, unknown>;998 };999 const properties = Object.keys(schema.properties ?? {});1000 1001 // Core properties that must match Claude Code1002 const coreProperties = [1003 'operation',1004 'filePath',1005 'line',1006 'character',1007 'includeDeclaration',1008 'query',1009 'callHierarchyItem',1010 ];1011 1012 for (const prop of coreProperties) {1013 expect(properties).toContain(prop);1014 }1015 });1016 1017 it('extension properties are documented', () => {1018 const tool = createTool();1019 const schema = tool.schema.parametersJsonSchema as {1020 properties?: Record<string, unknown>;1021 };1022 const properties = Object.keys(schema.properties ?? {});1023 1024 // Our extensions beyond Claude Code1025 const extensionProperties = [1026 'serverName',1027 'limit',1028 'endLine',1029 'endCharacter',1030 'diagnostics',1031 'codeActionKinds',1032 ];1033 1034 // All properties should be either core or documented extensions1035 const knownProperties = [1036 'operation',1037 'filePath',1038 'line',1039 'character',1040 'includeDeclaration',1041 'query',1042 'callHierarchyItem',1043 ...extensionProperties,1044 ];1045 1046 for (const prop of properties) {1047 expect(knownProperties).toContain(prop);1048 }1049 });1050 1051 it('filePath property has correct type', () => {1052 const tool = createTool();1053 const schema = tool.schema.parametersJsonSchema as {1054 properties?: {1055 filePath?: { type?: string };1056 };1057 };1058 expect(schema.properties?.filePath?.type).toBe('string');1059 });1060 1061 it('line and character properties have correct type', () => {1062 const tool = createTool();1063 const schema = tool.schema.parametersJsonSchema as {1064 properties?: {1065 line?: { type?: string };1066 character?: { type?: string };1067 };1068 };1069 expect(schema.properties?.line?.type).toBe('number');1070 expect(schema.properties?.character?.type).toBe('number');1071 });1072 1073 it('limit extension property has integer type', () => {1074 const tool = createTool();1075 const schema = tool.schema.parametersJsonSchema as {1076 properties?: {1077 limit?: { type?: string; minimum?: number };1078 };1079 };1080 expect(schema.properties?.limit?.type).toBe('integer');1081 expect(schema.properties?.limit?.minimum).toBe(1);1082 });1083 1084 it('includeDeclaration property has correct type', () => {1085 const tool = createTool();1086 const schema = tool.schema.parametersJsonSchema as {1087 properties?: {1088 includeDeclaration?: { type?: string };1089 };1090 };1091 expect(schema.properties?.includeDeclaration?.type).toBe('boolean');1092 });1093 1094 it('callHierarchyItem has required structure', () => {1095 const tool = createTool();1096 const schema = tool.schema.parametersJsonSchema as {1097 definitions?: {1098 LspCallHierarchyItem?: {1099 type?: string;1100 properties?: Record<string, unknown>;1101 required?: string[];1102 };1103 };1104 };1105 const itemDef = schema.definitions?.LspCallHierarchyItem;1106 expect(itemDef?.type).toBe('object');1107 expect(itemDef?.required).toEqual([1108 'name',1109 'uri',1110 'range',1111 'selectionRange',1112 ]);1113 expect(itemDef?.properties).toHaveProperty('name');1114 expect(itemDef?.properties).toHaveProperty('kind');1115 expect(itemDef?.properties).toHaveProperty('uri');1116 expect(itemDef?.properties).toHaveProperty('range');1117 expect(itemDef?.properties).toHaveProperty('selectionRange');1118 });1119 1120 it('supports rawKind for SymbolKind numeric preservation', () => {1121 const tool = createTool();1122 const schema = tool.schema.parametersJsonSchema as {1123 definitions?: {1124 LspCallHierarchyItem?: {1125 properties?: {1126 rawKind?: { type?: string };1127 };1128 };1129 };1130 };1131 const itemDef = schema.definitions?.LspCallHierarchyItem;1132 expect(itemDef?.properties?.rawKind?.type).toBe('number');1133 });1134 1135 describe('schema definitions deep validation', () => {1136 it('has LspPosition definition with correct structure', () => {1137 const tool = createTool();1138 const schema = tool.schema.parametersJsonSchema as {1139 definitions?: {1140 LspPosition?: {1141 type?: string;1142 properties?: {1143 line?: { type?: string };1144 character?: { type?: string };1145 };1146 required?: string[];1147 };1148 };1149 };1150 const posDef = schema.definitions?.LspPosition;1151 expect(posDef).toBeDefined();1152 expect(posDef?.type).toBe('object');1153 expect(posDef?.properties?.line?.type).toBe('number');1154 expect(posDef?.properties?.character?.type).toBe('number');1155 expect(posDef?.required).toEqual(['line', 'character']);1156 });1157 1158 it('has LspRange definition with correct structure', () => {1159 const tool = createTool();1160 const schema = tool.schema.parametersJsonSchema as {1161 definitions?: {1162 LspRange?: {1163 type?: string;1164 properties?: {1165 start?: { $ref?: string };1166 end?: { $ref?: string };1167 };1168 required?: string[];1169 };1170 };1171 };1172 const rangeDef = schema.definitions?.LspRange;1173 expect(rangeDef).toBeDefined();1174 expect(rangeDef?.type).toBe('object');1175 expect(rangeDef?.properties?.start?.$ref).toBe(1176 '#/definitions/LspPosition',1177 );1178 expect(rangeDef?.properties?.end?.$ref).toBe(1179 '#/definitions/LspPosition',1180 );1181 expect(rangeDef?.required).toEqual(['start', 'end']);1182 });1183 1184 it('callHierarchyItem uses $ref for range fields', () => {1185 const tool = createTool();1186 const schema = tool.schema.parametersJsonSchema as {1187 properties?: {1188 callHierarchyItem?: { $ref?: string };1189 };1190 definitions?: {1191 LspCallHierarchyItem?: {1192 properties?: {1193 range?: { $ref?: string };1194 selectionRange?: { $ref?: string };1195 };1196 };1197 };1198 };1199 // callHierarchyItem property should reference the definition1200 expect(schema.properties?.callHierarchyItem?.$ref).toBe(