Bläddra i källkod

fix grep exact file path searches (#22356)

Kit Langton 4 dagar sedan
förälder
incheckning
a06f40297b

+ 5 - 1
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(

+ 4 - 0
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

+ 9 - 3
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,

+ 19 - 0
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) => {

+ 81 - 0
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")
+        }
+      }),
+    ),
+  )
+})

+ 21 - 0
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")
+      }),
+    ),
+  )
 })