diff --git a/packages/cli/src/ui/hooks/atCommandProcessor.test.ts b/packages/cli/src/ui/hooks/atCommandProcessor.test.ts index dd91822e28..bb301f7039 100644 --- a/packages/cli/src/ui/hooks/atCommandProcessor.test.ts +++ b/packages/cli/src/ui/hooks/atCommandProcessor.test.ts @@ -1553,7 +1553,38 @@ describe('handleAtCommand', () => { // Malformed path should be skipped and original query part preserved as text expect(result.processedQuery).toEqual([{ text: query }]); expect(mockOnDebugMessage).toHaveBeenCalledWith( - expect.stringContaining('Skipping invalid path in @-command'), + expect.stringContaining( + 'Identified invalid path fragment, attempting to extract path', + ), + ); + }); + + it('should recover a buried path from a malformed fragment during handleAtCommand', async () => { + const buriedFile = 'src/recovered.ts'; + await createTestFile( + path.join(testRootDir, buriedFile), + 'Recovered content', + ); + const malformedFragment = `"FAIL ${buriedFile}:10:5 (AssertionError)"`; + const query = `@${malformedFragment}`; + + const result = await handleAtCommand({ + query, + config: mockConfig, + addItem: mockAddItem, + onDebugMessage: mockOnDebugMessage, + messageId: 703, + signal: abortController.signal, + }); + + // It should extract src/recovered.ts and attach its content + expect(result.processedQuery).toContainEqual( + expect.objectContaining({ text: 'Recovered content' }), + ); + expect(mockOnDebugMessage).toHaveBeenCalledWith( + expect.stringContaining( + 'Identified invalid path fragment, attempting to extract path', + ), ); }); }); diff --git a/packages/cli/src/utils/atCommandUtils.test.ts b/packages/cli/src/utils/atCommandUtils.test.ts index 015b5dd7d9..ce831de0da 100644 --- a/packages/cli/src/utils/atCommandUtils.test.ts +++ b/packages/cli/src/utils/atCommandUtils.test.ts @@ -275,6 +275,109 @@ describe('atCommandUtils', () => { } }); + describe('Best-Effort Path Extraction (tryExtractPath)', () => { + const mockFile = 'src/index.ts'; + const absMockFile = path.resolve('/mock/root', mockFile); + const mockStats = { isDirectory: () => false, isFile: () => true }; + + beforeEach(() => { + vi.mocked(fsPromises.stat).mockImplementation(async (p) => { + if (p === absMockFile) return mockStats as unknown as Stats; + throw new Error('ENOENT'); + }); + }); + + it('should extract path from "AssertionError: ..." format', async () => { + const result = await resolveAtCommandPath( + `AssertionError: expected something but got something else at ${mockFile}:10:5`, + mockConfig as unknown as Config, + ); + expect(result.status).toBe('resolved'); + if (result.status === 'resolved') { + expect(result.resolved.absolutePath).toBe(absMockFile); + } + }); + + it('should extract path wrapped in parentheses', async () => { + const result = await resolveAtCommandPath( + `FAIL (${mockFile})`, + mockConfig as unknown as Config, + ); + expect(result.status).toBe('resolved'); + if (result.status === 'resolved') { + expect(result.resolved.absolutePath).toBe(absMockFile); + } + }); + + it('should extract path wrapped in square brackets', async () => { + const result = await resolveAtCommandPath( + `FAIL [${mockFile}]`, + mockConfig as unknown as Config, + ); + expect(result.status).toBe('resolved'); + if (result.status === 'resolved') { + expect(result.resolved.absolutePath).toBe(absMockFile); + } + }); + + it('should extract path from "✓" pass marker', async () => { + const result = await resolveAtCommandPath( + `✓ ${mockFile}`, + mockConfig as unknown as Config, + ); + expect(result.status).toBe('resolved'); + }); + + it('should extract path from "×" fail marker', async () => { + const result = await resolveAtCommandPath( + `× ${mockFile}`, + mockConfig as unknown as Config, + ); + expect(result.status).toBe('resolved'); + }); + + it('should handle paths with slashes and extensions correctly', async () => { + const complexPath = 'packages/core/src/utils/deep.test.ts'; + const absComplexPath = path.resolve('/mock/root', complexPath); + vi.mocked(fsPromises.stat).mockImplementation(async (p) => { + if (p === absComplexPath) return mockStats as unknown as Stats; + throw new Error('ENOENT'); + }); + + const result = await resolveAtCommandPath( + `FAIL ${complexPath}:123`, + mockConfig as unknown as Config, + ); + expect(result.status).toBe('resolved'); + if (result.status === 'resolved') { + expect(result.resolved.relativePath).toBe(complexPath); + } + }); + + it('should fail gracefully if no valid path can be extracted', async () => { + const result = await resolveAtCommandPath( + 'FAIL some random text with no slashes or dots', + mockConfig as unknown as Config, + ); + expect(result.status).toBe('invalid'); + }); + + it('should return unauthorized if the extracted path is not authorized', async () => { + const secretFile = '/etc/passwd'; + (mockConfig['validatePathAccess'] as Mock).mockImplementation((p) => p === secretFile ? 'Unauthorized' : null); + vi.mocked(fsPromises.stat).mockResolvedValue( + mockStats as unknown as Stats, + ); + + const result = await resolveAtCommandPath( + `FAIL ${secretFile}`, + mockConfig as unknown as Config, + ); + // It should try to resolve /etc/passwd, identify it as unauthorized, and return that status. + expect(result.status).toBe('unauthorized'); + }); + }); + it('should include reason in debug message for unauthorized paths', async () => { const onDebug = vi.fn(); (mockConfig['validatePathAccess'] as Mock).mockReturnValue( diff --git a/packages/cli/src/utils/atCommandUtils.ts b/packages/cli/src/utils/atCommandUtils.ts index 9b6d9a4fcc..b3de60daba 100644 --- a/packages/cli/src/utils/atCommandUtils.ts +++ b/packages/cli/src/utils/atCommandUtils.ts @@ -37,20 +37,14 @@ export async function resolveAtCommandPath( ): Promise { const pathValidation = validatePath(pathName); if (!pathValidation.isValid) { - // If it's a log fragment, try to extract a real path from it - if ( - pathValidation.error === - 'Path appears to be a misinterpreted log fragment.' - ) { - const extractedPath = tryExtractPath(pathName); - if (extractedPath && extractedPath !== pathName) { - onDebugMessage( - `Identified log fragment, attempting to extract path: "${extractedPath}" from "${pathName}"`, - ); - // Recurse once with the extracted path. - // We pass a dummy onDebugMessage to avoid double logging the "invalid" reason if it fails. - return resolveAtCommandPath(extractedPath, config); - } + // Attempt to extract a real path from the invalid fragment + const extractedPath = tryExtractPath(pathName); + if (extractedPath && extractedPath !== pathName) { + onDebugMessage( + `Identified invalid path fragment, attempting to extract path: "${extractedPath}" from "${pathName}"`, + ); + // Recurse once with the extracted path. + return resolveAtCommandPath(extractedPath, config, onDebugMessage); } onDebugMessage( @@ -147,11 +141,11 @@ function tryExtractPath(noisyString: string): string | null { const segments = noisyString.split(/\s+/); for (const segment of segments) { - // 1. Strip leading/trailing punctuation commonly found in logs (commas, parens, etc.) + // 1. Strip leading/trailing punctuation and quotes commonly found in logs // 2. Strip trailing line/column numbers (e.g. src/main.ts:10:5) const cleanSegment = segment - .replace(/^[(),;[\]]/, '') - .replace(/[(),;[\]]$/, '') + .replace(/^[(),;[\]"']/, '') + .replace(/[(),;[\]"']$/, '') .replace(/:\d+(?::\d+)?$/, ''); if (cleanSegment.length === 0) continue;