Skip to content

fix(search): apply hard filters before candidate limits - #2254

Closed
rudycelekli wants to merge 3 commits into
colbymchenry:mainfrom
rudycelekli:fix/search-filter-before-limits-20261001
Closed

rudycelekli wants to merge 3 commits into
colbymchenry:mainfrom
rudycelekli:fix/search-filter-before-limits-20261001

Conversation

@rudycelekli

@rudycelekli rudycelekli commented Oct 1, 2026 •

Copy link
Copy Markdown

Summary

  • Apply literal, case-insensitive path: and name: filters while collecting FTS, substring, filter-only and exact-name candidates, before unrelated rows consume their budgets.
  • Restrict fuzzy names to eligible definitions before the existing follow-up cap (minimum 50 names); keep the existing bounded per-name lookup.
  • Honor offset among matching filter-only candidates and preserve Unicode case folding, literal %/_, repeated-filter semantics and the unfiltered SQL limit path.

Reproduction

On main 34ede4de (package 1.6.1), index 120 authenticate* functions outside product/ and one authenticateUserHandler inside it. searchNodes('authenticate path:product/', {limit: 1}) returns nothing; a large limit finds the symbol. The MCP search consequently reports “No results found” for an indexed match. Filter-only queries have the same problem when unrelated names fill the initial alphabetical pool.

A typo query also loses an eligible definition when more than 50 similar names outside its path fill the fuzzy follow-up cap. This patch applies the hard filters before both row and fuzzy-name budgets. The compiled public API and MCP now return the actual match at limits 1, 2 and 10; genuinely absent filters remain empty.

Validation

  • npm run build passed.
  • Repository Vitest setup: search-query-parser.test.ts, context-ranking.test.ts and fts5-fallback.test.ts: 55 tests passed on Node 22.19.0 with native SQLite.
  • Twelve public/native regression cases cover FTS, substring fallback, typo fallback with 64 unrelated names, filter-only queries, Unicode, literal wildcard characters, repeated filters, ranking/scope controls, offset and MCP success-shaped absence.
  • Original query implementation fails all twelve new cases. Removing only the fuzzy eligibility pass fails its cap regression while the other eleven pass.
  • Compiled public API/MCP reproduction and independent replay passed on Node 22/native SQLite 3.50.4. Original public reproduction used supported Node 24.19.0/native SQLite 3.53.3.
  • Independent source/evidence review passed. Full suite and upstream CI have not been claimed.

The literal substring and repeated-filter contract from #131 is retained. No active duplicate was found in the open PR inventory.

Current-main maintenance (2026-10-03)

Updated this PR against current main 6560052a6f856855d3f71eee838fd66ccfa4285d (release 1.6.2). The only conflict was the release-note promotion: the pending fix stays under Unreleased, and the entire published 1.6.2 changelog section is byte-identical to upstream. Production and test files retain Git's automatic merge result, with no manual changes.

Tested signed head aafccdb1112718f8c82d9a5c88389ce1c0732cd6 on Node 22.19.0 with native SQLite and the repository's normal Vitest/global build setup: 55 passed across search parser, context ranking and FTS fallback suites. The complete npm run build also passed, including the viewer and all 29 copied grammars. Validation remains scoped to those relevant suites; I have not claimed the full test suite or broader language/framework efficacy. No release/version/tag changes.

October 21 conflict reconciliation and validation

Normal merge of f66d064279c02f483c33602abec2e5ce9e13522a, preserving the focused fix and upstream history. Exact head: fe69b1d3eb0ab6bfccb75a9f329e8523ba34a5b3. The resulting tree also integrates cleanly with current main 81fd007850ca8af8b88819d2d33ede4f59aa7361; native validation below is specifically on the frozen merge tree, not that newer integration.

All 12 added regression cases passed on both Linux and Windows. Build, library build and all eight exact-tree identity guards passed across the four repairs. Full suites are not green:

Platform Passed Failed Skipped
ubuntu-24.04 7700 1 369
windows-latest 7633 21 416

Native full-suite run. The Linux dedup failure also reproduces on exact main: 25 passed, 1 failed. A matched Windows baseline 93-case diagnostic reproduced 12 failure pairs with identical test bytes (78 passed, 12 failed, 3 skipped). Other Windows timeouts/daemon teardown failures remain unclassified; the Windows dedup failure was not directly baselined. These are failed tests, not waived or green checks.

Windows proof used a hosted x64 Windows VM, not the maintainer ARM64 VM. Linux proof used an Ubuntu 24.04 hosted VM, not the Docker recipe. New upstream CI and maintainer review remain pending; no merge-readiness claim. AI assistance was used for reconciliation, investigation and validation.

Signed-off-by: Rudy Celekli <47457359+rudycelekli@users.noreply.github.com>
Signed-off-by: Rudy Celekli <47457359+rudycelekli@users.noreply.github.com>
@rudycelekli

Copy link
Copy Markdown
Author

