@bluewombat/slots 0.9.0 → 0.11.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 +205 -66
- package/agents/agent-plugins.mjs +52 -14
- package/agents/claude.mjs +3 -3
- package/agents/cursor.mjs +1 -1
- package/builders/feature-test.mjs +134 -0
- package/builders/producer.mjs +4 -0
- package/builders/repair.mjs +4 -0
- package/feature/fix.mjs +134 -0
- package/feature/validate.mjs +205 -0
- package/gates/base-diff.mjs +276 -0
- package/gates/ci-green.mjs +100 -9
- package/gates/feature-tests.mjs +280 -0
- package/gates/gitignore-leak.mjs +2 -1
- package/gates/project-checks.mjs +128 -0
- package/gates/tests-only.mjs +65 -0
- package/gates/workspace-changed.mjs +2 -1
- package/gates/workspace-unchanged.mjs +90 -0
- package/lib/junit.mjs +122 -0
- package/lib/output-tail.mjs +70 -0
- package/package.json +7 -5
- package/refreshers/git.mjs +35 -1
- package/{assembly → ticket}/fix.mjs +2 -2
- package/{assembly → ticket}/validate.mjs +5 -7
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { existsSync, readFileSync, rmSync } from "node:fs";
|
|
4
|
+
import { isAbsolute, resolve } from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
emitVerdict,
|
|
7
|
+
GATE_FLAGS,
|
|
8
|
+
ownArgv,
|
|
9
|
+
parseOptions,
|
|
10
|
+
take,
|
|
11
|
+
textOption,
|
|
12
|
+
} from "@bluewombat/slot-kit";
|
|
13
|
+
import { parseJunit, scenarioOf } from "../lib/junit.mjs";
|
|
14
|
+
import { createOutputTail } from "../lib/output-tail.mjs";
|
|
15
|
+
|
|
16
|
+
// The tests of a Feature, run on the assembled Feature line: does the Feature do what the
|
|
17
|
+
// operator agreed it should? Only the scenarios of this Feature run, not the Project's whole suite.
|
|
18
|
+
//
|
|
19
|
+
// Nothing here knows a stack. The Project supplies one command, run in the workspace through the
|
|
20
|
+
// shell, and the Gate owns the rest: running, comparing.
|
|
21
|
+
//
|
|
22
|
+
// --run COMMAND (required): runs the tests of the Feature and writes a JUnit XML report. `{ids}`
|
|
23
|
+
// stands for the scenario ids of the manifest joined with "|" (a regular expression:
|
|
24
|
+
// `pnpm test --grep "{ids}"`), `{junit}` for the path the report must be written to.
|
|
25
|
+
// --manifest PATH: the scenarios of the Feature. Default feature-tests/scenarios.json: a JSON
|
|
26
|
+
// array of ids, or { "scenarios": [ "S1" | { "id": "S1", "title": "…" } ] }.
|
|
27
|
+
// --junit PATH: where --run writes its report. Default feature-tests/results/junit.xml. Removed
|
|
28
|
+
// before the run.
|
|
29
|
+
// --max-run-ms N: the most the whole Gate may take. Default 600000 (10 minutes), and never more
|
|
30
|
+
// than the Gate's own ceiling.
|
|
31
|
+
//
|
|
32
|
+
// A scenario is a manifest id; each test carries its id in its name. The comparison is exact: an
|
|
33
|
+
// id with no test, a test with no id, or a test the runner skipped is a failure.
|
|
34
|
+
//
|
|
35
|
+
// Verdicts: pass; fail-retryable for a red run (a wrong test is its author's to fix, a wrong
|
|
36
|
+
// application the producer's: what the caller does with the report is its own rule); fail-blocking for what no change to the application fixes: a missing manifest,
|
|
37
|
+
// a run that writes no report or outlives its ceiling.
|
|
38
|
+
|
|
39
|
+
const cwd = process.cwd();
|
|
40
|
+
const KEEP_BYTES = 12_000;
|
|
41
|
+
const MARGIN_MS = 5_000;
|
|
42
|
+
const MIN_PHASE_MS = 1_000;
|
|
43
|
+
const REPORT_CHARS = 8_000;
|
|
44
|
+
|
|
45
|
+
const parsed = parseOptions(
|
|
46
|
+
ownArgv(process.argv, GATE_FLAGS),
|
|
47
|
+
{
|
|
48
|
+
"--run": {},
|
|
49
|
+
"--manifest": { fallback: "feature-tests/scenarios.json" },
|
|
50
|
+
"--junit": { fallback: "feature-tests/results/junit.xml" },
|
|
51
|
+
"--max-run-ms": { fallback: "600000" },
|
|
52
|
+
},
|
|
53
|
+
"feature-tests",
|
|
54
|
+
);
|
|
55
|
+
if (!parsed.ok) {
|
|
56
|
+
emitVerdict("fail-blocking", parsed.reason);
|
|
57
|
+
process.exit(0);
|
|
58
|
+
}
|
|
59
|
+
const runTemplate = textOption(parsed.values, "run");
|
|
60
|
+
if (runTemplate === undefined || runTemplate.trim().length === 0) {
|
|
61
|
+
emitVerdict(
|
|
62
|
+
"fail-blocking",
|
|
63
|
+
'feature-tests needs --run "<command>". The Project supplies the command that runs the tests.',
|
|
64
|
+
);
|
|
65
|
+
process.exit(0);
|
|
66
|
+
}
|
|
67
|
+
const maxRunMs = positive(textOption(parsed.values, "maxRunMs"));
|
|
68
|
+
if (maxRunMs === undefined) {
|
|
69
|
+
emitVerdict("fail-blocking", "feature-tests: --max-run-ms takes a positive integer.");
|
|
70
|
+
process.exit(0);
|
|
71
|
+
}
|
|
72
|
+
const manifestPath = inWorkspace(textOption(parsed.values, "manifest") ?? "");
|
|
73
|
+
const junitPath = inWorkspace(textOption(parsed.values, "junit") ?? "");
|
|
74
|
+
|
|
75
|
+
const manifest = readManifest();
|
|
76
|
+
if (typeof manifest === "string") {
|
|
77
|
+
emitVerdict("fail-blocking", manifest);
|
|
78
|
+
process.exit(0);
|
|
79
|
+
}
|
|
80
|
+
// Typed apart from the early exit above: the functions below are hoisted, and a narrowing does not reach them.
|
|
81
|
+
/** @type {string[]} */
|
|
82
|
+
const ids = manifest;
|
|
83
|
+
|
|
84
|
+
const ceilingMs = Number(take(process.argv, "--timeout-ms"));
|
|
85
|
+
const budgetMs =
|
|
86
|
+
Number.isFinite(ceilingMs) && ceilingMs > 0
|
|
87
|
+
? Math.min(maxRunMs, Math.max(MIN_PHASE_MS, ceilingMs - MARGIN_MS))
|
|
88
|
+
: maxRunMs;
|
|
89
|
+
|
|
90
|
+
const [verdict, report] = await judge();
|
|
91
|
+
emitVerdict(/** @type {"pass" | "fail-retryable" | "fail-blocking"} */ (verdict), report);
|
|
92
|
+
process.exit(0);
|
|
93
|
+
|
|
94
|
+
/** @returns {Promise<[string, string]>} */
|
|
95
|
+
async function judge() {
|
|
96
|
+
rmSync(junitPath, { force: true });
|
|
97
|
+
const command = runTemplate?.replaceAll("{ids}", ids.join("|")).replaceAll("{junit}", junitPath);
|
|
98
|
+
const run = await runShell(/** @type {string} */ (command), budgetMs);
|
|
99
|
+
if (run.timedOut) {
|
|
100
|
+
return ["fail-blocking", timeoutReport(run.output)];
|
|
101
|
+
}
|
|
102
|
+
const results = readResults();
|
|
103
|
+
if (typeof results === "string") {
|
|
104
|
+
return run.code === 0
|
|
105
|
+
? ["fail-blocking", results]
|
|
106
|
+
: ["fail-retryable", `${results}\n${exitLine(run)} End of its output:\n${run.output}`];
|
|
107
|
+
}
|
|
108
|
+
const failing = failingOf(results);
|
|
109
|
+
if (failing.length > 0) {
|
|
110
|
+
return ["fail-retryable", redReport(results, failing)];
|
|
111
|
+
}
|
|
112
|
+
const foreign = results.cases.filter((c) => scenarioOf(c.name, ids) === undefined);
|
|
113
|
+
if (foreign.length > 0) {
|
|
114
|
+
return [
|
|
115
|
+
"fail-retryable",
|
|
116
|
+
[
|
|
117
|
+
`feature-tests: ${foreign.length} test(s) carry no scenario id of the manifest (${ids.join(", ")}): only the scenarios of the Feature run.`,
|
|
118
|
+
...foreign.map((c) => ` ${c.name}`),
|
|
119
|
+
"Every test names its scenario id, and --run must run the ids of the manifest only ({ids}).",
|
|
120
|
+
].join("\n"),
|
|
121
|
+
];
|
|
122
|
+
}
|
|
123
|
+
return [
|
|
124
|
+
"pass",
|
|
125
|
+
`feature-tests: ${ids.length} scenario(s) of the feature ran and passed: ${ids.join(", ")}.`,
|
|
126
|
+
];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* @typedef {{ cases: import("../lib/junit.mjs").TestCase[] }} Result
|
|
131
|
+
*/
|
|
132
|
+
|
|
133
|
+
/** @returns {Result | string} */
|
|
134
|
+
function readResults() {
|
|
135
|
+
if (!existsSync(junitPath)) {
|
|
136
|
+
return `The run wrote no JUnit report at ${junitPath}. --run must write one there ({junit}).`;
|
|
137
|
+
}
|
|
138
|
+
const cases = parseJunit(readFileSync(junitPath, "utf8"));
|
|
139
|
+
if (cases.length === 0) {
|
|
140
|
+
return `The JUnit report at ${junitPath} holds no test case.`;
|
|
141
|
+
}
|
|
142
|
+
return { cases };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** The scenario ids that are not a pass: failed, skipped, or never run. */
|
|
146
|
+
function failingOf(/** @type {Result} */ results) {
|
|
147
|
+
return ids.filter((id) => {
|
|
148
|
+
const mine = results.cases.filter((c) => scenarioOf(c.name, ids) === id);
|
|
149
|
+
return mine.length === 0 || mine.some((c) => c.status !== "passed");
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function redReport(/** @type {Result} */ results, /** @type {string[]} */ failing) {
|
|
154
|
+
const lines = [
|
|
155
|
+
`feature-tests: ${failing.length} of ${ids.length} scenario(s) failed on the assembled feature: ${failing.join(", ")}.`,
|
|
156
|
+
];
|
|
157
|
+
for (const id of failing) {
|
|
158
|
+
const mine = results.cases.filter((c) => scenarioOf(c.name, ids) === id);
|
|
159
|
+
if (mine.length === 0) {
|
|
160
|
+
lines.push(`${id} — no test ran for it.`);
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
for (const c of mine.filter((m) => m.status !== "passed")) {
|
|
164
|
+
lines.push(`${id} — ${c.name}`);
|
|
165
|
+
if (c.status === "skipped") {
|
|
166
|
+
lines.push(" skipped: a skipped scenario is not a passing one.");
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
lines.push(` at: ${firstFrame(c.detail) ?? "(no step given)"}`);
|
|
170
|
+
lines.push(` message: ${c.message.slice(0, 600)}`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
lines.push(`Report: ${junitPath}.`);
|
|
174
|
+
const text = lines.join("\n");
|
|
175
|
+
return text.length <= REPORT_CHARS ? text : `${text.slice(0, REPORT_CHARS)}\n…`;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** The first stack line that is the test's own, where a step failed. */
|
|
179
|
+
function firstFrame(/** @type {string} */ detail) {
|
|
180
|
+
return detail
|
|
181
|
+
.split("\n")
|
|
182
|
+
.map((line) => line.trim())
|
|
183
|
+
.find((line) => /^at\s|\.(spec|test)\.[cm]?[jt]sx?:\d+/.test(line));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* @param {string} command
|
|
188
|
+
* @param {number} limitMs
|
|
189
|
+
* @returns {Promise<{ code: number | null, signal: string | null, timedOut: boolean, output: string }>}
|
|
190
|
+
*/
|
|
191
|
+
function runShell(command, limitMs) {
|
|
192
|
+
return new Promise((done) => {
|
|
193
|
+
const child = spawn("sh", ["-c", command], {
|
|
194
|
+
cwd,
|
|
195
|
+
env: { ...process.env, CI: "true", NO_COLOR: "1", FORCE_COLOR: "0" },
|
|
196
|
+
detached: true,
|
|
197
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
198
|
+
});
|
|
199
|
+
const tail = createOutputTail(KEEP_BYTES);
|
|
200
|
+
child.stdout.on("data", (chunk) => tail.push("stdout", chunk));
|
|
201
|
+
child.stderr.on("data", (chunk) => tail.push("stderr", chunk));
|
|
202
|
+
let timedOut = false;
|
|
203
|
+
const timer = globalThis.setTimeout(
|
|
204
|
+
() => {
|
|
205
|
+
timedOut = true;
|
|
206
|
+
killGroup(child);
|
|
207
|
+
},
|
|
208
|
+
Math.max(MIN_PHASE_MS, limitMs),
|
|
209
|
+
);
|
|
210
|
+
child.on("error", (error) => {
|
|
211
|
+
clearTimeout(timer);
|
|
212
|
+
done({ code: 127, signal: null, timedOut: false, output: `cannot start: ${error.message}` });
|
|
213
|
+
});
|
|
214
|
+
child.on("close", (code, signal) => {
|
|
215
|
+
clearTimeout(timer);
|
|
216
|
+
done({ code, signal, timedOut, output: tail.text() });
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** @param {import("node:child_process").ChildProcess} child */
|
|
222
|
+
function killGroup(child) {
|
|
223
|
+
try {
|
|
224
|
+
if (child.pid !== undefined) {
|
|
225
|
+
process.kill(-child.pid, "SIGKILL");
|
|
226
|
+
}
|
|
227
|
+
} catch {
|
|
228
|
+
child.kill("SIGKILL");
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function timeoutReport(/** @type {string} */ output) {
|
|
233
|
+
return [
|
|
234
|
+
`The run did not finish within the ${Math.round(budgetMs / 1000)}s this Gate may take, and was stopped.`,
|
|
235
|
+
"A person decides: the tests hang.",
|
|
236
|
+
output.length > 0 ? `Last output:\n${output}` : "It printed nothing.",
|
|
237
|
+
].join("\n");
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function exitLine(/** @type {{ code: number | null, signal: string | null }} */ run) {
|
|
241
|
+
return run.signal === null ? `The run exited ${run.code}.` : `The run got ${run.signal}.`;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** @returns {string[] | string} The ids, or why the manifest is not usable. */
|
|
245
|
+
function readManifest() {
|
|
246
|
+
if (!existsSync(manifestPath)) {
|
|
247
|
+
return `The scenarios manifest ${manifestPath} does not exist. The feature-test Ticket writes it: one id per scenario.`;
|
|
248
|
+
}
|
|
249
|
+
let raw;
|
|
250
|
+
try {
|
|
251
|
+
raw = JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
252
|
+
} catch (error) {
|
|
253
|
+
return `The scenarios manifest ${manifestPath} is not JSON: ${error instanceof Error ? error.message : error}`;
|
|
254
|
+
}
|
|
255
|
+
const list = Array.isArray(raw) ? raw : raw?.scenarios;
|
|
256
|
+
if (!Array.isArray(list)) {
|
|
257
|
+
return `The scenarios manifest ${manifestPath} must be an array of ids, or { "scenarios": [...] }.`;
|
|
258
|
+
}
|
|
259
|
+
const found = list.map((entry) => (typeof entry === "string" ? entry : entry?.id));
|
|
260
|
+
if (found.some((id) => typeof id !== "string" || id.trim().length === 0)) {
|
|
261
|
+
return `Every scenario of ${manifestPath} needs a non-empty id.`;
|
|
262
|
+
}
|
|
263
|
+
const unique = [...new Set(found.map((id) => id.trim()))];
|
|
264
|
+
if (unique.length !== found.length) {
|
|
265
|
+
return `The scenarios manifest ${manifestPath} lists an id twice.`;
|
|
266
|
+
}
|
|
267
|
+
if (unique.length === 0) {
|
|
268
|
+
return `The scenarios manifest ${manifestPath} lists no scenario: there is nothing to prove.`;
|
|
269
|
+
}
|
|
270
|
+
return unique;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function positive(/** @type {string | undefined} */ text) {
|
|
274
|
+
const value = Number(text);
|
|
275
|
+
return Number.isInteger(value) && value > 0 ? value : undefined;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function inWorkspace(/** @type {string} */ path) {
|
|
279
|
+
return isAbsolute(path) ? path : resolve(cwd, path);
|
|
280
|
+
}
|
package/gates/gitignore-leak.mjs
CHANGED
|
@@ -47,7 +47,8 @@ if (git(["rev-parse", "--is-inside-work-tree"]).stdout.trim() !== "true") {
|
|
|
47
47
|
process.exit(0);
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
|
|
50
|
+
const stage = stageOf(process.argv);
|
|
51
|
+
if (stage !== "ticket" && stage !== "feature") {
|
|
51
52
|
emitVerdict("pass");
|
|
52
53
|
process.exit(0);
|
|
53
54
|
}
|
|
@@ -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,65 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { emitVerdict, GATE_FLAGS, ownArgv, pathMatchesGlob } from "@bluewombat/slot-kit";
|
|
4
|
+
|
|
5
|
+
// Fail-retryable when the workspace changed a path that matches none of the globs: the one place
|
|
6
|
+
// the workspace may be written is what the globs name (the tests and their manifest). The other
|
|
7
|
+
// side of `sensitive-path`, which names what may not be touched; a Project whose tests sit among its
|
|
8
|
+
// code (`**/*.test.ts` in every package) cannot say "everything but" with globs that only deny.
|
|
9
|
+
// Remaining argv after Implementer's flags are the globs.
|
|
10
|
+
//
|
|
11
|
+
// What counts is what a fold can carry: tracked paths that differ from HEAD (added, modified,
|
|
12
|
+
// deleted, renamed) and new paths .gitignore does not exclude.
|
|
13
|
+
|
|
14
|
+
const cwd = process.cwd();
|
|
15
|
+
const globs = ownArgv(process.argv, GATE_FLAGS).filter((token) => token.length > 0);
|
|
16
|
+
|
|
17
|
+
if (globs.length === 0) {
|
|
18
|
+
emitVerdict("fail-blocking", "tests-only needs at least one glob.");
|
|
19
|
+
process.exit(0);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const status = spawnSync(
|
|
23
|
+
"git",
|
|
24
|
+
["-C", cwd, "status", "--porcelain=v1", "-z", "--untracked-files=all"],
|
|
25
|
+
{
|
|
26
|
+
encoding: "utf8",
|
|
27
|
+
},
|
|
28
|
+
);
|
|
29
|
+
if (status.status !== 0) {
|
|
30
|
+
emitVerdict("fail-blocking", `tests-only needs a git workspace: ${status.stderr.trim()}`);
|
|
31
|
+
process.exit(0);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** @type {Set<string>} */
|
|
35
|
+
const changed = new Set();
|
|
36
|
+
const fields = status.stdout.split("\0");
|
|
37
|
+
for (let i = 0; i < fields.length; i++) {
|
|
38
|
+
const entry = fields[i] ?? "";
|
|
39
|
+
if (entry.length < 4) {
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
changed.add(entry.slice(3));
|
|
43
|
+
// A rename or a copy carries the old path in the next field.
|
|
44
|
+
if (entry[0] === "R" || entry[0] === "C") {
|
|
45
|
+
const old = fields[++i];
|
|
46
|
+
if (old !== undefined && old.length > 0) {
|
|
47
|
+
changed.add(old);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const outside = [...changed]
|
|
53
|
+
.map((path) => path.replaceAll("\\", "/"))
|
|
54
|
+
.filter((path) => !globs.some((glob) => pathMatchesGlob(path, glob)))
|
|
55
|
+
.sort();
|
|
56
|
+
|
|
57
|
+
if (outside.length > 0) {
|
|
58
|
+
emitVerdict(
|
|
59
|
+
"fail-retryable",
|
|
60
|
+
`Paths changed that are not tests or their manifest (${globs.join(", ")}):\n${outside.join("\n")}`,
|
|
61
|
+
);
|
|
62
|
+
process.exit(0);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
emitVerdict("pass");
|
|
@@ -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
|
+
}
|