pi-tool-discipline 0.1.10 → 0.1.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/extensions/search.ts +44 -18
- package/package.json +1 -1
package/extensions/search.ts
CHANGED
|
@@ -11,6 +11,7 @@ const MAX_FILES = 2000;
|
|
|
11
11
|
const MAX_VISITED = 5000; // total entries touched, bounds slow/odd trees
|
|
12
12
|
const MAX_FILE_BYTES = 1024 * 1024; // content search skips files larger than 1 MiB
|
|
13
13
|
const MAX_OUTPUT_BYTES = 50 * 1024;
|
|
14
|
+
const MAX_RESULTS = 1000; // hard ceiling for matches/results
|
|
14
15
|
const TRUNCATE_MARKER = "\n[output truncated]";
|
|
15
16
|
const CAPPED_MARKER = "\n[search capped — traversal stopped early]";
|
|
16
17
|
|
|
@@ -35,6 +36,14 @@ interface WalkState {
|
|
|
35
36
|
rootError?: string;
|
|
36
37
|
}
|
|
37
38
|
|
|
39
|
+
/** Injectable fs ops so tests can deterministically trigger fs errors. */
|
|
40
|
+
interface WalkFsOps {
|
|
41
|
+
readdir: (dir: string) => Promise<string[]>;
|
|
42
|
+
lstat: (p: string) => Promise<{ isSymbolicLink(): boolean; isDirectory(): boolean; isFile(): boolean }>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const defaultFs: WalkFsOps = { readdir, lstat };
|
|
46
|
+
|
|
38
47
|
/**
|
|
39
48
|
* Collect regular files under dir (depth-limited, symlink-safe, bounded,
|
|
40
49
|
* abort-aware). Only isFile() entries are collected — FIFOs, devices, sockets
|
|
@@ -43,7 +52,14 @@ interface WalkState {
|
|
|
43
52
|
* A failure to read the REQUESTED ROOT (depth 0) is recorded in rootError;
|
|
44
53
|
* nested unreadable entries are skipped.
|
|
45
54
|
*/
|
|
46
|
-
async function walk(
|
|
55
|
+
export async function walk(
|
|
56
|
+
dir: string,
|
|
57
|
+
out: string[],
|
|
58
|
+
state: WalkState,
|
|
59
|
+
signal?: AbortSignal,
|
|
60
|
+
depth = 0,
|
|
61
|
+
fsOps: WalkFsOps = defaultFs,
|
|
62
|
+
): Promise<void> {
|
|
47
63
|
throwIfAborted(signal);
|
|
48
64
|
if (depth > 12 || state.visited >= MAX_VISITED) {
|
|
49
65
|
state.capped = true;
|
|
@@ -51,7 +67,7 @@ async function walk(dir: string, out: string[], state: WalkState, signal?: Abort
|
|
|
51
67
|
}
|
|
52
68
|
let entries: string[];
|
|
53
69
|
try {
|
|
54
|
-
entries = await readdir(dir);
|
|
70
|
+
entries = await fsOps.readdir(dir);
|
|
55
71
|
} catch (error: any) {
|
|
56
72
|
throwIfAborted(signal); // cancellation wins over the fs error
|
|
57
73
|
if (depth === 0) state.rootError = error?.message ?? String(error);
|
|
@@ -67,16 +83,19 @@ async function walk(dir: string, out: string[], state: WalkState, signal?: Abort
|
|
|
67
83
|
const p = join(dir, entry);
|
|
68
84
|
state.visited++;
|
|
69
85
|
try {
|
|
70
|
-
const lst = await lstat(p);
|
|
86
|
+
const lst = await fsOps.lstat(p);
|
|
71
87
|
throwIfAborted(signal);
|
|
72
88
|
if (lst.isSymbolicLink()) continue; // never follow symlinks
|
|
73
|
-
if (lst.isDirectory())
|
|
74
|
-
|
|
89
|
+
if (lst.isDirectory()) {
|
|
90
|
+
await walk(p, out, state, signal, depth + 1, fsOps);
|
|
91
|
+
throwIfAborted(signal);
|
|
92
|
+
} else if (lst.isFile()) out.push(p); // regular files only
|
|
75
93
|
} catch (error: any) {
|
|
76
|
-
|
|
94
|
+
throwIfAborted(signal); // cancellation wins over the fs error
|
|
77
95
|
// unreadable entries are skipped
|
|
78
96
|
}
|
|
79
97
|
}
|
|
98
|
+
throwIfAborted(signal); // post-recursion check
|
|
80
99
|
}
|
|
81
100
|
|
|
82
101
|
/**
|
|
@@ -90,7 +109,7 @@ function truncate(text: string, maxBytes = MAX_OUTPUT_BYTES, extraMarkerBytes =
|
|
|
90
109
|
if (budget <= 0) return TRUNCATE_MARKER.trim();
|
|
91
110
|
const cut = buf.subarray(0, budget).toString("utf8");
|
|
92
111
|
const lastNewline = cut.lastIndexOf("\n");
|
|
93
|
-
if (lastNewline <= 0) return TRUNCATE_MARKER
|
|
112
|
+
if (lastNewline <= 0) return `${cut}${TRUNCATE_MARKER}`; // no complete line: bounded prefix + marker
|
|
94
113
|
return `${cut.slice(0, lastNewline)}\n${TRUNCATE_MARKER}`;
|
|
95
114
|
}
|
|
96
115
|
|
|
@@ -122,17 +141,22 @@ export async function grepFiles(opts: {
|
|
|
122
141
|
}): Promise<string> {
|
|
123
142
|
throwIfAborted(opts.signal);
|
|
124
143
|
const root = await resolveRoot(opts.cwd, opts.path, opts.signal);
|
|
125
|
-
if (!root) return `Error: search path not found: ${resolve(opts.cwd, opts.path || ".")}
|
|
144
|
+
if (!root) return truncate(`Error: search path not found: ${resolve(opts.cwd, opts.path || ".")}`);
|
|
126
145
|
const pattern = opts.caseSensitive ? opts.pattern : opts.pattern.toLowerCase();
|
|
127
|
-
|
|
146
|
+
// Hard internal ceiling: bounds memory/work even for direct API calls that
|
|
147
|
+
// bypass schema validation.
|
|
148
|
+
const limit = Math.min(Math.max(1, opts.maxResults ?? 100), MAX_RESULTS);
|
|
128
149
|
const { files, state } = await walkFiles(root, opts.signal);
|
|
129
|
-
|
|
150
|
+
throwIfAborted(opts.signal);
|
|
151
|
+
if (state.rootError) return truncate(`Error: cannot read search root ${root}: ${state.rootError}`);
|
|
130
152
|
const matches: FileMatch[] = [];
|
|
131
153
|
for (const file of files) {
|
|
132
154
|
throwIfAborted(opts.signal);
|
|
133
155
|
if (matches.length >= limit) break;
|
|
134
156
|
try {
|
|
135
|
-
|
|
157
|
+
const size = (await stat(file)).size;
|
|
158
|
+
throwIfAborted(opts.signal);
|
|
159
|
+
if (size > MAX_FILE_BYTES) continue; // cap only before reading content
|
|
136
160
|
} catch (error: any) {
|
|
137
161
|
throwIfAborted(opts.signal);
|
|
138
162
|
continue;
|
|
@@ -173,10 +197,12 @@ export async function findFiles(opts: {
|
|
|
173
197
|
}): Promise<string> {
|
|
174
198
|
throwIfAborted(opts.signal);
|
|
175
199
|
const root = await resolveRoot(opts.cwd, opts.path, opts.signal);
|
|
176
|
-
if (!root) return `Error: search path not found: ${resolve(opts.cwd, opts.path || ".")}
|
|
200
|
+
if (!root) return truncate(`Error: search path not found: ${resolve(opts.cwd, opts.path || ".")}`);
|
|
177
201
|
const { files, state } = await walkFiles(root, opts.signal);
|
|
178
|
-
|
|
202
|
+
throwIfAborted(opts.signal);
|
|
203
|
+
if (state.rootError) return truncate(`Error: cannot read search root ${root}: ${state.rootError}`);
|
|
179
204
|
const needle = opts.pattern?.toLowerCase();
|
|
205
|
+
const resultLimit = Math.min(Math.max(1, opts.maxResults ?? 100), MAX_RESULTS);
|
|
180
206
|
// Match against the RELATIVE path so a pattern matching an ancestor
|
|
181
207
|
// directory does not hit every file, and rendered output stays relative.
|
|
182
208
|
const rel: string[] = [];
|
|
@@ -188,7 +214,7 @@ export async function findFiles(opts: {
|
|
|
188
214
|
for (const r of rel) {
|
|
189
215
|
throwIfAborted(opts.signal);
|
|
190
216
|
if (!needle || r.toLowerCase().includes(needle)) hits.push(r);
|
|
191
|
-
if (hits.length >=
|
|
217
|
+
if (hits.length >= resultLimit) break;
|
|
192
218
|
}
|
|
193
219
|
if (hits.length === 0) return state.capped ? `No matching files found${CAPPED_MARKER}` : "No matching files found";
|
|
194
220
|
let out = truncate(hits.join("\n"), MAX_OUTPUT_BYTES, state.capped ? Buffer.byteLength(CAPPED_MARKER) : 0);
|
|
@@ -199,7 +225,7 @@ export async function findFiles(opts: {
|
|
|
199
225
|
export async function listDir(opts: { path?: string; cwd: string; signal?: AbortSignal }): Promise<string> {
|
|
200
226
|
throwIfAborted(opts.signal);
|
|
201
227
|
const root = await resolveRoot(opts.cwd, opts.path, opts.signal);
|
|
202
|
-
if (!root) return `Error: directory not found: ${resolve(opts.cwd, opts.path || ".")}
|
|
228
|
+
if (!root) return truncate(`Error: directory not found: ${resolve(opts.cwd, opts.path || ".")}`);
|
|
203
229
|
try {
|
|
204
230
|
const entries = await readdir(root);
|
|
205
231
|
throwIfAborted(opts.signal);
|
|
@@ -207,7 +233,7 @@ export async function listDir(opts: { path?: string; cwd: string; signal?: Abort
|
|
|
207
233
|
return truncate(entries.join("\n"));
|
|
208
234
|
} catch (error: any) {
|
|
209
235
|
throwIfAborted(opts.signal);
|
|
210
|
-
return `Error listing ${root}: ${error.message}
|
|
236
|
+
return truncate(`Error listing ${root}: ${error.message}`);
|
|
211
237
|
}
|
|
212
238
|
}
|
|
213
239
|
|
|
@@ -215,13 +241,13 @@ export const grepSchema = Type.Object({
|
|
|
215
241
|
pattern: Type.String({ description: "Text to search for in file contents" }),
|
|
216
242
|
path: Type.Optional(Type.String({ description: "Directory to search (defaults to cwd)" })),
|
|
217
243
|
caseSensitive: Type.Optional(Type.Boolean({ description: "Case-sensitive match (default false)" })),
|
|
218
|
-
maxResults: Type.Optional(Type.Integer({ minimum: 1, description: "Max matches (default 100)" })),
|
|
244
|
+
maxResults: Type.Optional(Type.Integer({ minimum: 1, maximum: MAX_RESULTS, description: "Max matches (default 100)" })),
|
|
219
245
|
});
|
|
220
246
|
|
|
221
247
|
export const findSchema = Type.Object({
|
|
222
248
|
pattern: Type.Optional(Type.String({ description: "Substring to match in file path or name (empty lists all)" })),
|
|
223
249
|
path: Type.Optional(Type.String({ description: "Directory to search (defaults to cwd)" })),
|
|
224
|
-
maxResults: Type.Optional(Type.Integer({ minimum: 1, description: "Max results (default 100)" })),
|
|
250
|
+
maxResults: Type.Optional(Type.Integer({ minimum: 1, maximum: MAX_RESULTS, description: "Max results (default 100)" })),
|
|
225
251
|
});
|
|
226
252
|
|
|
227
253
|
export const lsSchema = Type.Object({
|
package/package.json
CHANGED