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.
@@ -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(dir: string, out: string[], state: WalkState, signal?: AbortSignal, depth = 0): Promise<void> {
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()) await walk(p, out, state, signal, depth + 1);
74
- else if (lst.isFile()) out.push(p); // regular files only
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
- if (error?.message === "Operation aborted") throw error;
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.trim(); // nothing complete fits
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
- const limit = opts.maxResults ?? 100;
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
- if (state.rootError) return `Error: cannot read search root ${root}: ${state.rootError}`;
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
- if ((await stat(file)).size > MAX_FILE_BYTES) continue; // cap only before reading content
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
- if (state.rootError) return `Error: cannot read search root ${root}: ${state.rootError}`;
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 >= (opts.maxResults ?? 100)) break;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-tool-discipline",
3
- "version": "0.1.10",
3
+ "version": "0.1.12",
4
4
  "description": "pi extension: enforce ffgrep/fffind-first search discipline and neutralize the default bash file-operation guideline",
5
5
  "type": "module",
6
6
  "license": "MIT",