@jwilger/pi-development-system 0.3.3 → 0.3.5

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.3",
3
+ "version": "0.3.5",
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"
@@ -3,8 +3,8 @@ 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)\/?$/;
6
+ const TEST_DIR = /(^|\/)(test|tests|__tests__)(\/|$)|^(spec|specs|e2e)(\/|$)/;
7
+ const TEST_DIR_ITSELF = /(^|\/)(test|tests|__tests__)\/?$|^(spec|specs|e2e)\/?$/;
8
8
  const SOURCE_EXT =
9
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
10
  const GLOBBY = /[*?[\]{]/;
@@ -16,15 +16,23 @@ const PATTERNS: readonly RegExp[] = [
16
16
  /_spec\.rb$/,
17
17
  ];
18
18
 
19
- const SPECIALS = /[.+^$(){}|[\]\\]/g;
20
-
21
- function globToRegExp(glob: string): RegExp {
22
- const escaped = glob
23
- .replace(SPECIALS, "\\$&")
24
- .replace(/\*\*/g, "@@GLOBSTAR@@")
25
- .replace(/\*/g, "[^/]*")
26
- .replace(/@@GLOBSTAR@@/g, ".*");
27
- return new RegExp(`^${escaped}$`);
19
+ /** Glob match without building a RegExp: `**` spans directories, `*` stays within a segment. */
20
+ function matchGlob(glob: string, path: string): boolean {
21
+ if (glob === "") return path === "";
22
+ if (glob.startsWith("**")) {
23
+ const rest = glob.slice(2);
24
+ for (let i = 0; i <= path.length; i++) if (matchGlob(rest, path.slice(i))) return true;
25
+ return false;
26
+ }
27
+ if (glob.startsWith("*")) {
28
+ const rest = glob.slice(1);
29
+ for (let i = 0; i <= path.length; i++) {
30
+ if (matchGlob(rest, path.slice(i))) return true;
31
+ if (path[i] === "/" || i === path.length) return false;
32
+ }
33
+ return false;
34
+ }
35
+ return path !== "" && path[0] === glob[0] && matchGlob(glob.slice(1), path.slice(1));
28
36
  }
29
37
 
30
38
  /** Whether a repo-relative path is a test file (conventions, plus profile-supplied globs). */
@@ -34,11 +42,11 @@ export function isTestPath(path: string, profile?: TestPathProfile): boolean {
34
42
  // Inside a test directory only source files (or globs that may expand to them) are tests;
35
43
  // logs, caches, snapshots and docs are not. The directory itself is, so `rm -rf tests` is caught.
36
44
  if (TEST_DIR.test(normalized)) {
37
- return (
38
- TEST_DIR_ITSELF.test(normalized) || SOURCE_EXT.test(normalized) || GLOBBY.test(normalized)
39
- );
45
+ if (TEST_DIR_ITSELF.test(normalized) || SOURCE_EXT.test(normalized)) return true;
46
+ // A glob may expand to tests, unless it ends in a literal non-source extension (*.log, *.snap).
47
+ return GLOBBY.test(normalized) && !/\.[A-Za-z0-9]+$/.test(normalized);
40
48
  }
41
- return (profile?.testGlobs ?? []).some((g) => globToRegExp(g).test(normalized));
49
+ return (profile?.testGlobs ?? []).some((g) => matchGlob(g, normalized));
42
50
  }
43
51
 
44
52
  /**
@@ -4,21 +4,20 @@ 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|only)\b/,
8
- /\.skip\(/,
9
- /\bskip:\s*true\b/,
10
- /\bskipTest\(/,
11
- /\bf(?:it|describe)\(/,
12
- /\bx(?:it|describe|test)\(/,
13
- /#\[ignore\b/,
14
- /@pytest\.mark\.(?:skip|skipif|xfail)|@unittest\.skip|\bpytest\.skip\(/,
15
- /\bt\.Skip(?:Now|f)?\(/,
16
- /@Disabled\b|@Ignore\b/,
17
- /\bprocess\.exit\(/,
7
+ /\b(?:it|test|describe|context)\.(?:skip|only)\b/g,
8
+ /\b(?:this|t|self|ctx)\.skip\(/g,
9
+ /\bskip:\s*true\b/g,
10
+ /\bskipTest\(/g,
11
+ /(?<![.\w])f(?:it|describe)\(/g,
12
+ /(?<![.\w])x(?:it|describe|test)\(/g,
13
+ /#\[ignore\b/g,
14
+ /@pytest\.mark\.(?:skip|skipif|xfail)|@unittest\.skip|\bpytest\.skip\(/g,
15
+ /\bt\.Skip(?:Now|f)?\(/g,
16
+ /@Disabled\b|@Ignore\b/g,
18
17
  ];
19
18
 
20
19
  const countSkips = (text: string): number =>
21
- SKIP_MARKERS.reduce((n, re) => n + (text.match(new RegExp(re.source, "g"))?.length ?? 0), 0);
20
+ SKIP_MARKERS.reduce((n, re) => n + [...text.matchAll(re)].length, 0);
22
21
 
23
22
  /** Lines that are neither blank, inside a C-style block comment, nor a line comment. */
24
23
  function codeLineCount(text: string): number {
@@ -39,11 +38,9 @@ function codeLineCount(text: string): number {
39
38
  inBlock = true;
40
39
  line = line.slice(2);
41
40
  } else {
41
+ // A `/*` after code is more likely inside a string than a comment opener.
42
42
  code = true;
43
- const start = line.indexOf("/*");
44
- if (start < 0) break;
45
- inBlock = true;
46
- line = line.slice(start + 2);
43
+ break;
47
44
  }
48
45
  }
49
46
  if (code) count++;
@@ -74,15 +71,30 @@ export function weakeningSignals(before: string, after: string): WeakeningSignal
74
71
  };
75
72
  }
