fix(coding-agent): make find tool match path-based glob patterns
fd --glob matches against the basename unless --full-path is set, so patterns containing '/' (e.g. 'src/**/*.spec.ts') silently returned no results. When the pattern contains '/', switch fd into --full-path mode and prepend '**/' unless the pattern already starts with '/', '**/', or is '**'. Basename patterns keep the default matcher. closes #3302
This commit is contained in:
@@ -5,6 +5,7 @@
|
||||
### Fixed
|
||||
|
||||
- Fixed `--verbose` startup output to begin with expanded startup help and loaded resource listings after the compact startup header change ([#3147](https://github.com/badlogic/pi-mono/issues/3147))
|
||||
- Fixed `find` tool returning no results for path-based glob patterns such as `src/**/*.spec.ts` or `some/parent/child/**` by switching fd into full-path mode and normalizing the pattern when it contains a `/` ([#3302](https://github.com/badlogic/pi-mono/issues/3302))
|
||||
|
||||
## [0.67.5] - 2026-04-16
|
||||
|
||||
|
||||
@@ -258,7 +258,18 @@ export function createFindToolDefinition(
|
||||
return;
|
||||
}
|
||||
for (const gitignorePath of gitignoreFiles) args.push("--ignore-file", gitignorePath);
|
||||
args.push(pattern, searchPath);
|
||||
|
||||
// fd --glob matches against the basename unless --full-path is set; in --full-path
|
||||
// mode it matches against the absolute candidate path, so a path-containing
|
||||
// pattern like 'src/**/*.spec.ts' needs a leading '**/' to match anything.
|
||||
let effectivePattern = pattern;
|
||||
if (pattern.includes("/")) {
|
||||
args.push("--full-path");
|
||||
if (!pattern.startsWith("/") && !pattern.startsWith("**/") && pattern !== "**") {
|
||||
effectivePattern = `**/${pattern}`;
|
||||
}
|
||||
}
|
||||
args.push(effectivePattern, searchPath);
|
||||
|
||||
const child = spawn(fdPath, args, { stdio: ["ignore", "pipe", "pipe"] });
|
||||
const rl = createInterface({ input: child.stdout });
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { createFindToolDefinition } from "../../../src/core/tools/find.js";
|
||||
|
||||
/**
|
||||
* Regression test for https://github.com/badlogic/pi-mono/issues/3302
|
||||
*
|
||||
* The `find` tool advertises glob patterns like `src/**\/*.spec.ts`, but the
|
||||
* default fd-backed implementation used `fd --glob <pattern>` without
|
||||
* `--full-path`, which makes fd match only against the basename. Any pattern
|
||||
* containing a `/` therefore silently returned no matches.
|
||||
*
|
||||
* The fix switches fd into full-path mode when the pattern contains a `/`
|
||||
* and prepends `**\/` so the pattern can match against the absolute candidate
|
||||
* path that fd feeds to the matcher.
|
||||
*/
|
||||
describe("issue #3302 find returns no results for path-based glob patterns", () => {
|
||||
let tempRoot: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempRoot = mkdtempSync(join(tmpdir(), "pi-3302-"));
|
||||
mkdirSync(join(tempRoot, "some", "parent", "child"), { recursive: true });
|
||||
mkdirSync(join(tempRoot, "src", "foo", "bar"), { recursive: true });
|
||||
writeFileSync(join(tempRoot, "some", "parent", "child", "file.ext"), "");
|
||||
writeFileSync(join(tempRoot, "some", "parent", "child", "test.spec.ts"), "");
|
||||
writeFileSync(join(tempRoot, "src", "foo", "bar", "example.spec.ts"), "");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tempRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function runFind(pattern: string): Promise<string[]> {
|
||||
const def = createFindToolDefinition(tempRoot);
|
||||
// The find tool implementation does not touch ctx; pass a minimal stub.
|
||||
const ctx = {} as Parameters<typeof def.execute>[4];
|
||||
const result = (await def.execute("call-1", { pattern }, undefined, undefined, ctx)) as {
|
||||
content: Array<{ type: string; text?: string }>;
|
||||
};
|
||||
const text = result.content[0]?.text ?? "";
|
||||
if (text === "No files found matching pattern") return [];
|
||||
return text
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l.length > 0 && !l.startsWith("["));
|
||||
}
|
||||
|
||||
it("basename pattern still matches (regression-safe)", async () => {
|
||||
const files = await runFind("*.spec.ts");
|
||||
expect(files.sort()).toEqual(["some/parent/child/test.spec.ts", "src/foo/bar/example.spec.ts"]);
|
||||
});
|
||||
|
||||
it("directory-prefixed pattern with ** tail matches subtree", async () => {
|
||||
const files = await runFind("some/parent/child/**");
|
||||
// Matches files (and possibly directories) under the subtree. Assert the two files are present.
|
||||
expect(files).toContain("some/parent/child/file.ext");
|
||||
expect(files).toContain("some/parent/child/test.spec.ts");
|
||||
});
|
||||
|
||||
it("leading ** wildcard with path segments matches", async () => {
|
||||
const files = await runFind("**/parent/child/*");
|
||||
expect(files.sort()).toContain("some/parent/child/file.ext");
|
||||
expect(files.sort()).toContain("some/parent/child/test.spec.ts");
|
||||
});
|
||||
|
||||
it("src/**/*.spec.ts matches nested spec file", async () => {
|
||||
const files = await runFind("src/**/*.spec.ts");
|
||||
expect(files).toEqual(["src/foo/bar/example.spec.ts"]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user