fix(explore): reliably pin extension-less kebab-case file basenames in queries

Previously, naming a kebab-case file without its extension (e.g., `background-image-table` vs. `background-image-table.tsx`) in a `codegraph_explore` query would shred the name into fragments (`background`, `image`, `table`), admitting irrelevant sibling files and crowding out the intended target.

This change introduces a new resolution pass in `extractQueryPaths` specifically for extension-less kebab basenames. Queries now accurately identify and pin these files. Unresolved hyphenated prose (e.g., `cross-call`) is left in the query for FTS without being flagged as an unknown path. Resolution prioritizes explicit slashed/dotted paths and respects an ambiguity budget for common stems to prevent over-pinning.
This commit is contained in:
Colby McHenry
2026-08-22 09:05:30 -07:00
parent 81e1f4a92f
commit ccb0295259
6 changed files with 241 additions and 8 deletions
+82 -8
View File
@@ -15,10 +15,13 @@
* 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.
* `extractQueryPaths` finds path-like spans — slashed paths, dotted basenames,
* and extension-less kebab basenames (`background-image-table`, the spelling
* import paths and prose actually use) — resolves them against the INDEXED
* file list (resolution IS the detector — `and/or`, `gen_server:call/2`,
* `non-blocking` and other path-shaped 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.
@@ -40,12 +43,18 @@ export interface QueryPathExtraction {
/**
* 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.
* could possibly contain a path: a slash, a dot-extension-shaped tail
* (`chat-manager.ts`), or a hyphen-joined word (`background-image-table` —
* kebab files are named WITHOUT their extension more often than with, so the
* shape must open the gate on its own). Extensions cap at 8 chars, which
* keeps `Class.method` spans (`app.isPackaged`) from qualifying; the kebab
* alternative requires clean non-word boundaries, which keeps `--flags` and
* snake_case-with-a-dash hybrids from firing it.
*/
export function queryMightContainPaths(query: string): boolean {
return /[/\\]/.test(query) || /\.[A-Za-z][A-Za-z0-9]{0,7}(?=[\s,;:)\]'"`]|$)/.test(query);
return /[/\\]/.test(query)
|| /\.[A-Za-z][A-Za-z0-9]{0,7}(?=[\s,;:)\]'"`]|$)/.test(query)
|| /(?:^|[^-\w])[A-Za-z0-9]+(?:-[A-Za-z0-9]+)+(?=[^-\w]|$)/.test(query);
}
/**
@@ -60,6 +69,39 @@ 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}$/;
/**
* Extension-less kebab basename (`background-image-table`). Hyphens are
* illegal in identifiers, so consuming these tokens can never steal one from
* the named-symbol seeder; ≥2 segments keeps single words out.
*/
const KEBAB_BASENAME = /^[A-Za-z0-9]+(?:-[A-Za-z0-9]+)+$/;
/** A basename's last dot-extension, same shape DOTTED_BASENAME accepts. */
const LAST_EXTENSION = /\.[A-Za-z][A-Za-z0-9]{0,7}$/;
/**
* Lowercased basename stems of the hyphen-named indexed files, stem → paths.
* A stem drops only the LAST extension (`a-b.module.scss` → `a-b.module`), so
* a bare kebab token can't accidentally pin a same-named stylesheet or
* `.d.ts` sibling of the source file it names; an extension-less basename
* (`pre-commit`) is its own stem. Hyphen-free basenames are skipped — a
* KEBAB_BASENAME token can never equal one, and the filter keeps the map
* near-empty in repos that don't name files this way.
*/
function buildBasenameStems(indexedPaths: readonly string[]): Map<string, string[]> {
const stems = new Map<string, string[]>();
for (const p of indexedPaths) {
const basename = p.slice(Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\')) + 1);
if (!basename.includes('-')) continue;
const stem = basename.replace(LAST_EXTENSION, '').toLowerCase();
if (!stem) continue;
const existing = stems.get(stem);
if (existing) existing.push(p);
else stems.set(stem, [p]);
}
return stems;
}
/**
* Strip prose punctuation wrapped around a token without eating punctuation
* that is PART of the path: quotes/backticks always strip; a trailing `)`/`]`
@@ -204,6 +246,38 @@ export function extractQueryPaths(
// leave the token for the normal matching pipeline.
}
// Second pass — extension-less kebab basenames. `background-image-table`
// opens no door above (no slash, no dotted tail), the hyphens disqualify it
// from the named-symbol seeder downstream, and FTS shreds it into the most
// common words in a kebab-cased repo (`background`, `image`, `table`) —
// which admit look-alike SIBLINGS that crowd out the named file. Resolution
// stays the detector: a token pins only when its whole lowercased form is
// the stem of an indexed basename. Two deliberate asymmetries vs the first
// pass: prose that resolves to nothing (`non-blocking`, `cross-call`) is
// LEFT IN the query — unlike a slashed span it may be legitimate wording,
// so it keeps feeding FTS and is not reported as an unresolved path — and a
// stem hotter than maxMatchesPerSpan is likewise left alone (pinning half a
// monorepo off one hot name trades precision the wrong way; a directory
// segment, which the first pass handles, disambiguates). Runs after the
// slashed/dotted pass so explicit paths win the shared maxPins budget, and
// examines every remaining token: lookups are O(1) map hits, so the
// scan-cost rationale behind MAX_CANDIDATE_SPANS doesn't apply.
let basenameStems: Map<string, string[]> | null = null;
for (let i = 0; i < tokens.length && pinned.length < maxPins; i++) {
if (consumed.has(i)) continue;
const stripped = stripWrapping(tokens[i]!);
if (stripped.length < 4 || !KEBAB_BASENAME.test(stripped)) continue;
basenameStems ??= buildBasenameStems(indexedPaths);
const matches = basenameStems.get(stripped.toLowerCase());
if (!matches || matches.length > maxMatchesPerSpan) continue;
consumed.add(i);
for (const m of matches) {
if (pinnedSeen.has(m) || pinned.length >= maxPins) continue;
pinnedSeen.add(m);
pinned.push(m);
}
}
if (consumed.size === 0) return passthrough;
return {
strippedQuery: tokens.filter((_, i) => !consumed.has(i)).join(' '),