From ad941825a7f0f99087b6786388c486cda457160c Mon Sep 17 00:00:00 2001 From: 69fd998aiw33bc <69fd998aiw33bc@users.noreply.github.com> Date: Sat, 8 Aug 2026 10:26:02 -0500 Subject: [PATCH 1/2] feat(search): surface camelCase symbols via segment vocab (#1520) Signed-off-by: 69fd998aiw33bc <69fd998aiw33bc@users.noreply.github.com> --- src/db/queries.ts | 64 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/src/db/queries.ts b/src/db/queries.ts index 451c6a90f..129649c50 100644 --- a/src/db/queries.ts +++ b/src/db/queries.ts @@ -1223,6 +1223,18 @@ export class QueryBuilder { // results despite the DB having plenty of matches. : this.searchAllByFilters({ kinds, languages, limit: limit * 5 }); + // FTS unicode61 keeps camelCase/PascalCase identifiers as opaque tokens, + // so a query like `checkout` misses `getShippingMethodIdFromCheckout`. + // name_segment_vocab already stores those sub-words at index time — merge + // them into the candidate set before falling through to LIKE (#1520). + if (text) { + results = this.supplementWithSegmentMatches(results, text, { + kinds, + languages, + limit: Math.max(limit * 5, 100), + }); + } + // If no FTS results, try LIKE-based substring search if (results.length === 0 && text.length >= 2) { results = this.searchNodesLike(text, { kinds, languages, limit, offset }); @@ -1399,6 +1411,58 @@ export class QueryBuilder { return results; } + /** + * Merge camelCase/PascalCase sub-word hits from `name_segment_vocab` into an + * FTS candidate set. FTS5's default tokenizer does not split case boundaries, + * so queries like `checkout` otherwise miss `getShippingMethodIdFromCheckout` + * even though that segment was materialized at index time (#1520). + */ + private supplementWithSegmentMatches( + results: SearchResult[], + text: string, + options: { kinds?: NodeKind[]; languages?: Language[]; limit: number }, + ): SearchResult[] { + const { kinds, languages, limit } = options; + const terms = text + .replace(/::/g, ' ') + .replace(/['"*():^]/g, '') + .split(/\s+/) + .map((t) => t.toLowerCase()) + .filter((t) => t.length >= 2) + .filter((t) => !/^(and|or|not|near)$/i.test(t)); + if (terms.length === 0) return results; + + const existingIds = new Set(results.map((r) => r.node.id)); + const baseScore = + results.length > 0 ? Math.max(...results.map((r) => r.score)) : 1; + const perTerm = Math.max(20, Math.ceil(limit / Math.max(terms.length, 1))); + + for (const term of terms) { + const names = this.getNamesForSegment(term, perTerm); + if (names.length === 0) continue; + const placeholders = names.map(() => '?').join(', '); + let sql = `SELECT * FROM nodes WHERE name IN (${placeholders})`; + const params: (string | number)[] = [...names]; + if (kinds && kinds.length > 0) { + sql += ` AND kind IN (${kinds.map(() => '?').join(',')})`; + params.push(...kinds); + } + if (languages && languages.length > 0) { + sql += ` AND language IN (${languages.map(() => '?').join(',')})`; + params.push(...languages); + } + sql += ' LIMIT ?'; + params.push(perTerm * 3); + const rows = this.db.prepare(sql).all(...params) as NodeRow[]; + for (const row of rows) { + if (existingIds.has(row.id)) continue; + results.push({ node: rowToNode(row), score: baseScore }); + existingIds.add(row.id); + } + } + return results; + } + /** * FTS5 search with prefix matching */ From fede0c4fb9002fce11c38c9424792f2924b0a88b Mon Sep 17 00:00:00 2001 From: 69fd998aiw33bc <69fd998aiw33bc@users.noreply.github.com> Date: Sat, 8 Aug 2026 10:26:03 -0500 Subject: [PATCH 2/2] feat(search): surface camelCase symbols via segment vocab (#1520) Signed-off-by: 69fd998aiw33bc <69fd998aiw33bc@users.noreply.github.com> --- __tests__/search-segment-supplement.test.ts | 41 +++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 __tests__/search-segment-supplement.test.ts diff --git a/__tests__/search-segment-supplement.test.ts b/__tests__/search-segment-supplement.test.ts new file mode 100644 index 000000000..4eb1a3baa --- /dev/null +++ b/__tests__/search-segment-supplement.test.ts @@ -0,0 +1,41 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { CodeGraph } from '../src/index'; + +/** + * #1520: FTS unicode61 keeps camelCase as one token. searchNodes must still + * surface sub-word hits via name_segment_vocab. + */ +describe('searchNodes camelCase segment supplement (#1520)', () => { + let tmpDir: string | undefined; + + afterEach(() => { + if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + }); + + it('finds getShippingMethodIdFromCheckout via query "checkout"', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-1520-seg-')); + fs.writeFileSync( + path.join(tmpDir, 'shipping.ts'), + [ + 'export function getShippingMethodIdFromCheckout(cartId: string): string {', + ' return cartId;', + '}', + 'export function unrelatedHelper(): void {}', + ].join('\n'), + ); + + const cg = CodeGraph.initSync(tmpDir); + try { + await cg.indexAll(); + const hits = cg.searchNodes('checkout', { limit: 20 }); + const names = hits.map((h) => h.node.name); + expect(names).toContain('getShippingMethodIdFromCheckout'); + } finally { + cg.close(); + } + }); +});