@jwilger/pi-development-system 0.3.1 → 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/package.json +1 -1
- package/src/core/test-paths.ts +15 -0
- package/src/core/test-weakening.ts +104 -18
- package/src/gates/test-guard.ts +26 -12
package/package.json
CHANGED
package/src/core/test-paths.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
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[] = [
|
|
@@ -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,13 +1,14 @@
|
|
|
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|todo)\b/,
|
|
7
|
+
/\b(?:it|test|describe|context)\.(?:skip|todo|only)\b/,
|
|
8
|
+
/\bf(?:it|describe)\(/,
|
|
8
9
|
/\bx(?:it|describe|test)\(/,
|
|
9
10
|
/#\[ignore\b/,
|
|
10
|
-
/@pytest\.mark\.skip|@unittest\.skip|\bpytest\.skip\(/,
|
|
11
|
+
/@pytest\.mark\.(?:skip|skipif|xfail)|@unittest\.skip|\bpytest\.skip\(/,
|
|
11
12
|
/\bt\.Skip(?:Now|f)?\(/,
|
|
12
13
|
/@Disabled\b|@Ignore\b/,
|
|
13
14
|
/\bprocess\.exit\(/,
|
|
@@ -16,11 +17,43 @@ const SKIP_MARKERS: readonly RegExp[] = [
|
|
|
16
17
|
const countSkips = (text: string): number =>
|
|
17
18
|
SKIP_MARKERS.reduce((n, re) => n + (text.match(new RegExp(re.source, "g"))?.length ?? 0), 0);
|
|
18
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
|
+
|
|
19
51
|
/** Deterministic weakening signals between two versions of a test file. */
|
|
20
52
|
export function weakeningSignals(before: string, after: string): WeakeningSignals {
|
|
21
53
|
return {
|
|
22
54
|
addsSkip: countSkips(after) > countSkips(before),
|
|
23
55
|
emptied: before.trim() !== "" && after.trim() === "",
|
|
56
|
+
commentedOut: codeLineCount(after) < codeLineCount(before) && isPureAddition(before, after),
|
|
24
57
|
};
|
|
25
58
|
}
|
|
26
59
|
|
|
@@ -47,12 +80,49 @@ export function isPureAddition(before: string, after: string): boolean {
|
|
|
47
80
|
return true;
|
|
48
81
|
}
|
|
49
82
|
|
|
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
|
+
|
|
50
95
|
type Segment = { words: string[]; truncates: string[] };
|
|
51
96
|
type Token = { kind: "word"; value: string } | { kind: "op"; op: string };
|
|
52
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
|
+
|
|
53
123
|
/** Boundary decode of shell-quote's output into domain tokens; globs count as words. */
|
|
54
124
|
function tokenize(command: string): Token[] {
|
|
55
|
-
return parse(command).flatMap((entry): Token[] => {
|
|
125
|
+
return parse(splitLines(command)).flatMap((entry): Token[] => {
|
|
56
126
|
if (typeof entry === "string") return [{ kind: "word", value: entry }];
|
|
57
127
|
if ("pattern" in entry) return [{ kind: "word", value: entry.pattern }];
|
|
58
128
|
if ("op" in entry) return [{ kind: "op", op: entry.op }];
|
|
@@ -60,8 +130,8 @@ function tokenize(command: string): Token[] {
|
|
|
60
130
|
});
|
|
61
131
|
}
|
|
62
132
|
|
|
63
|
-
const TRUNCATING = new Set([">", ">|"]);
|
|
64
|
-
const NON_TRUNCATING_REDIRECTS = new Set([">>", "<", ">&", "<&"]);
|
|
133
|
+
const TRUNCATING = new Set([">", ">|", "&>"]);
|
|
134
|
+
const NON_TRUNCATING_REDIRECTS = new Set([">>", "&>>", "<", ">&", "<&"]);
|
|
65
135
|
|
|
66
136
|
/** Splits a shell line into simple commands; `>` targets are kept apart from arguments. */
|
|
67
137
|
function segments(command: string): Segment[] {
|
|
@@ -104,24 +174,32 @@ const KNOWN = new Set([
|
|
|
104
174
|
"sed",
|
|
105
175
|
"tee",
|
|
106
176
|
"cp",
|
|
177
|
+
"dd",
|
|
107
178
|
"cd",
|
|
179
|
+
"eval",
|
|
108
180
|
]);
|
|
109
181
|
|
|
110
182
|
const base = (word: string): string => word.slice(word.lastIndexOf("/") + 1);
|
|
111
183
|
const operands = (words: readonly string[]): string[] => words.filter((w) => !w.startsWith("-"));
|
|
112
184
|
|
|
113
|
-
|
|
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. */
|
|
114
188
|
function realCommand(words: readonly string[]): string[] {
|
|
115
189
|
let rest = [...words];
|
|
116
190
|
for (;;) {
|
|
117
|
-
while (rest[0] !== undefined && /^\w+=/.test(rest[0])) rest = rest.slice(1);
|
|
118
191
|
const head = rest[0];
|
|
119
|
-
if (head === undefined
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
else
|
|
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;
|
|
125
203
|
}
|
|
126
204
|
}
|
|
127
205
|
|
|
@@ -154,8 +232,11 @@ const gitTargets = (args: readonly string[]): string[] => {
|
|
|
154
232
|
const sedTargets = (args: readonly string[]): string[] =>
|
|
155
233
|
args.some((a) => /^-[A-Za-z]*i|^--in-place/.test(a)) ? operands(args) : [];
|
|
156
234
|
|
|
157
|
-
|
|
158
|
-
|
|
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)] : []));
|
|
159
240
|
|
|
160
241
|
const truncateTargets = (args: readonly string[]): string[] =>
|
|
161
242
|
operands(args).filter((a) => !/^[+\-<>/%]?\d+$/.test(a));
|
|
@@ -171,6 +252,7 @@ const TARGETS: Readonly<Record<string, (args: readonly string[]) => string[]>> =
|
|
|
171
252
|
find: findTargets,
|
|
172
253
|
sed: sedTargets,
|
|
173
254
|
cp: cpTargets,
|
|
255
|
+
dd: ddTargets,
|
|
174
256
|
git: gitTargets,
|
|
175
257
|
};
|
|
176
258
|
|
|
@@ -190,8 +272,12 @@ function collect(command: string, startCwd: string, depth: number): string[] {
|
|
|
190
272
|
for (const seg of segments(command)) {
|
|
191
273
|
const words = realCommand(seg.words);
|
|
192
274
|
const head = words[0] === undefined ? "" : base(words[0]);
|
|
193
|
-
if (head === "cd"
|
|
194
|
-
cwd = joinCwd(cwd, words[
|
|
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));
|
|
195
281
|
continue;
|
|
196
282
|
}
|
|
197
283
|
if (SHELLS.has(head) && depth < 3) {
|
package/src/gates/test-guard.ts
CHANGED
|
@@ -1,16 +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,
|
|
7
8
|
type ToolCallEvent,
|
|
8
9
|
} from "@earendil-works/pi-coding-agent";
|
|
9
|
-
import { isTestPath } from "../core/test-paths.ts";
|
|
10
|
+
import { isTestPath, normalizeRepoPath } from "../core/test-paths.ts";
|
|
10
11
|
import {
|
|
12
|
+
addedLines,
|
|
11
13
|
applyEdits,
|
|
12
14
|
bashMutatedPaths,
|
|
13
15
|
isPureAddition,
|
|
16
|
+
type WeakeningSignals,
|
|
14
17
|
weakeningSignals,
|
|
15
18
|
} from "../core/test-weakening.ts";
|
|
16
19
|
import { type GateId, isParseError, parseGateId } from "../core/types.ts";
|
|
@@ -36,13 +39,11 @@ type Verdict =
|
|
|
36
39
|
|
|
37
40
|
const GATE_ID = "tests.weaken";
|
|
38
41
|
|
|
39
|
-
const describeSignals = (
|
|
40
|
-
deleted: boolean,
|
|
41
|
-
signals: { addsSkip: boolean; emptied: boolean },
|
|
42
|
-
): string | undefined => {
|
|
42
|
+
const describeSignals = (deleted: boolean, signals: WeakeningSignals): string | undefined => {
|
|
43
43
|
if (deleted) return "deletes the test file";
|
|
44
44
|
if (signals.addsSkip) return "skips tests";
|
|
45
45
|
if (signals.emptied) return "empties the test file";
|
|
46
|
+
if (signals.commentedOut) return "comments out test code";
|
|
46
47
|
return undefined;
|
|
47
48
|
};
|
|
48
49
|
|
|
@@ -54,8 +55,12 @@ const readIfExists = (file: string): string | undefined => {
|
|
|
54
55
|
}
|
|
55
56
|
};
|
|
56
57
|
|
|
57
|
-
const repoPath = (cwd: string, path: string): string =>
|
|
58
|
-
|
|
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 = /[*?[\]{]/;
|
|
59
64
|
|
|
60
65
|
/**
|
|
61
66
|
* Soft gate `tests.weaken`: deleting, skipping, emptying or loosening tests needs a recorded
|
|
@@ -90,10 +95,17 @@ export function registerTestGuard(deps: TestGuardDeps): void {
|
|
|
90
95
|
const judge = async (change: Change, ctx: ExtensionContext): Promise<Verdict> => {
|
|
91
96
|
const deleted = change.after === undefined;
|
|
92
97
|
const signals = deleted
|
|
93
|
-
? { addsSkip: false, emptied: false }
|
|
98
|
+
? { addsSkip: false, emptied: false, commentedOut: false }
|
|
94
99
|
: weakeningSignals(change.before, change.after ?? "");
|
|
95
100
|
// Adding lines never weakens a test unless the added line itself is a skip marker.
|
|
96
|
-
|
|
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
|
+
) {
|
|
97
109
|
return { kind: "allow" };
|
|
98
110
|
}
|
|
99
111
|
const deterministic = describeSignals(deleted, signals);
|
|
@@ -127,7 +139,9 @@ export function registerTestGuard(deps: TestGuardDeps): void {
|
|
|
127
139
|
if (isToolCallEventType("bash", event)) {
|
|
128
140
|
return bashMutatedPaths(event.input.command).flatMap((raw) => {
|
|
129
141
|
const path = repoPath(ctx.cwd, raw);
|
|
130
|
-
|
|
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
|
|
131
145
|
? [{ path, before: readIfExists(join(ctx.cwd, path)) ?? "", after: undefined }]
|
|
132
146
|
: [];
|
|
133
147
|
});
|