diff --git a/packages/pi-fff/src/index.ts b/packages/pi-fff/src/index.ts index 5a7e64cca..f2cedcd88 100644 --- a/packages/pi-fff/src/index.ts +++ b/packages/pi-fff/src/index.ts @@ -5,6 +5,7 @@ * @-mention autocomplete suggestions to the interactive editor. */ +import fs from "node:fs"; import nodePath from "node:path"; import type { ExtensionAPI, @@ -28,7 +29,7 @@ import { Type, type TSchema } from "@sinclair/typebox"; import { AuxFinderPool, routePathConstraint } from "./aux-finders"; import { type FffMode, loadConfig, VALID_MODES } from "./config"; import { FilePickerFactory } from "./file-picker"; -import { isHomeDir, resolveDbPaths } from "./paths"; +import { HOME_DIR, isHomeDir, resolveDbPaths } from "./paths"; import { buildQuery } from "./query"; export { SCAN_TIMEOUT_MS } from "./sdk"; @@ -929,7 +930,14 @@ export default function fffExtension(pi: ExtensionAPI) { // excluded / out-of-scope directories. const lastSeg = params.path?.split(/[\\/]/).pop() ?? ""; const pathTargetsFile = /\.[a-zA-Z][a-zA-Z0-9]{0,9}$/.test(lastSeg); - const fuzzyQuery = pathTargetsFile ? pattern : query; + // A pinned file that exists on disk is not a misnamed guess, so widening + // past it would answer a different question with repo-wide noise (#830). + const pinnedFile = + pathTargetsFile && params.path + ? resolvePinnedFile(params.path, activeCwd) + : null; + const dropConstraint = pathTargetsFile && !pinnedFile; + const fuzzyQuery = dropConstraint ? pattern : query; const fuzzy = picker.grep(fuzzyQuery, { mode: "fuzzy", smartCase, @@ -943,8 +951,15 @@ export default function fffExtension(pi: ExtensionAPI) { }); if (fuzzy.ok && fuzzy.value.items.length > 0) { - fuzzyNotice = `0 exact matches. Maybe you meant this?`; + fuzzyNotice = dropConstraint + ? `0 exact matches, path constraint dropped (no such file: ${params.path}); results are repo-wide` + : `0 exact matches. Maybe you meant this?`; result = fuzzy.value; + } else if ( + pinnedFile && + !isIndexedFile(picker, aux ? aux.root : activeCwd, pinnedFile) + ) { + fuzzyNotice = `${params.path} is not indexed (gitignored or excluded)`; } } @@ -1335,3 +1350,35 @@ export default function fffExtension(pi: ExtensionAPI) { }, }); } + +// Absolute path of an existing regular file named by a `path` constraint, or +// null when the constraint is a glob or points at nothing / a directory. +function resolvePinnedFile(pathParam: string, cwd: string): string | null { + const trimmed = pathParam.trim(); + if (!trimmed || /[*?[{]/.test(trimmed)) return null; + const expanded = + trimmed === "~" || trimmed.startsWith("~/") + ? nodePath.join(HOME_DIR, trimmed.slice(1)) + : trimmed; + + try { + const abs = nodePath.resolve(cwd, expanded); + return fs.statSync(abs).isFile() ? abs : null; + } catch { + return null; + } +} + +// Whether the picker's index holds `absPath`. Runs only on the zero-result path, +// so the extra index pass never touches a hot query. +function isIndexedFile(picker: FileFinderApi, root: string, absPath: string): boolean { + const rel = nodePath.relative(root, absPath).replaceAll(nodePath.sep, "/"); + if (!rel || rel.startsWith("../")) return false; + + try { + const result = picker.glob(rel, { pageSize: 1 }); + return result.ok && result.value.items.some((file) => file.relativePath === rel); + } catch { + return true; + } +} diff --git a/packages/pi-fff/test/extension.test.ts b/packages/pi-fff/test/extension.test.ts index c6820f207..b072654b6 100644 --- a/packages/pi-fff/test/extension.test.ts +++ b/packages/pi-fff/test/extension.test.ts @@ -7,6 +7,8 @@ type MockFinder = { isDestroyed: boolean; waitForScan: ReturnType; mixedSearch: ReturnType; + grep: ReturnType; + glob: ReturnType; getScanProgress: ReturnType; destroy: ReturnType; }; @@ -14,8 +16,17 @@ type MockFinder = { const createCalls: unknown[] = []; let finders: MockFinder[] = []; let mixedSearchImpl: ((query: string, options: unknown) => unknown) | undefined; +let grepImpl: ((query: string, options: any) => unknown) | undefined; +let globImpl: ((pattern: string, options: any) => unknown) | undefined; let scanProgressImpl: (() => unknown) | undefined; +function emptyGrepResult() { + return { + ok: true, + value: { items: [], totalMatched: 0, totalFiles: 0, nextCursor: null }, + }; +} + function createMockFinder(): MockFinder { return { isDestroyed: false, @@ -45,6 +56,14 @@ function createMockFinder(): MockFinder { }, }; }), + grep: mock((query: string, options: any) => { + if (grepImpl) return grepImpl(query, options); + return emptyGrepResult(); + }), + glob: mock((pattern: string, options: any) => { + if (globImpl) return globImpl(pattern, options); + return { ok: true, value: { items: [], totalMatched: 0, totalFiles: 0 } }; + }), destroy: mock(function (this: MockFinder) { this.isDestroyed = true; }), @@ -203,6 +222,8 @@ beforeEach(() => { createCalls.length = 0; finders = []; mixedSearchImpl = undefined; + grepImpl = undefined; + globImpl = undefined; scanProgressImpl = undefined; for (const key of CONFIG_ENV_KEYS) delete process.env[key]; @@ -461,6 +482,126 @@ describe("pi-fff $HOME scan warning", () => { }); }); +// Regression for #830: a path constraint pinning an unindexed file must not be +// silently dropped, turning the grep into a repo-wide fuzzy search. +describe("pi-fff grep fuzzy fallback", () => { + const grepWorkspace = fs.mkdtempSync(path.join(os.tmpdir(), "pi-fff-grep-")); + fs.mkdirSync(path.join(grepWorkspace, "node_modules", "react"), { recursive: true }); + fs.writeFileSync( + path.join(grepWorkspace, "node_modules", "react", "package.json"), + JSON.stringify({ exports: { "./jsx-runtime": "./jsx-runtime.js" } }), + ); + + afterAll(() => fs.rmSync(grepWorkspace, { recursive: true, force: true })); + + // Mimics the native picker: the constrained query hits nothing because + // node_modules is excluded from the index, an unconstrained one matches + // arbitrary frecency-ranked source files. + function excludedIndexGrep(query: string) { + if (query.includes("node_modules")) return emptyGrepResult(); + return { + ok: true, + value: { + items: [ + { + relativePath: "src/renderer/App.tsx", + lineNumber: 12, + lineContent: " useEffect(() => {", + }, + ], + totalMatched: 1, + totalFiles: 1, + nextCursor: null, + }, + }; + } + + async function grepTool(cwd: string) { + const setup = await start(undefined, cwd); + const tool = setup.pi.registerTool.mock.calls + .map(([t]) => t) + .find((t) => t.name === "grep" || t.name === "ffgrep"); + expect(tool).toBeDefined(); + return { setup, tool }; + } + + test("keeps the constraint when the pinned file exists but is not indexed", async () => { + grepImpl = (query) => excludedIndexGrep(query); + const { setup, tool } = await grepTool(grepWorkspace); + + const result = await tool.execute("call-1", { + pattern: "jsx-runtime", + path: "node_modules/react/package.json", + }); + + const text = result.content[0].text; + expect(text).not.toContain("src/renderer/App.tsx"); + expect(text).toContain("No matches found"); + expect(text).toContain("not indexed"); + // Every grep call must have carried the constraint. + for (const [query] of finders[0].grep.mock.calls) { + expect(query).toContain("node_modules/react/package.json"); + } + await shutdown(setup); + }); + + test("broadens for a misnamed file but says the constraint was dropped", async () => { + grepImpl = (query) => { + if (query.includes("does-not-exist.ts")) return emptyGrepResult(); + return excludedIndexGrep("jsx-runtime"); + }; + const { setup, tool } = await grepTool(grepWorkspace); + + const result = await tool.execute("call-2", { + pattern: "jsx-runtime", + path: "src/does-not-exist.ts", + }); + + const text = result.content[0].text; + expect(text).toContain("src/renderer/App.tsx"); + expect(text).toContain("path constraint dropped"); + await shutdown(setup); + }); + + test("an indexed pinned file with no hits stays a plain miss", async () => { + fs.writeFileSync(path.join(grepWorkspace, "package.json"), "{}"); + grepImpl = () => emptyGrepResult(); + globImpl = (pattern) => ({ + ok: true, + value: { + items: [{ relativePath: pattern, fileName: pattern, gitStatus: "clean" }], + totalMatched: 1, + totalFiles: 1, + }, + }); + const { setup, tool } = await grepTool(grepWorkspace); + + const result = await tool.execute("call-3", { + pattern: "jsx-runtime", + path: "package.json", + }); + + expect(result.content[0].text).toBe("No matches found"); + await shutdown(setup); + }); + + test("directory constraints keep the constrained fuzzy query", async () => { + grepImpl = (query) => excludedIndexGrep(query); + const { setup, tool } = await grepTool(grepWorkspace); + + const result = await tool.execute("call-4", { + pattern: "jsx-runtime", + path: "node_modules/react/", + }); + + expect(result.content[0].text).toContain("No matches found"); + for (const [query] of finders[0].grep.mock.calls) { + expect(query).toContain("node_modules/react/"); + } + await shutdown(setup); + }); +}); + describe("pi-fff autocomplete registration", () => { test("session_start registers a provider without replacing the editor", async () => { const { ctx } = await start();