pi-tool-discipline 0.1.11 → 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 +34 -15
- 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,11 +83,11 @@ 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
89
|
if (lst.isDirectory()) {
|
|
74
|
-
await walk(p, out, state, signal, depth + 1);
|
|
90
|
+
await walk(p, out, state, signal, depth + 1, fsOps);
|
|
75
91
|
throwIfAborted(signal);
|
|
76
92
|
} else if (lst.isFile()) out.push(p); // regular files only
|
|
77
93
|
} catch (error: any) {
|
|
@@ -93,7 +109,7 @@ function truncate(text: string, maxBytes = MAX_OUTPUT_BYTES, extraMarkerBytes =
|
|
|
93
109
|
if (budget <= 0) return TRUNCATE_MARKER.trim();
|
|
94
110
|
const cut = buf.subarray(0, budget).toString("utf8");
|
|
95
111
|
const lastNewline = cut.lastIndexOf("\n");
|
|
96
|
-
if (lastNewline <= 0) return TRUNCATE_MARKER
|
|
112
|
+
if (lastNewline <= 0) return `${cut}${TRUNCATE_MARKER}`; // no complete line: bounded prefix + marker
|
|
97
113
|
return `${cut.slice(0, lastNewline)}\n${TRUNCATE_MARKER}`;
|
|
98
114
|
}
|
|
99
115
|
|
|
@@ -125,12 +141,14 @@ export async function grepFiles(opts: {
|
|
|
125
141
|
}): Promise<string> {
|
|
126
142
|
throwIfAborted(opts.signal);
|
|
127
143
|
const root = await resolveRoot(opts.cwd, opts.path, opts.signal);
|
|
128
|
-
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 || ".")}`);
|
|
129
145
|
const pattern = opts.caseSensitive ? opts.pattern : opts.pattern.toLowerCase();
|
|
130
|
-
|
|
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);
|
|
131
149
|
const { files, state } = await walkFiles(root, opts.signal);
|
|
132
150
|
throwIfAborted(opts.signal);
|
|
133
|
-
if (state.rootError) return `Error: cannot read search root ${root}: ${state.rootError}
|
|
151
|
+
if (state.rootError) return truncate(`Error: cannot read search root ${root}: ${state.rootError}`);
|
|
134
152
|
const matches: FileMatch[] = [];
|
|
135
153
|
for (const file of files) {
|
|
136
154
|
throwIfAborted(opts.signal);
|
|
@@ -179,11 +197,12 @@ export async function findFiles(opts: {
|
|
|
179
197
|
}): Promise<string> {
|
|
180
198
|
throwIfAborted(opts.signal);
|
|
181
199
|
const root = await resolveRoot(opts.cwd, opts.path, opts.signal);
|
|
182
|
-
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 || ".")}`);
|
|
183
201
|
const { files, state } = await walkFiles(root, opts.signal);
|
|
184
202
|
throwIfAborted(opts.signal);
|
|
185
|
-
if (state.rootError) return `Error: cannot read search root ${root}: ${state.rootError}
|
|
203
|
+
if (state.rootError) return truncate(`Error: cannot read search root ${root}: ${state.rootError}`);
|
|
186
204
|
const needle = opts.pattern?.toLowerCase();
|
|
205
|
+
const resultLimit = Math.min(Math.max(1, opts.maxResults ?? 100), MAX_RESULTS);
|
|
187
206
|
// Match against the RELATIVE path so a pattern matching an ancestor
|
|
188
207
|
// directory does not hit every file, and rendered output stays relative.
|
|
189
208
|
const rel: string[] = [];
|
|
@@ -195,7 +214,7 @@ export async function findFiles(opts: {
|
|
|
195
214
|
for (const r of rel) {
|
|
196
215
|
throwIfAborted(opts.signal);
|
|
197
216
|
if (!needle || r.toLowerCase().includes(needle)) hits.push(r);
|
|
198
|
-
if (hits.length >=
|
|
217
|
+
if (hits.length >= resultLimit) break;
|
|
199
218
|
}
|
|
200
219
|
if (hits.length === 0) return state.capped ? `No matching files found${CAPPED_MARKER}` : "No matching files found";
|
|
201
220
|
let out = truncate(hits.join("\n"), MAX_OUTPUT_BYTES, state.capped ? Buffer.byteLength(CAPPED_MARKER) : 0);
|
|
@@ -206,7 +225,7 @@ export async function findFiles(opts: {
|
|
|
206
225
|
export async function listDir(opts: { path?: string; cwd: string; signal?: AbortSignal }): Promise<string> {
|
|
207
226
|
throwIfAborted(opts.signal);
|
|
208
227
|
const root = await resolveRoot(opts.cwd, opts.path, opts.signal);
|
|
209
|
-
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 || ".")}`);
|
|
210
229
|
try {
|
|
211
230
|
const entries = await readdir(root);
|
|
212
231
|
throwIfAborted(opts.signal);
|
|
@@ -214,7 +233,7 @@ export async function listDir(opts: { path?: string; cwd: string; signal?: Abort
|
|
|
214
233
|
return truncate(entries.join("\n"));
|
|
215
234
|
} catch (error: any) {
|
|
216
235
|
throwIfAborted(opts.signal);
|
|
217
|
-
return `Error listing ${root}: ${error.message}
|
|
236
|
+
return truncate(`Error listing ${root}: ${error.message}`);
|
|
218
237
|
}
|
|
219
238
|
}
|
|
220
239
|
|
|
@@ -222,13 +241,13 @@ export const grepSchema = Type.Object({
|
|
|
222
241
|
pattern: Type.String({ description: "Text to search for in file contents" }),
|
|
223
242
|
path: Type.Optional(Type.String({ description: "Directory to search (defaults to cwd)" })),
|
|
224
243
|
caseSensitive: Type.Optional(Type.Boolean({ description: "Case-sensitive match (default false)" })),
|
|
225
|
-
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)" })),
|
|
226
245
|
});
|
|
227
246
|
|
|
228
247
|
export const findSchema = Type.Object({
|
|
229
248
|
pattern: Type.Optional(Type.String({ description: "Substring to match in file path or name (empty lists all)" })),
|
|
230
249
|
path: Type.Optional(Type.String({ description: "Directory to search (defaults to cwd)" })),
|
|
231
|
-
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)" })),
|
|
232
251
|
});
|
|
233
252
|
|
|
234
253
|
export const lsSchema = Type.Object({
|
package/package.json
CHANGED