76
73
 
77
- /** Applies `edits` to `content` (first occurrence each, in order); unmatched edits are ignored. */
74
+ /** LF line endings and no trailing whitespace, so matching tolerates what pi's edit tool tolerates. */
75
+ export const normalizeText = (text: string): string =>
76
+ text
77
+ .replace(/\r\n?/g, "\n")
78
+ .split("\n")
79
+ .map((line) => line.trimEnd())
80
+ .join("\n");
81
+
82
+ /**
83
+ * Applies `edits` to normalised `content` (first occurrence each, in order). `undefined` when an
84
+ * edit does not match, so callers can hand the change to a judge instead of assuming "no change".
85
+ */
78
86
  export function applyEdits(
79
87
  content: string,
80
88
  edits: ReadonlyArray<{ oldText: string; newText: string }>,
81
- ): string {
82
- return edits.reduce((text, e) => {
83
- const at = text.indexOf(e.oldText);
84
- return at < 0 ? text : text.slice(0, at) + e.newText + text.slice(at + e.oldText.length);
85
- }, content);
89
+ ): string | undefined {
90
+ let text = normalizeText(content);
91
+ for (const e of edits) {
92
+ const old = normalizeText(e.oldText);
93
+ const at = old === "" ? -1 : text.indexOf(old);
94
+ if (at < 0) return undefined;
95
+ text = text.slice(0, at) + normalizeText(e.newText) + text.slice(at + old.length);
96
+ }
97
+ return text;
86
98
  }
87
99
 
88
100
  /** True when every line of `before` still appears (with multiplicity) in `after`. */
@@ -234,11 +246,18 @@ function findTargets(args: readonly string[]): string[] {
234
246
  ? [args[i + 1] ?? ""]
235
247
  : [],
236
248
  );
237
- return [...starts, ...patterns];
249
+ // With name patterns the start directory is only where to look, not what is deleted.
250
+ return patterns.length > 0 ? patterns : starts;
238
251
  }
239
252
 
240
253
  const removers = (args: readonly string[]): string[] => operands(args);
241
254
 
255
+ /** `mv src… dir/`: the trailing-slash destination is a directory receiving files, not a removal. */
256
+ const moveSources = (args: readonly string[]): string[] => {
257
+ const ops = operands(args);
258
+ return ops.length > 1 && ops.at(-1)?.endsWith("/") ? ops.slice(0, -1) : ops;
259
+ };
260
+
242
261
  export type MutationKind = "remove" | "overwrite";
243
262
  export type Mutation = { readonly path: string; readonly kind: MutationKind };
244
263
 
@@ -252,6 +271,9 @@ const gitTargets = (args: readonly string[]): string[] => {
252
271
  return sub === "rm" || sub === "mv" ? operands(args.slice(i + 1)) : [];
253
272
  };
254
273
 
