From aa91c1fb193ac82311baaad2b53d02c2b1e91d9e Mon Sep 17 00:00:00 2001 From: ismeth Date: Sat, 28 Feb 2026 01:12:22 +0100 Subject: [PATCH] fix(glob): extract directory prefix from pattern for ripgrep compatibility Ripgrep's --glob flag silently returns no results when the pattern contains a directory prefix (e.g. 'apps/backend/**/*.ts'). Models naturally write patterns this way when exploring unfamiliar codebases. Extract the static directory segments from the pattern, append them to the search path, and pass only the glob portion to ripgrep. --- src/features/background-agent/manager.ts | 1 - .../glob/extract-directory-prefix.test.ts | 94 +++++++++++++++++++ src/tools/glob/extract-directory-prefix.ts | 40 ++++++++ src/tools/glob/tools.ts | 7 +- 4 files changed, 139 insertions(+), 3 deletions(-) create mode 100644 src/tools/glob/extract-directory-prefix.test.ts create mode 100644 src/tools/glob/extract-directory-prefix.ts diff --git a/src/features/background-agent/manager.ts b/src/features/background-agent/manager.ts index 74061ef54..49e20dc78 100644 --- a/src/features/background-agent/manager.ts +++ b/src/features/background-agent/manager.ts @@ -1161,7 +1161,6 @@ export class BackgroundManager { properties: props as Record, findBySession: (id) => this.findBySession(id), idleDeferralTimers: this.idleDeferralTimers, - recentlyCompactedSessions: this.recentlyCompactedSessions, validateSessionHasOutput: (id) => this.validateSessionHasOutput(id), checkSessionTodos: (id) => this.checkSessionTodos(id), nudgeCouncilMemberIfNeeded: (task, sid) => this.nudgeCouncilMemberIfNeeded(task, sid), diff --git a/src/tools/glob/extract-directory-prefix.test.ts b/src/tools/glob/extract-directory-prefix.test.ts new file mode 100644 index 000000000..124e9b689 --- /dev/null +++ b/src/tools/glob/extract-directory-prefix.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, test } from "bun:test" +import { extractDirectoryPrefix } from "./extract-directory-prefix" + +describe("extractDirectoryPrefix", () => { + describe("#given a pattern with directory prefix before glob metacharacters", () => { + test("#then extracts the prefix and returns the remaining glob", () => { + expect(extractDirectoryPrefix("apps/backend/**/*.ts")).toEqual({ + prefix: "apps/backend", + glob: "**/*.ts", + }) + }) + + test("#then handles brace expansion after prefix", () => { + expect(extractDirectoryPrefix("apps/backend/**/*.{ts,js,json}")).toEqual({ + prefix: "apps/backend", + glob: "**/*.{ts,js,json}", + }) + }) + + test("#then handles single directory prefix", () => { + expect(extractDirectoryPrefix("src/**/*.tsx")).toEqual({ + prefix: "src", + glob: "**/*.tsx", + }) + }) + + test("#then handles deep prefix", () => { + expect(extractDirectoryPrefix("packages/core/src/**/*.ts")).toEqual({ + prefix: "packages/core/src", + glob: "**/*.ts", + }) + }) + + test("#then handles brace expansion in the first glob segment", () => { + expect(extractDirectoryPrefix("src/{hooks,components}/**/*.tsx")).toEqual({ + prefix: "src", + glob: "{hooks,components}/**/*.tsx", + }) + }) + + test("#then handles question mark metacharacter", () => { + expect(extractDirectoryPrefix("src/component?/**/*.ts")).toEqual({ + prefix: "src", + glob: "component?/**/*.ts", + }) + }) + + test("#then handles bracket metacharacter", () => { + expect(extractDirectoryPrefix("src/[a-z]omponents/**/*.ts")).toEqual({ + prefix: "src", + glob: "[a-z]omponents/**/*.ts", + }) + }) + }) + + describe("#given a pattern without directory prefix", () => { + test("#then returns empty prefix for **/ patterns", () => { + expect(extractDirectoryPrefix("**/*.ts")).toEqual({ + prefix: "", + glob: "**/*.ts", + }) + }) + + test("#then returns empty prefix for simple wildcard", () => { + expect(extractDirectoryPrefix("*.ts")).toEqual({ + prefix: "", + glob: "*.ts", + }) + }) + + test("#then returns empty prefix for brace-only pattern", () => { + expect(extractDirectoryPrefix("*.{ts,js}")).toEqual({ + prefix: "", + glob: "*.{ts,js}", + }) + }) + }) + + describe("#given a fully literal pattern (no metacharacters)", () => { + test("#then returns the pattern as-is with empty prefix", () => { + expect(extractDirectoryPrefix("src/components/Button.tsx")).toEqual({ + prefix: "", + glob: "src/components/Button.tsx", + }) + }) + + test("#then handles single filename", () => { + expect(extractDirectoryPrefix("package.json")).toEqual({ + prefix: "", + glob: "package.json", + }) + }) + }) +}) diff --git a/src/tools/glob/extract-directory-prefix.ts b/src/tools/glob/extract-directory-prefix.ts new file mode 100644 index 000000000..af9f6158e --- /dev/null +++ b/src/tools/glob/extract-directory-prefix.ts @@ -0,0 +1,40 @@ +const GLOB_META = /[*?[\]{]/ + +/** + * Splits a glob pattern into a static directory prefix and a glob-only portion. + * + * Ripgrep's `--glob` flag doesn't match directory prefixes in patterns. + * For example, `rg --files --glob='apps/backend/**\/*.ts' /project` returns nothing + * even though files exist under `/project/apps/backend/`. + * + * This function extracts the leading literal path segments so the caller can + * append them to the search path and pass only the glob portion to ripgrep. + * + * @example + * extractDirectoryPrefix("apps/backend/**\/*.ts") + * // { prefix: "apps/backend", glob: "**\/*.ts" } + * + * extractDirectoryPrefix("**\/*.ts") + * // { prefix: "", glob: "**\/*.ts" } + * + * extractDirectoryPrefix("src/{hooks,components}/**\/*.tsx") + * // { prefix: "src", glob: "{hooks,components}/**\/*.tsx" } + */ +export function extractDirectoryPrefix(pattern: string): { prefix: string; glob: string } { + const segments = pattern.split("/") + + let splitIndex = 0 + for (const segment of segments) { + if (GLOB_META.test(segment)) break + splitIndex++ + } + + if (splitIndex === 0 || splitIndex === segments.length) { + return { prefix: "", glob: pattern } + } + + return { + prefix: segments.slice(0, splitIndex).join("/"), + glob: segments.slice(splitIndex).join("/"), + } +} diff --git a/src/tools/glob/tools.ts b/src/tools/glob/tools.ts index d808377b2..3c6b7e159 100644 --- a/src/tools/glob/tools.ts +++ b/src/tools/glob/tools.ts @@ -4,6 +4,7 @@ import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool" import { runRgFiles } from "./cli" import { resolveGrepCliWithAutoInstall } from "./constants" import { formatGlobResult } from "./result-formatter" +import { extractDirectoryPrefix } from "./extract-directory-prefix" export function createGlobTools(ctx: PluginInput): Record { const glob: ToolDefinition = tool({ @@ -29,11 +30,13 @@ export function createGlobTools(ctx: PluginInput): Record const dir = typeof runtimeCtx.directory === "string" ? runtimeCtx.directory : ctx.directory const searchPath = args.path ? resolve(dir, args.path) : dir - const paths = [searchPath] + const { prefix, glob: effectivePattern } = extractDirectoryPrefix(args.pattern) + const effectivePath = prefix ? resolve(searchPath, prefix) : searchPath + const paths = [effectivePath] const result = await runRgFiles( { - pattern: args.pattern, + pattern: effectivePattern, paths, }, cli