@jwilger/pi-development-system 0.3.2 → 0.3.4
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 +13 -1
- package/src/core/test-weakening.ts +69 -22
- package/src/gates/test-guard.ts +25 -8
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
|
@@ -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,14 +4,16 @@ 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|
|
|
8
|
-
/\
|
|
9
|
-
/\
|
|
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)\(/,
|
|
10
13
|
/#\[ignore\b/,
|
|
11
14
|
/@pytest\.mark\.(?:skip|skipif|xfail)|@unittest\.skip|\bpytest\.skip\(/,
|
|
12
15
|
/\bt\.Skip(?:Now|f)?\(/,
|
|
13
16
|
/@Disabled\b|@Ignore\b/,
|
|
14
|
-
/\bprocess\.exit\(/,
|
|
15
17
|
];
|
|
16
18
|
|
|
17
19
|
const countSkips = (text: string): number =>
|
|
@@ -36,11 +38,9 @@ function codeLineCount(text: string): number {
|
|
|
36
38
|
inBlock = true;
|
|
37
39
|
line = line.slice(2);
|
|
38
40
|
} else {
|
|
41
|
+
// A `/*` after code is more likely inside a string than a comment opener.
|
|
39
42
|
code = true;
|
|
40
|
-
|
|
41
|
-
if (start < 0) break;
|
|
42
|
-
inBlock = true;
|
|
43
|
-
line = line.slice(start + 2);
|
|
43
|
+
break;
|
|
44
44
|
}
|
|
45
45
|
}
|
|
46
46
|
if (code) count++;
|
|
@@ -48,12 +48,26 @@ function codeLineCount(text: string): number {
|
|
|
48
48
|
return count;
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
const LINE_COMMENT = /^(?:\/\/+|#+|--|;+)\s?(.*)$/;
|
|
52
|
+
|
|
53
|
+
/** True when a line that was live code before survives only as a line comment after. */
|
|
54
|
+
function lineCommentedOut(before: string, after: string): boolean {
|
|
55
|
+
const live = new Set(after.split("\n").map((l) => l.trim()));
|
|
56
|
+
const beforeLines = new Set(before.split("\n").map((l) => l.trim()));
|
|
57
|
+
return after.split("\n").some((raw) => {
|
|
58
|
+
const rest = LINE_COMMENT.exec(raw.trim())?.[1]?.trim();
|
|
59
|
+
return rest !== undefined && rest.length > 3 && beforeLines.has(rest) && !live.has(rest);
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
51
63
|
/** Deterministic weakening signals between two versions of a test file. */
|
|
52
64
|
export function weakeningSignals(before: string, after: string): WeakeningSignals {
|
|
53
65
|
return {
|
|
54
66
|
addsSkip: countSkips(after) > countSkips(before),
|
|
55
67
|
emptied: before.trim() !== "" && after.trim() === "",
|
|
56
|
-
commentedOut:
|
|
68
|
+
commentedOut:
|
|
69
|
+
lineCommentedOut(before, after) ||
|
|
70
|
+
(codeLineCount(after) < codeLineCount(before) && isPureAddition(before, after)),
|
|
57
71
|
};
|
|
58
72
|
}
|
|
59
73
|
|
|
@@ -217,11 +231,24 @@ function findTargets(args: readonly string[]): string[] {
|
|
|
217
231
|
? [args[i + 1] ?? ""]
|
|
218
232
|
: [],
|
|
219
233
|
);
|
|
220
|
-
|
|
234
|
+
// With name patterns the start directory is only where to look, not what is deleted.
|
|
235
|
+
return patterns.length > 0 ? patterns : starts;
|
|
221
236
|
}
|
|
222
237
|
|
|
223
238
|
const removers = (args: readonly string[]): string[] => operands(args);
|
|
224
239
|
|
|
240
|
+
/** `mv src… dir/`: the trailing-slash destination is a directory receiving files, not a removal. */
|
|
241
|
+
const moveSources = (args: readonly string[]): string[] => {
|
|
242
|
+
const ops = operands(args);
|
|
243
|
+
return ops.length > 1 && ops.at(-1)?.endsWith("/") ? ops.slice(0, -1) : ops;
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
export type MutationKind = "remove" | "overwrite";
|
|
247
|
+
export type Mutation = { readonly path: string; readonly kind: MutationKind };
|
|
248
|
+
|
|
249
|
+
/** Commands whose targets cease to exist (or are emptied); every other target is rewritten in place. */
|
|
250
|
+
const REMOVING = new Set(["rm", "unlink", "trash", "rmdir", "mv", "truncate", "find", "git"]);
|
|
251
|
+
|
|
225
252
|
const gitTargets = (args: readonly string[]): string[] => {
|
|
226
253
|
let i = 0;
|
|
227
254
|
while (args[i]?.startsWith("-")) i += args[i] === "-C" || args[i] === "-c" ? 2 : 1;
|
|
@@ -229,8 +256,12 @@ const gitTargets = (args: readonly string[]): string[] => {
|
|
|
229
256
|
return sub === "rm" || sub === "mv" ? operands(args.slice(i + 1)) : [];
|
|
230
257
|
};
|
|
231
258
|
|
|
232
|
-
const sedTargets = (args: readonly string[]): string[] =>
|
|
233
|
-
args.some((a) => /^-[A-Za-z]*i|^--in-place/.test(a))
|
|
259
|
+
const sedTargets = (args: readonly string[]): string[] => {
|
|
260
|
+
if (!args.some((a) => /^-[A-Za-z]*i|^--in-place/.test(a))) return [];
|
|
261
|
+
// Without -e/-f the first operand is the script, not a file.
|
|
262
|
+
const scripted = args.some((a) => /^-[A-Za-z]*[ef]$|^--(?:expression|file)/.test(a));
|
|
263
|
+
return scripted ? operands(args) : operands(args).slice(1);
|
|
264
|
+
};
|
|
234
265
|
|
|
235
266
|
/** The destination operand is overwritten, whatever the source is. */
|
|
236
267
|
const cpTargets = (args: readonly string[]): string[] => operands(args).slice(-1);
|
|
@@ -246,7 +277,7 @@ const TARGETS: Readonly<Record<string, (args: readonly string[]) => string[]>> =
|
|
|
246
277
|
unlink: removers,
|
|
247
278
|
trash: removers,
|
|
248
279
|
rmdir: removers,
|
|
249
|
-
mv:
|
|
280
|
+
mv: moveSources,
|
|
250
281
|
tee: removers,
|
|
251
282
|
truncate: truncateTargets,
|
|
252
283
|
find: findTargets,
|
|
@@ -256,24 +287,31 @@ const TARGETS: Readonly<Record<string, (args: readonly string[]) => string[]>> =
|
|
|
256
287
|
git: gitTargets,
|
|
257
288
|
};
|
|
258
289
|
|
|
259
|
-
function commandTargets(words: readonly string[]):
|
|
290
|
+
function commandTargets(words: readonly string[]): Mutation[] {
|
|
260
291
|
const [head, ...args] = words;
|
|
261
292
|
if (head === undefined) return [];
|
|
262
|
-
const
|
|
263
|
-
|
|
293
|
+
const name = base(head);
|
|
294
|
+
const targets = Object.hasOwn(TARGETS, name) ? TARGETS[name] : undefined;
|
|
295
|
+
const kind: MutationKind = REMOVING.has(name) ? "remove" : "overwrite";
|
|
296
|
+
return targets === undefined ? [] : targets(args).map((path) => ({ path, kind }));
|
|
264
297
|
}
|
|
265
298
|
|
|
266
299
|
const joinCwd = (cwd: string, path: string): string =>
|
|
267
300
|
cwd === "" || path.startsWith("/") ? path : posix.join(cwd, path);
|
|
268
301
|
|
|
269
|
-
|
|
270
|
-
|
|
302
|
+
/** With these (or nothing) as the command, a `>` redirect empties its target. */
|
|
303
|
+
const PRODUCES_NOTHING = new Set(["", ":", "true", "false"]);
|
|
304
|
+
|
|
305
|
+
function collect(command: string, startCwd: string, depth: number): Mutation[] {
|
|
306
|
+
const found: Mutation[] = [];
|
|
271
307
|
let cwd = startCwd;
|
|
272
308
|
for (const seg of segments(command)) {
|
|
273
309
|
const words = realCommand(seg.words);
|
|
274
310
|
const head = words[0] === undefined ? "" : base(words[0]);
|
|
275
311
|
if (head === "cd") {
|
|
276
|
-
|
|
312
|
+
const target = operands(words.slice(1))[0] ?? "~";
|
|
313
|
+
// A computed directory cannot be resolved statically; keep the current one.
|
|
314
|
+
if (!/[$`]/.test(target)) cwd = joinCwd(cwd, target);
|
|
277
315
|
continue;
|
|
278
316
|
}
|
|
279
317
|
if (head === "eval" && depth < 3) {
|
|
@@ -286,8 +324,13 @@ function collect(command: string, startCwd: string, depth: number): string[] {
|
|
|
286
324
|
if (script !== undefined) found.push(...collect(script, cwd, depth + 1));
|
|
287
325
|
continue;
|
|
288
326
|
}
|
|
289
|
-
|
|
290
|
-
found.push(...
|
|
327
|
+
const redirectKind: MutationKind = PRODUCES_NOTHING.has(head) ? "remove" : "overwrite";
|
|
328
|
+
found.push(...commandTargets(words).map((m) => ({ ...m, path: joinCwd(cwd, m.path) })));
|
|
329
|
+
found.push(
|
|
330
|
+
...seg.truncates.flatMap((p): Mutation[] =>
|
|
331
|
+
p === "/dev/null" ? [] : [{ path: joinCwd(cwd, p), kind: redirectKind }],
|
|
332
|
+
),
|
|
333
|
+
);
|
|
291
334
|
}
|
|
292
335
|
return found;
|
|
293
336
|
}
|
|
@@ -297,6 +340,10 @@ function collect(command: string, startCwd: string, depth: number): string[] {
|
|
|
297
340
|
* `git rm|mv`, `find -delete`, truncate, `sed -i`, tee and `>` redirects; sees through wrappers,
|
|
298
341
|
* `bash -c`, `cd` and glob arguments.
|
|
299
342
|
*/
|
|
300
|
-
export function
|
|
343
|
+
export function bashMutations(command: string): Mutation[] {
|
|
301
344
|
return collect(command, "", 0);
|
|
302
345
|
}
|
|
346
|
+
|
|
347
|
+
export function bashMutatedPaths(command: string): string[] {
|
|
348
|
+
return bashMutations(command).map((m) => m.path);
|
|
349
|
+
}
|
package/src/gates/test-guard.ts
CHANGED
|
@@ -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
|
-
|
|
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 = {
|
|
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,
|
|
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
|
-
...(
|
|
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
|
-
|
|
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
|
-
? [
|
|
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
|
}
|