pi-tool-discipline 0.1.11 → 0.1.13
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 -16
- package/package.json +1 -1
package/extensions/search.ts
CHANGED
|
@@ -11,6 +11,13 @@ 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
|
|
15
|
+
|
|
16
|
+
/** Normalize maxResults: non-finite/non-number falls back to the default. */
|
|
17
|
+
function clampLimit(raw: number | undefined): number {
|
|
18
|
+
const n = typeof raw === "number" && Number.isFinite(raw) ? Math.floor(raw) : 100;
|
|
19
|
+
return Math.min(Math.max(1, n), MAX_RESULTS);
|
|
20
|
+
}
|
|
14
21
|
const TRUNCATE_MARKER = "\n[output truncated]";
|
|
15
22
|
const CAPPED_MARKER = "\n[search capped — traversal stopped early]";
|
|
16
23
|
|
|
@@ -35,6 +42,14 @@ interface WalkState {
|
|
|
35
42
|
rootError?: string;
|
|
36
43
|
}
|
|
37
44
|
|
|
45
|
+
/** Injectable fs ops so tests can deterministically trigger fs errors. */
|
|
46
|
+
interface WalkFsOps {
|
|
47
|
+
readdir: (dir: string) => Promise<string[]>;
|
|
48
|
+
lstat: (p: string) => Promise<{ isSymbolicLink(): boolean; isDirectory(): boolean; isFile(): boolean }>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const defaultFs: WalkFsOps = { readdir, lstat };
|
|
52
|
+
|
|
38
53
|
/**
|
|
39
54
|
* Collect regular files under dir (depth-limited, symlink-safe, bounded,
|
|
40
55
|
* abort-aware). Only isFile() entries are collected — FIFOs, devices, sockets
|
|
@@ -43,7 +58,14 @@ interface WalkState {
|
|
|
43
58
|
* A failure to read the REQUESTED ROOT (depth 0) is recorded in rootError;
|
|
44
59
|
* nested unreadable entries are skipped.
|
|
45
60
|
*/
|
|
46
|
-
async function walk(
|
|
61
|
+
export async function walk(
|
|
62
|
+
dir: string,
|
|
63
|
+
out: string[],
|
|
64
|
+
state: WalkState,
|
|
65
|
+
signal?: AbortSignal,
|
|
66
|
+
depth = 0,
|
|
67
|
+
fsOps: WalkFsOps = defaultFs,
|
|
68
|
+
): Promise<void> {
|
|
47
69
|
throwIfAborted(signal);
|
|
48
70
|
if (depth > 12 || state.visited >= MAX_VISITED) {
|
|
49
71
|
state.capped = true;
|
|
@@ -51,7 +73,7 @@ async function walk(dir: string, out: string[], state: WalkState, signal?: Abort
|
|
|
51
73
|
}
|
|
52
74
|
let entries: string[];
|
|
53
75
|
try {
|
|
54
|
-
entries = await readdir(dir);
|
|
76
|
+
entries = await fsOps.readdir(dir);
|
|
55
77
|
} catch (error: any) {
|
|
56
78
|
throwIfAborted(signal); // cancellation wins over the fs error
|
|
57
79
|
if (depth === 0) state.rootError = error?.message ?? String(error);
|
|
@@ -67,11 +89,11 @@ async function walk(dir: string, out: string[], state: WalkState, signal?: Abort
|
|
|
67
89
|
const p = join(dir, entry);
|
|
68
90
|
state.visited++;
|
|
69
91
|
try {
|
|
70
|
-
const lst = await lstat(p);
|
|
92
|
+
const lst = await fsOps.lstat(p);
|
|
71
93
|
throwIfAborted(signal);
|
|
72
94
|
if (lst.isSymbolicLink()) continue; // never follow symlinks
|
|
73
95
|
if (lst.isDirectory()) {
|
|
74
|
-
await walk(p, out, state, signal, depth + 1);
|
|
96
|
+
await walk(p, out, state, signal, depth + 1, fsOps);
|
|
75
97
|
throwIfAborted(signal);
|
|
76
98
|
} else if (lst.isFile()) out.push(p); // regular files only
|
|
77
99
|
} catch (error: any) {
|
|
@@ -91,9 +113,12 @@ function truncate(text: string, maxBytes = MAX_OUTPUT_BYTES, extraMarkerBytes =
|
|
|
91
113
|
if (buf.length + extraMarkerBytes <= maxBytes) return text;
|
|
92
114
|
const budget = maxBytes - Buffer.byteLength(TRUNCATE_MARKER) - extraMarkerBytes;
|
|
93
115
|
if (budget <= 0) return TRUNCATE_MARKER.trim();
|
|
94
|
-
|
|
116
|
+
let cut = buf.subarray(0, budget).toString("utf8");
|
|
117
|
+
// A multibyte char split by subarray becomes U+FFFD (3 bytes) which can
|
|
118
|
+
// exceed the budget — trim back to a valid encoded prefix.
|
|
119
|
+
while (Buffer.byteLength(cut, "utf8") > budget) cut = cut.slice(0, -1);
|
|
95
120
|
const lastNewline = cut.lastIndexOf("\n");
|
|
96
|
-
if (lastNewline <= 0) return TRUNCATE_MARKER
|
|
121
|
+
if (lastNewline <= 0) return `${cut}${TRUNCATE_MARKER}`; // no complete line: bounded prefix + marker
|
|
97
122
|
return `${cut.slice(0, lastNewline)}\n${TRUNCATE_MARKER}`;
|
|
98
123
|
}
|
|
99
124
|
|
|
@@ -125,12 +150,14 @@ export async function grepFiles(opts: {
|
|
|
125
150
|
}): Promise<string> {
|
|
126
151
|
throwIfAborted(opts.signal);
|
|
127
152
|
const root = await resolveRoot(opts.cwd, opts.path, opts.signal);
|
|
128
|
-
if (!root) return `Error: search path not found: ${resolve(opts.cwd, opts.path || ".")}
|
|
153
|
+
if (!root) return truncate(`Error: search path not found: ${resolve(opts.cwd, opts.path || ".")}`);
|
|
129
154
|
const pattern = opts.caseSensitive ? opts.pattern : opts.pattern.toLowerCase();
|
|
130
|
-
|
|
155
|
+
// Hard internal ceiling: bounds memory/work even for direct API calls that
|
|
156
|
+
// bypass schema validation (NaN/Infinity included).
|
|
157
|
+
const limit = clampLimit(opts.maxResults);
|
|
131
158
|
const { files, state } = await walkFiles(root, opts.signal);
|
|
132
159
|
throwIfAborted(opts.signal);
|
|
133
|
-
if (state.rootError) return `Error: cannot read search root ${root}: ${state.rootError}
|
|
160
|
+
if (state.rootError) return truncate(`Error: cannot read search root ${root}: ${state.rootError}`);
|
|
134
161
|
const matches: FileMatch[] = [];
|
|
135
162
|
for (const file of files) {
|
|
136
163
|
throwIfAborted(opts.signal);
|
|
@@ -179,11 +206,12 @@ export async function findFiles(opts: {
|
|
|
179
206
|
}): Promise<string> {
|
|
180
207
|
throwIfAborted(opts.signal);
|
|
181
208
|
const root = await resolveRoot(opts.cwd, opts.path, opts.signal);
|
|
182
|
-
if (!root) return `Error: search path not found: ${resolve(opts.cwd, opts.path || ".")}
|
|
209
|
+
if (!root) return truncate(`Error: search path not found: ${resolve(opts.cwd, opts.path || ".")}`);
|
|
183
210
|
const { files, state } = await walkFiles(root, opts.signal);
|
|
184
211
|
throwIfAborted(opts.signal);
|
|
185
|
-
if (state.rootError) return `Error: cannot read search root ${root}: ${state.rootError}
|
|
212
|
+
if (state.rootError) return truncate(`Error: cannot read search root ${root}: ${state.rootError}`);
|
|
186
213
|
const needle = opts.pattern?.toLowerCase();
|
|
214
|
+
const resultLimit = clampLimit(opts.maxResults);
|
|
187
215
|
// Match against the RELATIVE path so a pattern matching an ancestor
|
|
188
216
|
// directory does not hit every file, and rendered output stays relative.
|
|
189
217
|
const rel: string[] = [];
|
|
@@ -195,7 +223,7 @@ export async function findFiles(opts: {
|
|
|
195
223
|
for (const r of rel) {
|
|
196
224
|
throwIfAborted(opts.signal);
|
|
197
225
|
if (!needle || r.toLowerCase().includes(needle)) hits.push(r);
|
|
198
|
-
if (hits.length >=
|
|
226
|
+
if (hits.length >= resultLimit) break;
|
|
199
227
|
}
|
|
200
228
|
if (hits.length === 0) return state.capped ? `No matching files found${CAPPED_MARKER}` : "No matching files found";
|
|
201
229
|
let out = truncate(hits.join("\n"), MAX_OUTPUT_BYTES, state.capped ? Buffer.byteLength(CAPPED_MARKER) : 0);
|
|
@@ -206,7 +234,7 @@ export async function findFiles(opts: {
|
|
|
206
234
|
export async function listDir(opts: { path?: string; cwd: string; signal?: AbortSignal }): Promise<string> {
|
|
207
235
|
throwIfAborted(opts.signal);
|
|
208
236
|
const root = await resolveRoot(opts.cwd, opts.path, opts.signal);
|
|
209
|
-
if (!root) return `Error: directory not found: ${resolve(opts.cwd, opts.path || ".")}
|
|
237
|
+
if (!root) return truncate(`Error: directory not found: ${resolve(opts.cwd, opts.path || ".")}`);
|
|
210
238
|
try {
|
|
211
239
|
const entries = await readdir(root);
|
|
212
240
|
throwIfAborted(opts.signal);
|
|
@@ -214,7 +242,7 @@ export async function listDir(opts: { path?: string; cwd: string; signal?: Abort
|
|
|
214
242
|
return truncate(entries.join("\n"));
|
|
215
243
|
} catch (error: any) {
|
|
216
244
|
throwIfAborted(opts.signal);
|
|
217
|
-
return `Error listing ${root}: ${error.message}
|
|
245
|
+
return truncate(`Error listing ${root}: ${error.message}`);
|
|
218
246
|
}
|
|
219
247
|
}
|
|
220
248
|
|
|
@@ -222,13 +250,13 @@ export const grepSchema = Type.Object({
|
|
|
222
250
|
pattern: Type.String({ description: "Text to search for in file contents" }),
|
|
223
251
|
path: Type.Optional(Type.String({ description: "Directory to search (defaults to cwd)" })),
|
|
224
252
|
caseSensitive: Type.Optional(Type.Boolean({ description: "Case-sensitive match (default false)" })),
|
|
225
|
-
maxResults: Type.Optional(Type.Integer({ minimum: 1, description: "Max matches (default 100)" })),
|
|
253
|
+
maxResults: Type.Optional(Type.Integer({ minimum: 1, maximum: MAX_RESULTS, description: "Max matches (default 100)" })),
|
|
226
254
|
});
|
|
227
255
|
|
|
228
256
|
export const findSchema = Type.Object({
|
|
229
257
|
pattern: Type.Optional(Type.String({ description: "Substring to match in file path or name (empty lists all)" })),
|
|
230
258
|
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)" })),
|
|
259
|
+
maxResults: Type.Optional(Type.Integer({ minimum: 1, maximum: MAX_RESULTS, description: "Max results (default 100)" })),
|
|
232
260
|
});
|
|
233
261
|
|
|
234
262
|
export const lsSchema = Type.Object({
|
package/package.json
CHANGED