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.
@@ -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,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.trim(); // nothing complete fits
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
- 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);
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 >= (opts.maxResults ?? 100)) break;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-tool-discipline",
3
- "version": "0.1.11",
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",