From a06f40297b06e3ce39c0618f4347db34074003f7 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 13 Apr 2026 19:26:50 -0400 Subject: [PATCH] fix grep exact file path searches (#22356) --- packages/opencode/src/file/ripgrep.ts | 6 +- packages/opencode/src/tool/glob.ts | 4 + packages/opencode/src/tool/grep.ts | 12 ++- packages/opencode/test/file/ripgrep.test.ts | 19 +++++ packages/opencode/test/tool/glob.test.ts | 81 +++++++++++++++++++++ packages/opencode/test/tool/grep.test.ts | 21 ++++++ 6 files changed, 139 insertions(+), 4 deletions(-) create mode 100644 packages/opencode/test/tool/glob.test.ts diff --git a/packages/opencode/src/file/ripgrep.ts b/packages/opencode/src/file/ripgrep.ts index 81cd2bf0dc..c77fbe3210 100644 --- a/packages/opencode/src/file/ripgrep.ts +++ b/packages/opencode/src/file/ripgrep.ts @@ -330,6 +330,7 @@ export namespace Ripgrep { glob?: string[] limit?: number follow?: boolean + file?: string[] }) => Effect.Effect<{ items: Item[]; partial: boolean }, PlatformError | Error> } @@ -351,6 +352,7 @@ export namespace Ripgrep { maxDepth?: number limit?: number pattern?: string + file?: string[] }) { const out = [yield* bin(), input.mode === "search" ? "--json" : "--files", "--glob=!.git/*"] if (input.follow) out.push("--follow") @@ -363,7 +365,7 @@ export namespace Ripgrep { } if (input.limit) out.push(`--max-count=${input.limit}`) if (input.mode === "search") out.push("--no-messages") - if (input.pattern) out.push("--", input.pattern) + if (input.pattern) out.push("--", input.pattern, ...(input.file ?? [])) return out }) @@ -405,6 +407,7 @@ export namespace Ripgrep { glob?: string[] limit?: number follow?: boolean + file?: string[] }) { return yield* Effect.scoped( Effect.gen(function* () { @@ -414,6 +417,7 @@ export namespace Ripgrep { follow: input.follow, limit: input.limit, pattern: input.pattern, + file: input.file, }) const handle = yield* spawner.spawn( diff --git a/packages/opencode/src/tool/glob.ts b/packages/opencode/src/tool/glob.ts index a3ff5aef71..ea0fbf0134 100644 --- a/packages/opencode/src/tool/glob.ts +++ b/packages/opencode/src/tool/glob.ts @@ -40,6 +40,10 @@ export const GlobTool = Tool.define( let search = params.path ?? Instance.directory search = path.isAbsolute(search) ? search : path.resolve(Instance.directory, search) + const info = yield* fs.stat(search).pipe(Effect.catch(() => Effect.succeed(undefined))) + if (info?.type === "File") { + throw new Error(`glob path must be a directory: ${search}`) + } yield* assertExternalDirectoryEffect(ctx, search, { kind: "directory" }) const limit = 100 diff --git a/packages/opencode/src/tool/grep.ts b/packages/opencode/src/tool/grep.ts index 9b5143cec5..10a8de9170 100644 --- a/packages/opencode/src/tool/grep.ts +++ b/packages/opencode/src/tool/grep.ts @@ -51,19 +51,25 @@ export const GrepTool = Tool.define( ? (params.path ?? Instance.directory) : path.join(Instance.directory, params.path ?? "."), ) - yield* assertExternalDirectoryEffect(ctx, searchPath, { kind: "directory" }) + const info = yield* fs.stat(searchPath).pipe(Effect.catch(() => Effect.succeed(undefined))) + const cwd = info?.type === "Directory" ? searchPath : path.dirname(searchPath) + const file = info?.type === "Directory" ? undefined : [searchPath] + yield* assertExternalDirectoryEffect(ctx, searchPath, { + kind: info?.type === "Directory" ? "directory" : "file", + }) const result = yield* rg.search({ - cwd: searchPath, + cwd, pattern: params.pattern, glob: params.include ? [params.include] : undefined, + file, }) if (result.items.length === 0) return empty const rows = result.items.map((item) => ({ path: AppFileSystem.resolve( - path.isAbsolute(item.path.text) ? item.path.text : path.join(searchPath, item.path.text), + path.isAbsolute(item.path.text) ? item.path.text : path.join(cwd, item.path.text), ), line: item.line_number, text: item.lines.text, diff --git a/packages/opencode/test/file/ripgrep.test.ts b/packages/opencode/test/file/ripgrep.test.ts index 11d212a086..cdc3493bd9 100644 --- a/packages/opencode/test/file/ripgrep.test.ts +++ b/packages/opencode/test/file/ripgrep.test.ts @@ -76,6 +76,25 @@ describe("Ripgrep.Service", () => { expect(result.items[0]?.lines.text).toContain("needle") }) + test("search supports explicit file targets", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "match.ts"), "const value = 'needle'\n") + await Bun.write(path.join(dir, "skip.ts"), "const value = 'needle'\n") + }, + }) + + const file = path.join(tmp.path, "match.ts") + const result = await Effect.gen(function* () { + const rg = yield* Ripgrep.Service + return yield* rg.search({ cwd: tmp.path, pattern: "needle", file: [file] }) + }).pipe(Effect.provide(Ripgrep.defaultLayer), Effect.runPromise) + + expect(result.partial).toBe(false) + expect(result.items).toHaveLength(1) + expect(result.items[0]?.path.text).toBe(file) + }) + test("files returns stream of filenames", async () => { await using tmp = await tmpdir({ init: async (dir) => { diff --git a/packages/opencode/test/tool/glob.test.ts b/packages/opencode/test/tool/glob.test.ts new file mode 100644 index 0000000000..092885ed18 --- /dev/null +++ b/packages/opencode/test/tool/glob.test.ts @@ -0,0 +1,81 @@ +import { describe, expect } from "bun:test" +import path from "path" +import { Cause, Effect, Exit, Layer } from "effect" +import { GlobTool } from "../../src/tool/glob" +import { SessionID, MessageID } from "../../src/session/schema" +import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" +import { Ripgrep } from "../../src/file/ripgrep" +import { AppFileSystem } from "../../src/filesystem" +import { Truncate } from "../../src/tool/truncate" +import { Agent } from "../../src/agent/agent" +import { provideTmpdirInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +const it = testEffect( + Layer.mergeAll( + CrossSpawnSpawner.defaultLayer, + AppFileSystem.defaultLayer, + Ripgrep.defaultLayer, + Truncate.defaultLayer, + Agent.defaultLayer, + ), +) + +const ctx = { + sessionID: SessionID.make("ses_test"), + messageID: MessageID.make(""), + callID: "", + agent: "build", + abort: AbortSignal.any([]), + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, +} + +describe("tool.glob", () => { + it.live("matches files from a directory path", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + yield* Effect.promise(() => Bun.write(path.join(dir, "a.ts"), "export const a = 1\n")) + yield* Effect.promise(() => Bun.write(path.join(dir, "b.txt"), "hello\n")) + const info = yield* GlobTool + const glob = yield* info.init() + const result = yield* glob.execute( + { + pattern: "*.ts", + path: dir, + }, + ctx, + ) + expect(result.metadata.count).toBe(1) + expect(result.output).toContain(path.join(dir, "a.ts")) + expect(result.output).not.toContain(path.join(dir, "b.txt")) + }), + ), + ) + + it.live("rejects exact file paths", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + const file = path.join(dir, "a.ts") + yield* Effect.promise(() => Bun.write(file, "export const a = 1\n")) + const info = yield* GlobTool + const glob = yield* info.init() + const exit = yield* glob + .execute( + { + pattern: "*.ts", + path: file, + }, + ctx, + ) + .pipe(Effect.exit) + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) { + const err = Cause.squash(exit.cause) + expect(err instanceof Error ? err.message : String(err)).toContain("glob path must be a directory") + } + }), + ), + ) +}) diff --git a/packages/opencode/test/tool/grep.test.ts b/packages/opencode/test/tool/grep.test.ts index 07ac231df0..678aeee3d4 100644 --- a/packages/opencode/test/tool/grep.test.ts +++ b/packages/opencode/test/tool/grep.test.ts @@ -90,4 +90,25 @@ describe("tool.grep", () => { }), ), ) + + it.live("supports exact file paths", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + const file = path.join(dir, "test.txt") + yield* Effect.promise(() => Bun.write(file, "line1\nline2\nline3")) + const info = yield* GrepTool + const grep = yield* info.init() + const result = yield* grep.execute( + { + pattern: "line2", + path: file, + }, + ctx, + ) + expect(result.metadata.matches).toBe(1) + expect(result.output).toContain(file) + expect(result.output).toContain("Line 2: line2") + }), + ), + ) })