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.
@@ -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(dir: string, out: string[], state: WalkState, signal?: AbortSignal, depth = 0): Promise<void> {
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
- const cut = buf.subarray(0, budget).toString("utf8");
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.trim(); // nothing complete fits
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
- const limit = opts.maxResults ?? 100;
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 >= (opts.maxResults ?? 100)) break;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-tool-discipline",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
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",