query-paths.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287
  1. /**
  2. * File-path recognition for explore queries.
  3. *
  4. * Agents routinely name files by path in a `codegraph_explore` query —
  5. * "the scroll logic in src/routes/m/projects/[id]/runs/[runId]/+page.svelte" —
  6. * and until this module existed those spans were SHREDDED by the downstream
  7. * tokenizers instead of being read as file references:
  8. *
  9. * - the named-symbol seeder splits on `[\s,()[\]]+`, so SvelteKit/Next
  10. * bracketed segments (`[id]`, `[runId]`) and route groups (`(protected)`)
  11. * exploded the path into fragments; the identifier-shaped survivors
  12. * (`runId`, `scope`) then seeded as "symbols the agent named" and
  13. * headlined the blast radius;
  14. * - FTS saw the fragments (`page`, `chat`, `runs`) and admitted every
  15. * sibling `+page.svelte` in the repo, which ate the output envelope and
  16. * truncated the files the agent actually asked for.
  17. *
  18. * `extractQueryPaths` finds path-like spans — slashed paths, dotted basenames,
  19. * and extension-less kebab basenames (`background-image-table`, the spelling
  20. * import paths and prose actually use) — resolves them against the INDEXED
  21. * file list (resolution IS the detector — `and/or`, `gen_server:call/2`,
  22. * `non-blocking` and other path-shaped non-paths match nothing and are left
  23. * alone), and returns the matches as pinned files plus the query with those
  24. * spans removed.
  25. * Callers treat pinned files as first-class: guaranteed admission, top rank,
  26. * funded first. Pure string work — no DB, no fs — so it is trivially testable
  27. * and safe inside the query-pool workers.
  28. */
  29. export interface QueryPathExtraction {
  30. /** The query with resolved/clearly-path spans removed, whitespace-joined. */
  31. strippedQuery: string;
  32. /** Indexed file paths the query named, appearance-ordered, deduped. */
  33. pinnedFiles: string[];
  34. /**
  35. * Spans that are unambiguously path-shaped but resolved to nothing (stale
  36. * path, unindexed file) or to too many files (bare `+page.svelte`). Stripped
  37. * from the query — their fragments could only mint junk matches — and
  38. * surfaced to the agent so the miss is visible instead of silent.
  39. */
  40. unresolvedPathSpans: string[];
  41. }
  42. /**
  43. * Cheap pre-gate so callers only fetch the indexed file list when the query
  44. * could possibly contain a path: a slash, a dot-extension-shaped tail
  45. * (`chat-manager.ts`), or a hyphen-joined word (`background-image-table` —
  46. * kebab files are named WITHOUT their extension more often than with, so the
  47. * shape must open the gate on its own). Extensions cap at 8 chars, which
  48. * keeps `Class.method` spans (`app.isPackaged`) from qualifying; the kebab
  49. * alternative requires clean non-word boundaries, which keeps `--flags` and
  50. * snake_case-with-a-dash hybrids from firing it.
  51. */
  52. export function queryMightContainPaths(query: string): boolean {
  53. return /[/\\]/.test(query)
  54. || /\.[A-Za-z][A-Za-z0-9]{0,7}(?=[\s,;:)\]'"`]|$)/.test(query)
  55. || /(?:^|[^-\w])[A-Za-z0-9]+(?:-[A-Za-z0-9]+)+(?=[^-\w]|$)/.test(query);
  56. }
  57. /**
  58. * Longest span→suffix walk tried per span. 8 covers an absolute macOS path
  59. * (`/Users/<user>/dev/<repo>/…`) over a deeply nested repo-relative file;
  60. * deeper prefixes buy nothing.
  61. */
  62. const MAX_SUFFIX_TRIES = 8;
  63. /** Spans examined per query — a prose sentence is not 50 paths. */
  64. const MAX_CANDIDATE_SPANS = 8;
  65. /** `name.ext` shape with a plausible source extension (no slash required). */
  66. const DOTTED_BASENAME = /^[^\s/\\]+\.[A-Za-z][A-Za-z0-9]{0,7}$/;
  67. /**
  68. * Extension-less kebab basename (`background-image-table`). Hyphens are
  69. * illegal in identifiers, so consuming these tokens can never steal one from
  70. * the named-symbol seeder; ≥2 segments keeps single words out.
  71. */
  72. const KEBAB_BASENAME = /^[A-Za-z0-9]+(?:-[A-Za-z0-9]+)+$/;
  73. /** A basename's last dot-extension, same shape DOTTED_BASENAME accepts. */
  74. const LAST_EXTENSION = /\.[A-Za-z][A-Za-z0-9]{0,7}$/;
  75. /**
  76. * Lowercased basename stems of the hyphen-named indexed files, stem → paths.
  77. * A stem drops only the LAST extension (`a-b.module.scss` → `a-b.module`), so
  78. * a bare kebab token can't accidentally pin a same-named stylesheet or
  79. * `.d.ts` sibling of the source file it names; an extension-less basename
  80. * (`pre-commit`) is its own stem. Hyphen-free basenames are skipped — a
  81. * KEBAB_BASENAME token can never equal one, and the filter keeps the map
  82. * near-empty in repos that don't name files this way.
  83. */
  84. function buildBasenameStems(indexedPaths: readonly string[]): Map<string, string[]> {
  85. const stems = new Map<string, string[]>();
  86. for (const p of indexedPaths) {
  87. const basename = p.slice(Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\')) + 1);
  88. if (!basename.includes('-')) continue;
  89. const stem = basename.replace(LAST_EXTENSION, '').toLowerCase();
  90. if (!stem) continue;
  91. const existing = stems.get(stem);
  92. if (existing) existing.push(p);
  93. else stems.set(stem, [p]);
  94. }
  95. return stems;
  96. }
  97. /**
  98. * Strip prose punctuation wrapped around a token without eating punctuation
  99. * that is PART of the path: quotes/backticks always strip; a trailing `)`/`]`
  100. * strips only when the token has no matching opener (so `(protected)` and
  101. * `[id]` segments survive, while "…(see src/foo.ts)" loses its parenthesis);
  102. * a leading `(`/`[` mirrors that. Trailing sentence punctuation strips last,
  103. * so "src/foo.ts." resolves.
  104. */
  105. function stripWrapping(token: string): string {
  106. let s = token;
  107. for (;;) {
  108. const first = s[0];
  109. if (!first) break;
  110. if ('\'"`<'.includes(first)) { s = s.slice(1); continue; }
  111. if (first === '(' && !s.includes(')')) { s = s.slice(1); continue; }
  112. if (first === '[' && !s.includes(']')) { s = s.slice(1); continue; }
  113. if (first === '{' && !s.includes('}')) { s = s.slice(1); continue; }
  114. break;
  115. }
  116. for (;;) {
  117. const last = s[s.length - 1];
  118. if (!last) break;
  119. if ('\'"`>.,;!?'.includes(last)) { s = s.slice(0, -1); continue; }
  120. if (last === ')' && !s.includes('(')) { s = s.slice(0, -1); continue; }
  121. if (last === ']' && !s.includes('[')) { s = s.slice(0, -1); continue; }
  122. if (last === '}' && !s.includes('{')) { s = s.slice(0, -1); continue; }
  123. break;
  124. }
  125. // Line references ride along in agent-written paths: `foo.ts:123`,
  126. // `foo.ts:12-40`, `foo.ts#L88`. The file is what gets pinned.
  127. s = s.replace(/(?::\d+(?:-\d+)?|#L\d+(?:-L?\d+)?)$/, '');
  128. return s;
  129. }
  130. /** Normalize a span into the repo-relative shape the files table stores. */
  131. function normalizeSpan(span: string): string {
  132. return span
  133. .replace(/\\/g, '/')
  134. .replace(/^(?:\.\/)+/, '')
  135. .replace(/\/{2,}/g, '/')
  136. .replace(/\/+$/, '');
  137. }
  138. /** Path-shaped beyond doubt: ≥2 segments and a dot-extension on the last. */
  139. function isClearlyPathShaped(normalized: string): boolean {
  140. const slash = normalized.lastIndexOf('/');
  141. if (slash <= 0) return false;
  142. return DOTTED_BASENAME.test(normalized.slice(slash + 1));
  143. }
  144. /**
  145. * Resolve one normalized span against the indexed paths: exact match first,
  146. * then segment-aligned suffix matches, dropping leading segments one at a
  147. * time (so an absolute path, or one prefixed with the repo directory name,
  148. * still lands on the indexed repo-relative file). Suffixes only get shorter —
  149. * and therefore only match MORE — so the walk stops at the first suffix that
  150. * matches anything: within budget it resolves, over budget it is ambiguous.
  151. */
  152. function resolveSpan(
  153. normalizedLower: string,
  154. lowerToOriginal: ReadonlyMap<string, string>,
  155. maxMatches: number,
  156. ): { matches: string[]; ambiguous: boolean } {
  157. const exact = lowerToOriginal.get(normalizedLower);
  158. if (exact) return { matches: [exact], ambiguous: false };
  159. const segments = normalizedLower.split('/').filter(Boolean);
  160. const tries = Math.min(segments.length, MAX_SUFFIX_TRIES);
  161. for (let drop = 0; drop < tries; drop++) {
  162. const suffix = segments.slice(drop).join('/');
  163. if (!suffix) break;
  164. const withSlash = '/' + suffix;
  165. const matches: string[] = [];
  166. for (const [lower, original] of lowerToOriginal) {
  167. if (lower === suffix || lower.endsWith(withSlash)) {
  168. matches.push(original);
  169. if (matches.length > maxMatches) return { matches: [], ambiguous: true };
  170. }
  171. }
  172. if (matches.length > 0) return { matches, ambiguous: false };
  173. }
  174. return { matches: [], ambiguous: false };
  175. }
  176. export function extractQueryPaths(
  177. query: string,
  178. indexedPaths: readonly string[],
  179. opts: { maxPins?: number; maxMatchesPerSpan?: number } = {},
  180. ): QueryPathExtraction {
  181. const maxPins = Math.max(1, opts.maxPins ?? 8);
  182. const maxMatchesPerSpan = Math.max(1, opts.maxMatchesPerSpan ?? 3);
  183. const passthrough: QueryPathExtraction = {
  184. strippedQuery: query,
  185. pinnedFiles: [],
  186. unresolvedPathSpans: [],
  187. };
  188. if (!query.trim() || indexedPaths.length === 0) return passthrough;
  189. // Lowercase view of the index, built once per call. Last writer wins on a
  190. // case-colliding pair, which is the existing file-view behavior too.
  191. const lowerToOriginal = new Map<string, string>();
  192. for (const p of indexedPaths) lowerToOriginal.set(p.toLowerCase(), p);
  193. const tokens = query.split(/\s+/).filter(Boolean);
  194. const consumed = new Set<number>();
  195. const pinned: string[] = [];
  196. const pinnedSeen = new Set<string>();
  197. const unresolved: string[] = [];
  198. let candidatesExamined = 0;
  199. for (let i = 0; i < tokens.length; i++) {
  200. if (pinned.length >= maxPins) break;
  201. if (candidatesExamined >= MAX_CANDIDATE_SPANS) break;
  202. const stripped = stripWrapping(tokens[i]!);
  203. if (stripped.length < 4) continue;
  204. const hasSlash = /[/\\]/.test(stripped);
  205. if (!hasSlash && !DOTTED_BASENAME.test(stripped)) continue;
  206. const normalized = normalizeSpan(stripped);
  207. if (!normalized) continue;
  208. candidatesExamined++;
  209. const { matches, ambiguous } = resolveSpan(
  210. normalized.toLowerCase(), lowerToOriginal, maxMatchesPerSpan,
  211. );
  212. if (matches.length > 0) {
  213. consumed.add(i);
  214. for (const m of matches) {
  215. if (pinnedSeen.has(m) || pinned.length >= maxPins) continue;
  216. pinnedSeen.add(m);
  217. pinned.push(m);
  218. }
  219. } else if (ambiguous || isClearlyPathShaped(normalized)) {
  220. // A real path that didn't resolve to a usable set. Keeping it in the
  221. // query is strictly worse — its fragments are what minted the junk
  222. // matches this module exists to stop — so strip it and say so.
  223. consumed.add(i);
  224. if (unresolved.length < 4) unresolved.push(normalized);
  225. }
  226. // Anything else (`and/or`, `call/2`, `foo.Bar`) is not a path reference:
  227. // leave the token for the normal matching pipeline.
  228. }
  229. // Second pass — extension-less kebab basenames. `background-image-table`
  230. // opens no door above (no slash, no dotted tail), the hyphens disqualify it
  231. // from the named-symbol seeder downstream, and FTS shreds it into the most
  232. // common words in a kebab-cased repo (`background`, `image`, `table`) —
  233. // which admit look-alike SIBLINGS that crowd out the named file. Resolution
  234. // stays the detector: a token pins only when its whole lowercased form is
  235. // the stem of an indexed basename. Two deliberate asymmetries vs the first
  236. // pass: prose that resolves to nothing (`non-blocking`, `cross-call`) is
  237. // LEFT IN the query — unlike a slashed span it may be legitimate wording,
  238. // so it keeps feeding FTS and is not reported as an unresolved path — and a
  239. // stem hotter than maxMatchesPerSpan is likewise left alone (pinning half a
  240. // monorepo off one hot name trades precision the wrong way; a directory
  241. // segment, which the first pass handles, disambiguates). Runs after the
  242. // slashed/dotted pass so explicit paths win the shared maxPins budget, and
  243. // examines every remaining token: lookups are O(1) map hits, so the
  244. // scan-cost rationale behind MAX_CANDIDATE_SPANS doesn't apply.
  245. let basenameStems: Map<string, string[]> | null = null;
  246. for (let i = 0; i < tokens.length && pinned.length < maxPins; i++) {
  247. if (consumed.has(i)) continue;
  248. const stripped = stripWrapping(tokens[i]!);
  249. if (stripped.length < 4 || !KEBAB_BASENAME.test(stripped)) continue;
  250. basenameStems ??= buildBasenameStems(indexedPaths);
  251. const matches = basenameStems.get(stripped.toLowerCase());
  252. if (!matches || matches.length > maxMatchesPerSpan) continue;
  253. consumed.add(i);
  254. for (const m of matches) {
  255. if (pinnedSeen.has(m) || pinned.length >= maxPins) continue;
  256. pinnedSeen.add(m);
  257. pinned.push(m);
  258. }
  259. }
  260. if (consumed.size === 0) return passthrough;
  261. return {
  262. strippedQuery: tokens.filter((_, i) => !consumed.has(i)).join(' '),
  263. pinnedFiles: pinned,
  264. unresolvedPathSpans: unresolved,
  265. };
  266. }