fix(ast_query): reliable first-page pagination cursor

The page-1 walk was capped at max_results via SkipAll, but buildASTCursorFooter
derives the remaining count from the total number of matches seen. Capping
truncated that total, so on page 1 the cursor fired only when the last parsed
file happened to overshoot the cap — nondeterministic — and reported a wrong
remaining count when it did. Same failure class as the file_search first-page
cursor bug.

Always walk the full tree (pages 2+ already did). The common case where matches
fit in one page already visited everything (SkipAll never triggered), and parse
results are LRU-cached, so the extra cost falls only on multi-page queries and is
mostly file I/O.

Adds a strict regression test asserting page 1 emits a cursor with an accurate
remaining count; the existing pagination test silently skipped when no cursor.
This commit is contained in:
2026-06-20 01:44:47 +01:00
parent 62971c9f8c
commit 5f0bb90e28
2 changed files with 37 additions and 10 deletions
+7 -10
View File
@@ -109,12 +109,13 @@ func (s *Server) runASTQueryWalk(ctx context.Context, p *astQueryParams, exts []
},
}
// Collect limit for early-exit: when paginating we need all results first.
collectLimit := p.maxResults
if p.offset > 0 {
collectLimit = 0
}
// A full walk is required on every page: the cursor's "remaining" count is
// derived from the total match count (see buildASTCursorFooter), so the page-1
// response can only emit a correct cursor if it has seen every match. Capping
// page 1 at max_results made the cursor fire nondeterministically (only when
// the last parsed file overshot the cap) with a wrong remaining count. Parse
// results are LRU-cached, so re-walking on later pages is mostly file I/O, and
// matches below max_results already visit the whole tree regardless.
var allResults []query.MatchResult
for _, searchPath := range p.paths {
if !s.cfg.IsPathAllowed(searchPath) {
@@ -162,10 +163,6 @@ func (s *Server) runASTQueryWalk(ctx context.Context, p *astQueryParams, exts []
return nil
}
allResults = append(allResults, matches...)
if collectLimit > 0 && len(allResults) >= collectLimit {
return filepath.SkipAll
}
return nil
})
if walkErr != nil {