fix(explore): accurately resolve query file paths and find camelCase symbols

Previously, `codegraph_explore` queries explicitly naming files by path (e.g., `src/routes/m/projects/[id]/runs/[runId]/+page.svelte`) were shredded. Bracketed path segments exploded into "named symbol" seeds, and FTS on fragments like `page` or `runs` admitted every sibling file, starving the user's intended target.

This change introduces:
- **Query path pinning:** File paths named in a query are now resolved against the index, "pinned," and stripped from the query. Pinned files are guaranteed inclusion, top ranking, and fair allocation. Unresolvable path-like spans are reported.
- **Segment vocabulary supplement:** Natural language query terms (e.g., "auto-scroll to bottom") can now reach camelCase identifiers (e.g., `pinFeedIfNearBottom`, `feedAtBottom`) by matching against their constituent segments.
- **Variable seeding:** `variable` and `constant` node kinds are now included in identifier seeding, improving recall for `$state`-style variables common in frameworks like Svelte.
This commit is contained in:
Colby McHenry
2026-08-20 09:10:07 -07:00
parent c6aaa20358
commit 238dbc5cec
15 changed files with 905 additions and 31 deletions
+27
View File
@@ -126,6 +126,33 @@ export function extractProseCandidates(prompt: string): string[] {
return [...seen];
}
/**
* Words to look up in the segment vocabulary for a SEARCH query (as opposed
* to a prompt-hook gate): the query's prose candidates PLUS the segments of
* its identifier-shaped tokens. An agent's query names concepts both ways —
* "auto-scroll to bottom" (prose) and "atBottom tracking" (camel) — and the
* camel token must still reach the segment "bottom" even though the whole
* token matches no name. Same stopword/length rules as the hook path, since
* both feeds run through {@link extractProseCandidates}.
*/
export function extractSegmentSearchWords(query: string): string[] {
if (!query) return [];
const words = new Set(extractProseCandidates(query));
const segments: string[] = [];
for (const run of query.match(/[\p{L}\p{N}]+/gu) ?? []) {
// Only camel-humped tokens contribute segments — a plain word's
// "segments" are itself (already covered above), and snake_case arrives
// as separate runs because `_` is not a letter.
if (/[\p{Ll}\p{N}]\p{Lu}/u.test(run)) {
segments.push(...splitIdentifierSegments(run));
}
}
if (segments.length > 0) {
for (const w of extractProseCandidates(segments.join(' '))) words.add(w);
}
return [...words];
}
/**
* Lookup variants for a prose word: the word itself plus light plural folding
* ("services" → service, "dependencies" → dependencie/dependency is NOT
+213
View File
@@ -0,0 +1,213 @@
/**
* File-path recognition for explore queries.
*
* Agents routinely name files by path in a `codegraph_explore` query —
* "the scroll logic in src/routes/m/projects/[id]/runs/[runId]/+page.svelte" —
* and until this module existed those spans were SHREDDED by the downstream
* tokenizers instead of being read as file references:
*
* - the named-symbol seeder splits on `[\s,()[\]]+`, so SvelteKit/Next
* bracketed segments (`[id]`, `[runId]`) and route groups (`(protected)`)
* exploded the path into fragments; the identifier-shaped survivors
* (`runId`, `scope`) then seeded as "symbols the agent named" and
* headlined the blast radius;
* - FTS saw the fragments (`page`, `chat`, `runs`) and admitted every
* sibling `+page.svelte` in the repo, which ate the output envelope and
* truncated the files the agent actually asked for.
*
* `extractQueryPaths` finds path-like spans, resolves them against the
* INDEXED file list (resolution IS the detector — `and/or`, `gen_server:call/2`
* and other slash-bearing non-paths match nothing and are left alone), and
* returns the matches as pinned files plus the query with those spans removed.
* Callers treat pinned files as first-class: guaranteed admission, top rank,
* funded first. Pure string work — no DB, no fs — so it is trivially testable
* and safe inside the query-pool workers.
*/
export interface QueryPathExtraction {
/** The query with resolved/clearly-path spans removed, whitespace-joined. */
strippedQuery: string;
/** Indexed file paths the query named, appearance-ordered, deduped. */
pinnedFiles: string[];
/**
* Spans that are unambiguously path-shaped but resolved to nothing (stale
* path, unindexed file) or to too many files (bare `+page.svelte`). Stripped
* from the query — their fragments could only mint junk matches — and
* surfaced to the agent so the miss is visible instead of silent.
*/
unresolvedPathSpans: string[];
}
/**
* Cheap pre-gate so callers only fetch the indexed file list when the query
* could possibly contain a path: a slash, or a dot-extension-shaped tail
* (`chat-manager.ts`). Extensions cap at 8 chars, which keeps `Class.method`
* spans (`app.isPackaged`) from qualifying.
*/
export function queryMightContainPaths(query: string): boolean {
return /[/\\]/.test(query) || /\.[A-Za-z][A-Za-z0-9]{0,7}(?=[\s,;:)\]'"`]|$)/.test(query);
}
/**
* Longest span→suffix walk tried per span. 8 covers an absolute macOS path
* (`/Users/<user>/dev/<repo>/…`) over a deeply nested repo-relative file;
* deeper prefixes buy nothing.
*/
const MAX_SUFFIX_TRIES = 8;
/** Spans examined per query — a prose sentence is not 50 paths. */
const MAX_CANDIDATE_SPANS = 8;
/** `name.ext` shape with a plausible source extension (no slash required). */
const DOTTED_BASENAME = /^[^\s/\\]+\.[A-Za-z][A-Za-z0-9]{0,7}$/;
/**
* Strip prose punctuation wrapped around a token without eating punctuation
* that is PART of the path: quotes/backticks always strip; a trailing `)`/`]`
* strips only when the token has no matching opener (so `(protected)` and
* `[id]` segments survive, while "…(see src/foo.ts)" loses its parenthesis);
* a leading `(`/`[` mirrors that. Trailing sentence punctuation strips last,
* so "src/foo.ts." resolves.
*/
function stripWrapping(token: string): string {
let s = token;
for (;;) {
const first = s[0];
if (!first) break;
if ('\'"`<'.includes(first)) { s = s.slice(1); continue; }
if (first === '(' && !s.includes(')')) { s = s.slice(1); continue; }
if (first === '[' && !s.includes(']')) { s = s.slice(1); continue; }
if (first === '{' && !s.includes('}')) { s = s.slice(1); continue; }
break;
}
for (;;) {
const last = s[s.length - 1];
if (!last) break;
if ('\'"`>.,;!?'.includes(last)) { s = s.slice(0, -1); continue; }
if (last === ')' && !s.includes('(')) { s = s.slice(0, -1); continue; }
if (last === ']' && !s.includes('[')) { s = s.slice(0, -1); continue; }
if (last === '}' && !s.includes('{')) { s = s.slice(0, -1); continue; }
break;
}
// Line references ride along in agent-written paths: `foo.ts:123`,
// `foo.ts:12-40`, `foo.ts#L88`. The file is what gets pinned.
s = s.replace(/(?::\d+(?:-\d+)?|#L\d+(?:-L?\d+)?)$/, '');
return s;
}
/** Normalize a span into the repo-relative shape the files table stores. */
function normalizeSpan(span: string): string {
return span
.replace(/\\/g, '/')
.replace(/^(?:\.\/)+/, '')
.replace(/\/{2,}/g, '/')
.replace(/\/+$/, '');
}
/** Path-shaped beyond doubt: ≥2 segments and a dot-extension on the last. */
function isClearlyPathShaped(normalized: string): boolean {
const slash = normalized.lastIndexOf('/');
if (slash <= 0) return false;
return DOTTED_BASENAME.test(normalized.slice(slash + 1));
}
/**
* Resolve one normalized span against the indexed paths: exact match first,
* then segment-aligned suffix matches, dropping leading segments one at a
* time (so an absolute path, or one prefixed with the repo directory name,
* still lands on the indexed repo-relative file). Suffixes only get shorter —
* and therefore only match MORE — so the walk stops at the first suffix that
* matches anything: within budget it resolves, over budget it is ambiguous.
*/
function resolveSpan(
normalizedLower: string,
lowerToOriginal: ReadonlyMap<string, string>,
maxMatches: number,
): { matches: string[]; ambiguous: boolean } {
const exact = lowerToOriginal.get(normalizedLower);
if (exact) return { matches: [exact], ambiguous: false };
const segments = normalizedLower.split('/').filter(Boolean);
const tries = Math.min(segments.length, MAX_SUFFIX_TRIES);
for (let drop = 0; drop < tries; drop++) {
const suffix = segments.slice(drop).join('/');
if (!suffix) break;
const withSlash = '/' + suffix;
const matches: string[] = [];
for (const [lower, original] of lowerToOriginal) {
if (lower === suffix || lower.endsWith(withSlash)) {
matches.push(original);
if (matches.length > maxMatches) return { matches: [], ambiguous: true };
}
}
if (matches.length > 0) return { matches, ambiguous: false };
}
return { matches: [], ambiguous: false };
}
export function extractQueryPaths(
query: string,
indexedPaths: readonly string[],
opts: { maxPins?: number; maxMatchesPerSpan?: number } = {},
): QueryPathExtraction {
const maxPins = Math.max(1, opts.maxPins ?? 8);
const maxMatchesPerSpan = Math.max(1, opts.maxMatchesPerSpan ?? 3);
const passthrough: QueryPathExtraction = {
strippedQuery: query,
pinnedFiles: [],
unresolvedPathSpans: [],
};
if (!query.trim() || indexedPaths.length === 0) return passthrough;
// Lowercase view of the index, built once per call. Last writer wins on a
// case-colliding pair, which is the existing file-view behavior too.
const lowerToOriginal = new Map<string, string>();
for (const p of indexedPaths) lowerToOriginal.set(p.toLowerCase(), p);
const tokens = query.split(/\s+/).filter(Boolean);
const consumed = new Set<number>();
const pinned: string[] = [];
const pinnedSeen = new Set<string>();
const unresolved: string[] = [];
let candidatesExamined = 0;
for (let i = 0; i < tokens.length; i++) {
if (pinned.length >= maxPins) break;
if (candidatesExamined >= MAX_CANDIDATE_SPANS) break;
const stripped = stripWrapping(tokens[i]!);
if (stripped.length < 4) continue;
const hasSlash = /[/\\]/.test(stripped);
if (!hasSlash && !DOTTED_BASENAME.test(stripped)) continue;
const normalized = normalizeSpan(stripped);
if (!normalized) continue;
candidatesExamined++;
const { matches, ambiguous } = resolveSpan(
normalized.toLowerCase(), lowerToOriginal, maxMatchesPerSpan,
);
if (matches.length > 0) {
consumed.add(i);
for (const m of matches) {
if (pinnedSeen.has(m) || pinned.length >= maxPins) continue;
pinnedSeen.add(m);
pinned.push(m);
}
} else if (ambiguous || isClearlyPathShaped(normalized)) {
// A real path that didn't resolve to a usable set. Keeping it in the
// query is strictly worse — its fragments are what minted the junk
// matches this module exists to stop — so strip it and say so.
consumed.add(i);
if (unresolved.length < 4) unresolved.push(normalized);
}
// Anything else (`and/or`, `call/2`, `foo.Bar`) is not a path reference:
// leave the token for the normal matching pipeline.
}
if (consumed.size === 0) return passthrough;
return {
strippedQuery: tokens.filter((_, i) => !consumed.has(i)).join(' '),
pinnedFiles: pinned,
unresolvedPathSpans: unresolved,
};
}