@jwilger/pi-development-system 0.3.2 → 0.3.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jwilger/pi-development-system",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
4
4
  "description": "A pi extension package representing a seasoned approach to software development using a full AI SDLC.",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -6,5 +6,13 @@ export function redactSecrets(text: string): string {
6
6
  .replace(/\b(\w*(?:TOKEN|KEY|SECRET|PASSWORD)\w*)=("[^"]*"|'[^']*'|\S+)/gi, "$1=[redacted]")
7
7
  .replace(/\b(Authorization:\s*)\w+\s+[^\s'"]+/gi, "$1[redacted]")
8
8
  .replace(/\bBearer\s+[^\s'"]+/g, "Bearer [redacted]")
9
+ .replace(
10
+ /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z ]*PRIVATE KEY-----|$)/g,
11
+ "[redacted private key]",
12
+ )
13
+ .replace(
14
+ /\b(\w*(?:token|key|secret|password|passwd)\w*["']?\s*[:=]\s*)(["'])[^"'\n]+\2/gi,
15
+ "$1$2[redacted]$2",
16
+ )
9
17
  .replace(/\bsk-[A-Za-z0-9_-]{16,}/g, "[redacted]");
10
18
  }
@@ -3,8 +3,13 @@ import { fileURLToPath } from "node:url";
3
3
 
4
4
  export type TestPathProfile = { readonly testGlobs?: readonly string[] };
5
5
 
6
+ const TEST_DIR = /(^|\/)(test|tests|__tests__|spec|specs|e2e)(\/|$)/;
7
+ const TEST_DIR_ITSELF = /(^|\/)(test|tests|__tests__|spec|specs|e2e)\/?$/;
8
+ const SOURCE_EXT =
9
+ /\.(?:[cm]?[jt]sx?|py|rb|rs|go|java|kt|kts|scala|cs|ex|exs|php|swift|c|cc|cpp|h|hpp|sh|lua|clj|hs|ml)$/;
10
+ const GLOBBY = /[*?[\]{]/;
11
+
6
12
  const PATTERNS: readonly RegExp[] = [
7
- /(^|\/)(test|tests|__tests__|spec|specs|e2e)(\/|$)/,
8
13
  /\.(test|spec|cy)\.[cm]?[jt]sx?$/,
9
14
  /_test\.(go|rb|py|exs?|rs)$/,
10
15
  /(^|\/)test_[^/]+\.py$/,
@@ -26,6 +31,13 @@ function globToRegExp(glob: string): RegExp {
26
31
  export function isTestPath(path: string, profile?: TestPathProfile): boolean {
27
32
  const normalized = path.replace(/^\.\//, "");
28
33
  if (PATTERNS.some((p) => p.test(normalized))) return true;
34
+ // Inside a test directory only source files (or globs that may expand to them) are tests;
35
+ // logs, caches, snapshots and docs are not. The directory itself is, so `rm -rf tests` is caught.
36
+ if (TEST_DIR.test(normalized)) {
37
+ return (
38
+ TEST_DIR_ITSELF.test(normalized) || SOURCE_EXT.test(normalized) || GLOBBY.test(normalized)
39
+ );
40
+ }
29
41
  return (profile?.testGlobs ?? []).some((g) => globToRegExp(g).test(normalized));
30
42
  }
31
43
 
@@ -4,7 +4,10 @@ import { parse } from "shell-quote";
4
4
  export type WeakeningSignals = { addsSkip: boolean; emptied: boolean; commentedOut: boolean };
5
5
 
6
6
  const SKIP_MARKERS: readonly RegExp[] = [
7
- /\b(?:it|test|describe|context)\.(?:skip|todo|only)\b/,
7
+ /\b(?:it|test|describe|context)\.(?:skip|only)\b/,
8
+ /\.skip\(/,
9
+ /\bskip:\s*true\b/,
10
+ /\bskipTest\(/,
8
11
  /\bf(?:it|describe)\(/,
9
12
  /\bx(?:it|describe|test)\(/,
10
13
  /#\[ignore\b/,
@@ -48,12 +51,26 @@ function codeLineCount(text: string): number {
48
51
  return count;
49
52
  }
50
53
 
54
+ const LINE_COMMENT = /^(?:\/\/+|#+|--|;+)\s?(.*)$/;
55
+
56
+ /** True when a line that was live code before survives only as a line comment after. */
57
+ function lineCommentedOut(before: string, after: string): boolean {
58
+ const live = new Set(after.split("\n").map((l) => l.trim()));
59
+ const beforeLines = new Set(before.split("\n").map((l) => l.trim()));
60
+ return after.split("\n").some((raw) => {
61
+ const rest = LINE_COMMENT.exec(raw.trim())?.[1]?.trim();
62
+ return rest !== undefined && rest.length > 3 && beforeLines.has(rest) && !live.has(rest);
63
+ });
64
+ }
65
+
51
66
  /** Deterministic weakening signals between two versions of a test file. */
52
67
  export function weakeningSignals(before: string, after: string): WeakeningSignals {
53
68
  return {
54
69
  addsSkip: countSkips(after) > countSkips(before),
55
70
  emptied: before.trim() !== "" && after.trim() === "",
56
- commentedOut: codeLineCount(after) < codeLineCount(before) && isPureAddition(before, after),
71
+ commentedOut:
72
+ lineCommentedOut(before, after) ||
73
+ (codeLineCount(after) < codeLineCount(before) && isPureAddition(before, after)),
57
74
  };
58
75
  }
59
76
 
@@ -222,6 +239,12 @@ function findTargets(args: readonly string[]): string[] {
222
239
 
223
240
  const removers = (args: readonly string[]): string[] => operands(args);
224
241
 
242
+ export type MutationKind = "remove" | "overwrite";
243
+ export type Mutation = { readonly path: string; readonly kind: MutationKind };
244
+
245
+ /** Commands whose targets cease to exist (or are emptied); every other target is rewritten in place. */
246
+ const REMOVING = new Set(["rm", "unlink", "trash", "rmdir", "mv", "truncate", "find", "git"]);
247
+
225
248
  const gitTargets = (args: readonly string[]): string[] => {
226
249
  let i = 0;
227
250
  while (args[i]?.startsWith("-")) i += args[i] === "-C" || args[i] === "-c" ? 2 : 1;
@@ -229,8 +252,12 @@ const gitTargets = (args: readonly string[]): string[] => {
229
252
  return sub === "rm" || sub === "mv" ? operands(args.slice(i + 1)) : [];
230
253
  };
231
254
 
232
- const sedTargets = (args: readonly string[]): string[] =>
233
- args.some((a) => /^-[A-Za-z]*i|^--in-place/.test(a)) ? operands(args) : [];
255
+ const sedTargets = (args: readonly string[]): string[] => {
256
+ if (!args.some((a) => /^-[A-Za-z]*i|^--in-place/.test(a))) return [];
257
+ // Without -e/-f the first operand is the script, not a file.
258
+ const scripted = args.some((a) => /^-[A-Za-z]*[ef]$|^--(?:expression|file)/.test(a));
259
+ return scripted ? operands(args) : operands(args).slice(1);
260
+ };
234
261
 
235
262
  /** The destination operand is overwritten, whatever the source is. */
236
263
  const cpTargets = (args: readonly string[]): string[] => operands(args).slice(-1);
@@ -256,24 +283,31 @@ const TARGETS: Readonly<Record<string, (args: readonly string[]) => string[]>> =
256
283
  git: gitTargets,
257
284
  };
258
285
 
259
- function commandTargets(words: readonly string[]): string[] {
286
+ function commandTargets(words: readonly string[]): Mutation[] {
260
287
  const [head, ...args] = words;
261
288
  if (head === undefined) return [];
262
- const targets = Object.hasOwn(TARGETS, base(head)) ? TARGETS[base(head)] : undefined;
263
- return targets === undefined ? [] : targets(args);
289
+ const name = base(head);
290
+ const targets = Object.hasOwn(TARGETS, name) ? TARGETS[name] : undefined;
291
+ const kind: MutationKind = REMOVING.has(name) ? "remove" : "overwrite";
292
+ return targets === undefined ? [] : targets(args).map((path) => ({ path, kind }));
264
293
  }
265
294
 
266
295
  const joinCwd = (cwd: string, path: string): string =>
267
296
  cwd === "" || path.startsWith("/") ? path : posix.join(cwd, path);
268
297
 
269
- function collect(command: string, startCwd: string, depth: number): string[] {
270
- const found: string[] = [];
298
+ /** With these (or nothing) as the command, a `>` redirect empties its target. */
299
+ const PRODUCES_NOTHING = new Set(["", ":", "true", "false"]);
300
+
301
+ function collect(command: string, startCwd: string, depth: number): Mutation[] {
302
+ const found: Mutation[] = [];
271
303
  let cwd = startCwd;
272
304
  for (const seg of segments(command)) {
273
305
  const words = realCommand(seg.words);
274
306
  const head = words[0] === undefined ? "" : base(words[0]);
275
307
  if (head === "cd") {
276
- cwd = joinCwd(cwd, operands(words.slice(1))[0] ?? "~");
308
+ const target = operands(words.slice(1))[0] ?? "~";
309
+ // A computed directory cannot be resolved statically; keep the current one.
310
+ if (!/[$`]/.test(target)) cwd = joinCwd(cwd, target);
277
311
  continue;
278
312
  }
279
313
  if (head === "eval" && depth < 3) {
@@ -286,8 +320,14 @@ function collect(command: string, startCwd: string, depth: number): string[] {
286
320
  if (script !== undefined) found.push(...collect(script, cwd, depth + 1));
287
321
  continue;
288
322
  }
289
- found.push(...commandTargets(words).map((p) => joinCwd(cwd, p)));
290
- found.push(...seg.truncates.flatMap((p) => (p === "/dev/null" ? [] : [joinCwd(cwd, p)])));
323
+ found.push(...commandTargets(words).map((m) => ({ ...m, path: joinCwd(cwd, m.path) })));
324
+ found.push(
325
+ ...seg.truncates.flatMap((p): Mutation[] =>
326
+ p === "/dev/null"
327
+ ? []
328
+ : [{ path: joinCwd(cwd, p), kind: PRODUCES_NOTHING.has(head) ? "remove" : "overwrite" }],
329
+ ),
330
+ );
291
331
  }
292
332
  return found;
293
333
  }
@@ -297,6 +337,10 @@ function collect(command: string, startCwd: string, depth: number): string[] {
297
337
  * `git rm|mv`, `find -delete`, truncate, `sed -i`, tee and `>` redirects; sees through wrappers,
298
338
  * `bash -c`, `cd` and glob arguments.
299
339
  */
300
- export function bashMutatedPaths(command: string): string[] {
340
+ export function bashMutations(command: string): Mutation[] {
301
341
  return collect(command, "", 0);
302
342
  }
343
+
344
+ export function bashMutatedPaths(command: string): string[] {
345
+ return bashMutations(command).map((m) => m.path);
346
+ }
@@ -7,11 +7,12 @@ import {
7
7
  isToolCallEventType,
8
8
  type ToolCallEvent,
9
9
  } from "@earendil-works/pi-coding-agent";
10
+ import { redactSecrets } from "../core/redact.ts";
10
11
  import { isTestPath, normalizeRepoPath } from "../core/test-paths.ts";
11
12
  import {
12
13
  addedLines,
13
14
  applyEdits,
14
- bashMutatedPaths,
15
+ bashMutations,
15
16
  isPureAddition,
16
17
  type WeakeningSignals,
17
18
  weakeningSignals,
@@ -30,7 +31,13 @@ export type TestGuardDeps = {
30
31
  now?: () => Date;
31
32
  };
32
33
 
33
- type Change = { path: string; before: string; after: string | undefined };
34
+ type Change = {
35
+ path: string;
36
+ before: string;
37
+ /** `undefined` means the file is gone; an `overwrite` carries the shell command instead. */
38
+ after: string | undefined;
39
+ rewritten?: string;
40
+ };
34
41
 
35
42
  type Verdict =
36
43
  | { kind: "allow" }
@@ -93,12 +100,12 @@ export function registerTestGuard(deps: TestGuardDeps): void {
93
100
  };
94
101
 
95
102
  const judge = async (change: Change, ctx: ExtensionContext): Promise<Verdict> => {
96
- const deleted = change.after === undefined;
103
+ const deleted = change.after === undefined && change.rewritten === undefined;
104
+ const after = change.after ?? change.rewritten ?? "";
97
105
  const signals = deleted
98
106
  ? { addsSkip: false, emptied: false, commentedOut: false }
99
- : weakeningSignals(change.before, change.after ?? "");
107
+ : weakeningSignals(change.before, after);
100
108
  // Adding lines never weakens a test unless the added line itself is a skip marker.
101
- const after = change.after ?? "";
102
109
  if (
103
110
  !deleted &&
104
111
  !signals.addsSkip &&
@@ -112,7 +119,7 @@ export function registerTestGuard(deps: TestGuardDeps): void {
112
119
  const judged = await judgeTestChange(deps.jev(ctx), {
113
120
  path: change.path,
114
121
  before: change.before,
115
- ...(change.after !== undefined ? { after: change.after } : {}),
122
+ ...(after !== "" ? { after: redactSecrets(after) } : {}),
116
123
  });
117
124
  if (judged.ok) {
118
125
  const { weakens, motive } = judged.value;
@@ -137,12 +144,22 @@ export function registerTestGuard(deps: TestGuardDeps): void {
137
144
 
138
145
  const collectChanges = (event: ToolCallEvent, ctx: ExtensionContext): Change[] => {
139
146
  if (isToolCallEventType("bash", event)) {
140
- return bashMutatedPaths(event.input.command).flatMap((raw) => {
147
+ const { command } = event.input;
148
+ return bashMutations(command).flatMap(({ path: raw, kind }) => {
141
149
  const path = repoPath(ctx.cwd, raw);
142
150
  // Missing plain paths have nothing to weaken; globs and directories may still hide tests.
143
151
  const touched = GLOB.test(path) || existsSync(join(ctx.cwd, path));
144
152
  return isTestPath(path) && touched
145
- ? [{ path, before: readIfExists(join(ctx.cwd, path)) ?? "", after: undefined }]
153
+ ? [
154
+ {
155
+ path,
156
+ before: readIfExists(join(ctx.cwd, path)) ?? "",
157
+ after: undefined,
158
+ ...(kind === "overwrite"
159
+ ? { rewritten: `(rewritten by shell command) ${command}` }
160
+ : {}),
161
+ },
162
+ ]
146
163
  : [];
147
164
  });
148
165
  }