diff --git a/src/main/http/utility.ts b/src/main/http/utility.ts index b3aa982f..e3839e71 100644 --- a/src/main/http/utility.ts +++ b/src/main/http/utility.ts @@ -111,6 +111,10 @@ export function registerUtilityRoutes(app: FastifyInstance): void { estimatedTokens, }; } catch (error) { + // ENOENT is expected — file simply doesn't exist (e.g. stale or misdetected references) + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return null; + } logger.error( `Error in POST /api/read-mentioned-file for ${request.body.absolutePath}:`, error diff --git a/src/main/ipc/utility.ts b/src/main/ipc/utility.ts index 2ff7642a..182beca9 100644 --- a/src/main/ipc/utility.ts +++ b/src/main/ipc/utility.ts @@ -256,6 +256,10 @@ async function handleReadMentionedFile( estimatedTokens, }; } catch (error) { + // ENOENT is expected — file simply doesn't exist (e.g. stale or misdetected references) + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return null; + } logger.error(`Error in read-mentioned-file for ${absolutePath}:`, error); return null; } diff --git a/src/renderer/utils/groupTransformer.ts b/src/renderer/utils/groupTransformer.ts index 6682fbca..0a975adf 100644 --- a/src/renderer/utils/groupTransformer.ts +++ b/src/renderer/utils/groupTransformer.ts @@ -13,7 +13,12 @@ import { isEnhancedSystemChunk, isEnhancedUserChunk, } from '@renderer/types/data'; -import { getFirstSegment, hasPathSeparator, isRelativePath } from '@renderer/utils/pathUtils'; +import { + getFirstSegment, + hasPathSeparator, + isRelativePath, + splitPathSegments, +} from '@renderer/utils/pathUtils'; import { isCommandContent, sanitizeDisplayContent } from '@shared/utils/contentSanitizer'; import { createLogger } from '@shared/utils/logger'; @@ -369,8 +374,13 @@ function isValidFileRef(path: string): boolean { return true; } // Check if contains a path separator (indicates directory structure) + // Require either 3+ segments or a file extension to avoid matching + // npm scoped packages like @scope/name (extracted as "scope/name") if (hasPathSeparator(path) && path.length > 2) { - return true; + const segments = splitPathSegments(path); + if (segments.length > 2 || /\.[a-zA-Z0-9]+$/.test(path)) { + return true; + } } return false; } diff --git a/test/renderer/utils/fileReferences.test.ts b/test/renderer/utils/fileReferences.test.ts new file mode 100644 index 00000000..ee7e5c0c --- /dev/null +++ b/test/renderer/utils/fileReferences.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, it } from 'vitest'; + +import { extractFileReferences } from '@renderer/utils/groupTransformer'; + +describe('extractFileReferences', () => { + describe('accepts valid file references', () => { + it('accepts paths starting with known directories', () => { + const refs = extractFileReferences('Check @src/components/App.tsx'); + expect(refs).toHaveLength(1); + expect(refs[0].path).toBe('src/components/App.tsx'); + }); + + it('accepts utils/ paths (known dir)', () => { + const refs = extractFileReferences('See @utils/helpers'); + expect(refs).toHaveLength(1); + expect(refs[0].path).toBe('utils/helpers'); + }); + + it('accepts hooks/ paths (known dir)', () => { + const refs = extractFileReferences('Use @hooks/auth'); + expect(refs).toHaveLength(1); + expect(refs[0].path).toBe('hooks/auth'); + }); + + it('accepts lib/ paths (known dir)', () => { + const refs = extractFileReferences('In @lib/core'); + expect(refs).toHaveLength(1); + expect(refs[0].path).toBe('lib/core'); + }); + + it('accepts config/ paths (known dir)', () => { + const refs = extractFileReferences('Edit @config/database'); + expect(refs).toHaveLength(1); + expect(refs[0].path).toBe('config/database'); + }); + + it('accepts relative paths with ./', () => { + const refs = extractFileReferences('See @./src/file.ts'); + expect(refs).toHaveLength(1); + expect(refs[0].path).toBe('./src/file.ts'); + }); + + it('accepts relative paths with ../', () => { + const refs = extractFileReferences('See @../lib/utils.ts'); + expect(refs).toHaveLength(1); + expect(refs[0].path).toBe('../lib/utils.ts'); + }); + + it('accepts unknown-dir paths with 3+ segments', () => { + const refs = extractFileReferences('See @custom/deep/path.ts'); + expect(refs).toHaveLength(1); + expect(refs[0].path).toBe('custom/deep/path.ts'); + }); + + it('accepts unknown-dir paths with file extension', () => { + const refs = extractFileReferences('See @foo/bar.ts'); + expect(refs).toHaveLength(1); + expect(refs[0].path).toBe('foo/bar.ts'); + }); + + it('accepts node_modules paths (known dir)', () => { + const refs = extractFileReferences('Check @node_modules/lodash/index.js'); + expect(refs).toHaveLength(1); + expect(refs[0].path).toBe('node_modules/lodash/index.js'); + }); + + it('accepts types/ paths (known dir)', () => { + const refs = extractFileReferences('See @types/node'); + expect(refs).toHaveLength(1); + expect(refs[0].path).toBe('types/node'); + }); + + it('extracts multiple references from one string', () => { + const refs = extractFileReferences('Compare @src/a.ts and @lib/b.ts'); + expect(refs).toHaveLength(2); + expect(refs[0].path).toBe('src/a.ts'); + expect(refs[1].path).toBe('lib/b.ts'); + }); + + it('accepts tilde paths', () => { + const refs = extractFileReferences('See @~/projects/foo'); + expect(refs).toHaveLength(1); + expect(refs[0].path).toBe('~/projects/foo'); + }); + }); + + describe('rejects npm scoped packages', () => { + it('rejects @babel/core', () => { + const refs = extractFileReferences('Use @babel/core for transpilation'); + expect(refs).toHaveLength(0); + }); + + it('rejects @xyflow/react', () => { + const refs = extractFileReferences('Install @xyflow/react'); + expect(refs).toHaveLength(0); + }); + + it('rejects @dagrejs/dagre', () => { + const refs = extractFileReferences('Use @dagrejs/dagre for layout'); + expect(refs).toHaveLength(0); + }); + + it('rejects @testing-library/react', () => { + const refs = extractFileReferences('Import from @testing-library/react'); + expect(refs).toHaveLength(0); + }); + + it('rejects @vitest/ui', () => { + const refs = extractFileReferences('Run @vitest/ui'); + expect(refs).toHaveLength(0); + }); + + it('rejects @radix-ui/react-dialog', () => { + const refs = extractFileReferences('Use @radix-ui/react-dialog'); + expect(refs).toHaveLength(0); + }); + + it('rejects @codemirror/lang-javascript', () => { + const refs = extractFileReferences('Import @codemirror/lang-javascript'); + expect(refs).toHaveLength(0); + }); + + it('rejects @emotion/react', () => { + const refs = extractFileReferences('Style with @emotion/react'); + expect(refs).toHaveLength(0); + }); + }); + + describe('edge cases', () => { + it('returns empty for text without @', () => { + const refs = extractFileReferences('No mentions here'); + expect(refs).toHaveLength(0); + }); + + it('returns empty for empty string', () => { + const refs = extractFileReferences(''); + expect(refs).toHaveLength(0); + }); + + it('rejects bare @ mentions without path separator', () => { + const refs = extractFileReferences('Hello @user'); + expect(refs).toHaveLength(0); + }); + + it('handles mixed valid and invalid refs', () => { + const refs = extractFileReferences( + 'Use @babel/core and check @src/components/App.tsx' + ); + expect(refs).toHaveLength(1); + expect(refs[0].path).toBe('src/components/App.tsx'); + }); + + it('rejects single-segment bare names', () => { + const refs = extractFileReferences('@react is a library'); + expect(refs).toHaveLength(0); + }); + }); +});