Skip to content

[Bug]: pi-fff ffgrep cursor drops matches beyond the per-file limit #825

Description

@HerbertGao

Environment

  • @ff-labs/pi-fff: 0.10.5
  • @ff-labs/fff-node / @ff-labs/fff-bun: 0.10.5
  • @earendil-works/pi-coding-agent: 0.84.2
  • macOS arm64
  • Reproduced with Node 22.23.1 and Bun 1.4.0

Summary

ffgrep uses the requested/default limit as both pageSize and maxMatchesPerFile. Grep cursors advance by file offset, so matches beyond maxMatchesPerFile in a file are permanently unreachable: following every returned cursor still does not retrieve them.

With 33 indexed matches—30 in one file and one in each of three other files—the default limit=20 retrieves only 23. Matches 21–30 in the noisy file are never returned. A limit=50 control call returns all 33, proving the files and lines were indexed before pagination was tested.

Reproduction

Run in a fresh temporary directory:

npm init -y
npm install --ignore-scripts \
  @ff-labs/pi-fff@0.10.5 \
  @ff-labs/fff-node@0.10.5 \
  @ff-labs/fff-bun@0.10.5 \
  @earendil-works/pi-coding-agent@0.84.2 \
  @earendil-works/pi-tui@0.84.2 \
  @sinclair/typebox@0.34.49

Save as repro.mjs:

import assert from "node:assert/strict";
import { mkdir, rm, writeFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { setTimeout as delay } from "node:timers/promises";

const root = process.cwd();
const fixture = join(root, "fixture");
const state = join(root, ".fff-repro-state");

await rm(fixture, { recursive: true, force: true });
await rm(state, { recursive: true, force: true });
await mkdir(join(fixture, "src"), { recursive: true });
await writeFile(
  join(fixture, "noise.ts"),
  Array.from({ length: 30 }, (_, i) =>
    `TODO fix ${String(i + 1).padStart(2, "0")}`,
  ).join("\n") + "\n",
);
await writeFile(join(fixture, "src/app.ts"), "TODO app\n");
await writeFile(join(fixture, "src/utils.ts"), "TODO utils\n");
await writeFile(join(fixture, "README.md"), "TODO readme\n");

// Avoid reusing an existing fff.nvim or pi database.
process.env.FFF_FRECENCY_DB = join(state, "frecency");
process.env.FFF_HISTORY_DB = join(state, "history");

const loaderPath = resolve(
  root,
  "node_modules/@earendil-works/pi-coding-agent/dist/core/extensions/loader.js",
);
const extensionPath = resolve(root, "node_modules/@ff-labs/pi-fff/src/index.ts");
const { loadExtensions } = await import(pathToFileURL(loaderPath));
const loaded = await loadExtensions([extensionPath], root);
assert.equal(loaded.errors.length, 0, JSON.stringify(loaded.errors));

const extension = loaded.extensions[0];
const ffgrep = extension.tools.get("ffgrep")?.definition;
assert.ok(ffgrep, "ffgrep was not registered");

const ui = {
  notify() {},
  setStatus() {},
  addAutocompleteProvider() {},
};
for (const handler of extension.handlers.get("session_start") ?? []) {
  await handler(
    { type: "session_start", reason: "startup" },
    { cwd: fixture, ui, sessionManager: { getEntries: () => [] } },
  );
}

async function grep(params) {
  const result = await ffgrep.execute(
    "repro",
    params,
    new AbortController().signal,
  );
  const text = result.content.find((item) => item.type === "text")?.text ?? "";
  return {
    details: result.details,
    matches: [...text.matchAll(/^\s*\d+:\s+(TODO[^\n]*)$/gm)].map(
      (match) => match[1],
    ),
    cursor: text.match(/cursor="([^"]+)"/)?.[1],
  };
}

// Control: prove all 33 matches are indexed before exercising pagination.
let control;
for (let attempt = 0; attempt < 50; attempt++) {
  control = await grep({ pattern: "TODO", path: fixture, limit: 50 });
  if (control.matches.length === 33) break;
  await delay(100);
}
assert.equal(control.matches.length, 33, "index did not become ready");

let cursor;
const pages = [];
const matches = [];
for (let pageNumber = 1; pageNumber <= 10; pageNumber++) {
  const page = await grep({
    pattern: "TODO",
    path: fixture,
    ...(cursor ? { cursor } : {}),
  });
  pages.push({
    page: pageNumber,
    matches: page.matches.length,
    cursor: page.cursor ?? null,
    details: page.details,
  });
  matches.push(...page.matches);
  cursor = page.cursor;
  if (!cursor) break;
}

const expected = [
  ...Array.from(
    { length: 30 },
    (_, i) => `TODO fix ${String(i + 1).padStart(2, "0")}`,
  ),
  "TODO app",
  "TODO utils",
  "TODO readme",
];
const missing = expected.filter((match) => !matches.includes(match));

console.log(
  JSON.stringify(
    {
      runtime: process.versions.bun
        ? `bun ${process.versions.bun}`
        : `node ${process.version}`,
      controlMatches: control.matches.length,
      pages,
      retrieved: matches.length,
      missing,
    },
    null,
    2,
  ),
);

for (const handler of extension.handlers.get("session_shutdown") ?? []) {
  await handler({ type: "session_shutdown" });
}

assert.equal(matches.length, 33, "cursor pagination did not retrieve all matches");

Run either runtime:

node repro.mjs
# or
bun repro.mjs

Actual behavior

Both runtimes consistently produce:

{
  "controlMatches": 33,
  "pages": [
    { "page": 1, "matches": 21, "cursor": "fff_c1" },
    { "page": 2, "matches": 2, "cursor": null }
  ],
  "retrieved": 23,
  "missing": [
    "TODO fix 21",
    "TODO fix 22",
    "TODO fix 23",
    "TODO fix 24",
    "TODO fix 25",
    "TODO fix 26",
    "TODO fix 27",
    "TODO fix 28",
    "TODO fix 29",
    "TODO fix 30"
  ]
}

The exact per-page distribution may vary with frecency ordering, but the ten matches beyond the per-file cap remain unreachable after cursor exhaustion.

Expected behavior

Walking ffgrep cursors until no cursor remains should retrieve all indexed exact matches, or the tool should explicitly report that per-file matches were discarded and cannot appear on later pages.

This is especially important because pi-fff offers an override mode for Pi's built-in grep.

Apparent cause

Current pi-fff passes the same value for both limits:

const pageSize = Math.min(effectiveLimit, GREP_PAGE_SIZE_MAX);

picker.grep(query, {
  maxMatchesPerFile: pageSize,
  pageSize,
  cursor: ...,
});

FFF grep cursors store a file offset rather than an offset within a file. Once the first 20 matches from noise.ts are collected, the next cursor advances to later files and can never return matches 21–30 from noise.ts.

This appears related to:

Current main still passes pageSize as maxMatchesPerFile, and packages/pi-fff/test does not appear to exercise ffgrep execution or cursor completeness.

Possible directions

  • Use a cursor that can resume within a file, or otherwise decouple page size from a destructive per-file cap.
  • Add an explicit exhaustive mode if ranked preview behavior is intentional.
  • At minimum, expose a clear truncation notice when matches beyond maxMatchesPerFile cannot be retrieved.
  • Add a regression test with more than limit matches in one file and consume cursors to exhaustion.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions