@bluewombat/slots 0.10.0 → 0.11.1
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/README.md +152 -63
- package/agents/agent-plugins.mjs +154 -6
- package/agents/claude.mjs +2 -1
- package/agents/cursor.mjs +2 -0
- package/builders/{e2e.mjs → feature-test.mjs} +27 -17
- package/gates/base-diff.mjs +276 -0
- package/gates/ci-green.mjs +83 -3
- package/gates/feature-tests.mjs +280 -0
- package/gates/parent-clean.mjs +46 -8
- package/gates/tests-only.mjs +65 -0
- package/package.json +2 -2
- package/publishers/git.mjs +117 -16
- package/builders/tests.mjs +0 -139
- package/gates/e2e.mjs +0 -538
- package/gates/tests-frozen.mjs +0 -96
- package/gates/tests-red.mjs +0 -227
package/gates/tests-frozen.mjs
DELETED
|
@@ -1,96 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import { spawnSync } from "node:child_process";
|
|
3
|
-
import {
|
|
4
|
-
emitVerdict,
|
|
5
|
-
GATE_FLAGS,
|
|
6
|
-
listOption,
|
|
7
|
-
ownArgv,
|
|
8
|
-
parseOptions,
|
|
9
|
-
pathMatchesGlob,
|
|
10
|
-
textOption,
|
|
11
|
-
} from "@bluewombat/slot-kit";
|
|
12
|
-
|
|
13
|
-
// The tests written first are not the producer's to change.
|
|
14
|
-
//
|
|
15
|
-
// The pass that writes the tests ends with a checkpoint: its work is committed, with a
|
|
16
|
-
// subject that starts with "Tests: ". From then on the producer leaves its own work
|
|
17
|
-
// uncommitted, so whatever differs from HEAD is the producer's. This Gate looks at the test
|
|
18
|
-
// files that checkpoint holds and fails when one was modified, deleted or renamed: a test
|
|
19
|
-
// edited until it passes proves nothing.
|
|
20
|
-
//
|
|
21
|
-
// Adding a new test file is allowed. Where HEAD is not a checkpoint of the tests pass (a
|
|
22
|
-
// Project that runs none), nothing is frozen and the Gate passes.
|
|
23
|
-
//
|
|
24
|
-
// --test-glob GLOB (repeatable): which paths are test files. Default: **/*.test.*,
|
|
25
|
-
// **/*.spec.*, **/__tests__/**
|
|
26
|
-
// --subject-prefix TEXT: how the checkpoint's subject starts. Default: "Tests: "
|
|
27
|
-
|
|
28
|
-
const cwd = process.cwd();
|
|
29
|
-
const DEFAULT_GLOBS = ["**/*.test.*", "**/*.spec.*", "**/__tests__/**"];
|
|
30
|
-
|
|
31
|
-
const parsed = parseOptions(
|
|
32
|
-
ownArgv(process.argv, GATE_FLAGS),
|
|
33
|
-
{ "--test-glob": { many: true }, "--subject-prefix": {} },
|
|
34
|
-
"tests-frozen",
|
|
35
|
-
);
|
|
36
|
-
if (!parsed.ok) {
|
|
37
|
-
emitVerdict("fail-blocking", parsed.reason);
|
|
38
|
-
process.exit(0);
|
|
39
|
-
}
|
|
40
|
-
const globs = listOption(parsed.values, "testGlob");
|
|
41
|
-
const testGlobs = globs.length > 0 ? globs : DEFAULT_GLOBS;
|
|
42
|
-
const prefix = textOption(parsed.values, "subjectPrefix") ?? "Tests: ";
|
|
43
|
-
|
|
44
|
-
if (git(["rev-parse", "--is-inside-work-tree"]).stdout.trim() !== "true") {
|
|
45
|
-
emitVerdict("fail-blocking", "tests-frozen needs a git workspace.");
|
|
46
|
-
process.exit(0);
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
const subject = git(["log", "-1", "--format=%s"]);
|
|
50
|
-
if (subject.status !== 0 || !subject.stdout.trim().startsWith(prefix)) {
|
|
51
|
-
emitVerdict("pass");
|
|
52
|
-
process.exit(0);
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
// The test files the checkpoint holds: those it added or changed in its own commit.
|
|
56
|
-
const held = git(["show", "--name-only", "--diff-filter=AM", "--format=", "HEAD"])
|
|
57
|
-
.stdout.split("\n")
|
|
58
|
-
.map((line) => line.trim())
|
|
59
|
-
.filter((path) => path.length > 0 && testGlobs.some((glob) => pathMatchesGlob(path, glob)));
|
|
60
|
-
if (held.length === 0) {
|
|
61
|
-
emitVerdict("pass");
|
|
62
|
-
process.exit(0);
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
// The working files against HEAD, whatever the index says; a new file is not in the diff.
|
|
66
|
-
const diff = git(["diff", "--no-renames", "--name-status", "-z", "HEAD", "--", ...held]);
|
|
67
|
-
if (diff.status !== 0) {
|
|
68
|
-
emitVerdict("fail-blocking", `tests-frozen cannot read git diff: ${diff.stderr.trim()}`);
|
|
69
|
-
process.exit(0);
|
|
70
|
-
}
|
|
71
|
-
const touched = [];
|
|
72
|
-
const parts = diff.stdout.split("\0").filter(Boolean);
|
|
73
|
-
for (let i = 0; i < parts.length; i += 2) {
|
|
74
|
-
const path = parts[i + 1];
|
|
75
|
-
if (path !== undefined) {
|
|
76
|
-
touched.push(path);
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
if (touched.length === 0) {
|
|
81
|
-
emitVerdict("pass");
|
|
82
|
-
process.exit(0);
|
|
83
|
-
}
|
|
84
|
-
emitVerdict(
|
|
85
|
-
"fail-retryable",
|
|
86
|
-
[
|
|
87
|
-
"These tests were written first, from the task's cases, and are not yours to change:",
|
|
88
|
-
...touched,
|
|
89
|
-
`Restore them (git checkout HEAD -- ${touched.join(" ")}) and make the code pass them as they are. If a test is wrong, say so in your final message instead of editing it.`,
|
|
90
|
-
].join("\n"),
|
|
91
|
-
);
|
|
92
|
-
|
|
93
|
-
/** @param {string[]} args */
|
|
94
|
-
function git(args) {
|
|
95
|
-
return spawnSync("git", ["-C", cwd, ...args], { encoding: "utf8" });
|
|
96
|
-
}
|
package/gates/tests-red.mjs
DELETED
|
@@ -1,227 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import { spawn, spawnSync } from "node:child_process";
|
|
3
|
-
import { existsSync } from "node:fs";
|
|
4
|
-
import { join } from "node:path";
|
|
5
|
-
import {
|
|
6
|
-
emitVerdict,
|
|
7
|
-
GATE_FLAGS,
|
|
8
|
-
listOption,
|
|
9
|
-
ownArgv,
|
|
10
|
-
parseOptions,
|
|
11
|
-
pathMatchesGlob,
|
|
12
|
-
take,
|
|
13
|
-
textOption,
|
|
14
|
-
} from "@bluewombat/slot-kit";
|
|
15
|
-
import { createOutputTail } from "../lib/output-tail.mjs";
|
|
16
|
-
|
|
17
|
-
// The tests that were just written must FAIL, and for the right reason.
|
|
18
|
-
//
|
|
19
|
-
// A test that passes before the behaviour exists tests nothing, and one that fails on a
|
|
20
|
-
// missing import or a syntax error says nothing about the behaviour either. This Gate
|
|
21
|
-
// finds the test files the pass added or changed, runs the Project's own test command on
|
|
22
|
-
// them, and answers:
|
|
23
|
-
//
|
|
24
|
-
// - no test file added or changed -> fail-retryable: the pass wrote no test
|
|
25
|
-
// - the tests pass -> fail-retryable: they test nothing that is missing
|
|
26
|
-
// - they fail on a wrong reason -> fail-retryable: fix imports, syntax, stubs
|
|
27
|
-
// - they fail on something else -> pass: red, as a test written first should be
|
|
28
|
-
// - the command cannot run, or hangs -> fail-blocking: no change to the tests fixes it
|
|
29
|
-
//
|
|
30
|
-
// --cmd COMMAND (required): run through the shell. `{files}` is replaced by the test files
|
|
31
|
-
// of the pass, quoted, so "pnpm vitest run {files}" runs only those. Without `{files}`
|
|
32
|
-
// the whole command runs as written.
|
|
33
|
-
// --test-glob GLOB (repeatable): which paths are test files. Default: **/*.test.*,
|
|
34
|
-
// **/*.spec.*, **/__tests__/**
|
|
35
|
-
// --not-red-when REGEX (repeatable, case-insensitive): output that means the tests are red
|
|
36
|
-
// for the wrong reason. Default: a module that cannot be found or resolved, a syntax
|
|
37
|
-
// error, a TypeScript error. Naming any replaces the defaults.
|
|
38
|
-
|
|
39
|
-
const cwd = process.cwd();
|
|
40
|
-
const KEEP_BYTES = 12_000;
|
|
41
|
-
const MARGIN_MS = 5_000;
|
|
42
|
-
const MIN_RUN_MS = 1_000;
|
|
43
|
-
const DEFAULT_GLOBS = ["**/*.test.*", "**/*.spec.*", "**/__tests__/**"];
|
|
44
|
-
const DEFAULT_WRONG_REASONS = [
|
|
45
|
-
"Cannot find module",
|
|
46
|
-
"Failed to resolve (import|entry)",
|
|
47
|
-
"ERR_MODULE_NOT_FOUND",
|
|
48
|
-
"SyntaxError",
|
|
49
|
-
"error TS\\d+",
|
|
50
|
-
"Cannot find name",
|
|
51
|
-
];
|
|
52
|
-
|
|
53
|
-
const parsed = parseOptions(
|
|
54
|
-
ownArgv(process.argv, GATE_FLAGS),
|
|
55
|
-
{ "--cmd": {}, "--test-glob": { many: true }, "--not-red-when": { many: true } },
|
|
56
|
-
"tests-red",
|
|
57
|
-
);
|
|
58
|
-
if (!parsed.ok) {
|
|
59
|
-
emitVerdict("fail-blocking", `${parsed.reason} Use --cmd "<command>".`);
|
|
60
|
-
process.exit(0);
|
|
61
|
-
}
|
|
62
|
-
const template = textOption(parsed.values, "cmd");
|
|
63
|
-
if (template === undefined || template.trim().length === 0) {
|
|
64
|
-
emitVerdict("fail-blocking", 'tests-red needs --cmd "<command>": the Project\'s test command.');
|
|
65
|
-
process.exit(0);
|
|
66
|
-
}
|
|
67
|
-
const globs = listOption(parsed.values, "testGlob");
|
|
68
|
-
const testGlobs = globs.length > 0 ? globs : DEFAULT_GLOBS;
|
|
69
|
-
const reasons = listOption(parsed.values, "notRedWhen");
|
|
70
|
-
let wrongReasons;
|
|
71
|
-
try {
|
|
72
|
-
wrongReasons = (reasons.length > 0 ? reasons : DEFAULT_WRONG_REASONS).map(
|
|
73
|
-
(source) => new RegExp(source, "i"),
|
|
74
|
-
);
|
|
75
|
-
} catch (error) {
|
|
76
|
-
emitVerdict(
|
|
77
|
-
"fail-blocking",
|
|
78
|
-
`tests-red: --not-red-when is not a pattern: ${error instanceof Error ? error.message : error}`,
|
|
79
|
-
);
|
|
80
|
-
process.exit(0);
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
if (git(["rev-parse", "--is-inside-work-tree"]).stdout.trim() !== "true") {
|
|
84
|
-
emitVerdict("fail-blocking", "tests-red needs a git workspace.");
|
|
85
|
-
process.exit(0);
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
const files = testFilesOfThePass();
|
|
89
|
-
if (files.length === 0) {
|
|
90
|
-
emitVerdict(
|
|
91
|
-
"fail-retryable",
|
|
92
|
-
`No test file was added or changed (test files match: ${testGlobs.join(", ")}). This pass writes the tests of the task, in the files its "Tests" section names.`,
|
|
93
|
-
);
|
|
94
|
-
process.exit(0);
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
const command = template.replaceAll("{files}", files.map(quote).join(" "));
|
|
98
|
-
const ceilingMs = Number(take(process.argv, "--timeout-ms"));
|
|
99
|
-
const limitMs =
|
|
100
|
-
Number.isFinite(ceilingMs) && ceilingMs > 0
|
|
101
|
-
? Math.max(MIN_RUN_MS, ceilingMs - MARGIN_MS)
|
|
102
|
-
: undefined;
|
|
103
|
-
|
|
104
|
-
const child = spawn("sh", ["-c", command], {
|
|
105
|
-
cwd,
|
|
106
|
-
detached: true,
|
|
107
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
108
|
-
env: { ...process.env, CI: "true", NO_COLOR: "1", FORCE_COLOR: "0" },
|
|
109
|
-
});
|
|
110
|
-
const tail = createOutputTail(KEEP_BYTES * 2);
|
|
111
|
-
child.stdout.on("data", (chunk) => tail.push("stdout", chunk));
|
|
112
|
-
child.stderr.on("data", (chunk) => tail.push("stderr", chunk));
|
|
113
|
-
|
|
114
|
-
let timedOut = false;
|
|
115
|
-
const timer =
|
|
116
|
-
limitMs === undefined
|
|
117
|
-
? undefined
|
|
118
|
-
: setTimeout(() => {
|
|
119
|
-
timedOut = true;
|
|
120
|
-
killGroup();
|
|
121
|
-
}, limitMs);
|
|
122
|
-
|
|
123
|
-
child.on("error", (error) => {
|
|
124
|
-
clearTimeout(timer);
|
|
125
|
-
emitVerdict("fail-blocking", `tests-red cannot start "${command}": ${error.message}`);
|
|
126
|
-
process.exit(0);
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
child.on("close", (code) => {
|
|
130
|
-
clearTimeout(timer);
|
|
131
|
-
const output = tail.text();
|
|
132
|
-
const shown = endOf(output);
|
|
133
|
-
if (timedOut) {
|
|
134
|
-
emitVerdict(
|
|
135
|
-
"fail-blocking",
|
|
136
|
-
`"${command}" did not finish in ${Math.round((limitMs ?? 0) / 1000)}s and was stopped. A person decides: the tests hang, or this Gate's timeoutMs is too short.`,
|
|
137
|
-
);
|
|
138
|
-
return;
|
|
139
|
-
}
|
|
140
|
-
if (code === 126 || code === 127) {
|
|
141
|
-
emitVerdict(
|
|
142
|
-
"fail-blocking",
|
|
143
|
-
`"${command}" could not be run here (exit ${code}): ${shown || "no output"}`,
|
|
144
|
-
);
|
|
145
|
-
return;
|
|
146
|
-
}
|
|
147
|
-
if (code === 0) {
|
|
148
|
-
emitVerdict(
|
|
149
|
-
"fail-retryable",
|
|
150
|
-
[
|
|
151
|
-
`These tests pass before the behaviour exists (${files.join(", ")}), so they test nothing that is missing.`,
|
|
152
|
-
"Make each case assert the behaviour the task asks for and the code does not have yet.",
|
|
153
|
-
].join("\n"),
|
|
154
|
-
);
|
|
155
|
-
return;
|
|
156
|
-
}
|
|
157
|
-
const wrong = wrongReasons.find((pattern) => pattern.test(output));
|
|
158
|
-
if (wrong !== undefined) {
|
|
159
|
-
const line =
|
|
160
|
-
output
|
|
161
|
-
.split("\n")
|
|
162
|
-
.find((one) => wrong.test(one))
|
|
163
|
-
?.trim() ?? wrong.source;
|
|
164
|
-
emitVerdict(
|
|
165
|
-
"fail-retryable",
|
|
166
|
-
[
|
|
167
|
-
`The tests fail, but not on the missing behaviour: ${line}`,
|
|
168
|
-
"Make them compile and run: fix the import or the syntax, and give the interface under test a minimal stub (right name and signature, no behaviour), so they fail on what they check. End of the output:",
|
|
169
|
-
shown,
|
|
170
|
-
].join("\n"),
|
|
171
|
-
);
|
|
172
|
-
return;
|
|
173
|
-
}
|
|
174
|
-
emitVerdict("pass");
|
|
175
|
-
});
|
|
176
|
-
|
|
177
|
-
function killGroup() {
|
|
178
|
-
try {
|
|
179
|
-
if (child.pid !== undefined) {
|
|
180
|
-
process.kill(-child.pid, "SIGKILL");
|
|
181
|
-
}
|
|
182
|
-
} catch {
|
|
183
|
-
child.kill("SIGKILL");
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
/** The test files this pass added or changed, and that are still there. */
|
|
188
|
-
function testFilesOfThePass() {
|
|
189
|
-
const status = git(["status", "--porcelain", "-z", "-uall"]);
|
|
190
|
-
if (status.status !== 0) {
|
|
191
|
-
return [];
|
|
192
|
-
}
|
|
193
|
-
const found = [];
|
|
194
|
-
const entries = status.stdout.split("\0").filter(Boolean);
|
|
195
|
-
for (let i = 0; i < entries.length; i++) {
|
|
196
|
-
const entry = entries[i] ?? "";
|
|
197
|
-
const path = entry.slice(3);
|
|
198
|
-
if (entry[0] === "R" || entry[1] === "R") {
|
|
199
|
-
i += 1;
|
|
200
|
-
}
|
|
201
|
-
const deleted = entry[0] === "D" || entry[1] === "D";
|
|
202
|
-
if (
|
|
203
|
-
!deleted &&
|
|
204
|
-
existsSync(join(cwd, path)) &&
|
|
205
|
-
testGlobs.some((glob) => pathMatchesGlob(path, glob))
|
|
206
|
-
) {
|
|
207
|
-
found.push(path);
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
return found;
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
/** @param {string} path */
|
|
214
|
-
function quote(path) {
|
|
215
|
-
return `'${path.replaceAll("'", "'\\''")}'`;
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
/** The end of the output, where the error is, cut on a line boundary. */
|
|
219
|
-
function endOf(/** @type {string} */ text) {
|
|
220
|
-
const cut = text.length > KEEP_BYTES ? text.slice(text.length - KEEP_BYTES) : text;
|
|
221
|
-
return (text.length > KEEP_BYTES ? cut.slice(cut.indexOf("\n") + 1) : cut).trim();
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
/** @param {string[]} args */
|
|
225
|
-
function git(args) {
|
|
226
|
-
return spawnSync("git", ["-C", cwd, ...args], { encoding: "utf8" });
|
|
227
|
-
}
|