274
+ const teeTargets = (args: readonly string[]): string[] =>
275
+ args.some((a) => a === "-a" || a === "--append") ? [] : operands(args);
276
+
255
277
  const sedTargets = (args: readonly string[]): string[] => {
256
278
  if (!args.some((a) => /^-[A-Za-z]*i|^--in-place/.test(a))) return [];
257
279
  // Without -e/-f the first operand is the script, not a file.
@@ -273,8 +295,8 @@ const TARGETS: Readonly<Record<string, (args: readonly string[]) => string[]>> =
273
295
  unlink: removers,
274
296
  trash: removers,
275
297
  rmdir: removers,
276
- mv: removers,
277
- tee: removers,
298
+ mv: moveSources,
299
+ tee: teeTargets,
278
300
  truncate: truncateTargets,
279
301
  find: findTargets,
280
302
  sed: sedTargets,
@@ -320,12 +342,11 @@ function collect(command: string, startCwd: string, depth: number): Mutation[] {
320
342
  if (script !== undefined) found.push(...collect(script, cwd, depth + 1));
321
343
  continue;
322
344
  }
345
+ const redirectKind: MutationKind = PRODUCES_NOTHING.has(head) ? "remove" : "overwrite";
323
346
  found.push(...commandTargets(words).map((m) => ({ ...m, path: joinCwd(cwd, m.path) })));
324
347
  found.push(
325
348
  ...seg.truncates.flatMap((p): Mutation[] =>
326
- p === "/dev/null"
327
- ? []
328
- : [{ path: joinCwd(cwd, p), kind: PRODUCES_NOTHING.has(head) ? "remove" : "overwrite" }],
349
+ p === "/dev/null" ? [] : [{ path: joinCwd(cwd, p), kind: redirectKind }],
329
350
  ),
330
351
  );
331
352
  }
@@ -67,7 +67,7 @@ export type HardStopRequest = {
67
67
  toolCallId: string;
68
68
  /** What goes wrong if the approved action is a mistake; defaults to the irreversible-git wording. */
69
69
  costIfWrong?: string;
70
- now?: () => Date;
70
+ now?: (() => Date) | undefined;
71
71
  };
72
72
 
73
73
  export type HardStopOutcome =
@@ -14,6 +14,7 @@ import {
14
14
  applyEdits,
15
15
  bashMutations,
16
16
  isPureAddition,
17
+ normalizeText,
17
18
  type WeakeningSignals,
18
19
  weakeningSignals,
19
20
  } from "../core/test-weakening.ts";
@@ -36,7 +37,7 @@ type Change = {
36
37
  before: string;
37
38
  /** `undefined` means the file is gone; an `overwrite` carries the shell command instead. */
38
39
  after: string | undefined;
39
- rewritten?: string;
40
+ rewritten?: string | undefined;
40
41
  };
41
42
 
42
43
  type Verdict =
@@ -119,7 +120,7 @@ export function registerTestGuard(deps: TestGuardDeps): void {
119
120
  const judged = await judgeTestChange(deps.jev(ctx), {
120
121
  path: change.path,
121
122
  before: change.before,
122
- ...(after !== "" ? { after: redactSecrets(after) } : {}),
123
+ after: after === "" ? undefined : redactSecrets(after),
123
124
  });
124
125
  if (judged.ok) {
125
126
  const { weakens, motive } = judged.value;
@@ -145,32 +146,33 @@ export function registerTestGuard(deps: TestGuardDeps): void {
145
146
  const collectChanges = (event: ToolCallEvent, ctx: ExtensionContext): Change[] => {
146
147
  if (isToolCallEventType("bash", event)) {
147
148
  const { command } = event.input;
148
- return bashMutations(command).flatMap(({ path: raw, kind }) => {
149
+ return bashMutations(command).flatMap(({ path: raw, kind }): Change[] => {
149
150
  const path = repoPath(ctx.cwd, raw);
150
151
  // Missing plain paths have nothing to weaken; globs and directories may still hide tests.
151
152
  const touched = GLOB.test(path) || existsSync(join(ctx.cwd, path));
152
- return isTestPath(path) && touched
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
- ]
163
- : [];
153
+ if (!isTestPath(path) || !touched) return [];
154
+ const rewritten =
155
+ kind === "overwrite" ? `(rewritten by shell command) ${command}` : undefined;
156
+ return [
157
+ { path, before: readIfExists(join(ctx.cwd, path)) ?? "", after: undefined, rewritten },
158
+ ];
164
159
  });
165
160
  }
166
161
  if (!isToolCallEventType("write", event) && !isToolCallEventType("edit", event)) return [];
167
162
  const path = repoPath(ctx.cwd, event.input.path);
168
- const before = isTestPath(path) ? readIfExists(join(ctx.cwd, path)) : undefined;
169
- if (before === undefined) return [];
170
- const after = isToolCallEventType("write", event)
171
- ? event.input.content
172
- : applyEdits(before, event.input.edits);
173
- return [{ path, before, after }];
163
+ const raw = isTestPath(path) ? readIfExists(join(ctx.cwd, path)) : undefined;
164
+ if (raw === undefined) return [];
165
+ const before = normalizeText(raw);
166
+ if (isToolCallEventType("write", event)) {
167
+ return [{ path, before, after: normalizeText(event.input.content) }];
168
+ }
169
+ const after = applyEdits(before, event.input.edits);
170
+ if (after !== undefined) return [{ path, before, after }];
171
+ // The edit text does not match statically; let Jev read what the agent intends to write.
172
+ const intended = event.input.edits.map((e) => e.newText).join("\n");
173
+ return [
174
+ { path, before, after: undefined, rewritten: `(edit not matched statically) ${intended}` },
175
+ ];
174
176
  };
175
177
 
176
178
  const hardStop = async (
@@ -189,7 +191,7 @@ export function registerTestGuard(deps: TestGuardDeps): void {
189
191
  why,
190
192
  costIfWrong: "the tests verify less behaviour, so regressions can ship undetected",
191
193
  toolCallId: event.toolCallId,
192
- ...(deps.now !== undefined ? { now: deps.now } : {}),
194
+ now: deps.now,
193
195
  });
194
196
  if (outcome.kind === "approved") return undefined;
195
197
  return {
@@ -10,8 +10,8 @@ export type TestChangeMotive = "requirement-change" | "gate-gaming" | "refactor"
10
10
  export type TestChangeInput = {
11
11
  path: string;
12
12
  before?: string;
13
- after?: string;
14
- recentFailure?: string;
13
+ after?: string | undefined;
14
+ recentFailure?: string | undefined;
15
15
  };
16
16
 
17
17
  export const WEAKENS_QUESTION: ClassifierBoolQuestion = {
@@ -50,15 +50,38 @@ const isMotive = (value: string): value is TestChangeMotive => MOTIVES.some((m)
50
50
  export const motiveFromChoice = (choice: string, confidence: number): TestChangeMotive =>
51
51
  confidence >= MOTIVE_THRESHOLD && isMotive(choice) ? choice : "unclear";
52
52
 
53
+ const CONTEXT_LINES = 40;
54
+ const CLIP = 6000;
55
+
56
+ /** The lines around what changed, so a large file's edit is not hidden behind its unchanged head. */
57
+ export function changeWindow(before: string, after: string): { before: string; after: string } {
58
+ const b = before.split("\n");
59
+ const a = after.split("\n");
60
+ let head = 0;
61
+ while (head < b.length && head < a.length && b[head] === a[head]) head++;
62
+ let tail = 0;
63
+ while (tail < b.length - head && tail < a.length - head && b.at(-1 - tail) === a.at(-1 - tail)) {
64
+ tail++;
65
+ }
66
+ const from = Math.max(0, head - CONTEXT_LINES);
67
+ const slice = (lines: string[]): string =>
68
+ lines.slice(from, Math.min(lines.length, lines.length - tail + CONTEXT_LINES)).join("\n");
69
+ return { before: slice(b), after: slice(a) };
70
+ }
71
+
53
72
  export async function judgeTestChange(
54
73
  jev: Jev,
55
74
  input: TestChangeInput,
56
75
  ): Promise<Result<{ weakens: number; motive: TestChangeMotive; confidence: number }, JevError>> {
57
- const clip = (text: string | undefined): string => redactSecrets(text ?? "").slice(0, 6000);
76
+ const clip = (text: string | undefined): string => redactSecrets(text ?? "").slice(0, CLIP);
77
+ const windowed =
78
+ input.after === undefined
79
+ ? { before: input.before ?? "", after: "" }
80
+ : changeWindow(input.before ?? "", input.after);
58
81
  const state = {
59
82
  path: input.path,
60
- before: clip(input.before),
61
- after: input.after === undefined ? "" : clip(input.after),
83
+ before: clip(windowed.before),
84
+ after: input.after === undefined ? "" : clip(windowed.after),
62
85
  deleted: input.after === undefined,
63
86
  recentFailure: clip(input.recentFailure),
64
87
  };