opencode/packages/core/test/location-search.test.ts
opencode-agent[bot] b0a929440b chore: generate
2026-06-04 03:03:39 +00:00

286 lines
13 KiB
TypeScript

import fs from "fs/promises"
import path from "path"
import { describe, expect, test } from "bun:test"
import { Cause, Effect, Exit, Layer, Schema } from "effect"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Location } from "@opencode-ai/core/location"
import { FileSystem } from "@opencode-ai/core/filesystem"
import { LocationSearch } from "@opencode-ai/core/location-search"
import { AppProcess } from "@opencode-ai/core/process"
import { Ripgrep as FileSystemRipgrep } from "@opencode-ai/core/filesystem/ripgrep"
import { ProjectReference } from "@opencode-ai/core/project-reference"
import { Ripgrep } from "@opencode-ai/core/ripgrep"
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
import { tmpdir } from "./fixture/tmpdir"
import { location } from "./fixture/location"
import { it } from "./lib/effect"
const inertReferences = references({})
function provide(directory: string, projectReferences = inertReferences) {
const dependencies = Layer.mergeAll(
FSUtil.defaultLayer,
FileSystemRipgrep.defaultLayer,
AppProcess.defaultLayer,
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
Layer.succeed(ProjectReference.Service, projectReferences),
)
const filesystem = FileSystem.layer.pipe(Layer.provide(dependencies))
const search = LocationSearch.layer.pipe(
Layer.provide(filesystem),
Layer.provide(Ripgrep.layer.pipe(Layer.provide(dependencies))),
Layer.provide(FSUtil.defaultLayer),
Layer.provide(dependencies),
)
return Effect.provide(Layer.merge(filesystem, search))
}
function withTmp<A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) {
return Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(Effect.flatMap((tmp) => f(tmp.path)))
}
describe("LocationSearch", () => {
it.live("searches files in the active Location with structured bounded results", () =>
withTmp((directory) =>
Effect.gen(function* () {
yield* Effect.promise(async () => {
await fs.mkdir(path.join(directory, "src"))
await fs.writeFile(path.join(directory, "src", "index.ts"), "export const value = 1\n")
await fs.writeFile(path.join(directory, "notes.txt"), "notes\n")
})
const result = yield* (yield* LocationSearch.Service).files({ pattern: "*.ts" })
const canonical = yield* Effect.promise(() => fs.realpath(path.join(directory, "src", "index.ts")))
expect(result).toMatchObject({ truncated: false, partial: false })
expect(result.items).toHaveLength(1)
expect(result.items[0]).toMatchObject({
path: RelativePath.make("src/index.ts"),
canonical,
resource: "src/index.ts",
})
expect(typeof result.items[0].mtime).toBe("number")
}).pipe(provide(directory)),
),
)
it.live("searches files under a relative subdirectory and named local reference", () =>
withTmp((directory) => {
const docs = path.join(directory, "docs")
return Effect.gen(function* () {
yield* Effect.promise(async () => {
await fs.mkdir(path.join(directory, "src"))
await fs.mkdir(docs)
await fs.writeFile(path.join(directory, "src", "active.ts"), "active\n")
await fs.writeFile(path.join(docs, "guide.md"), "guide\n")
})
const search = yield* LocationSearch.Service
expect(
(yield* search.files({ pattern: "*.ts", path: RelativePath.make("src") })).items.map((item) => item.path),
).toEqual([RelativePath.make("src/active.ts")])
const guide = yield* Effect.promise(() => fs.realpath(path.join(docs, "guide.md")))
expect((yield* search.files({ pattern: "*.md", reference: "docs" })).items).toMatchObject([
{ path: RelativePath.make("guide.md"), resource: "docs:guide.md", canonical: guide },
])
}).pipe(provide(directory, references({ docs: { name: "docs", kind: "local", path: docs } })))
}),
)
it.live("greps the Location, exact relative files and directories, and include globs", () =>
withTmp((directory) =>
Effect.gen(function* () {
yield* Effect.promise(async () => {
await fs.mkdir(path.join(directory, "src"))
await fs.writeFile(path.join(directory, "src", "one.ts"), "needle ts\n")
await fs.writeFile(path.join(directory, "src", "two.txt"), "needle txt\n")
await fs.writeFile(path.join(directory, "root.md"), "needle root\n")
})
const search = yield* LocationSearch.Service
expect((yield* search.grep({ pattern: "needle" })).items.map((item) => item.path).sort()).toEqual([
RelativePath.make("root.md"),
RelativePath.make("src/one.ts"),
RelativePath.make("src/two.txt"),
])
expect(
(yield* search.grep({ pattern: "needle", path: RelativePath.make("src") })).items
.map((item) => item.path)
.sort(),
).toEqual([RelativePath.make("src/one.ts"), RelativePath.make("src/two.txt")])
expect((yield* search.grep({ pattern: "needle", path: RelativePath.make("src/one.ts") })).items).toMatchObject([
{ path: RelativePath.make("src/one.ts"), resource: "src/one.ts", lines: "needle ts\n", line: 1, offset: 0 },
])
expect((yield* search.grep({ pattern: "needle", include: "*.ts" })).items.map((item) => item.path)).toEqual([
RelativePath.make("src/one.ts"),
])
}).pipe(provide(directory)),
),
)
it.live("does not discover hidden files during broad V2 searches", () =>
withTmp((directory) =>
Effect.gen(function* () {
yield* Effect.promise(async () => {
await fs.mkdir(path.join(directory, "nested", ".private"), { recursive: true })
await fs.writeFile(path.join(directory, "visible.txt"), "needle visible\n")
await fs.writeFile(path.join(directory, ".env"), "needle root secret\n")
await fs.writeFile(path.join(directory, "nested", "visible.txt"), "needle nested visible\n")
await fs.writeFile(path.join(directory, "nested", ".env"), "needle nested secret\n")
await fs.writeFile(path.join(directory, "nested", ".private", "secret.txt"), "needle hidden directory\n")
})
const search = yield* LocationSearch.Service
expect((yield* search.files({ pattern: "*" })).items.map((item) => item.path).sort()).toEqual([
RelativePath.make("nested/visible.txt"),
RelativePath.make("visible.txt"),
])
expect((yield* search.files({ pattern: ".env" })).items).toEqual([])
expect((yield* search.grep({ pattern: "needle", include: "*" })).items.map((item) => item.path).sort()).toEqual(
[RelativePath.make("nested/visible.txt"), RelativePath.make("visible.txt")],
)
}).pipe(provide(directory)),
),
)
it.live("caps result counts and line previews", () =>
withTmp((directory) =>
Effect.gen(function* () {
yield* Effect.promise(async () => {
await Promise.all(
Array.from({ length: 101 }, (_, index) => fs.writeFile(path.join(directory, `${index}.txt`), "needle\n")),
)
await fs.writeFile(
path.join(directory, "long.txt"),
`needle ${"x".repeat(LocationSearch.MAX_LINE_PREVIEW_LENGTH)}\n`,
)
})
const search = yield* LocationSearch.Service
const files = yield* search.files({ pattern: "*.txt", limit: 2 })
const hardCappedFiles = yield* search.files({ pattern: "*.txt", limit: LocationSearch.MAX_RESULT_LIMIT + 1 })
const hardCappedGrep = yield* search.grep({ pattern: "needle", limit: LocationSearch.MAX_RESULT_LIMIT + 1 })
const grep = yield* search.grep({ pattern: "needle", path: RelativePath.make("long.txt") })
expect(files.items).toHaveLength(2)
expect(files.truncated).toBe(true)
expect(hardCappedFiles.items).toHaveLength(LocationSearch.MAX_RESULT_LIMIT)
expect(hardCappedFiles.truncated).toBe(true)
expect(hardCappedGrep.items).toHaveLength(LocationSearch.MAX_RESULT_LIMIT)
expect(hardCappedGrep.truncated).toBe(true)
expect(grep.items[0].lines).toHaveLength(LocationSearch.MAX_LINE_PREVIEW_LENGTH)
expect(grep.items[0].linePreviewTruncated).toBe(true)
}).pipe(provide(directory)),
),
)
it.live("reports invalid regex as a typed failure", () =>
withTmp((directory) =>
Effect.gen(function* () {
yield* Effect.promise(() => fs.writeFile(path.join(directory, "notes.txt"), "notes\n"))
const exit = yield* (yield* LocationSearch.Service).grep({ pattern: "[" }).pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Ripgrep.InvalidPatternError)
}).pipe(provide(directory)),
),
)
it.live("rejects oversized ripgrep JSON records before durable projection", () =>
withTmp((directory) =>
Effect.gen(function* () {
yield* Effect.promise(() =>
fs.writeFile(path.join(directory, "huge.txt"), `needle ${"x".repeat(Ripgrep.MAX_RECORD_BYTES)}\n`),
)
const exit = yield* (yield* LocationSearch.Service).grep({ pattern: "needle" }).pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) expect(String(Cause.squash(exit.cause))).toContain("Ripgrep JSON record exceeded")
}).pipe(provide(directory)),
),
)
it.live("rejects lexical and symlink escapes through root resolution", () =>
withTmp((directory) =>
Effect.gen(function* () {
if (process.platform === "win32") return
const outside = `${directory}-outside`
yield* Effect.promise(async () => {
await fs.mkdir(outside)
await fs.writeFile(path.join(outside, "secret.txt"), "secret\n")
await fs.symlink(outside, path.join(directory, "escape"))
})
const search = yield* LocationSearch.Service
expect(
Exit.isFailure(
yield* search.files({ pattern: "*", path: RelativePath.make("../outside") }).pipe(Effect.exit),
),
).toBe(true)
expect(
Exit.isFailure(yield* search.files({ pattern: "*", path: RelativePath.make("escape") }).pipe(Effect.exit)),
).toBe(true)
yield* Effect.promise(() => fs.rm(outside, { recursive: true, force: true }))
}).pipe(provide(directory)),
),
)
it.live("rejects an approved root swapped to a symlink before ripgrep traversal", () =>
withTmp((directory) =>
Effect.gen(function* () {
if (process.platform === "win32") return
const source = path.join(directory, "src")
const outside = `${directory}-outside`
yield* Effect.promise(async () => {
await fs.mkdir(source)
await fs.mkdir(outside)
await fs.writeFile(path.join(outside, "secret.txt"), "secret\n")
})
const filesystem = yield* FileSystem.Service
const approved = yield* filesystem.resolveRoot({ path: RelativePath.make("src") })
yield* Effect.promise(async () => {
await fs.rmdir(source)
await fs.symlink(outside, source)
})
expect(
Exit.isFailure(yield* (yield* LocationSearch.Service).files({ pattern: "*" }, approved).pipe(Effect.exit)),
).toBe(true)
yield* Effect.promise(() => fs.rm(outside, { recursive: true, force: true }))
}).pipe(provide(directory)),
),
)
it.live("honors a pre-aborted cancellation signal", () =>
withTmp((directory) =>
Effect.gen(function* () {
const controller = new AbortController()
controller.abort()
const exit = yield* (yield* LocationSearch.Service)
.files({ pattern: "*", signal: controller.signal })
.pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
}).pipe(provide(directory)),
),
)
test("exposes schema-testable search bounds", () => {
const decode = Schema.decodeUnknownSync(LocationSearch.FilesInput)
expect(LocationSearch.DEFAULT_RESULT_LIMIT).toBe(100)
expect(LocationSearch.MAX_RESULT_LIMIT).toBe(100)
expect(LocationSearch.MAX_LINE_PREVIEW_LENGTH).toBe(2_000)
expect(() => decode({ pattern: "*", limit: LocationSearch.MAX_RESULT_LIMIT + 1 })).toThrow()
})
})
function references(entries: Record<string, ProjectReference.Resolved>) {
return ProjectReference.Service.of({
list: () => Effect.succeed(Object.values(entries)),
get: (name) => Effect.succeed(entries[name]),
resolveMention: () => Effect.succeed(undefined),
ensurePath: () => Effect.void,
containsManagedPath: () => Effect.succeed(false),
})
}