Files
codegraph/__tests__/identifier-segments.test.ts
T
Colby McHenry 238dbc5cec 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.
2026-08-20 09:10:07 -07:00

134 lines
6.1 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import {
splitIdentifierSegments,
extractProseCandidates,
extractSegmentSearchWords,
normalizeProseWord,
segmentLookupVariants,
} from '../src/search/identifier-segments';
describe('splitIdentifierSegments — symbol names → prose words', () => {
it('splits camelCase / PascalCase at humps', () => {
expect(splitIdentifierSegments('OrderStateMachine')).toEqual(['order', 'state', 'machine']);
expect(splitIdentifierSegments('userId')).toEqual(['user', 'id']);
});
it('handles acronym runs — HTML stays one segment', () => {
expect(splitIdentifierSegments('parseHTMLDocument')).toEqual(['parse', 'html', 'document']);
expect(splitIdentifierSegments('HTMLParser')).toEqual(['html', 'parser']);
});
it('keeps digits glued to their word', () => {
expect(splitIdentifierSegments('base64Encode')).toEqual(['base64', 'encode']);
expect(splitIdentifierSegments('parseHTML5Doc')).toEqual(['parse', 'html5', 'doc']);
});
it('splits snake_case, kebab-case, and dotted file names', () => {
expect(splitIdentifierSegments('snake_case_name')).toEqual(['snake', 'case', 'name']);
expect(splitIdentifierSegments('MAX_RETRY_COUNT')).toEqual(['max', 'retry', 'count']);
expect(splitIdentifierSegments('checkout.service.ts')).toEqual(['checkout', 'service', 'ts']);
expect(splitIdentifierSegments('state-machine')).toEqual(['state', 'machine']);
});
it('drops sub-minimum and digit-only fragments, dedupes', () => {
expect(splitIdentifierSegments('x')).toEqual([]);
expect(splitIdentifierSegments('42')).toEqual([]);
expect(splitIdentifierSegments('getData_getData')).toEqual(['get', 'data']);
});
});
describe('extractProseCandidates — prompt prose → lookup words', () => {
it('keeps content words, drops short function words, in any Latin language', () => {
expect(extractProseCandidates('comment marche la state machine des commandes ?')).toEqual([
'comment', 'marche', 'state', 'machine', 'commandes',
]);
});
it('strips diacritics so loanwords meet ASCII identifier segments', () => {
expect(extractProseCandidates('la résolution des références')).toEqual(['resolution', 'references']);
expect(normalizeProseWord('Übersicht')).toBe('ubersicht');
});
it("splits on apostrophes — l'architecture keeps the noun", () => {
expect(extractProseCandidates("explique l'architecture du module de stock")).toEqual([
'explique', 'architecture', 'module', 'stock',
]);
});
it('caps candidates and skips unsegmented-script sentence runs', () => {
const many = Array.from({ length: 25 }, (_, i) => `distinctword${String.fromCharCode(97 + i)}`).join(' ');
expect(extractProseCandidates(many)).toHaveLength(16);
// A no-spaces CJK sentence is one giant run — over the length ceiling, skipped.
expect(extractProseCandidates('請解釋一下這個訂單狀態機的整體運作流程與架構設計方式')).toEqual([]);
// Short CJK runs pass through as candidates — no script filter; the graph
// verification tier rejects them (identifiers are almost never CJK).
expect(extractProseCandidates('修复这个拼写错误')).toEqual(['修复这个拼写错误']);
});
it('drops digit-only and sub-4-char words', () => {
expect(extractProseCandidates('fix the bug in v2 at 1234')).toEqual([]);
});
});
describe('segmentLookupVariants — light plural folding', () => {
it('folds trailing s/es so plurals hit singular segments', () => {
expect(segmentLookupVariants('services')).toContain('service');
expect(segmentLookupVariants('machines')).toContain('machine');
expect(segmentLookupVariants('classes')).toContain('class');
});
it('bare-s plurals no longer mint a bogus -es sibling (#1145)', () => {
expect(segmentLookupVariants('services')).toEqual(['services', 'service']);
expect(segmentLookupVariants('machines')).toEqual(['machines', 'machine']);
});
it('unambiguous sibilant-es plurals no longer mint a bogus -s sibling (#1145)', () => {
expect(segmentLookupVariants('classes')).toEqual(['classes', 'class']);
expect(segmentLookupVariants('hashes')).toEqual(['hashes', 'hash']);
});
it('a trailing -ss is a singular, not a plural — no strip (#1145)', () => {
expect(segmentLookupVariants('class')).toEqual(['class']);
expect(segmentLookupVariants('process')).toEqual(['process']);
});
it('ambiguous endings emit BOTH candidate keys — a wrong exclusive guess would lose the real match', () => {
expect(segmentLookupVariants('caches')).toEqual(['caches', 'cach', 'cache']); // cache + s
expect(segmentLookupVariants('databases')).toEqual(['databases', 'databas', 'database']); // database + s
});
it('never strips a word below the minimum', () => {
expect(segmentLookupVariants('bus')).toEqual(['bus']);
expect(segmentLookupVariants('boxes')).toEqual(['boxes']); // -es strip would go sub-minimum
});
});
describe('extractSegmentSearchWords — query words for the search-side vocab supplement', () => {
it('keeps prose words and adds camel-token segments', () => {
const words = extractSegmentSearchWords('auto-scroll to bottom — atBottom tracking');
// Prose candidates survive as before…
expect(words).toContain('scroll');
expect(words).toContain('bottom');
expect(words).toContain('tracking');
// …and the camel token contributed its ≥4-char segments ("at" is under
// the prose minimum; "bottom" arrives from the split even when the prose
// pass missed it).
expect(extractSegmentSearchWords('where is atBottom set')).toContain('bottom');
});
it('splits multi-hump tokens into every usable segment', () => {
const words = extractSegmentSearchWords('trace pinFeedIfNearBottom please');
expect(words).toEqual(expect.arrayContaining(['feed', 'near', 'bottom']));
});
it('does not invent segments for plain prose', () => {
const words = extractSegmentSearchWords('how does checkout work');
expect(words).toContain('checkout');
expect(words).not.toContain('check');
});
it('returns nothing for an empty query', () => {
expect(extractSegmentSearchWords('')).toEqual([]);
});
});