@jwilger/pi-development-system 0.3.4 → 0.3.6
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 +1 -1
- package/src/core/git-intent.ts +2 -2
- package/src/core/shell-parse.ts +21 -0
- package/src/core/test-paths.ts +23 -15
- package/src/core/test-weakening.ts +38 -20
- package/src/gates/approvals.ts +1 -1
- package/src/gates/test-guard.ts +29 -24
- package/src/jev/questions/test-change.ts +28 -5
package/package.json
CHANGED
package/src/core/git-intent.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { parseShell } from "./shell-parse.ts";
|
|
2
2
|
|
|
3
3
|
export type GitIntent =
|
|
4
4
|
| "history-rewrite"
|
|
@@ -58,7 +58,7 @@ const isRedirect = (op: string): boolean => /^[<>]/.test(op) && !op.endsWith("("
|
|
|
58
58
|
/** Tokens of one line grouped into simple commands (operators separate segments). */
|
|
59
59
|
function segments(line: string): string[][] {
|
|
60
60
|
const result: string[][] = [[]];
|
|
61
|
-
for (const token of
|
|
61
|
+
for (const token of parseShell(line, (name) => `$${name}`)) {
|
|
62
62
|
if (typeof token === "string") result[result.length - 1]?.push(token);
|
|
63
63
|
else if ("pattern" in token) result[result.length - 1]?.push(token.pattern);
|
|
64
64
|
else if ("op" in token && isRedirect(token.op)) continue;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type ParseEntry, parse } from "shell-quote";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* shell-quote throws on text that is valid shell or valid file content (a template literal inside a
|
|
5
|
+
* heredoc), and a guard must never crash the tool call. Retry with the `${` openers neutralised, then
|
|
6
|
+
* fall back to whitespace-split words so command classification still sees every token.
|
|
7
|
+
*/
|
|
8
|
+
export function parseShell(line: string, env?: (name: string) => string): ParseEntry[] {
|
|
9
|
+
const attempt = (text: string): ParseEntry[] | undefined => {
|
|
10
|
+
try {
|
|
11
|
+
return env === undefined ? parse(text) : parse(text, env);
|
|
12
|
+
} catch {
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
return (
|
|
17
|
+
attempt(line) ??
|
|
18
|
+
attempt(line.replaceAll("${", "$")) ??
|
|
19
|
+
line.split(/\s+/).filter((word) => word !== "")
|
|
20
|
+
);
|
|
21
|
+
}
|
package/src/core/test-paths.ts
CHANGED
|
@@ -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__
|
|
7
|
-
const TEST_DIR_ITSELF = /(^|\/)(test|tests|__tests__
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
.
|
|
24
|
-
.
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
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) =>
|
|
49
|
+
return (profile?.testGlobs ?? []).some((g) => matchGlob(g, normalized));
|
|
42
50
|
}
|
|
43
51
|
|
|
44
52
|
/**
|
|
@@ -1,23 +1,23 @@
|
|
|
1
1
|
import { posix } from "node:path";
|
|
2
|
-
import {
|
|
2
|
+
import { parseShell } from "./shell-parse.ts";
|
|
3
3
|
|
|
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
|
-
/\b(?:this|t|self|ctx)\.skip\(
|
|
9
|
-
/\bskip:\s*true\b
|
|
10
|
-
/\bskipTest\(
|
|
11
|
-
/(?<![.\w])f(?:it|describe)\(
|
|
12
|
-
/(?<![.\w])x(?: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
|
|
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,
|
|
17
17
|
];
|
|
18
18
|
|
|
19
19
|
const countSkips = (text: string): number =>
|
|
20
|
-
SKIP_MARKERS.reduce((n, re) => n +
|
|
20
|
+
SKIP_MARKERS.reduce((n, re) => n + [...text.matchAll(re)].length, 0);
|
|
21
21
|
|
|
22
22
|
/** Lines that are neither blank, inside a C-style block comment, nor a line comment. */
|
|
23
23
|
function codeLineCount(text: string): number {
|
|
@@ -71,15 +71,30 @@ export function weakeningSignals(before: string, after: string): WeakeningSignal
|
|
|
71
71
|
};
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
/**
|
|
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
|
+
*/
|
|
75
86
|
export function applyEdits(
|
|
76
87
|
content: string,
|
|
77
88
|
edits: ReadonlyArray<{ oldText: string; newText: string }>,
|
|
78
|
-
): string {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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;
|
|
83
98
|
}
|
|
84
99
|
|
|
85
100
|
/** True when every line of `before` still appears (with multiplicity) in `after`. */
|
|
@@ -136,7 +151,7 @@ function splitLines(command: string): string {
|
|
|
136
151
|
|
|
137
152
|
/** Boundary decode of shell-quote's output into domain tokens; globs count as words. */
|
|
138
153
|
function tokenize(command: string): Token[] {
|
|
139
|
-
return
|
|
154
|
+
return parseShell(splitLines(command)).flatMap((entry): Token[] => {
|
|
140
155
|
if (typeof entry === "string") return [{ kind: "word", value: entry }];
|
|
141
156
|
if ("pattern" in entry) return [{ kind: "word", value: entry.pattern }];
|
|
142
157
|
if ("op" in entry) return [{ kind: "op", op: entry.op }];
|
|
@@ -256,6 +271,9 @@ const gitTargets = (args: readonly string[]): string[] => {
|
|
|
256
271
|
return sub === "rm" || sub === "mv" ? operands(args.slice(i + 1)) : [];
|
|
257
272
|
};
|
|
258
273
|
|
|
274
|
+
const teeTargets = (args: readonly string[]): string[] =>
|
|
275
|
+
args.some((a) => a === "-a" || a === "--append") ? [] : operands(args);
|
|
276
|
+
|
|
259
277
|
const sedTargets = (args: readonly string[]): string[] => {
|
|
260
278
|
if (!args.some((a) => /^-[A-Za-z]*i|^--in-place/.test(a))) return [];
|
|
261
279
|
// Without -e/-f the first operand is the script, not a file.
|
|
@@ -278,7 +296,7 @@ const TARGETS: Readonly<Record<string, (args: readonly string[]) => string[]>> =
|
|
|
278
296
|
trash: removers,
|
|
279
297
|
rmdir: removers,
|
|
280
298
|
mv: moveSources,
|
|
281
|
-
tee:
|
|
299
|
+
tee: teeTargets,
|
|
282
300
|
truncate: truncateTargets,
|
|
283
301
|
find: findTargets,
|
|
284
302
|
sed: sedTargets,
|
package/src/gates/approvals.ts
CHANGED
|
@@ -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 =
|
package/src/gates/test-guard.ts
CHANGED
|
@@ -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 =
|
|
@@ -89,10 +90,13 @@ export function registerTestGuard(deps: TestGuardDeps): void {
|
|
|
89
90
|
|
|
90
91
|
const consumeOpenDeparture = (): void => {
|
|
91
92
|
const { openDepartures, activeSlice } = deps.state.get();
|
|
92
|
-
const
|
|
93
|
+
const applicable = openDepartures.filter(
|
|
93
94
|
(d) => d.gate === gate && (d.scope.kind !== "slice" || d.scope.slice === activeSlice),
|
|
94
95
|
);
|
|
95
|
-
|
|
96
|
+
// A broader departure covers the call without being spent; only a lone once-scoped one is used up.
|
|
97
|
+
if (applicable.some((d) => d.scope.kind !== "once")) return;
|
|
98
|
+
const used = applicable[0];
|
|
99
|
+
if (used === undefined) return;
|
|
96
100
|
deps.state.update((s) => ({
|
|
97
101
|
...s,
|
|
98
102
|
openDepartures: s.openDepartures.filter((d) => d.id !== used.id),
|
|
@@ -119,7 +123,7 @@ export function registerTestGuard(deps: TestGuardDeps): void {
|
|
|
119
123
|
const judged = await judgeTestChange(deps.jev(ctx), {
|
|
120
124
|
path: change.path,
|
|
121
125
|
before: change.before,
|
|
122
|
-
|
|
126
|
+
after: after === "" ? undefined : redactSecrets(after),
|
|
123
127
|
});
|
|
124
128
|
if (judged.ok) {
|
|
125
129
|
const { weakens, motive } = judged.value;
|
|
@@ -145,32 +149,33 @@ export function registerTestGuard(deps: TestGuardDeps): void {
|
|
|
145
149
|
const collectChanges = (event: ToolCallEvent, ctx: ExtensionContext): Change[] => {
|
|
146
150
|
if (isToolCallEventType("bash", event)) {
|
|
147
151
|
const { command } = event.input;
|
|
148
|
-
return bashMutations(command).flatMap(({ path: raw, kind }) => {
|
|
152
|
+
return bashMutations(command).flatMap(({ path: raw, kind }): Change[] => {
|
|
149
153
|
const path = repoPath(ctx.cwd, raw);
|
|
150
154
|
// Missing plain paths have nothing to weaken; globs and directories may still hide tests.
|
|
151
155
|
const touched = GLOB.test(path) || existsSync(join(ctx.cwd, path));
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
...(kind === "overwrite"
|
|
159
|
-
? { rewritten: `(rewritten by shell command) ${command}` }
|
|
160
|
-
: {}),
|
|
161
|
-
},
|
|
162
|
-
]
|
|
163
|
-
: [];
|
|
156
|
+
if (!isTestPath(path) || !touched) return [];
|
|
157
|
+
const rewritten =
|
|
158
|
+
kind === "overwrite" ? `(rewritten by shell command) ${command}` : undefined;
|
|
159
|
+
return [
|
|
160
|
+
{ path, before: readIfExists(join(ctx.cwd, path)) ?? "", after: undefined, rewritten },
|
|
161
|
+
];
|
|
164
162
|
});
|
|
165
163
|
}
|
|
166
164
|
if (!isToolCallEventType("write", event) && !isToolCallEventType("edit", event)) return [];
|
|
167
165
|
const path = repoPath(ctx.cwd, event.input.path);
|
|
168
|
-
const
|
|
169
|
-
if (
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
166
|
+
const raw = isTestPath(path) ? readIfExists(join(ctx.cwd, path)) : undefined;
|
|
167
|
+
if (raw === undefined) return [];
|
|
168
|
+
const before = normalizeText(raw);
|
|
169
|
+
if (isToolCallEventType("write", event)) {
|
|
170
|
+
return [{ path, before, after: normalizeText(event.input.content) }];
|
|
171
|
+
}
|
|
172
|
+
const after = applyEdits(before, event.input.edits);
|
|
173
|
+
if (after !== undefined) return [{ path, before, after }];
|
|
174
|
+
// The edit text does not match statically; let Jev read what the agent intends to write.
|
|
175
|
+
const intended = event.input.edits.map((e) => e.newText).join("\n");
|
|
176
|
+
return [
|
|
177
|
+
{ path, before, after: undefined, rewritten: `(edit not matched statically) ${intended}` },
|
|
178
|
+
];
|
|
174
179
|
};
|
|
175
180
|
|
|
176
181
|
const hardStop = async (
|
|
@@ -189,7 +194,7 @@ export function registerTestGuard(deps: TestGuardDeps): void {
|
|
|
189
194
|
why,
|
|
190
195
|
costIfWrong: "the tests verify less behaviour, so regressions can ship undetected",
|
|
191
196
|
toolCallId: event.toolCallId,
|
|
192
|
-
|
|
197
|
+
now: deps.now,
|
|
193
198
|
});
|
|
194
199
|
if (outcome.kind === "approved") return undefined;
|
|
195
200
|
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,
|
|
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(
|
|
61
|
-
after: input.after === undefined ? "" : clip(
|
|
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
|
};
|