query-paths.test.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238
  1. /**
  2. * File-path recognition in explore queries (src/search/query-paths.ts).
  3. *
  4. * The originating bug: an agent named two SvelteKit route files by exact path
  5. * (`src/routes/m/projects/[id]/runs/[runId]/+page.svelte`) and the explore
  6. * pipeline shredded them — the seeding tokenizer splits on brackets, so the
  7. * fragments `runId`/`scope` seeded as "named symbols" and headlined the blast
  8. * radius, while FTS admitted every sibling `+page.svelte` off the `page`/`runs`
  9. * fragments. These tests pin the module that stops that: path spans resolve
  10. * against the indexed file list, matching files pin, and the spans leave the
  11. * query. Resolution IS the detector — slash-bearing non-paths stay untouched.
  12. */
  13. import { describe, it, expect } from 'vitest';
  14. import { extractQueryPaths, queryMightContainPaths } from '../src/search/query-paths';
  15. const INDEX = [
  16. 'src/routes/m/projects/[id]/runs/[runId]/+page.svelte',
  17. 'src/routes/m/projects/[id]/chat/[scope]/+page.svelte',
  18. 'src/routes/m/projects/[id]/+page.svelte',
  19. 'src/routes/(protected)/chat-window/+page.svelte',
  20. 'src/lib/chat-manager.ts',
  21. 'src/lib/task-runner-manager.ts',
  22. 'src/lib/stores/sqlite-store.ts',
  23. 'src/lib/stores/postgresql-store.ts',
  24. // Kebab-case frontend shapes (the amnisphere extension-less-basename bug):
  25. 'src/components/training-set-page/training-set-page.tsx',
  26. 'src/components/training-set-page/training-set-page-background-images.tsx',
  27. 'src/components/training-set-page/training-set-page.module.scss',
  28. 'src/components/training-set-page/background-image-table.tsx',
  29. 'src/components/modal/add-to-training-set/add-to-training-set.tsx',
  30. 'src/pages/library-page-layout.tsx',
  31. 'src/api/job-manager/backgrounds.ts',
  32. 'src/x/generic-modal.tsx',
  33. 'src/y/generic-modal.tsx',
  34. 'scripts/pre-commit',
  35. 'src/a/user-profile.tsx',
  36. 'src/b/user-profile.tsx',
  37. 'src/c/user-profile.tsx',
  38. 'src/d/user-profile.tsx',
  39. ];
  40. describe('queryMightContainPaths — the cheap pre-gate', () => {
  41. it('fires on slashes and dotted basenames', () => {
  42. expect(queryMightContainPaths('look at src/lib/chat-manager.ts')).toBe(true);
  43. expect(queryMightContainPaths('look at chat-manager.ts please')).toBe(true);
  44. });
  45. it('stays quiet on plain prose and Class.method spans', () => {
  46. expect(queryMightContainPaths('how does the scroll pinning work')).toBe(false);
  47. // `.isPackaged` is 10 chars — past the 8-char extension cap.
  48. expect(queryMightContainPaths('what reads app.isPackaged here')).toBe(false);
  49. });
  50. it('fires on extension-less kebab basenames — with or without wrapping', () => {
  51. expect(queryMightContainPaths('background-image-table Source column')).toBe(true);
  52. expect(queryMightContainPaths('the `library-page-layout` wrapper')).toBe(true);
  53. expect(queryMightContainPaths('usage, add-to-training-set.')).toBe(true);
  54. });
  55. it('stays quiet on flags, snake_case, and snake-with-a-dash hybrids', () => {
  56. expect(queryMightContainPaths('run it with --no-cache maybe')).toBe(false);
  57. expect(queryMightContainPaths('where is background_image_table used')).toBe(false);
  58. expect(queryMightContainPaths('the foo_bar-baz helper')).toBe(false);
  59. });
  60. });
  61. describe('extractQueryPaths — resolution and stripping', () => {
  62. it('resolves a bracketed SvelteKit path and strips it from the query', () => {
  63. const q = 'auto-scroll logic in src/routes/m/projects/[id]/runs/[runId]/+page.svelte — atBottom tracking';
  64. const out = extractQueryPaths(q, INDEX);
  65. expect(out.pinnedFiles).toEqual(['src/routes/m/projects/[id]/runs/[runId]/+page.svelte']);
  66. expect(out.strippedQuery).not.toContain('+page.svelte');
  67. expect(out.strippedQuery).not.toContain('runId');
  68. expect(out.strippedQuery).toContain('atBottom tracking');
  69. expect(out.unresolvedPathSpans).toEqual([]);
  70. });
  71. it('pins multiple named files in appearance order', () => {
  72. const q = 'compare src/routes/m/projects/[id]/chat/[scope]/+page.svelte and src/routes/m/projects/[id]/runs/[runId]/+page.svelte';
  73. const out = extractQueryPaths(q, INDEX);
  74. expect(out.pinnedFiles).toEqual([
  75. 'src/routes/m/projects/[id]/chat/[scope]/+page.svelte',
  76. 'src/routes/m/projects/[id]/runs/[runId]/+page.svelte',
  77. ]);
  78. });
  79. it('resolves a (protected) route-group path — parens are path characters', () => {
  80. const out = extractQueryPaths('read src/routes/(protected)/chat-window/+page.svelte', INDEX);
  81. expect(out.pinnedFiles).toEqual(['src/routes/(protected)/chat-window/+page.svelte']);
  82. });
  83. it('resolves an absolute path by walking suffixes to the indexed relative path', () => {
  84. const q = 'fix /Users/colby/dev/beads-live-dashboard/src/lib/chat-manager.ts';
  85. const out = extractQueryPaths(q, INDEX);
  86. expect(out.pinnedFiles).toEqual(['src/lib/chat-manager.ts']);
  87. });
  88. it('resolves a unique basename and a partial path', () => {
  89. expect(extractQueryPaths('see chat-manager.ts', INDEX).pinnedFiles)
  90. .toEqual(['src/lib/chat-manager.ts']);
  91. expect(extractQueryPaths('see stores/sqlite-store.ts', INDEX).pinnedFiles)
  92. .toEqual(['src/lib/stores/sqlite-store.ts']);
  93. });
  94. it('strips wrapping punctuation and line references', () => {
  95. const out = extractQueryPaths('the bug (see `src/lib/chat-manager.ts:243`).', INDEX);
  96. expect(out.pinnedFiles).toEqual(['src/lib/chat-manager.ts']);
  97. const hash = extractQueryPaths('regression at src/lib/task-runner-manager.ts#L88-L120', INDEX);
  98. expect(hash.pinnedFiles).toEqual(['src/lib/task-runner-manager.ts']);
  99. });
  100. it('treats an over-ambiguous basename as unresolved — stripped and reported', () => {
  101. const out = extractQueryPaths('why do all +page.svelte files flash', INDEX);
  102. expect(out.pinnedFiles).toEqual([]);
  103. expect(out.unresolvedPathSpans).toEqual(['+page.svelte']);
  104. expect(out.strippedQuery).toBe('why do all files flash');
  105. });
  106. it('strips and reports a clearly-path-shaped span that matches nothing', () => {
  107. const out = extractQueryPaths('crash in src/routes/gone/missing-page.svelte on load', INDEX);
  108. expect(out.pinnedFiles).toEqual([]);
  109. expect(out.unresolvedPathSpans).toEqual(['src/routes/gone/missing-page.svelte']);
  110. expect(out.strippedQuery).toBe('crash in on load');
  111. });
  112. it('leaves slash-bearing non-paths alone', () => {
  113. const q = 'does gen_server:call/2 block and/or timeout';
  114. const out = extractQueryPaths(q, INDEX);
  115. expect(out.pinnedFiles).toEqual([]);
  116. expect(out.unresolvedPathSpans).toEqual([]);
  117. expect(out.strippedQuery).toBe(q);
  118. });
  119. it('dedupes a path named twice and honors maxPins', () => {
  120. const twice = extractQueryPaths(
  121. 'src/lib/chat-manager.ts wraps src/lib/chat-manager.ts', INDEX,
  122. );
  123. expect(twice.pinnedFiles).toEqual(['src/lib/chat-manager.ts']);
  124. const capped = extractQueryPaths(
  125. 'src/lib/chat-manager.ts src/lib/task-runner-manager.ts', INDEX, { maxPins: 1 },
  126. );
  127. expect(capped.pinnedFiles).toEqual(['src/lib/chat-manager.ts']);
  128. });
  129. it('matches case-insensitively but returns the indexed spelling', () => {
  130. const out = extractQueryPaths('SRC/LIB/CHAT-MANAGER.TS', INDEX);
  131. expect(out.pinnedFiles).toEqual(['src/lib/chat-manager.ts']);
  132. });
  133. it('passes through untouched when nothing resolves', () => {
  134. const q = 'plain prose question about scrolling';
  135. const out = extractQueryPaths(q, INDEX);
  136. expect(out).toEqual({ strippedQuery: q, pinnedFiles: [], unresolvedPathSpans: [] });
  137. });
  138. });
  139. describe('extractQueryPaths — extension-less kebab basenames', () => {
  140. it('pins the file a bare kebab basename names and consumes the token', () => {
  141. const out = extractQueryPaths('background-image-table Source column', INDEX);
  142. expect(out.pinnedFiles)
  143. .toEqual(['src/components/training-set-page/background-image-table.tsx']);
  144. expect(out.strippedQuery).toBe('Source column');
  145. expect(out.unresolvedPathSpans).toEqual([]);
  146. });
  147. it('resolves with no slash or extension anywhere in the query (session-4 shape)', () => {
  148. const out = extractQueryPaths(
  149. 'TrainingSetPage train modal library-page-layout AddToTrainingSetModal usage', INDEX,
  150. );
  151. expect(out.pinnedFiles).toEqual(['src/pages/library-page-layout.tsx']);
  152. // Identifier-shaped tokens stay for the named-symbol seeder.
  153. expect(out.strippedQuery).toBe('TrainingSetPage train modal AddToTrainingSetModal usage');
  154. });
  155. it('pins every named file in a mixed dotted + kebab query (session-1 shape)', () => {
  156. const out = extractQueryPaths(
  157. 'add-to-training-set training-set-page-background-images backgrounds.ts background-image-table Source column',
  158. INDEX,
  159. );
  160. expect(out.pinnedFiles).toEqual([
  161. // The dotted pass runs first, so the explicit basename pins ahead of the kebabs.
  162. 'src/api/job-manager/backgrounds.ts',
  163. 'src/components/modal/add-to-training-set/add-to-training-set.tsx',
  164. 'src/components/training-set-page/training-set-page-background-images.tsx',
  165. 'src/components/training-set-page/background-image-table.tsx',
  166. ]);
  167. expect(out.strippedQuery).toBe('Source column');
  168. });
  169. it('leaves kebab prose that names no indexed file untouched — and unreported', () => {
  170. const q = 'how does cross-call dedup make explore non-blocking';
  171. const out = extractQueryPaths(q, INDEX);
  172. expect(out).toEqual({ strippedQuery: q, pinnedFiles: [], unresolvedPathSpans: [] });
  173. });
  174. it('leaves a stem shared by too many files alone — one hot name must not pin half the repo', () => {
  175. const q = 'refactor the user-profile rendering';
  176. const out = extractQueryPaths(q, INDEX);
  177. expect(out).toEqual({ strippedQuery: q, pinnedFiles: [], unresolvedPathSpans: [] });
  178. });
  179. it('pins all files sharing a stem when within the ambiguity budget', () => {
  180. const out = extractQueryPaths('generic-modal close behavior', INDEX);
  181. expect(out.pinnedFiles).toEqual(['src/x/generic-modal.tsx', 'src/y/generic-modal.tsx']);
  182. });
  183. it('matches case-insensitively and through wrapping punctuation', () => {
  184. expect(extractQueryPaths('see `Background-Image-Table`.', INDEX).pinnedFiles)
  185. .toEqual(['src/components/training-set-page/background-image-table.tsx']);
  186. });
  187. it('stems drop only the last extension — a kebab token cannot pin a .module.scss sibling', () => {
  188. const out = extractQueryPaths('training-set-page props flow', INDEX);
  189. expect(out.pinnedFiles)
  190. .toEqual(['src/components/training-set-page/training-set-page.tsx']);
  191. });
  192. it('pins an extension-less indexed file by its exact name', () => {
  193. expect(extractQueryPaths('what does the pre-commit hook run', INDEX).pinnedFiles)
  194. .toEqual(['scripts/pre-commit']);
  195. });
  196. it('skips tokens the dotted pass consumed and dedupes a file named both ways', () => {
  197. const out = extractQueryPaths('src/lib/chat-manager.ts vs chat-manager internals', INDEX);
  198. expect(out.pinnedFiles).toEqual(['src/lib/chat-manager.ts']);
  199. expect(out.strippedQuery).toBe('vs internals');
  200. });
  201. it('explicit paths win the shared maxPins budget over kebab tokens', () => {
  202. const out = extractQueryPaths(
  203. 'background-image-table then src/lib/chat-manager.ts', INDEX, { maxPins: 1 },
  204. );
  205. expect(out.pinnedFiles).toEqual(['src/lib/chat-manager.ts']);
  206. // The kebab token was not consumed once the budget was spent — it stays for FTS.
  207. expect(out.strippedQuery).toBe('background-image-table then');
  208. });
  209. });