Skip to content

Commit 287d7b7

Browse files
authored
fix(pi-fff): canonicalize find path constraints (#427)
closes #427 Canonicalize structured find path constraints before building the compact FFF query string. Absolute paths inside the active workspace now become repo-relative constraints, absolute paths outside the workspace fail clearly, and simple trailing recursive directory globs become directgory-prefix constraints while file globs remain unchanged. This keeps the invariant at the pi-fff adapter boundary, where structured tool parameters are translated into FFF query syntax, and avoids changing core indexing, ranking, parser, or matcher behavior.
1 parent 0f5ead1 commit 287d7b7

5 files changed

Lines changed: 124 additions & 61 deletions

File tree

Makefile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ prepare-node: build
7878

7979
test-bun: prepare-bun
8080
cd packages/fff-bun && bun test src/
81+
cd packages/pi-fff && bun test test/
8182

8283
test-node: prepare-node
8384
cd packages/fff-node && npm run build && node test/e2e.mjs

packages/pi-fff/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
"access": "public"
3737
},
3838
"scripts": {
39+
"test": "bun test test/",
3940
"typecheck": "tsc --noEmit"
4041
},
4142
"dependencies": {

packages/pi-fff/src/index.ts

Lines changed: 3 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import type {
2121
SearchResult,
2222
MixedItem,
2323
} from "@ff-labs/fff-node";
24+
import { buildQuery } from "./query";
2425

2526
// ---------------------------------------------------------------------------
2627
// Constants
@@ -104,65 +105,6 @@ function getFindCursor(id: string): FindCursor | undefined {
104105
return findCursorCache.get(id);
105106
}
106107

107-
// ---------------------------------------------------------------------------
108-
// Query building helpers
109-
// ---------------------------------------------------------------------------
110-
111-
function normalizePathConstraint(path: string): string | null {
112-
let trimmed = path.trim();
113-
if (!trimmed) return trimmed;
114-
if (trimmed === "." || trimmed === "./") return null;
115-
// Strip a leading `./` so `./**/*.rs` and `**/*.rs` behave identically.
116-
if (trimmed.startsWith("./")) trimmed = trimmed.slice(2);
117-
// Already signals path-constraint syntax to the parser.
118-
if (trimmed.startsWith("/") || trimmed.endsWith("/")) return trimmed;
119-
// Globs (`*.ts`, `src/**/*.cc`, `{src,lib}`) are handled by the parser.
120-
if (/[*?\[{]/.test(trimmed)) return trimmed;
121-
// Filename with extension (`main.rs`, `config.json`) → FilePath constraint.
122-
const lastSegment = trimmed.split("/").pop() ?? "";
123-
if (/\.[a-zA-Z][a-zA-Z0-9]{0,9}$/.test(lastSegment)) return trimmed;
124-
// Bare directory prefix → append `/` so the parser sees a PathSegment.
125-
return `${trimmed}/`;
126-
}
127-
128-
// Exclusions are emitted as `!<constraint>` tokens, which the Rust parser
129-
// understands (crates/fff-query-parser/src/parser.rs). We normalize each one
130-
// the same way as the include path so bare dirs become PathSegment excludes.
131-
// Tolerate callers passing already-negated forms like `!src/` by stripping
132-
// the leading `!` before normalizing so we never double-negate (`!!src/`).
133-
function normalizeExcludes(exclude: string | string[] | undefined): string[] {
134-
if (!exclude) return [];
135-
const list = Array.isArray(exclude) ? exclude : [exclude];
136-
const out: string[] = [];
137-
for (const raw of list) {
138-
const parts = raw
139-
.split(/[,\s]+/)
140-
.map((s) => s.trim())
141-
.filter(Boolean);
142-
for (const p of parts) {
143-
const stripped = p.startsWith("!") ? p.slice(1) : p;
144-
const normalized = normalizePathConstraint(stripped);
145-
if (normalized) out.push(`!${normalized}`);
146-
}
147-
}
148-
return out;
149-
}
150-
151-
function buildQuery(
152-
path: string | undefined,
153-
pattern: string,
154-
exclude?: string | string[],
155-
): string {
156-
const parts: string[] = [];
157-
if (path) {
158-
const pathConstraint = normalizePathConstraint(path);
159-
if (pathConstraint) parts.push(pathConstraint);
160-
}
161-
parts.push(...normalizeExcludes(exclude));
162-
parts.push(pattern);
163-
return parts.join(" ");
164-
}
165-
166108
// ---------------------------------------------------------------------------
167109
// Output formatting helpers
168110
// ---------------------------------------------------------------------------
@@ -650,7 +592,7 @@ export default function fffExtension(pi: ExtensionAPI) {
650592

651593
const f = await ensureFinder(activeCwd);
652594
const effectiveLimit = Math.max(1, params.limit ?? DEFAULT_GREP_LIMIT);
653-
const query = buildQuery(params.path, params.pattern, params.exclude);
595+
const query = buildQuery(params.path, params.pattern, params.exclude, activeCwd);
654596
// Auto-detect: regex if the pattern has regex metacharacters AND parses
655597
// as a valid regex, otherwise plain literal. The fuzzy fallback below
656598
// only kicks in for plain mode — regex queries are intentional.
@@ -829,7 +771,7 @@ export default function fffExtension(pi: ExtensionAPI) {
829771
: Math.max(1, params.limit ?? DEFAULT_FIND_LIMIT);
830772
const query = resumed
831773
? resumed.query
832-
: buildQuery(params.path, params.pattern, params.exclude);
774+
: buildQuery(params.path, params.pattern, params.exclude, activeCwd);
833775
const pattern = resumed ? resumed.pattern : params.pattern;
834776
const pageIndex = resumed?.nextPageIndex ?? 0;
835777

packages/pi-fff/src/query.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import path from "node:path";
2+
3+
export function normalizePathConstraint(
4+
pathConstraint: string,
5+
cwd = process.cwd(),
6+
): string | null {
7+
let trimmed = pathConstraint.trim();
8+
if (!trimmed) return trimmed;
9+
10+
if (path.isAbsolute(trimmed)) {
11+
const relative = path.relative(cwd, trimmed).replaceAll(path.sep, "/");
12+
if (relative === "") return null;
13+
if (relative.startsWith("../") || relative === ".." || path.isAbsolute(relative)) {
14+
throw new Error(
15+
`Path constraint must be relative to the workspace: ${pathConstraint}`,
16+
);
17+
}
18+
trimmed = relative;
19+
}
20+
21+
if (trimmed === "." || trimmed === "./") return null;
22+
// Strip a leading `./` so `./**/*.rs` and `**/*.rs` behave identically.
23+
if (trimmed.startsWith("./")) trimmed = trimmed.slice(2);
24+
25+
// FFF's glob matcher can treat a hidden directory root glob such as
26+
// `.agents/**` as empty, while the tool contract says this means "inside
27+
// this directory". Collapse simple trailing recursive directory globs to the
28+
// directory-prefix constraint understood by the parser. Keep real file globs
29+
// such as `src/**/*.ts` unchanged.
30+
const recursiveDir = trimmed.match(/^(.*)\/\*\*(?:\/\*)?$/);
31+
if (recursiveDir) {
32+
const dir = recursiveDir[1];
33+
if (dir && !/[*?[{]/.test(dir)) return `${dir}/`;
34+
}
35+
36+
// Already signals path-constraint syntax to the parser.
37+
if (trimmed.startsWith("/") || trimmed.endsWith("/")) return trimmed;
38+
// Globs (`*.ts`, `src/**/*.cc`, `{src,lib}`) are handled by the parser.
39+
if (/[*?[{]/.test(trimmed)) return trimmed;
40+
// Filename with extension (`main.rs`, `config.json`) → FilePath constraint.
41+
const lastSegment = trimmed.split("/").pop() ?? "";
42+
if (/\.[a-zA-Z][a-zA-Z0-9]{0,9}$/.test(lastSegment)) return trimmed;
43+
// Bare directory prefix → append `/` so the parser sees a PathSegment.
44+
return `${trimmed}/`;
45+
}
46+
47+
// Exclusions are emitted as `!<constraint>` tokens, which the Rust parser
48+
// understands (crates/fff-query-parser/src/parser.rs). We normalize each one
49+
// the same way as the include path so bare dirs become PathSegment excludes.
50+
// Tolerate callers passing already-negated forms like `!src/` by stripping
51+
// the leading `!` before normalizing so we never double-negate (`!!src/`).
52+
export function normalizeExcludes(
53+
exclude: string | string[] | undefined,
54+
cwd = process.cwd(),
55+
): string[] {
56+
if (!exclude) return [];
57+
const list = Array.isArray(exclude) ? exclude : [exclude];
58+
const out: string[] = [];
59+
for (const raw of list) {
60+
const parts = raw
61+
.split(/[,\s]+/)
62+
.map((s) => s.trim())
63+
.filter(Boolean);
64+
for (const p of parts) {
65+
const stripped = p.startsWith("!") ? p.slice(1) : p;
66+
const normalized = normalizePathConstraint(stripped, cwd);
67+
if (normalized) out.push(`!${normalized}`);
68+
}
69+
}
70+
return out;
71+
}
72+
73+
export function buildQuery(
74+
path: string | undefined,
75+
pattern: string,
76+
exclude?: string | string[],
77+
cwd = process.cwd(),
78+
): string {
79+
const parts: string[] = [];
80+
if (path) {
81+
const pathConstraint = normalizePathConstraint(path, cwd);
82+
if (pathConstraint) parts.push(pathConstraint);
83+
}
84+
parts.push(...normalizeExcludes(exclude, cwd));
85+
parts.push(pattern);
86+
return parts.join(" ");
87+
}

packages/pi-fff/test/query.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { describe, expect, test } from "bun:test";
2+
import { buildQuery, normalizePathConstraint } from "../src/query";
3+
4+
const cwd = "/tmp/workspace";
5+
6+
describe("path constraint normalization", () => {
7+
test("converts absolute in-workspace paths to repo-relative constraints", () => {
8+
expect(normalizePathConstraint("/tmp/workspace/.agents/**", cwd)).toBe(".agents/");
9+
expect(normalizePathConstraint("/tmp/workspace/.agents/plans/**", cwd)).toBe(
10+
".agents/plans/",
11+
);
12+
});
13+
14+
test("rejects absolute paths outside the workspace", () => {
15+
expect(() => normalizePathConstraint("/tmp/other/.agents/**", cwd)).toThrow(
16+
"Path constraint must be relative to the workspace",
17+
);
18+
});
19+
20+
test("collapses only simple trailing recursive directory globs", () => {
21+
expect(normalizePathConstraint(".agents/**", cwd)).toBe(".agents/");
22+
expect(normalizePathConstraint("src/**/*", cwd)).toBe("src/");
23+
expect(normalizePathConstraint("src/**/*.ts", cwd)).toBe("src/**/*.ts");
24+
expect(normalizePathConstraint("{src,lib}/**", cwd)).toBe("{src,lib}/**");
25+
});
26+
27+
test("builds find queries with normalized include and exclude constraints", () => {
28+
expect(
29+
buildQuery("/tmp/workspace/.agents/**", "*", "/tmp/workspace/test/**", cwd),
30+
).toBe(".agents/ !test/ *");
31+
});
32+
});

0 commit comments

Comments
 (0)