@jwilger/pi-development-system 0.3.1 → 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 +1 -1
- package/src/core/redact.ts +8 -0
- package/src/core/test-paths.ts +28 -1
- package/src/core/test-weakening.ts +158 -28
- package/src/gates/test-guard.ts +50 -19
package/package.json
CHANGED
package/src/core/redact.ts
CHANGED
|
@@ -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
|
}
|
package/src/core/test-paths.ts
CHANGED
|
@@ -1,7 +1,15 @@
|
|
|
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
|
|
|
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
|
+
|
|
3
12
|
const PATTERNS: readonly RegExp[] = [
|
|
4
|
-
/(^|\/)(test|tests|__tests__|spec|specs|e2e)(\/|$)/,
|
|
5
13
|
/\.(test|spec|cy)\.[cm]?[jt]sx?$/,
|
|
6
14
|
/_test\.(go|rb|py|exs?|rs)$/,
|
|
7
15
|
/(^|\/)test_[^/]+\.py$/,
|
|
@@ -23,5 +31,24 @@ function globToRegExp(glob: string): RegExp {
|
|
|
23
31
|
export function isTestPath(path: string, profile?: TestPathProfile): boolean {
|
|
24
32
|
const normalized = path.replace(/^\.\//, "");
|
|
25
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
|
+
}
|
|
26
41
|
return (profile?.testGlobs ?? []).some((g) => globToRegExp(g).test(normalized));
|
|
27
42
|
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Repo-relative form of a path exactly as pi would resolve it: a leading `@` is dropped, `~` expands
|
|
46
|
+
* to the home directory and `file://` URLs are decoded, so guards see the file that is really touched.
|
|
47
|
+
*/
|
|
48
|
+
export function normalizeRepoPath(cwd: string, input: string, home: string): string {
|
|
49
|
+
let path = input.startsWith("@") ? input.slice(1) : input;
|
|
50
|
+
if (path === "~") path = home;
|
|
51
|
+
else if (path.startsWith("~/")) path = join(home, path.slice(2));
|
|
52
|
+
else if (/^file:\/\//.test(path)) path = fileURLToPath(path);
|
|
53
|
+
return isAbsolute(path) ? relative(cwd, path) : relative(cwd, resolve(cwd, path)) || ".";
|
|
54
|
+
}
|
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
import { posix } from "node:path";
|
|
2
2
|
import { parse } from "shell-quote";
|
|
3
3
|
|
|
4
|
-
export type WeakeningSignals = { addsSkip: boolean; emptied: boolean };
|
|
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|
|
|
7
|
+
/\b(?:it|test|describe|context)\.(?:skip|only)\b/,
|
|
8
|
+
/\.skip\(/,
|
|
9
|
+
/\bskip:\s*true\b/,
|
|
10
|
+
/\bskipTest\(/,
|
|
11
|
+
/\bf(?:it|describe)\(/,
|
|
8
12
|
/\bx(?:it|describe|test)\(/,
|
|
9
13
|
/#\[ignore\b/,
|
|
10
|
-
/@pytest\.mark\.skip|@unittest\.skip|\bpytest\.skip\(/,
|
|
14
|
+
/@pytest\.mark\.(?:skip|skipif|xfail)|@unittest\.skip|\bpytest\.skip\(/,
|
|
11
15
|
/\bt\.Skip(?:Now|f)?\(/,
|
|
12
16
|
/@Disabled\b|@Ignore\b/,
|
|
13
17
|
/\bprocess\.exit\(/,
|
|
@@ -16,11 +20,57 @@ const SKIP_MARKERS: readonly RegExp[] = [
|
|
|
16
20
|
const countSkips = (text: string): number =>
|
|
17
21
|
SKIP_MARKERS.reduce((n, re) => n + (text.match(new RegExp(re.source, "g"))?.length ?? 0), 0);
|
|
18
22
|
|
|
23
|
+
/** Lines that are neither blank, inside a C-style block comment, nor a line comment. */
|
|
24
|
+
function codeLineCount(text: string): number {
|
|
25
|
+
let inBlock = false;
|
|
26
|
+
let count = 0;
|
|
27
|
+
for (const raw of text.split("\n")) {
|
|
28
|
+
let line = raw.trim();
|
|
29
|
+
let code = false;
|
|
30
|
+
while (line !== "") {
|
|
31
|
+
if (inBlock) {
|
|
32
|
+
const end = line.indexOf("*/");
|
|
33
|
+
if (end < 0) break;
|
|
34
|
+
inBlock = false;
|
|
35
|
+
line = line.slice(end + 2).trim();
|
|
36
|
+
} else if (line.startsWith("//") || line.startsWith("#")) {
|
|
37
|
+
break;
|
|
38
|
+
} else if (line.startsWith("/*")) {
|
|
39
|
+
inBlock = true;
|
|
40
|
+
line = line.slice(2);
|
|
41
|
+
} else {
|
|
42
|
+
code = true;
|
|
43
|
+
const start = line.indexOf("/*");
|
|
44
|
+
if (start < 0) break;
|
|
45
|
+
inBlock = true;
|
|
46
|
+
line = line.slice(start + 2);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (code) count++;
|
|
50
|
+
}
|
|
51
|
+
return count;
|
|
52
|
+
}
|
|
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
|
+
|
|
19
66
|
/** Deterministic weakening signals between two versions of a test file. */
|
|
20
67
|
export function weakeningSignals(before: string, after: string): WeakeningSignals {
|
|
21
68
|
return {
|
|
22
69
|
addsSkip: countSkips(after) > countSkips(before),
|
|
23
70
|
emptied: before.trim() !== "" && after.trim() === "",
|
|
71
|
+
commentedOut:
|
|
72
|
+
lineCommentedOut(before, after) ||
|
|
73
|
+
(codeLineCount(after) < codeLineCount(before) && isPureAddition(before, after)),
|
|
24
74
|
};
|
|
25
75
|
}
|
|
26
76
|
|
|
@@ -47,12 +97,49 @@ export function isPureAddition(before: string, after: string): boolean {
|
|
|
47
97
|
return true;
|
|
48
98
|
}
|
|
49
99
|
|
|
100
|
+
/** Lines of `after` not matched by a line of `before` (multiset difference, order preserved). */
|
|
101
|
+
export function addedLines(before: string, after: string): string[] {
|
|
102
|
+
const remaining = new Map<string, number>();
|
|
103
|
+
for (const line of before.split("\n")) remaining.set(line, (remaining.get(line) ?? 0) + 1);
|
|
104
|
+
return after.split("\n").filter((line) => {
|
|
105
|
+
const left = remaining.get(line) ?? 0;
|
|
106
|
+
if (left === 0) return true;
|
|
107
|
+
remaining.set(line, left - 1);
|
|
108
|
+
return false;
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
50
112
|
type Segment = { words: string[]; truncates: string[] };
|
|
51
113
|
type Token = { kind: "word"; value: string } | { kind: "op"; op: string };
|
|
52
114
|
|
|
115
|
+
/** Turns unquoted newlines into `;` (and joins backslash continuations) so shell-quote sees every command. */
|
|
116
|
+
function splitLines(command: string): string {
|
|
117
|
+
let out = "";
|
|
118
|
+
let quote: "'" | '"' | undefined;
|
|
119
|
+
for (let i = 0; i < command.length; i++) {
|
|
120
|
+
const c = command.charAt(i);
|
|
121
|
+
if (quote === undefined && c === "\\" && command.charAt(i + 1) === "\n") {
|
|
122
|
+
out += " ";
|
|
123
|
+
i++;
|
|
124
|
+
} else if (quote === undefined && c === "\\") {
|
|
125
|
+
out += c + command.charAt(i + 1);
|
|
126
|
+
i++;
|
|
127
|
+
} else if (quote === undefined && (c === "'" || c === '"')) {
|
|
128
|
+
quote = c;
|
|
129
|
+
out += c;
|
|
130
|
+
} else if (quote === c) {
|
|
131
|
+
quote = undefined;
|
|
132
|
+
out += c;
|
|
133
|
+
} else if (quote === undefined && c === "\n") {
|
|
134
|
+
out += ";";
|
|
135
|
+
} else out += c;
|
|
136
|
+
}
|
|
137
|
+
return out;
|
|
138
|
+
}
|
|
139
|
+
|
|
53
140
|
/** Boundary decode of shell-quote's output into domain tokens; globs count as words. */
|
|
54
141
|
function tokenize(command: string): Token[] {
|
|
55
|
-
return parse(command).flatMap((entry): Token[] => {
|
|
142
|
+
return parse(splitLines(command)).flatMap((entry): Token[] => {
|
|
56
143
|
if (typeof entry === "string") return [{ kind: "word", value: entry }];
|
|
57
144
|
if ("pattern" in entry) return [{ kind: "word", value: entry.pattern }];
|
|
58
145
|
if ("op" in entry) return [{ kind: "op", op: entry.op }];
|
|
@@ -60,8 +147,8 @@ function tokenize(command: string): Token[] {
|
|
|
60
147
|
});
|
|
61
148
|
}
|
|
62
149
|
|
|
63
|
-
const TRUNCATING = new Set([">", ">|"]);
|
|
64
|
-
const NON_TRUNCATING_REDIRECTS = new Set([">>", "<", ">&", "<&"]);
|
|
150
|
+
const TRUNCATING = new Set([">", ">|", "&>"]);
|
|
151
|
+
const NON_TRUNCATING_REDIRECTS = new Set([">>", "&>>", "<", ">&", "<&"]);
|
|
65
152
|
|
|
66
153
|
/** Splits a shell line into simple commands; `>` targets are kept apart from arguments. */
|
|
67
154
|
function segments(command: string): Segment[] {
|
|
@@ -104,24 +191,32 @@ const KNOWN = new Set([
|
|
|
104
191
|
"sed",
|
|
105
192
|
"tee",
|
|
106
193
|
"cp",
|
|
194
|
+
"dd",
|
|
107
195
|
"cd",
|
|
196
|
+
"eval",
|
|
108
197
|
]);
|
|
109
198
|
|
|
110
199
|
const base = (word: string): string => word.slice(word.lastIndexOf("/") + 1);
|
|
111
200
|
const operands = (words: readonly string[]): string[] => words.filter((w) => !w.startsWith("-"));
|
|
112
201
|
|
|
113
|
-
|
|
202
|
+
const KEYWORDS = new Set(["then", "do", "else", "elif", "if", "while", "until", "{", "!"]);
|
|
203
|
+
|
|
204
|
+
/** Strips env assignments, shell keywords and wrappers so the real command is first. */
|
|
114
205
|
function realCommand(words: readonly string[]): string[] {
|
|
115
206
|
let rest = [...words];
|
|
116
207
|
for (;;) {
|
|
117
|
-
while (rest[0] !== undefined && /^\w+=/.test(rest[0])) rest = rest.slice(1);
|
|
118
208
|
const head = rest[0];
|
|
119
|
-
if (head === undefined
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
else
|
|
209
|
+
if (head === undefined) return rest;
|
|
210
|
+
if (/^\w+=/.test(head) || KEYWORDS.has(head)) rest = rest.slice(1);
|
|
211
|
+
else if (base(head) === "timeout") {
|
|
212
|
+
const at = rest.findIndex((w, i) => i > 0 && /^\d+[smhd]?$/.test(w));
|
|
213
|
+
rest = at < 0 ? [] : rest.slice(at + 1);
|
|
214
|
+
} else if (WRAPPERS.has(base(head))) {
|
|
215
|
+
const next = rest.findIndex((w, i) => i > 0 && KNOWN.has(base(w)) && !WRAPPERS.has(base(w)));
|
|
216
|
+
const nextWrapper = rest.findIndex((w, i) => i > 0 && WRAPPERS.has(base(w)));
|
|
217
|
+
if (nextWrapper > 0 && (next < 0 || nextWrapper < next)) rest = rest.slice(nextWrapper);
|
|
218
|
+
else return next > 0 ? rest.slice(next) : [];
|
|
219
|
+
} else return rest;
|
|
125
220
|
}
|
|
126
221
|
}
|
|
127
222
|
|
|
@@ -144,6 +239,12 @@ function findTargets(args: readonly string[]): string[] {
|
|
|
144
239
|
|
|
145
240
|
const removers = (args: readonly string[]): string[] => operands(args);
|
|
146
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
|
+
|
|
147
248
|
const gitTargets = (args: readonly string[]): string[] => {
|
|
148
249
|
let i = 0;
|
|
149
250
|
while (args[i]?.startsWith("-")) i += args[i] === "-C" || args[i] === "-c" ? 2 : 1;
|
|
@@ -151,11 +252,18 @@ const gitTargets = (args: readonly string[]): string[] => {
|
|
|
151
252
|
return sub === "rm" || sub === "mv" ? operands(args.slice(i + 1)) : [];
|
|
152
253
|
};
|
|
153
254
|
|
|
154
|
-
const sedTargets = (args: readonly string[]): string[] =>
|
|
155
|
-
args.some((a) => /^-[A-Za-z]*i|^--in-place/.test(a))
|
|
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
|
+
};
|
|
261
|
+
|
|
262
|
+
/** The destination operand is overwritten, whatever the source is. */
|
|
263
|
+
const cpTargets = (args: readonly string[]): string[] => operands(args).slice(-1);
|
|
156
264
|
|
|
157
|
-
const
|
|
158
|
-
args.
|
|
265
|
+
const ddTargets = (args: readonly string[]): string[] =>
|
|
266
|
+
args.flatMap((a) => (a.startsWith("of=") ? [a.slice(3)] : []));
|
|
159
267
|
|
|
160
268
|
const truncateTargets = (args: readonly string[]): string[] =>
|
|
161
269
|
operands(args).filter((a) => !/^[+\-<>/%]?\d+$/.test(a));
|
|
@@ -171,27 +279,39 @@ const TARGETS: Readonly<Record<string, (args: readonly string[]) => string[]>> =
|
|
|
171
279
|
find: findTargets,
|
|
172
280
|
sed: sedTargets,
|
|
173
281
|
cp: cpTargets,
|
|
282
|
+
dd: ddTargets,
|
|
174
283
|
git: gitTargets,
|
|
175
284
|
};
|
|
176
285
|
|
|
177
|
-
function commandTargets(words: readonly string[]):
|
|
286
|
+
function commandTargets(words: readonly string[]): Mutation[] {
|
|
178
287
|
const [head, ...args] = words;
|
|
179
288
|
if (head === undefined) return [];
|
|
180
|
-
const
|
|
181
|
-
|
|
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 }));
|
|
182
293
|
}
|
|
183
294
|
|
|
184
295
|
const joinCwd = (cwd: string, path: string): string =>
|
|
185
296
|
cwd === "" || path.startsWith("/") ? path : posix.join(cwd, path);
|
|
186
297
|
|
|
187
|
-
|
|
188
|
-
|
|
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[] = [];
|
|
189
303
|
let cwd = startCwd;
|
|
190
304
|
for (const seg of segments(command)) {
|
|
191
305
|
const words = realCommand(seg.words);
|
|
192
306
|
const head = words[0] === undefined ? "" : base(words[0]);
|
|
193
|
-
if (head === "cd"
|
|
194
|
-
|
|
307
|
+
if (head === "cd") {
|
|
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);
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
if (head === "eval" && depth < 3) {
|
|
314
|
+
found.push(...collect(words.slice(1).join(" "), cwd, depth + 1));
|
|
195
315
|
continue;
|
|
196
316
|
}
|
|
197
317
|
if (SHELLS.has(head) && depth < 3) {
|
|
@@ -200,8 +320,14 @@ function collect(command: string, startCwd: string, depth: number): string[] {
|
|
|
200
320
|
if (script !== undefined) found.push(...collect(script, cwd, depth + 1));
|
|
201
321
|
continue;
|
|
202
322
|
}
|
|
203
|
-
found.push(...commandTargets(words).map((
|
|
204
|
-
found.push(
|
|
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
|
+
);
|
|
205
331
|
}
|
|
206
332
|
return found;
|
|
207
333
|
}
|
|
@@ -211,6 +337,10 @@ function collect(command: string, startCwd: string, depth: number): string[] {
|
|
|
211
337
|
* `git rm|mv`, `find -delete`, truncate, `sed -i`, tee and `>` redirects; sees through wrappers,
|
|
212
338
|
* `bash -c`, `cd` and glob arguments.
|
|
213
339
|
*/
|
|
214
|
-
export function
|
|
340
|
+
export function bashMutations(command: string): Mutation[] {
|
|
215
341
|
return collect(command, "", 0);
|
|
216
342
|
}
|
|
343
|
+
|
|
344
|
+
export function bashMutatedPaths(command: string): string[] {
|
|
345
|
+
return bashMutations(command).map((m) => m.path);
|
|
346
|
+
}
|
package/src/gates/test-guard.ts
CHANGED
|
@@ -1,16 +1,20 @@
|
|
|
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,
|
|
7
8
|
type ToolCallEvent,
|
|
8
9
|
} from "@earendil-works/pi-coding-agent";
|
|
9
|
-
import {
|
|
10
|
+
import { redactSecrets } from "../core/redact.ts";
|
|
11
|
+
import { isTestPath, normalizeRepoPath } from "../core/test-paths.ts";
|
|
10
12
|
import {
|
|
13
|
+
addedLines,
|
|
11
14
|
applyEdits,
|
|
12
|
-
|
|
15
|
+
bashMutations,
|
|
13
16
|
isPureAddition,
|
|
17
|
+
type WeakeningSignals,
|
|
14
18
|
weakeningSignals,
|
|
15
19
|
} from "../core/test-weakening.ts";
|
|
16
20
|
import { type GateId, isParseError, parseGateId } from "../core/types.ts";
|
|
@@ -27,7 +31,13 @@ export type TestGuardDeps = {
|
|
|
27
31
|
now?: () => Date;
|
|
28
32
|
};
|
|
29
33
|
|
|
30
|
-
type Change = {
|
|
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
|
+
};
|
|
31
41
|
|
|
32
42
|
type Verdict =
|
|
33
43
|
| { kind: "allow" }
|
|
@@ -36,13 +46,11 @@ type Verdict =
|
|
|
36
46
|
|
|
37
47
|
const GATE_ID = "tests.weaken";
|
|
38
48
|
|
|
39
|
-
const describeSignals = (
|
|
40
|
-
deleted: boolean,
|
|
41
|
-
signals: { addsSkip: boolean; emptied: boolean },
|
|
42
|
-
): string | undefined => {
|
|
49
|
+
const describeSignals = (deleted: boolean, signals: WeakeningSignals): string | undefined => {
|
|
43
50
|
if (deleted) return "deletes the test file";
|
|
44
51
|
if (signals.addsSkip) return "skips tests";
|
|
45
52
|
if (signals.emptied) return "empties the test file";
|
|
53
|
+
if (signals.commentedOut) return "comments out test code";
|
|
46
54
|
return undefined;
|
|
47
55
|
};
|
|
48
56
|
|
|
@@ -54,8 +62,12 @@ const readIfExists = (file: string): string | undefined => {
|
|
|
54
62
|
}
|
|
55
63
|
};
|
|
56
64
|
|
|
57
|
-
const repoPath = (cwd: string, path: string): string =>
|
|
58
|
-
|
|
65
|
+
const repoPath = (cwd: string, path: string): string => normalizeRepoPath(cwd, path, homedir());
|
|
66
|
+
|
|
67
|
+
/** Added lines that can disable tests without matching a skip marker (block comments, early exits). */
|
|
68
|
+
const RISKY_ADDITION = /\/\*|<!--|=begin\b|"""|'''|\breturn\b|\bthrow\b|\bexit\b|\bpanic\(/;
|
|
69
|
+
|
|
70
|
+
const GLOB = /[*?[\]{]/;
|
|
59
71
|
|
|
60
72
|
/**
|
|
61
73
|
* Soft gate `tests.weaken`: deleting, skipping, emptying or loosening tests needs a recorded
|
|
@@ -88,19 +100,26 @@ export function registerTestGuard(deps: TestGuardDeps): void {
|
|
|
88
100
|
};
|
|
89
101
|
|
|
90
102
|
const judge = async (change: Change, ctx: ExtensionContext): Promise<Verdict> => {
|
|
91
|
-
const deleted = change.after === undefined;
|
|
103
|
+
const deleted = change.after === undefined && change.rewritten === undefined;
|
|
104
|
+
const after = change.after ?? change.rewritten ?? "";
|
|
92
105
|
const signals = deleted
|
|
93
|
-
? { addsSkip: false, emptied: false }
|
|
94
|
-
: weakeningSignals(change.before,
|
|
106
|
+
? { addsSkip: false, emptied: false, commentedOut: false }
|
|
107
|
+
: weakeningSignals(change.before, after);
|
|
95
108
|
// Adding lines never weakens a test unless the added line itself is a skip marker.
|
|
96
|
-
if (
|
|
109
|
+
if (
|
|
110
|
+
!deleted &&
|
|
111
|
+
!signals.addsSkip &&
|
|
112
|
+
!signals.commentedOut &&
|
|
113
|
+
isPureAddition(change.before, after) &&
|
|
114
|
+
!addedLines(change.before, after).some((line) => RISKY_ADDITION.test(line))
|
|
115
|
+
) {
|
|
97
116
|
return { kind: "allow" };
|
|
98
117
|
}
|
|
99
118
|
const deterministic = describeSignals(deleted, signals);
|
|
100
119
|
const judged = await judgeTestChange(deps.jev(ctx), {
|
|
101
120
|
path: change.path,
|
|
102
121
|
before: change.before,
|
|
103
|
-
...(
|
|
122
|
+
...(after !== "" ? { after: redactSecrets(after) } : {}),
|
|
104
123
|
});
|
|
105
124
|
if (judged.ok) {
|
|
106
125
|
const { weakens, motive } = judged.value;
|
|
@@ -125,10 +144,22 @@ export function registerTestGuard(deps: TestGuardDeps): void {
|
|
|
125
144
|
|
|
126
145
|
const collectChanges = (event: ToolCallEvent, ctx: ExtensionContext): Change[] => {
|
|
127
146
|
if (isToolCallEventType("bash", event)) {
|
|
128
|
-
|
|
147
|
+
const { command } = event.input;
|
|
148
|
+
return bashMutations(command).flatMap(({ path: raw, kind }) => {
|
|
129
149
|
const path = repoPath(ctx.cwd, raw);
|
|
130
|
-
|
|
131
|
-
|
|
150
|
+
// Missing plain paths have nothing to weaken; globs and directories may still hide tests.
|
|
151
|
+
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
|
+
]
|
|
132
163
|
: [];
|
|
133
164
|
});
|
|
134
165
|
}
|