Updated this PR against current main 6560052a6f856855d3f71eee838fd66ccfa4285d (release 1.6.2). The only conflict was the release-note promotion: the pending fix stays under Unreleased, and the entire published 1.6.2 changelog section is byte-identical to upstream. Production and test files retain Git's automatic merge result, with no manual changes.

Tested signed head aafccdb1112718f8c82d9a5c88389ce1c0732cd6 on Node 22.19.0 with native SQLite and the repository's normal Vitest/global build setup: 55 passed across search parser, context ranking and FTS fallback suites. The complete npm run build also passed, including the viewer and all 29 copied grammars. Validation remains scoped to those relevant suites; I have not claimed the full test suite or broader language/framework efficacy. No release/version/tag changes.

Resolve conflict while preserving the focused fix and upstream history.
Native focused regressions pass; full-suite failures remain disclosed.
colbymchenry added a commit that referenced this pull request Oct 29, 2026
QueryBuilder.searchNodes applied `path:` and `name:` only to its final
list, after every candidate source had cut its rows to the limit: full-text
search to max(limit*5, 100) rows, the substring fallback to `limit`, the
typo fallback to 50 names, a filter-only search to the first limit*5 names
in name order, and the exact-name supplement to 20 rows. Whenever enough
symbols outside the filters ranked first, nothing the filters asked for was
left, so `codegraph query "authenticate path:product/"` found nothing at
limits 1, 2 and 10, and found the match at 200. That broke the query
parser's contract: filters narrow the set and the search ranks within it.

Every source now applies the filters before its own limit and offset. Its
statement is streamed and JavaScript decides each row, so matching keeps
toLowerCase's Unicode folding and literal substrings (SQLite's lower() and
LIKE fold ASCII only, and LIKE reads % and _ as wildcards). A page and its
offset count only the rows that pass, and the read stops once the page is
full. To spare JavaScript the rows that cannot pass, each statement also
carries a SQL pre-filter that keeps every row the filters could pass:
instr(lower(...)) for a printable ASCII value, which agrees exactly with
JavaScript on printable ASCII text, while text with any other character
passes through to JavaScript. The typo fallback looks names up until it
has its results instead of giving up after 50 names, and skips names that
`name:` rules out. A filter that no symbol's name, or no symbol's file,
passes returns nothing at once, checked against the memoized name list and
a memoized list of file paths. The old final filter stays as a safety net,
now before the cut. A search without these filters runs the same SQL as
before, except that a filter-only search now honours its offset (the same
rows at offset 0, which every caller in the codebase passes).

Validation:
- Kit repro through `codegraph query --json`: on main all 18 filtered
  queries (6 queries at limits 1, 2 and 10) returned nothing; with the fix
  each returns its match.
- 9 new tests in search-field-filters.test.ts; 7 fail on main's source.
  Mutations caught: each of the five candidate sources left unfiltered, the
  offset ignored, the non-ASCII pass-through removed or kept only for
  non-ASCII values, and the JavaScript check removed.
- Latency A/B, main vs fix in one process on one index, 5 interleaved
  rounds of the median of 5 calls: vscode src/vs/base + src/vs/editor
  (56,406 nodes) and a 10x replica of it (564,060 nodes). The 45 unfiltered
  queries (explore's kinds at limit 16, plain limit 10) return identical
  ids and scores: 89.7 vs 88.4 ms and 435.7 vs 441.1 ms in total. On the
  replica the filtered queries now find their matches in 0.2-26 ms; the
  slowest are a substring fallback that finds nothing (156 ms, what an
  unfiltered miss already costs) and `kind:method path:...` (116 ms). A
  filter nothing passes answers in 0.2 ms instead of reading every row.

Reimplements PR #2254 by @rudycelekli.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
colbymchenry added a commit that referenced this pull request Oct 29, 2026
QueryBuilder.searchNodes applied `path:` and `name:` only to its final
list, after every candidate source had cut its rows to the limit: full-text
search to max(limit*5, 100) rows, the substring fallback to `limit`, the
typo fallback to 50 names, a filter-only search to the first limit*5 names
in name order, and the exact-name supplement to 20 rows. Whenever enough
symbols outside the filters ranked first, nothing the filters asked for was
left, so `codegraph query "authenticate path:product/"` found nothing at
limits 1, 2 and 10, and found the match at 200. That broke the query
parser's contract: filters narrow the set and the search ranks within it.

Every source now applies the filters before its own limit and offset. Its
statement is streamed and JavaScript decides each row, so matching keeps
toLowerCase's Unicode folding and literal substrings (SQLite's lower() and
LIKE fold ASCII only, and LIKE reads % and _ as wildcards). A page and its
offset count only the rows that pass, and the read stops once the page is
full. To spare JavaScript the rows that cannot pass, each statement also
carries a SQL pre-filter that keeps every row the filters could pass:
instr(lower(...)) for a printable ASCII value, which agrees exactly with
JavaScript on printable ASCII text, while text with any other character
passes through to JavaScript. The typo fallback looks names up until it
has its results instead of giving up after 50 names, and skips names that
`name:` rules out. A filter that no symbol's name, or no symbol's file,
passes returns nothing at once, checked against the memoized name list and
a memoized list of file paths. The old final filter stays as a safety net,
now before the cut. A search without these filters runs the same SQL as
before, except that a filter-only search now honours its offset (the same
rows at offset 0, which every caller in the codebase passes).

