@jwilger/pi-development-system 0.3.0 → 0.3.2
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/extensions/development-system.ts +4 -3
- package/package.json +1 -1
- package/src/core/test-paths.ts +16 -1
- package/src/core/test-weakening.ts +251 -17
- package/src/gates/approvals.ts +3 -1
- package/src/gates/test-guard.ts +115 -75
- package/src/jev/client.ts +1 -2
- package/src/jev/questions/shell-intent.ts +5 -1
- package/src/jev/questions/test-change.ts +2 -1
|
@@ -31,14 +31,15 @@ export function createDevelopmentSystem(pi: ExtensionAPI) {
|
|
|
31
31
|
|
|
32
32
|
const rebuild = (ctx: ExtensionContext) => {
|
|
33
33
|
lastCtx = ctx;
|
|
34
|
-
const probe = jevHolder.probe(ctx);
|
|
35
|
-
jevModel = probe.model;
|
|
36
|
-
state.update((s) => (s.jev === probe.availability ? s : { ...s, jev: probe.availability }));
|
|
37
34
|
const entries = ctx.sessionManager
|
|
38
35
|
.getBranch()
|
|
39
36
|
.flatMap((e) => (e.type === "custom" ? [{ customType: e.customType, data: e.data }] : []));
|
|
37
|
+
// Restore persisted state first: updating before it would append an empty state entry that wins.
|
|
40
38
|
state.rebuildFrom(entries);
|
|
41
39
|
approvals.rebuildFrom(entries);
|
|
40
|
+
const probe = jevHolder.probe(ctx);
|
|
41
|
+
jevModel = probe.model;
|
|
42
|
+
state.update((s) => (s.jev === probe.availability ? s : { ...s, jev: probe.availability }));
|
|
42
43
|
refreshStatus();
|
|
43
44
|
};
|
|
44
45
|
|
package/package.json
CHANGED
package/src/core/test-paths.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
|
+
import { isAbsolute, join, relative, resolve } from "node:path";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
|
|
1
4
|
export type TestPathProfile = { readonly testGlobs?: readonly string[] };
|
|
2
5
|
|
|
3
6
|
const PATTERNS: readonly RegExp[] = [
|
|
4
|
-
/(^|\/)(test|tests|__tests__|spec|specs|e2e)
|
|
7
|
+
/(^|\/)(test|tests|__tests__|spec|specs|e2e)(\/|$)/,
|
|
5
8
|
/\.(test|spec|cy)\.[cm]?[jt]sx?$/,
|
|
6
9
|
/_test\.(go|rb|py|exs?|rs)$/,
|
|
7
10
|
/(^|\/)test_[^/]+\.py$/,
|
|
@@ -25,3 +28,15 @@ export function isTestPath(path: string, profile?: TestPathProfile): boolean {
|
|
|
25
28
|
if (PATTERNS.some((p) => p.test(normalized))) return true;
|
|
26
29
|
return (profile?.testGlobs ?? []).some((g) => globToRegExp(g).test(normalized));
|
|
27
30
|
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Repo-relative form of a path exactly as pi would resolve it: a leading `@` is dropped, `~` expands
|
|
34
|
+
* to the home directory and `file://` URLs are decoded, so guards see the file that is really touched.
|
|
35
|
+
*/
|
|
36
|
+
export function normalizeRepoPath(cwd: string, input: string, home: string): string {
|
|
37
|
+
let path = input.startsWith("@") ? input.slice(1) : input;
|
|
38
|
+
if (path === "~") path = home;
|
|
39
|
+
else if (path.startsWith("~/")) path = join(home, path.slice(2));
|
|
40
|
+
else if (/^file:\/\//.test(path)) path = fileURLToPath(path);
|
|
41
|
+
return isAbsolute(path) ? relative(cwd, path) : relative(cwd, resolve(cwd, path)) || ".";
|
|
42
|
+
}
|
|
@@ -1,24 +1,59 @@
|
|
|
1
|
+
import { posix } from "node:path";
|
|
1
2
|
import { parse } from "shell-quote";
|
|
2
3
|
|
|
3
|
-
export type WeakeningSignals = { addsSkip: boolean; emptied: boolean };
|
|
4
|
+
export type WeakeningSignals = { addsSkip: boolean; emptied: boolean; commentedOut: boolean };
|
|
4
5
|
|
|
5
6
|
const SKIP_MARKERS: readonly RegExp[] = [
|
|
6
|
-
/\b(?:it|test|describe|context)\.(?:skip|todo)\b/,
|
|
7
|
+
/\b(?:it|test|describe|context)\.(?:skip|todo|only)\b/,
|
|
8
|
+
/\bf(?:it|describe)\(/,
|
|
7
9
|
/\bx(?:it|describe|test)\(/,
|
|
8
10
|
/#\[ignore\b/,
|
|
9
|
-
/@pytest\.mark\.skip|@unittest\.skip|\bpytest\.skip\(/,
|
|
11
|
+
/@pytest\.mark\.(?:skip|skipif|xfail)|@unittest\.skip|\bpytest\.skip\(/,
|
|
10
12
|
/\bt\.Skip(?:Now|f)?\(/,
|
|
11
13
|
/@Disabled\b|@Ignore\b/,
|
|
14
|
+
/\bprocess\.exit\(/,
|
|
12
15
|
];
|
|
13
16
|
|
|
14
17
|
const countSkips = (text: string): number =>
|
|
15
18
|
SKIP_MARKERS.reduce((n, re) => n + (text.match(new RegExp(re.source, "g"))?.length ?? 0), 0);
|
|
16
19
|
|
|
20
|
+
/** Lines that are neither blank, inside a C-style block comment, nor a line comment. */
|
|
21
|
+
function codeLineCount(text: string): number {
|
|
22
|
+
let inBlock = false;
|
|
23
|
+
let count = 0;
|
|
24
|
+
for (const raw of text.split("\n")) {
|
|
25
|
+
let line = raw.trim();
|
|
26
|
+
let code = false;
|
|
27
|
+
while (line !== "") {
|
|
28
|
+
if (inBlock) {
|
|
29
|
+
const end = line.indexOf("*/");
|
|
30
|
+
if (end < 0) break;
|
|
31
|
+
inBlock = false;
|
|
32
|
+
line = line.slice(end + 2).trim();
|
|
33
|
+
} else if (line.startsWith("//") || line.startsWith("#")) {
|
|
34
|
+
break;
|
|
35
|
+
} else if (line.startsWith("/*")) {
|
|
36
|
+
inBlock = true;
|
|
37
|
+
line = line.slice(2);
|
|
38
|
+
} else {
|
|
39
|
+
code = true;
|
|
40
|
+
const start = line.indexOf("/*");
|
|
41
|
+
if (start < 0) break;
|
|
42
|
+
inBlock = true;
|
|
43
|
+
line = line.slice(start + 2);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (code) count++;
|
|
47
|
+
}
|
|
48
|
+
return count;
|
|
49
|
+
}
|
|
50
|
+
|
|
17
51
|
/** Deterministic weakening signals between two versions of a test file. */
|
|
18
52
|
export function weakeningSignals(before: string, after: string): WeakeningSignals {
|
|
19
53
|
return {
|
|
20
54
|
addsSkip: countSkips(after) > countSkips(before),
|
|
21
55
|
emptied: before.trim() !== "" && after.trim() === "",
|
|
56
|
+
commentedOut: codeLineCount(after) < codeLineCount(before) && isPureAddition(before, after),
|
|
22
57
|
};
|
|
23
58
|
}
|
|
24
59
|
|
|
@@ -45,24 +80,223 @@ export function isPureAddition(before: string, after: string): boolean {
|
|
|
45
80
|
return true;
|
|
46
81
|
}
|
|
47
82
|
|
|
48
|
-
|
|
83
|
+
/** Lines of `after` not matched by a line of `before` (multiset difference, order preserved). */
|
|
84
|
+
export function addedLines(before: string, after: string): string[] {
|
|
85
|
+
const remaining = new Map<string, number>();
|
|
86
|
+
for (const line of before.split("\n")) remaining.set(line, (remaining.get(line) ?? 0) + 1);
|
|
87
|
+
return after.split("\n").filter((line) => {
|
|
88
|
+
const left = remaining.get(line) ?? 0;
|
|
89
|
+
if (left === 0) return true;
|
|
90
|
+
remaining.set(line, left - 1);
|
|
91
|
+
return false;
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
type Segment = { words: string[]; truncates: string[] };
|
|
96
|
+
type Token = { kind: "word"; value: string } | { kind: "op"; op: string };
|
|
97
|
+
|
|
98
|
+
/** Turns unquoted newlines into `;` (and joins backslash continuations) so shell-quote sees every command. */
|
|
99
|
+
function splitLines(command: string): string {
|
|
100
|
+
let out = "";
|
|
101
|
+
let quote: "'" | '"' | undefined;
|
|
102
|
+
for (let i = 0; i < command.length; i++) {
|
|
103
|
+
const c = command.charAt(i);
|
|
104
|
+
if (quote === undefined && c === "\\" && command.charAt(i + 1) === "\n") {
|
|
105
|
+
out += " ";
|
|
106
|
+
i++;
|
|
107
|
+
} else if (quote === undefined && c === "\\") {
|
|
108
|
+
out += c + command.charAt(i + 1);
|
|
109
|
+
i++;
|
|
110
|
+
} else if (quote === undefined && (c === "'" || c === '"')) {
|
|
111
|
+
quote = c;
|
|
112
|
+
out += c;
|
|
113
|
+
} else if (quote === c) {
|
|
114
|
+
quote = undefined;
|
|
115
|
+
out += c;
|
|
116
|
+
} else if (quote === undefined && c === "\n") {
|
|
117
|
+
out += ";";
|
|
118
|
+
} else out += c;
|
|
119
|
+
}
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Boundary decode of shell-quote's output into domain tokens; globs count as words. */
|
|
124
|
+
function tokenize(command: string): Token[] {
|
|
125
|
+
return parse(splitLines(command)).flatMap((entry): Token[] => {
|
|
126
|
+
if (typeof entry === "string") return [{ kind: "word", value: entry }];
|
|
127
|
+
if ("pattern" in entry) return [{ kind: "word", value: entry.pattern }];
|
|
128
|
+
if ("op" in entry) return [{ kind: "op", op: entry.op }];
|
|
129
|
+
return [];
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const TRUNCATING = new Set([">", ">|", "&>"]);
|
|
134
|
+
const NON_TRUNCATING_REDIRECTS = new Set([">>", "&>>", "<", ">&", "<&"]);
|
|
49
135
|
|
|
50
|
-
/**
|
|
51
|
-
|
|
52
|
-
const
|
|
53
|
-
let
|
|
136
|
+
/** Splits a shell line into simple commands; `>` targets are kept apart from arguments. */
|
|
137
|
+
function segments(command: string): Segment[] {
|
|
138
|
+
const out: Segment[] = [];
|
|
139
|
+
let words: string[] = [];
|
|
140
|
+
let truncates: string[] = [];
|
|
141
|
+
let redirect: "none" | "truncate" | "other" = "none";
|
|
54
142
|
const flush = () => {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
}
|
|
60
|
-
current = [];
|
|
143
|
+
if (words.length > 0 || truncates.length > 0) out.push({ words, truncates });
|
|
144
|
+
words = [];
|
|
145
|
+
truncates = [];
|
|
146
|
+
redirect = "none";
|
|
61
147
|
};
|
|
62
|
-
for (const token of
|
|
63
|
-
if (
|
|
148
|
+
for (const token of tokenize(command)) {
|
|
149
|
+
if (token.kind === "word") {
|
|
150
|
+
if (redirect === "truncate") truncates.push(token.value);
|
|
151
|
+
else if (redirect === "none") words.push(token.value);
|
|
152
|
+
redirect = "none";
|
|
153
|
+
} else if (TRUNCATING.has(token.op)) redirect = "truncate";
|
|
154
|
+
else if (NON_TRUNCATING_REDIRECTS.has(token.op)) redirect = "other";
|
|
64
155
|
else flush();
|
|
65
156
|
}
|
|
66
157
|
flush();
|
|
67
|
-
return
|
|
158
|
+
return out;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const WRAPPERS = new Set(["sudo", "env", "command", "nohup", "nice", "time", "exec", "doas"]);
|
|
162
|
+
const SHELLS = new Set(["bash", "sh", "zsh", "dash"]);
|
|
163
|
+
const KNOWN = new Set([
|
|
164
|
+
...WRAPPERS,
|
|
165
|
+
...SHELLS,
|
|
166
|
+
"rm",
|
|
167
|
+
"unlink",
|
|
168
|
+
"trash",
|
|
169
|
+
"rmdir",
|
|
170
|
+
"mv",
|
|
171
|
+
"git",
|
|
172
|
+
"truncate",
|
|
173
|
+
"find",
|
|
174
|
+
"sed",
|
|
175
|
+
"tee",
|
|
176
|
+
"cp",
|
|
177
|
+
"dd",
|
|
178
|
+
"cd",
|
|
179
|
+
"eval",
|
|
180
|
+
]);
|
|
181
|
+
|
|
182
|
+
const base = (word: string): string => word.slice(word.lastIndexOf("/") + 1);
|
|
183
|
+
const operands = (words: readonly string[]): string[] => words.filter((w) => !w.startsWith("-"));
|
|
184
|
+
|
|
185
|
+
const KEYWORDS = new Set(["then", "do", "else", "elif", "if", "while", "until", "{", "!"]);
|
|
186
|
+
|
|
187
|
+
/** Strips env assignments, shell keywords and wrappers so the real command is first. */
|
|
188
|
+
function realCommand(words: readonly string[]): string[] {
|
|
189
|
+
let rest = [...words];
|
|
190
|
+
for (;;) {
|
|
191
|
+
const head = rest[0];
|
|
192
|
+
if (head === undefined) return rest;
|
|
193
|
+
if (/^\w+=/.test(head) || KEYWORDS.has(head)) rest = rest.slice(1);
|
|
194
|
+
else if (base(head) === "timeout") {
|
|
195
|
+
const at = rest.findIndex((w, i) => i > 0 && /^\d+[smhd]?$/.test(w));
|
|
196
|
+
rest = at < 0 ? [] : rest.slice(at + 1);
|
|
197
|
+
} else if (WRAPPERS.has(base(head))) {
|
|
198
|
+
const next = rest.findIndex((w, i) => i > 0 && KNOWN.has(base(w)) && !WRAPPERS.has(base(w)));
|
|
199
|
+
const nextWrapper = rest.findIndex((w, i) => i > 0 && WRAPPERS.has(base(w)));
|
|
200
|
+
if (nextWrapper > 0 && (next < 0 || nextWrapper < next)) rest = rest.slice(nextWrapper);
|
|
201
|
+
else return next > 0 ? rest.slice(next) : [];
|
|
202
|
+
} else return rest;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function findTargets(args: readonly string[]): string[] {
|
|
207
|
+
const deletes =
|
|
208
|
+
args.includes("-delete") || (args.includes("-exec") && args.some((a) => base(a) === "rm"));
|
|
209
|
+
if (!deletes) return [];
|
|
210
|
+
const starts: string[] = [];
|
|
211
|
+
for (const a of args) {
|
|
212
|
+
if (a.startsWith("-") || a === "(" || a === "!") break;
|
|
213
|
+
starts.push(a);
|
|
214
|
+
}
|
|
215
|
+
const patterns = args.flatMap((a, i) =>
|
|
216
|
+
["-name", "-iname", "-path", "-ipath", "-regex"].includes(a) && args[i + 1] !== undefined
|
|
217
|
+
? [args[i + 1] ?? ""]
|
|
218
|
+
: [],
|
|
219
|
+
);
|
|
220
|
+
return [...starts, ...patterns];
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const removers = (args: readonly string[]): string[] => operands(args);
|
|
224
|
+
|
|
225
|
+
const gitTargets = (args: readonly string[]): string[] => {
|
|
226
|
+
let i = 0;
|
|
227
|
+
while (args[i]?.startsWith("-")) i += args[i] === "-C" || args[i] === "-c" ? 2 : 1;
|
|
228
|
+
const sub = args[i];
|
|
229
|
+
return sub === "rm" || sub === "mv" ? operands(args.slice(i + 1)) : [];
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const sedTargets = (args: readonly string[]): string[] =>
|
|
233
|
+
args.some((a) => /^-[A-Za-z]*i|^--in-place/.test(a)) ? operands(args) : [];
|
|
234
|
+
|
|
235
|
+
/** The destination operand is overwritten, whatever the source is. */
|
|
236
|
+
const cpTargets = (args: readonly string[]): string[] => operands(args).slice(-1);
|
|
237
|
+
|
|
238
|
+
const ddTargets = (args: readonly string[]): string[] =>
|
|
239
|
+
args.flatMap((a) => (a.startsWith("of=") ? [a.slice(3)] : []));
|
|
240
|
+
|
|
241
|
+
const truncateTargets = (args: readonly string[]): string[] =>
|
|
242
|
+
operands(args).filter((a) => !/^[+\-<>/%]?\d+$/.test(a));
|
|
243
|
+
|
|
244
|
+
const TARGETS: Readonly<Record<string, (args: readonly string[]) => string[]>> = {
|
|
245
|
+
rm: removers,
|
|
246
|
+
unlink: removers,
|
|
247
|
+
trash: removers,
|
|
248
|
+
rmdir: removers,
|
|
249
|
+
mv: removers,
|
|
250
|
+
tee: removers,
|
|
251
|
+
truncate: truncateTargets,
|
|
252
|
+
find: findTargets,
|
|
253
|
+
sed: sedTargets,
|
|
254
|
+
cp: cpTargets,
|
|
255
|
+
dd: ddTargets,
|
|
256
|
+
git: gitTargets,
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
function commandTargets(words: readonly string[]): string[] {
|
|
260
|
+
const [head, ...args] = words;
|
|
261
|
+
if (head === undefined) return [];
|
|
262
|
+
const targets = Object.hasOwn(TARGETS, base(head)) ? TARGETS[base(head)] : undefined;
|
|
263
|
+
return targets === undefined ? [] : targets(args);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const joinCwd = (cwd: string, path: string): string =>
|
|
267
|
+
cwd === "" || path.startsWith("/") ? path : posix.join(cwd, path);
|
|
268
|
+
|
|
269
|
+
function collect(command: string, startCwd: string, depth: number): string[] {
|
|
270
|
+
const found: string[] = [];
|
|
271
|
+
let cwd = startCwd;
|
|
272
|
+
for (const seg of segments(command)) {
|
|
273
|
+
const words = realCommand(seg.words);
|
|
274
|
+
const head = words[0] === undefined ? "" : base(words[0]);
|
|
275
|
+
if (head === "cd") {
|
|
276
|
+
cwd = joinCwd(cwd, operands(words.slice(1))[0] ?? "~");
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
if (head === "eval" && depth < 3) {
|
|
280
|
+
found.push(...collect(words.slice(1).join(" "), cwd, depth + 1));
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
if (SHELLS.has(head) && depth < 3) {
|
|
284
|
+
const at = words.findIndex((w, i) => i > 0 && /^-[A-Za-z]*c$/.test(w));
|
|
285
|
+
const script = at < 0 ? undefined : words[at + 1];
|
|
286
|
+
if (script !== undefined) found.push(...collect(script, cwd, depth + 1));
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
found.push(...commandTargets(words).map((p) => joinCwd(cwd, p)));
|
|
290
|
+
found.push(...seg.truncates.flatMap((p) => (p === "/dev/null" ? [] : [joinCwd(cwd, p)])));
|
|
291
|
+
}
|
|
292
|
+
return found;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Paths a bash command removes, moves away, truncates or overwrites (best effort): rm/unlink/mv,
|
|
297
|
+
* `git rm|mv`, `find -delete`, truncate, `sed -i`, tee and `>` redirects; sees through wrappers,
|
|
298
|
+
* `bash -c`, `cd` and glob arguments.
|
|
299
|
+
*/
|
|
300
|
+
export function bashMutatedPaths(command: string): string[] {
|
|
301
|
+
return collect(command, "", 0);
|
|
68
302
|
}
|
package/src/gates/approvals.ts
CHANGED
|
@@ -65,6 +65,8 @@ export type HardStopRequest = {
|
|
|
65
65
|
command: string;
|
|
66
66
|
why: string;
|
|
67
67
|
toolCallId: string;
|
|
68
|
+
/** What goes wrong if the approved action is a mistake; defaults to the irreversible-git wording. */
|
|
69
|
+
costIfWrong?: string;
|
|
68
70
|
now?: () => Date;
|
|
69
71
|
};
|
|
70
72
|
|
|
@@ -89,7 +91,7 @@ export async function requestHardStop(req: HardStopRequest): Promise<HardStopOut
|
|
|
89
91
|
default: lookupGate(req.gate)?.default ?? "hard stop",
|
|
90
92
|
chosen: redactSecrets(req.command),
|
|
91
93
|
why: redactSecrets(req.why),
|
|
92
|
-
costIfWrong: "an irreversible git operation is performed",
|
|
94
|
+
costIfWrong: redactSecrets(req.costIfWrong ?? "an irreversible git operation is performed"),
|
|
93
95
|
approver: "user",
|
|
94
96
|
scope: { kind: "once", toolCallId: req.toolCallId },
|
|
95
97
|
recordedAt: at.toISOString().replace(/\.\d{3}Z$/, "Z"),
|
package/src/gates/test-guard.ts
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
1
|
-
import { readFileSync } from "node:fs";
|
|
2
|
-
import {
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
3
4
|
import {
|
|
4
5
|
type ExtensionAPI,
|
|
5
6
|
type ExtensionContext,
|
|
6
7
|
isToolCallEventType,
|
|
8
|
+
type ToolCallEvent,
|
|
7
9
|
} from "@earendil-works/pi-coding-agent";
|
|
8
|
-
import { isTestPath } from "../core/test-paths.ts";
|
|
10
|
+
import { isTestPath, normalizeRepoPath } from "../core/test-paths.ts";
|
|
9
11
|
import {
|
|
12
|
+
addedLines,
|
|
10
13
|
applyEdits,
|
|
11
|
-
|
|
14
|
+
bashMutatedPaths,
|
|
12
15
|
isPureAddition,
|
|
16
|
+
type WeakeningSignals,
|
|
13
17
|
weakeningSignals,
|
|
14
18
|
} from "../core/test-weakening.ts";
|
|
15
19
|
import { type GateId, isParseError, parseGateId } from "../core/types.ts";
|
|
@@ -35,13 +39,11 @@ type Verdict =
|
|
|
35
39
|
|
|
36
40
|
const GATE_ID = "tests.weaken";
|
|
37
41
|
|
|
38
|
-
const describeSignals = (
|
|
39
|
-
deleted: boolean,
|
|
40
|
-
signals: { addsSkip: boolean; emptied: boolean },
|
|
41
|
-
): string | undefined => {
|
|
42
|
+
const describeSignals = (deleted: boolean, signals: WeakeningSignals): string | undefined => {
|
|
42
43
|
if (deleted) return "deletes the test file";
|
|
43
44
|
if (signals.addsSkip) return "skips tests";
|
|
44
45
|
if (signals.emptied) return "empties the test file";
|
|
46
|
+
if (signals.commentedOut) return "comments out test code";
|
|
45
47
|
return undefined;
|
|
46
48
|
};
|
|
47
49
|
|
|
@@ -53,8 +55,12 @@ const readIfExists = (file: string): string | undefined => {
|
|
|
53
55
|
}
|
|
54
56
|
};
|
|
55
57
|
|
|
56
|
-
const repoPath = (cwd: string, path: string): string =>
|
|
57
|
-
|
|
58
|
+
const repoPath = (cwd: string, path: string): string => normalizeRepoPath(cwd, path, homedir());
|
|
59
|
+
|
|
60
|
+
/** Added lines that can disable tests without matching a skip marker (block comments, early exits). */
|
|
61
|
+
const RISKY_ADDITION = /\/\*|<!--|=begin\b|"""|'''|\breturn\b|\bthrow\b|\bexit\b|\bpanic\(/;
|
|
62
|
+
|
|
63
|
+
const GLOB = /[*?[\]{]/;
|
|
58
64
|
|
|
59
65
|
/**
|
|
60
66
|
* Soft gate `tests.weaken`: deleting, skipping, emptying or loosening tests needs a recorded
|
|
@@ -88,89 +94,123 @@ export function registerTestGuard(deps: TestGuardDeps): void {
|
|
|
88
94
|
|
|
89
95
|
const judge = async (change: Change, ctx: ExtensionContext): Promise<Verdict> => {
|
|
90
96
|
const deleted = change.after === undefined;
|
|
91
|
-
if (!deleted && isPureAddition(change.before, change.after ?? "")) return { kind: "allow" };
|
|
92
97
|
const signals = deleted
|
|
93
|
-
? { addsSkip: false, emptied: false }
|
|
98
|
+
? { addsSkip: false, emptied: false, commentedOut: false }
|
|
94
99
|
: weakeningSignals(change.before, change.after ?? "");
|
|
100
|
+
// Adding lines never weakens a test unless the added line itself is a skip marker.
|
|
101
|
+
const after = change.after ?? "";
|
|
102
|
+
if (
|
|
103
|
+
!deleted &&
|
|
104
|
+
!signals.addsSkip &&
|
|
105
|
+
!signals.commentedOut &&
|
|
106
|
+
isPureAddition(change.before, after) &&
|
|
107
|
+
!addedLines(change.before, after).some((line) => RISKY_ADDITION.test(line))
|
|
108
|
+
) {
|
|
109
|
+
return { kind: "allow" };
|
|
110
|
+
}
|
|
95
111
|
const deterministic = describeSignals(deleted, signals);
|
|
96
112
|
const judged = await judgeTestChange(deps.jev(ctx), {
|
|
97
113
|
path: change.path,
|
|
98
114
|
before: change.before,
|
|
99
115
|
...(change.after !== undefined ? { after: change.after } : {}),
|
|
100
116
|
});
|
|
101
|
-
if (judged.ok
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
117
|
+
if (judged.ok) {
|
|
118
|
+
const { weakens, motive } = judged.value;
|
|
119
|
+
const weakening = weakens >= WEAKENS_THRESHOLD || deterministic !== undefined;
|
|
120
|
+
if (weakening && motive === "gate-gaming") {
|
|
121
|
+
return {
|
|
122
|
+
kind: "hard-stop",
|
|
123
|
+
why: "Jev reads this change as weakening a check to get a green result",
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
if (weakens >= WEAKENS_THRESHOLD) {
|
|
127
|
+
return {
|
|
128
|
+
kind: "require-departure",
|
|
129
|
+
why: `Jev reads this change as weakening the tests (motive: ${motive})`,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
111
132
|
}
|
|
112
133
|
return deterministic !== undefined
|
|
113
134
|
? { kind: "require-departure", why: `this change ${deterministic}` }
|
|
114
135
|
: { kind: "allow" };
|
|
115
136
|
};
|
|
116
137
|
|
|
117
|
-
|
|
118
|
-
const changes: Change[] = [];
|
|
138
|
+
const collectChanges = (event: ToolCallEvent, ctx: ExtensionContext): Change[] => {
|
|
119
139
|
if (isToolCallEventType("bash", event)) {
|
|
120
|
-
|
|
140
|
+
return bashMutatedPaths(event.input.command).flatMap((raw) => {
|
|
121
141
|
const path = repoPath(ctx.cwd, raw);
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
const before = readIfExists(join(ctx.cwd, path));
|
|
129
|
-
if (before === undefined) return undefined;
|
|
130
|
-
const after = isToolCallEventType("write", event)
|
|
131
|
-
? event.input.content
|
|
132
|
-
: applyEdits(before, event.input.edits);
|
|
133
|
-
changes.push({ path, before, after });
|
|
134
|
-
} else {
|
|
135
|
-
return undefined;
|
|
142
|
+
// Missing plain paths have nothing to weaken; globs and directories may still hide tests.
|
|
143
|
+
const touched = GLOB.test(path) || existsSync(join(ctx.cwd, path));
|
|
144
|
+
return isTestPath(path) && touched
|
|
145
|
+
? [{ path, before: readIfExists(join(ctx.cwd, path)) ?? "", after: undefined }]
|
|
146
|
+
: [];
|
|
147
|
+
});
|
|
136
148
|
}
|
|
149
|
+
if (!isToolCallEventType("write", event) && !isToolCallEventType("edit", event)) return [];
|
|
150
|
+
const path = repoPath(ctx.cwd, event.input.path);
|
|
151
|
+
const before = isTestPath(path) ? readIfExists(join(ctx.cwd, path)) : undefined;
|
|
152
|
+
if (before === undefined) return [];
|
|
153
|
+
const after = isToolCallEventType("write", event)
|
|
154
|
+
? event.input.content
|
|
155
|
+
: applyEdits(before, event.input.edits);
|
|
156
|
+
return [{ path, before, after }];
|
|
157
|
+
};
|
|
137
158
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
? `hard stop ${GATE_ID}: ${verdict.why} (${change.path}); requires user approval; run interactively`
|
|
171
|
-
: `hard stop ${GATE_ID}: the user declined this change to ${change.path}. Fix the code instead of weakening the test.`,
|
|
172
|
-
};
|
|
159
|
+
const hardStop = async (
|
|
160
|
+
event: ToolCallEvent,
|
|
161
|
+
ctx: ExtensionContext,
|
|
162
|
+
change: Change,
|
|
163
|
+
why: string,
|
|
164
|
+
): Promise<{ block: true; reason: string } | undefined> => {
|
|
165
|
+
const command = `${isToolCallEventType("bash", event) ? "delete" : "change"} ${change.path}`;
|
|
166
|
+
if (deps.approvals.consume(gate, command)) return undefined;
|
|
167
|
+
const outcome = await requestHardStop({
|
|
168
|
+
pi: deps.pi,
|
|
169
|
+
ctx,
|
|
170
|
+
gate,
|
|
171
|
+
command,
|
|
172
|
+
why,
|
|
173
|
+
costIfWrong: "the tests verify less behaviour, so regressions can ship undetected",
|
|
174
|
+
toolCallId: event.toolCallId,
|
|
175
|
+
...(deps.now !== undefined ? { now: deps.now } : {}),
|
|
176
|
+
});
|
|
177
|
+
if (outcome.kind === "approved") return undefined;
|
|
178
|
+
return {
|
|
179
|
+
block: true,
|
|
180
|
+
reason:
|
|
181
|
+
outcome.kind === "unavailable"
|
|
182
|
+
? `hard stop ${GATE_ID}: ${why} (${change.path}); requires user approval; run interactively`
|
|
183
|
+
: `hard stop ${GATE_ID}: the user declined this change to ${change.path}. Fix the code instead of weakening the test.`,
|
|
184
|
+
};
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
deps.pi.on("tool_call", async (event, ctx) => {
|
|
188
|
+
const verdicts: Array<{ change: Change; verdict: Verdict }> = [];
|
|
189
|
+
for (const change of collectChanges(event, ctx)) {
|
|
190
|
+
verdicts.push({ change, verdict: await judge(change, ctx) });
|
|
173
191
|
}
|
|
174
|
-
|
|
192
|
+
for (const { change, verdict } of verdicts) {
|
|
193
|
+
if (verdict.kind !== "hard-stop") continue;
|
|
194
|
+
const blocked = await hardStop(event, ctx, change, verdict.why);
|
|
195
|
+
if (blocked !== undefined) return blocked;
|
|
196
|
+
}
|
|
197
|
+
const needing = verdicts.flatMap(({ change, verdict }) =>
|
|
198
|
+
verdict.kind === "require-departure" ? [{ change, why: verdict.why }] : [],
|
|
199
|
+
);
|
|
200
|
+
const first = needing[0];
|
|
201
|
+
if (first === undefined) return undefined;
|
|
202
|
+
if (hasOpenDeparture()) {
|
|
203
|
+
// One departure covers every path in this single tool call.
|
|
204
|
+
consumeOpenDeparture();
|
|
205
|
+
return undefined;
|
|
206
|
+
}
|
|
207
|
+
return {
|
|
208
|
+
block: true,
|
|
209
|
+
reason:
|
|
210
|
+
`${GATE_ID}: ${first.why} (${needing.map((n) => n.change.path).join(", ")}). Tests are the oracle; ` +
|
|
211
|
+
`weakening them needs a recorded decision. If this is deliberate, call devsys_record_departure ` +
|
|
212
|
+
`with gate "${GATE_ID}", what you are doing instead, why, and the cost if wrong; then retry. ` +
|
|
213
|
+
"Otherwise fix the code, not the test.",
|
|
214
|
+
};
|
|
175
215
|
});
|
|
176
216
|
}
|
package/src/jev/client.ts
CHANGED
|
@@ -23,8 +23,7 @@ type Classifier = ClassifierModel<ClassifierApi>;
|
|
|
23
23
|
/** The slice of pi's `ctx.modelRegistry` that Jev needs; tests pass a fake. */
|
|
24
24
|
export interface ClassifierRegistry {
|
|
25
25
|
findOfType(type: "classifier", provider: string, modelId: string): Classifier | undefined;
|
|
26
|
-
|
|
27
|
-
hasConfiguredAuth(model: any): boolean;
|
|
26
|
+
hasConfiguredAuth(model: unknown): boolean;
|
|
28
27
|
classify(
|
|
29
28
|
model: Classifier,
|
|
30
29
|
context: ClassifierContext,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ClassifierChoiceQuestion } from "@earendil-works/pi-ai";
|
|
2
2
|
import type { GitIntent } from "../../core/git-intent.ts";
|
|
3
|
+
import { redactSecrets } from "../../core/redact.ts";
|
|
3
4
|
import { err, ok, type Result } from "../../core/result.ts";
|
|
4
5
|
import type { Jev, JevError } from "../client.ts";
|
|
5
6
|
|
|
@@ -43,7 +44,10 @@ export async function judgeShellIntent(
|
|
|
43
44
|
jev: Jev,
|
|
44
45
|
command: string,
|
|
45
46
|
): Promise<Result<{ intent: GitIntent; confidence: number }, JevError>> {
|
|
46
|
-
const asked = await jev.ask(
|
|
47
|
+
const asked = await jev.ask(
|
|
48
|
+
{ command: redactSecrets(command) },
|
|
49
|
+
{ intent: SHELL_INTENT_QUESTION },
|
|
50
|
+
);
|
|
47
51
|
if (!asked.ok) return asked;
|
|
48
52
|
const answer = asked.value.intent;
|
|
49
53
|
if (answer?.type !== "choice")
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ClassifierBoolQuestion, ClassifierChoiceQuestion } from "@earendil-works/pi-ai";
|
|
2
|
+
import { redactSecrets } from "../../core/redact.ts";
|
|
2
3
|
import { err, ok, type Result } from "../../core/result.ts";
|
|
3
4
|
import type { Jev, JevError } from "../client.ts";
|
|
4
5
|
|
|
@@ -53,7 +54,7 @@ export async function judgeTestChange(
|
|
|
53
54
|
jev: Jev,
|
|
54
55
|
input: TestChangeInput,
|
|
55
56
|
): Promise<Result<{ weakens: number; motive: TestChangeMotive; confidence: number }, JevError>> {
|
|
56
|
-
const clip = (text: string | undefined): string => (text ?? "").slice(0, 6000);
|
|
57
|
+
const clip = (text: string | undefined): string => redactSecrets(text ?? "").slice(0, 6000);
|
|
57
58
|
const state = {
|
|
58
59
|
path: input.path,
|
|
59
60
|
before: clip(input.before),
|