@bluewombat/slots 0.8.0 → 0.10.0
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 +190 -122
- package/agents/agent-plugins.mjs +52 -14
- package/agents/claude.mjs +3 -3
- package/agents/cursor.mjs +1 -1
- package/builders/e2e.mjs +124 -0
- package/builders/producer.mjs +6 -6
- package/builders/repair.mjs +4 -3
- package/builders/tests.mjs +139 -0
- package/feature/fix.mjs +134 -0
- package/feature/validate.mjs +205 -0
- package/gates/ci-green.mjs +17 -6
- package/gates/e2e.mjs +538 -0
- package/gates/gitignore-leak.mjs +10 -9
- package/gates/project-checks.mjs +128 -0
- package/gates/tests-frozen.mjs +96 -0
- package/gates/tests-red.mjs +227 -0
- package/gates/workspace-changed.mjs +2 -1
- package/gates/workspace-unchanged.mjs +90 -0
- package/lib/fill.mjs +0 -6
- package/lib/junit.mjs +122 -0
- package/lib/output-tail.mjs +70 -0
- package/messages/conventional.mjs +1 -1
- package/messages/git-producer.mjs +2 -2
- package/package.json +9 -10
- package/publishers/git.mjs +1 -1
- package/refreshers/git.mjs +52 -2
- package/{assembly → ticket}/fix.mjs +5 -5
- package/{assembly → ticket}/validate.mjs +11 -13
- package/planners/one-subtask.mjs +0 -21
- package/planners/producer.mjs +0 -233
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import {
|
|
4
|
+
emitVerdict,
|
|
5
|
+
GATE_FLAGS,
|
|
6
|
+
ownArgv,
|
|
7
|
+
parseOptions,
|
|
8
|
+
take,
|
|
9
|
+
textOption,
|
|
10
|
+
} from "@bluewombat/slot-kit";
|
|
11
|
+
import { createOutputTail } from "../lib/output-tail.mjs";
|
|
12
|
+
|
|
13
|
+
// Runs one command of the Project's own — a typecheck, a test suite, a linter,
|
|
14
|
+
// a build — in the workspace, and answers on its exit code.
|
|
15
|
+
//
|
|
16
|
+
// --cmd COMMAND (required): run through the shell, so "pnpm test" and
|
|
17
|
+
// "pnpm --filter web typecheck" are written as the Project writes them. One
|
|
18
|
+
// command per Gate: the report then says which check failed, and the first red
|
|
19
|
+
// one stops the sequence before a slower one starts. Give each check its own
|
|
20
|
+
// entry, and its own timeoutMs.
|
|
21
|
+
//
|
|
22
|
+
// Nothing here knows a stack. The command is the Project's; the Gate only owns
|
|
23
|
+
// what comes back: 0 passes, a red check is the producer's to fix
|
|
24
|
+
// (fail-retryable, with the end of its output, where the error is), and what a
|
|
25
|
+
// producer cannot fix is a person's (fail-blocking): a command the shell cannot
|
|
26
|
+
// start, or a run that outlived its ceiling.
|
|
27
|
+
//
|
|
28
|
+
// It runs at both stages. At assembly it is the local answer on the assembled
|
|
29
|
+
// ticket, so a Project without a CI of its own is still checked by a machine.
|
|
30
|
+
|
|
31
|
+
const cwd = process.cwd();
|
|
32
|
+
const KEEP_BYTES = 12_000;
|
|
33
|
+
// Leave the Gate time to answer before Implementer's own clock reads the silence
|
|
34
|
+
// as a refusal with nothing to fix.
|
|
35
|
+
const MARGIN_MS = 5_000;
|
|
36
|
+
const MIN_RUN_MS = 1_000;
|
|
37
|
+
|
|
38
|
+
const parsed = parseOptions(ownArgv(process.argv, GATE_FLAGS), { "--cmd": {} }, "project-checks");
|
|
39
|
+
if (!parsed.ok) {
|
|
40
|
+
emitVerdict("fail-blocking", `${parsed.reason} Use --cmd "<command>".`);
|
|
41
|
+
process.exit(0);
|
|
42
|
+
}
|
|
43
|
+
const command = textOption(parsed.values, "cmd");
|
|
44
|
+
if (command === undefined || command.trim().length === 0) {
|
|
45
|
+
emitVerdict("fail-blocking", 'project-checks needs --cmd "<command>": the check to run.');
|
|
46
|
+
process.exit(0);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const ceilingMs = Number(take(process.argv, "--timeout-ms"));
|
|
50
|
+
const limitMs =
|
|
51
|
+
Number.isFinite(ceilingMs) && ceilingMs > 0
|
|
52
|
+
? Math.max(MIN_RUN_MS, ceilingMs - MARGIN_MS)
|
|
53
|
+
: undefined;
|
|
54
|
+
|
|
55
|
+
const child = spawn("sh", ["-c", command], {
|
|
56
|
+
cwd,
|
|
57
|
+
detached: true,
|
|
58
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
59
|
+
// CI=true keeps watchers (vitest, jest) from waiting on a terminal that is not there.
|
|
60
|
+
env: { ...process.env, CI: "true", NO_COLOR: "1", FORCE_COLOR: "0" },
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// Cleaned as it arrives (progress redraws dropped), so they cannot push the error out of the tail.
|
|
64
|
+
const tail = createOutputTail(KEEP_BYTES);
|
|
65
|
+
child.stdout.on("data", (chunk) => tail.push("stdout", chunk));
|
|
66
|
+
child.stderr.on("data", (chunk) => tail.push("stderr", chunk));
|
|
67
|
+
|
|
68
|
+
let timedOut = false;
|
|
69
|
+
const timer =
|
|
70
|
+
limitMs === undefined
|
|
71
|
+
? undefined
|
|
72
|
+
: setTimeout(() => {
|
|
73
|
+
timedOut = true;
|
|
74
|
+
killGroup();
|
|
75
|
+
}, limitMs);
|
|
76
|
+
|
|
77
|
+
child.on("error", (error) => {
|
|
78
|
+
clearTimeout(timer);
|
|
79
|
+
emitVerdict("fail-blocking", `project-checks cannot start "${command}": ${error.message}`);
|
|
80
|
+
process.exit(0);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
child.on("close", (code, signal) => {
|
|
84
|
+
clearTimeout(timer);
|
|
85
|
+
const output = tail.text();
|
|
86
|
+
if (timedOut) {
|
|
87
|
+
emitVerdict(
|
|
88
|
+
"fail-blocking",
|
|
89
|
+
[
|
|
90
|
+
`"${command}" did not finish in ${Math.round((limitMs ?? 0) / 1000)}s and was stopped.`,
|
|
91
|
+
"A person decides: the check is hung, or this Gate's timeoutMs is too short for it.",
|
|
92
|
+
output.length > 0 ? `Last output:\n${output}` : "It printed nothing.",
|
|
93
|
+
].join("\n"),
|
|
94
|
+
);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (code === 0) {
|
|
98
|
+
emitVerdict("pass");
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
// 126 and 127: the shell found the command but could not run it, or did not
|
|
102
|
+
// find it. No change to the work fixes that.
|
|
103
|
+
if (code === 126 || code === 127) {
|
|
104
|
+
emitVerdict(
|
|
105
|
+
"fail-blocking",
|
|
106
|
+
`"${command}" could not be run here (exit ${code}): ${output || "no output"}`,
|
|
107
|
+
);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
emitVerdict(
|
|
111
|
+
"fail-retryable",
|
|
112
|
+
[
|
|
113
|
+
`"${command}" failed (${signal === null ? `exit ${code}` : `signal ${signal}`}). End of its output:`,
|
|
114
|
+
output.length > 0 ? output : "(it printed nothing)",
|
|
115
|
+
].join("\n"),
|
|
116
|
+
);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
function killGroup() {
|
|
120
|
+
// The shell's whole group: a test runner leaves workers that outlive their parent.
|
|
121
|
+
try {
|
|
122
|
+
if (child.pid !== undefined) {
|
|
123
|
+
process.kill(-child.pid, "SIGKILL");
|
|
124
|
+
}
|
|
125
|
+
} catch {
|
|
126
|
+
child.kill("SIGKILL");
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
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
|
+
}
|
|
@@ -25,7 +25,8 @@ if (git(["rev-parse", "--is-inside-work-tree"]).stdout.trim() !== "true") {
|
|
|
25
25
|
process.exit(0);
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
const stage = stageOf(process.argv);
|
|
29
|
+
if (stage === "ticket" || stage === "feature") {
|
|
29
30
|
emitVerdict("pass");
|
|
30
31
|
process.exit(0);
|
|
31
32
|
}
|
|
@@ -0,0 +1,90 @@
|
|
|
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
|
+
} from "@bluewombat/slot-kit";
|
|
11
|
+
|
|
12
|
+
// Fail-blocking when the workspace holds any change at all: a modified file, a
|
|
13
|
+
// deleted one, a new one that .gitignore does not exclude.
|
|
14
|
+
//
|
|
15
|
+
// For a pass that must not write — `assembly.validate`, a read-only review. Its
|
|
16
|
+
// other Gates look at parts (`sensitive-path` at some globs, `parent-clean` at the
|
|
17
|
+
// Parent), so a reviewer that edits a file nobody named goes unseen. This one
|
|
18
|
+
// watches all of them.
|
|
19
|
+
//
|
|
20
|
+
// Run it where the workspace is committed and clean: after the align, every
|
|
21
|
+
// change of the ticket is in a commit, so whatever `git status` shows was
|
|
22
|
+
// written since. A check run before it can leave files of its own (a coverage
|
|
23
|
+
// report, a build directory) when the Project does not ignore them; name those
|
|
24
|
+
// with --allow GLOB (repeatable) instead of loosening the Gate.
|
|
25
|
+
//
|
|
26
|
+
// Blocking, not retryable: the review is not something another round repairs,
|
|
27
|
+
// and what it wrote would be committed by the next align. A person looks.
|
|
28
|
+
|
|
29
|
+
const cwd = process.cwd();
|
|
30
|
+
const parsed = parseOptions(
|
|
31
|
+
ownArgv(process.argv, GATE_FLAGS),
|
|
32
|
+
{ "--allow": { many: true } },
|
|
33
|
+
"workspace-unchanged",
|
|
34
|
+
);
|
|
35
|
+
if (!parsed.ok) {
|
|
36
|
+
emitVerdict("fail-blocking", `${parsed.reason} Use --allow GLOB for a path that may change.`);
|
|
37
|
+
process.exit(0);
|
|
38
|
+
}
|
|
39
|
+
const allowed = listOption(parsed.values, "allow");
|
|
40
|
+
|
|
41
|
+
if (git(["rev-parse", "--is-inside-work-tree"]).stdout.trim() !== "true") {
|
|
42
|
+
emitVerdict("fail-blocking", "workspace-unchanged needs a git workspace.");
|
|
43
|
+
process.exit(0);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const status = git(["status", "--porcelain", "-z", "-uall"]);
|
|
47
|
+
if (status.status !== 0) {
|
|
48
|
+
emitVerdict(
|
|
49
|
+
"fail-blocking",
|
|
50
|
+
`workspace-unchanged cannot read git status: ${status.stderr.trim()}`,
|
|
51
|
+
);
|
|
52
|
+
process.exit(0);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// `XY path`, NUL-separated; a rename carries its origin as one more entry.
|
|
56
|
+
const changed = [];
|
|
57
|
+
const entries = status.stdout.split("\0").filter(Boolean);
|
|
58
|
+
for (let i = 0; i < entries.length; i++) {
|
|
59
|
+
const entry = entries[i] ?? "";
|
|
60
|
+
const path = entry.slice(3);
|
|
61
|
+
if (entry[0] === "R" || entry[1] === "R") {
|
|
62
|
+
i += 1;
|
|
63
|
+
}
|
|
64
|
+
if (!allowed.some((glob) => pathMatchesGlob(path, glob))) {
|
|
65
|
+
changed.push(path);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (changed.length === 0) {
|
|
70
|
+
emitVerdict("pass");
|
|
71
|
+
process.exit(0);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const shown = changed.slice(0, 50);
|
|
75
|
+
const rest = changed.length - shown.length;
|
|
76
|
+
emitVerdict(
|
|
77
|
+
"fail-blocking",
|
|
78
|
+
[
|
|
79
|
+
"This pass is read-only and it changed the workspace. What it wrote would be",
|
|
80
|
+
"committed by the next align, so nothing is repaired automatically:",
|
|
81
|
+
...shown,
|
|
82
|
+
...(rest > 0 ? [`… and ${rest} more`] : []),
|
|
83
|
+
"Look at the changes (git status, git diff), undo them, and run the ticket again.",
|
|
84
|
+
].join("\n"),
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
/** @param {string[]} args */
|
|
88
|
+
function git(args) {
|
|
89
|
+
return spawnSync("git", ["-C", cwd, ...args], { encoding: "utf8" });
|
|
90
|
+
}
|
package/lib/fill.mjs
CHANGED
|
@@ -4,12 +4,6 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { take } from "@bluewombat/slot-kit";
|
|
6
6
|
|
|
7
|
-
/** @param {string | undefined} raw */
|
|
8
|
-
export function doneWhen(raw) {
|
|
9
|
-
const done = (raw ?? "").trim();
|
|
10
|
-
return done.length > 0 ? `Done when:\n${done}` : "";
|
|
11
|
-
}
|
|
12
|
-
|
|
13
7
|
/** @param {readonly string[]} argv */
|
|
14
8
|
export function attemptOf(argv) {
|
|
15
9
|
const parsed = Number.parseInt(take(argv, "--attempt") ?? "", 10);
|
package/lib/junit.mjs
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// A JUnit XML report, reduced to what a Gate needs: one entry per test case, what happened to
|
|
2
|
+
// it, and the words of its failure. Playwright, Vitest, Jest, pytest and the JVM runners all
|
|
3
|
+
// write this shape; none of them is asked for more than `testcase` with an optional `failure`,
|
|
4
|
+
// `error` or `skipped` child.
|
|
5
|
+
//
|
|
6
|
+
// Not a general XML parser: it reads the elements above, decodes the five predefined entities and
|
|
7
|
+
// CDATA, and leaves the rest alone.
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @typedef {object} TestCase
|
|
11
|
+
* @property {string} name
|
|
12
|
+
* @property {string} suite The `classname`, or the enclosing suite's name.
|
|
13
|
+
* @property {"passed" | "failed" | "skipped"} status
|
|
14
|
+
* @property {string} message The first line of what went wrong, when it did.
|
|
15
|
+
* @property {string} detail The failure's own text: the step and the stack.
|
|
16
|
+
* @property {string[]} attachments Paths the runner says it saved for this case (traces, shots).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const ENTITIES = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'" };
|
|
20
|
+
|
|
21
|
+
/** @param {string} text */
|
|
22
|
+
function decode(text) {
|
|
23
|
+
return text
|
|
24
|
+
.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, (_, inner) => inner)
|
|
25
|
+
.replace(/&(#x[0-9a-fA-F]+|#[0-9]+|amp|lt|gt|quot|apos);/g, (whole, code) => {
|
|
26
|
+
if (code.startsWith("#x")) {
|
|
27
|
+
return String.fromCodePoint(Number.parseInt(code.slice(2), 16));
|
|
28
|
+
}
|
|
29
|
+
if (code.startsWith("#")) {
|
|
30
|
+
return String.fromCodePoint(Number.parseInt(code.slice(1), 10));
|
|
31
|
+
}
|
|
32
|
+
return ENTITIES[/** @type {keyof typeof ENTITIES} */ (code)] ?? whole;
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** @param {string} tag the opening tag text, `<testcase a="b" …>` */
|
|
37
|
+
function attributesOf(tag) {
|
|
38
|
+
/** @type {Record<string, string>} */
|
|
39
|
+
const out = {};
|
|
40
|
+
for (const match of tag.matchAll(/([\w:.-]+)\s*=\s*("([^"]*)"|'([^']*)')/g)) {
|
|
41
|
+
out[match[1] ?? ""] = decode(match[3] ?? match[4] ?? "");
|
|
42
|
+
}
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* @param {string} xml
|
|
48
|
+
* @returns {TestCase[]}
|
|
49
|
+
*/
|
|
50
|
+
export function parseJunit(xml) {
|
|
51
|
+
/** @type {TestCase[]} */
|
|
52
|
+
const cases = [];
|
|
53
|
+
// The enclosing suite names, to give a case a home when it has no classname.
|
|
54
|
+
const events = xml.matchAll(
|
|
55
|
+
/<testsuite\b[^>]*>|<\/testsuite>|<testcase\b[^>]*\/>|<testcase\b[^>]*>[\s\S]*?<\/testcase>/g,
|
|
56
|
+
);
|
|
57
|
+
/** @type {string[]} */
|
|
58
|
+
const suites = [];
|
|
59
|
+
for (const event of events) {
|
|
60
|
+
const text = event[0];
|
|
61
|
+
if (text.startsWith("</testsuite")) {
|
|
62
|
+
suites.pop();
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (text.startsWith("<testsuite")) {
|
|
66
|
+
if (!text.endsWith("/>")) {
|
|
67
|
+
suites.push(attributesOf(text).name ?? "");
|
|
68
|
+
}
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
const open = text.match(/^<testcase\b[^>]*>/)?.[0] ?? text;
|
|
72
|
+
const attributes = attributesOf(open);
|
|
73
|
+
const body = text.endsWith("/>") && open === text ? "" : text.slice(open.length);
|
|
74
|
+
cases.push(caseOf(attributes, body, suites.at(-1) ?? ""));
|
|
75
|
+
}
|
|
76
|
+
return cases;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* @param {Record<string, string>} attributes
|
|
81
|
+
* @param {string} body
|
|
82
|
+
* @param {string} suite
|
|
83
|
+
* @returns {TestCase}
|
|
84
|
+
*/
|
|
85
|
+
function caseOf(attributes, body, suite) {
|
|
86
|
+
const failure = body.match(/<(failure|error)\b([^>]*?)(?:\/>|>([\s\S]*?)<\/\1>)/);
|
|
87
|
+
const skipped = /<skipped\b/.test(body);
|
|
88
|
+
const detail = failure === null ? "" : decode(failure[3] ?? "").trim();
|
|
89
|
+
const said = failure === null ? "" : (attributesOf(`<x ${failure[2] ?? ""}>`).message ?? "");
|
|
90
|
+
const message = (said.length > 0 ? said : (detail.split("\n")[0] ?? "")).trim();
|
|
91
|
+
/** @type {string[]} */
|
|
92
|
+
const attachments = [];
|
|
93
|
+
for (const match of body.matchAll(/\[\[ATTACHMENT\|([^\]]+)\]\]/g)) {
|
|
94
|
+
attachments.push((match[1] ?? "").trim());
|
|
95
|
+
}
|
|
96
|
+
return {
|
|
97
|
+
name: attributes.name ?? "",
|
|
98
|
+
suite: attributes.classname ?? suite,
|
|
99
|
+
status: failure !== null ? "failed" : skipped ? "skipped" : "passed",
|
|
100
|
+
message,
|
|
101
|
+
detail,
|
|
102
|
+
attachments,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Which of the manifest's scenario ids a test case name carries. An id is found as a whole
|
|
108
|
+
* token, so `E1` is not in `E10 …`.
|
|
109
|
+
*
|
|
110
|
+
* @param {string} name
|
|
111
|
+
* @param {readonly string[]} ids
|
|
112
|
+
* @returns {string | undefined}
|
|
113
|
+
*/
|
|
114
|
+
export function scenarioOf(name, ids) {
|
|
115
|
+
for (const id of ids) {
|
|
116
|
+
const escaped = id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
117
|
+
if (new RegExp(`(^|[^A-Za-z0-9_])${escaped}($|[^A-Za-z0-9_])`).test(name)) {
|
|
118
|
+
return id;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|