Validation:
- Kit repro through `codegraph query --json`: on main all 18 filtered
  queries (6 queries at limits 1, 2 and 10) returned nothing; with the fix
  each returns its match.
- 9 new tests in search-field-filters.test.ts; 7 fail on main's source.
  Mutations caught: each of the five candidate sources left unfiltered, the
  offset ignored, the non-ASCII pass-through removed or kept only for
  non-ASCII values, and the JavaScript check removed.
- Latency A/B, main vs fix in one process on one index, 5 interleaved
  rounds of the median of 5 calls: vscode src/vs/base + src/vs/editor
  (56,406 nodes) and a 10x replica of it (564,060 nodes). The 45 unfiltered
  queries (explore's kinds at limit 16, plain limit 10) return identical
  ids and scores: 89.7 vs 88.4 ms and 435.7 vs 441.1 ms in total. On the
  replica the filtered queries now find their matches in 0.2-26 ms; the
  slowest are a substring fallback that finds nothing (156 ms, what an
  unfiltered miss already costs) and `kind:method path:...` (116 ms). A
  filter nothing passes answers in 0.2 ms instead of reading every row.

Reimplements PR #2254 by @rudycelekli.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
colbymchenry added a commit that referenced this pull request Oct 29, 2026
…3018)

QueryBuilder.searchNodes applied `path:` and `name:` only to its final
list, after every candidate source had cut its rows to the limit: full-text
search to max(limit*5, 100) rows, the substring fallback to `limit`, the
typo fallback to 50 names, a filter-only search to the first limit*5 names
in name order, and the exact-name supplement to 20 rows. Whenever enough
symbols outside the filters ranked first, nothing the filters asked for was
left, so `codegraph query "authenticate path:product/"` found nothing at
limits 1, 2 and 10, and found the match at 200. That broke the query
parser's contract: filters narrow the set and the search ranks within it.

Every source now applies the filters before its own limit and offset. Its
statement is streamed and JavaScript decides each row, so matching keeps
toLowerCase's Unicode folding and literal substrings (SQLite's lower() and
LIKE fold ASCII only, and LIKE reads % and _ as wildcards). A page and its
offset count only the rows that pass, and the read stops once the page is
full. To spare JavaScript the rows that cannot pass, each statement also
carries a SQL pre-filter that keeps every row the filters could pass:
instr(lower(...)) for a printable ASCII value, which agrees exactly with
JavaScript on printable ASCII text, while text with any other character
passes through to JavaScript. The typo fallback looks names up until it
has its results instead of giving up after 50 names, and skips names that
`name:` rules out. A filter that no symbol's name, or no symbol's file,
passes returns nothing at once, checked against the memoized name list and
a memoized list of file paths. The old final filter stays as a safety net,
now before the cut. A search without these filters runs the same SQL as
before, except that a filter-only search now honours its offset (the same
rows at offset 0, which every caller in the codebase passes).

Validation:
- Kit repro through `codegraph query --json`: on main all 18 filtered
  queries (6 queries at limits 1, 2 and 10) returned nothing; with the fix
  each returns its match.
- 9 new tests in search-field-filters.test.ts; 7 fail on main's source.
  Mutations caught: each of the five candidate sources left unfiltered, the
  offset ignored, the non-ASCII pass-through removed or kept only for
  non-ASCII values, and the JavaScript check removed.
- Latency A/B, main vs fix in one process on one index, 5 interleaved
  rounds of the median of 5 calls: vscode src/vs/base + src/vs/editor
  (56,406 nodes) and a 10x replica of it (564,060 nodes). The 45 unfiltered
  queries (explore's kinds at limit 16, plain limit 10) return identical
  ids and scores: 89.7 vs 88.4 ms and 435.7 vs 441.1 ms in total. On the
  replica the filtered queries now find their matches in 0.2-26 ms; the
  slowest are a substring fallback that finds nothing (156 ms, what an
  unfiltered miss already costs) and `kind:method path:...` (116 ms). A
  filter nothing passes answers in 0.2 ms instead of reading every row.

Reimplements PR #2254 by @rudycelekli.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@colbymchenry

Copy link
Copy Markdown
Owner

Thanks, @rudycelekli, for finding that path: and name: filters were applied only after every search source had already cut its results to the limit, so a real match could vanish at small limits. We fixed it in #3018 with an independent implementation: each source now applies the filters before its own limit and offset, matching keeps JavaScript's Unicode case folding and literal %/_, and searches without filters run exactly as before. You're credited in the changelog, so we're closing this one in favor of #3018; it ships in the next release.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants