fix(search): a pasted call like leaf(entry) finds both names instead of nothing (#3069)

Full-text search dropped the FTS5 syntax characters ( ) ^ " ' * : from a
query before quoting each word, so the words on either side of one were
glued together: leaf(entry) became the single prefix term "leafentry"*,
which no symbol has, and the LIKE and fuzzy fallbacks don't recover it.
codegraph query, the codegraph_search tool and searchNodes() all came
back empty for a pasted call like render(props).

The index's unicode61 tokenizer splits stored text at anything but
letters and digits (a signature's leaf(entry) is stored as leaf and
entry), so the query now splits there too: each of those characters
becomes a space, the treatment :: already gets (issue #173).

Validation: new search-special-characters.test.ts (8 tests) fails 8 of 8
on main and passes with the fix; fts5-fallback, search-field-filters,
search-query-parser, cli-query-command and symbol-lookup pass. A query
A/B on expressjs/express 0dee2c52, both arms over one index: 12 control
queries without such a character between words are byte-identical; 10
pasted calls changed, 7 of them from no results to results led by the
function they name (res.send(body), res.json(obj),
View.prototype.lookup(name)).

Reimplements PR #2987 by @kjgggggg.

Fixes #2971

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-10-30 13:31:02 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent cc17978174
commit f67eafcbc5
3 changed files with 69 additions and 1 deletions
+1
View File
@@ -36,6 +36,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- `codegraph_explore`, the CLI `codegraph explore` and the prompt hook no longer stall for a minute or more on a large project after a full index; the step that finds the project's busiest file no longer slows to a crawl when a few files are huge, like a generated Android `R.java` (#2571). Thanks @yangfengfan2011.
- `codegraph_explore`, the CLI `codegraph explore` and the Claude Code prompt hook now answer a long query, like a pasted log, document or source file, in seconds on a large project instead of minutes. Every word of such a query used to search the whole index several times over, so the prompt hook ran out of time on long prompts and added nothing. Those searches now reuse what they read, and a very long query is searched by its code names and its opening words rather than by every word in it. Answers to ordinary questions and lists of symbol names are unchanged. Thanks @retsamekohC for the report and the first fix. (#2620)
- A search with a `path:` or `name:` filter, in `codegraph query`, the `codegraph_search` tool or the library's `searchNodes()`, now finds the symbols it asks for at any limit, and an offset pages through those symbols alone. Before, the filter was applied only after the results had been cut to the limit, so `codegraph query "authenticate path:src/api"` could find nothing while a much larger `--limit` showed the match. Thanks @rudycelekli.
- A search in `codegraph query`, the `codegraph_search` tool or the library's `searchNodes()` now finds the symbols a pasted call names, like `res.send(body)` or `leaf(entry)`: a parenthesis, quote, `*`, `^` or `:` between two words now separates them, as it does in the index. Before, those characters were dropped, which glued the words around them into one that no symbol has, so such a search came back empty or missed the names it was given. Thanks @inth3shadows for the report and @kjgggggg for the first fix. (#2971)
- The Claude Code prompt hook now goes by what you typed. Claude Code's desktop app puts status notes, like which other sessions are linked, in front of your prompt, and the hook read them as part of it, so every turn, even a one-word "yes", could get code context about the note's words, and so could a task notification, a subagent's report or a message from another session that came after one. The notes are now skipped, and a message from another session gets no context, as task notifications and subagent reports already didn't. Thanks @WonderMr for the report and @yang0228 for the first fix. (#2635)
- `codegraph_explore` and `codegraph context` now find your project's busiest file, and favor the code around it, even when twenty or more test or generated files are busier still, such as a package of protobuf stubs. Before, the search looked only at the 20 busiest files and then set the tests and generated ones aside, so it could find no file at all, or judge one far ahead of the next when it was not. `getTopRouteFile()` in the library counts every file's routes the same way. Thanks @inth3shadows for the report. (#2578)
- In JavaScript and TypeScript, a call written as the body of an `if`, `while`, `for` or `do` without braces, like `if (e.key === "Escape") setOpen(false);`, no longer links to a same-named property or method in another file when the name is the file's own, such as a `setOpen` from `useState` (#2585).
@@ -0,0 +1,62 @@
/**
* A character full-text search can't take literally separates the words on
* either side of it, as it separates them in the index (#2971).
*
* The index splits text into words at anything that isn't a letter or digit,
* so `leaf(entry)` in a signature is stored as `leaf` and `entry`. The query
* side dropped `( ) ^ " ' * :` instead, gluing `leaf(entry)` into `leafentry`,
* a word no symbol has: a pasted call like `render(props)` or `getUser(id)`
* came back "No results found" while `render props` found both.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import CodeGraph from '../src/index';
let root = '';
let cg: CodeGraph;
beforeAll(async () => {
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-search-special-'));
fs.mkdirSync(path.join(root, 'src'));
fs.writeFileSync(
path.join(root, 'src', 'a.ts'),
[
'export function entry() { mid1(); mid2(); }',
'export function leaf() { return 1; }',
'function mid1() { return leaf(); }',
'function mid2() { return 2; }',
'export function render(props: { title: string }) { return props.title; }',
'',
].join('\n'),
);
cg = await CodeGraph.init(root, { index: true });
});
afterAll(() => {
cg?.close();
if (root) fs.rmSync(root, { recursive: true, force: true });
});
const names = (query: string) => [...new Set(cg.searchNodes(query).map((r) => r.node.name))].sort();
describe('a character search cannot take literally', () => {
it.each(['leaf(entry)', 'leaf^entry', 'leaf"entry', "leaf'entry", 'leaf*entry', 'leaf:entry'])(
'separates the words around it: %s',
(query) => {
expect(names(query)).toEqual(expect.arrayContaining(['entry', 'leaf']));
},
);
it('finds the function a pasted call names', () => {
expect(names('render(props)')).toContain('render');
expect(names('mid1()')).toContain('mid1');
});
it('reads as the words alone did, and a query of nothing else finds nothing', () => {
expect(names('leaf(entry)')).toEqual(names('leaf entry'));
expect(names('leaf()')).toEqual(names('leaf'));
expect(names('()')).toEqual([]);
});
});
+6 -1
View File
@@ -2147,9 +2147,14 @@ export class QueryBuilder {
// so treat it as whitespace before the strip step. Otherwise queries
// like `stage_apply::run` collapse to `stage_applyrun` (the colons
// are stripped without splitting) and find nothing. See #173.
//
// The other FTS5 syntax characters separate words too, as they do in
// the index, which splits text at anything but letters and digits:
// `leaf(entry)` is stored as `leaf` and `entry`. Dropping them glued a
// pasted call into one word no symbol has (`leafentry`, #2971).
const ftsQuery = query
.replace(/::/g, ' ') // Rust/C++/Ruby qualifier separator
.replace(/['"*():^]/g, '') // Remove FTS5 special chars
.replace(/['"*():^]/g, ' ') // FTS5 syntax characters separate words
.split(/\s+/)
.filter(term => term.length > 0)
// Strip FTS5 boolean operators to prevent query manipulation