@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
|
@@ -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/parent-clean.mjs
CHANGED
|
@@ -5,11 +5,17 @@ import { join, relative, resolve } from "node:path";
|
|
|
5
5
|
import { emitVerdict, GATE_FLAGS, ownArgv } from "@bluewombat/slot-kit";
|
|
6
6
|
|
|
7
7
|
// Parent working files must not change during the Attempt.
|
|
8
|
-
// --parent DIR (repeatable)
|
|
8
|
+
// --parent DIR (repeatable): always watched.
|
|
9
|
+
// The main worktree of this Child's repository is watched too. Its other linked
|
|
10
|
+
// worktrees are sibling Children (parallel tickets, escalated leftovers): they are
|
|
11
|
+
// legitimately dirty, so they are ignored unless --siblings is given.
|
|
12
|
+
// A watched git tree only counts files modified after this Child was created, so
|
|
13
|
+
// work that was already there (a human's uncommitted edit) is not a leak.
|
|
9
14
|
|
|
10
15
|
const cwd = resolve(process.cwd());
|
|
11
16
|
const tokens = ownArgv(process.argv, GATE_FLAGS);
|
|
12
17
|
const named = [];
|
|
18
|
+
let withSiblings = false;
|
|
13
19
|
|
|
14
20
|
for (let i = 0; i < tokens.length; i++) {
|
|
15
21
|
const token = tokens[i];
|
|
@@ -23,12 +29,19 @@ for (let i = 0; i < tokens.length; i++) {
|
|
|
23
29
|
i += 1;
|
|
24
30
|
continue;
|
|
25
31
|
}
|
|
26
|
-
|
|
32
|
+
if (token === "--siblings") {
|
|
33
|
+
withSiblings = true;
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
emitVerdict(
|
|
37
|
+
"fail-blocking",
|
|
38
|
+
`parent-clean does not take "${token}". Use --parent <dir> or --siblings.`,
|
|
39
|
+
);
|
|
27
40
|
process.exit(0);
|
|
28
41
|
}
|
|
29
42
|
|
|
30
43
|
const parents = [...named];
|
|
31
|
-
for (const worktree of otherWorktrees(cwd)) {
|
|
44
|
+
for (const worktree of otherWorktrees(cwd, withSiblings)) {
|
|
32
45
|
if (!parents.includes(worktree)) {
|
|
33
46
|
parents.push(worktree);
|
|
34
47
|
}
|
|
@@ -67,15 +80,22 @@ if (leaked.length > 0) {
|
|
|
67
80
|
|
|
68
81
|
emitVerdict("pass");
|
|
69
82
|
|
|
70
|
-
/**
|
|
71
|
-
|
|
72
|
-
|
|
83
|
+
/**
|
|
84
|
+
* The main worktree of this repository (git lists it first), plus the other linked
|
|
85
|
+
* worktrees when `siblings` is set. Worktrees git has marked prunable are left out.
|
|
86
|
+
*/
|
|
87
|
+
/** @param {string} workspace @param {boolean} siblings @returns {string[]} */
|
|
88
|
+
function otherWorktrees(workspace, siblings) {
|
|
73
89
|
const result = git(workspace, ["worktree", "list", "--porcelain"]);
|
|
74
90
|
if (result.status !== 0) {
|
|
75
91
|
return [];
|
|
76
92
|
}
|
|
77
93
|
const trees = [];
|
|
78
|
-
|
|
94
|
+
const records = result.stdout.split("\n\n");
|
|
95
|
+
for (const [index, record] of records.entries()) {
|
|
96
|
+
if (index > 0 && !siblings) {
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
79
99
|
const lines = record.split("\n");
|
|
80
100
|
const entry = lines.find((line) => line.startsWith("worktree "));
|
|
81
101
|
if (entry === undefined || lines.some((line) => line.startsWith("prunable"))) {
|
|
@@ -100,10 +120,12 @@ function leaksIn(parent, workspace) {
|
|
|
100
120
|
if (status.status !== 0) {
|
|
101
121
|
return [`${parent}: git status failed`];
|
|
102
122
|
}
|
|
123
|
+
const referenceMs = referenceTime(workspace);
|
|
103
124
|
const lines = status.stdout
|
|
104
125
|
.split("\n")
|
|
105
126
|
.map((line) => line.trimEnd())
|
|
106
|
-
.filter(Boolean)
|
|
127
|
+
.filter(Boolean)
|
|
128
|
+
.filter((line) => !untouchedSince(parent, line, referenceMs));
|
|
107
129
|
return lines.map((line) => `${relative(workspace, parent) || parent}: ${line}`);
|
|
108
130
|
}
|
|
109
131
|
const referenceMs = referenceTime(workspace);
|
|
@@ -117,6 +139,22 @@ function leaksIn(parent, workspace) {
|
|
|
117
139
|
return dirty;
|
|
118
140
|
}
|
|
119
141
|
|
|
142
|
+
/**
|
|
143
|
+
* A status line whose file still exists and was last written before the Child was
|
|
144
|
+
* created is older than the Attempt. A deletion has no date and always counts.
|
|
145
|
+
* @param {string} parent
|
|
146
|
+
* @param {string} line
|
|
147
|
+
* @param {number} referenceMs
|
|
148
|
+
*/
|
|
149
|
+
function untouchedSince(parent, line, referenceMs) {
|
|
150
|
+
const path = line.slice(3).split(" -> ").at(-1) ?? "";
|
|
151
|
+
try {
|
|
152
|
+
return statSync(join(parent, path.replace(/^"|"$/g, ""))).mtimeMs <= referenceMs;
|
|
153
|
+
} catch {
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
120
158
|
/** @param {string} workspace */
|
|
121
159
|
function referenceTime(workspace) {
|
|
122
160
|
const gitFile = join(workspace, ".git");
|
|
@@ -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");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bluewombat/slots",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Shipped mason slots: Builders, Gates, publishers, and refreshers a Project names in its config.",
|
|
6
6
|
"keywords": [
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
"test": "tsx --test $(find test -name '*.test.ts')"
|
|
53
53
|
},
|
|
54
54
|
"dependencies": {
|
|
55
|
-
"@bluewombat/slot-kit": "^0.
|
|
55
|
+
"@bluewombat/slot-kit": "^0.11.1",
|
|
56
56
|
"@pinkpuma/deck": "1.1.0"
|
|
57
57
|
}
|
|
58
58
|
}
|
package/publishers/git.mjs
CHANGED
|
@@ -14,18 +14,29 @@ import {
|
|
|
14
14
|
// Put the ticket's work where the Authority can read it, by pushing the name
|
|
15
15
|
// Isolator gave it to the work line's own remote.
|
|
16
16
|
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
// a
|
|
21
|
-
//
|
|
17
|
+
// What is pushed is the Ticket's result, not what its workspace went through:
|
|
18
|
+
// the workspace is snapshotted again at every integration, and a blob that was
|
|
19
|
+
// added and removed along the way (an install, a build) would travel with its
|
|
20
|
+
// history and make a pack no host accepts. So one commit is made from the
|
|
21
|
+
// branch's tree, on top of the work line's tip, and that commit is pushed.
|
|
22
|
+
// A resubmission continues what this slot published before, which the work line
|
|
23
|
+
// remembers, so the push stays a fast-forward.
|
|
24
|
+
//
|
|
25
|
+
// A plain push, and no force of any kind. A rejection means the name moved
|
|
26
|
+
// under us — someone else's commit, or a rewrite — and that is exactly the
|
|
27
|
+
// case worth refusing rather than overwriting: a branch this slot did not
|
|
28
|
+
// publish is never built upon.
|
|
22
29
|
//
|
|
23
30
|
// The remote is `origin`, and there is no option for it: Host reads the work
|
|
24
31
|
// line back from `origin` after the Authority folds, with no way to learn what
|
|
25
32
|
// this slot was told. An option that publishes to one remote while the refresh
|
|
26
33
|
// reads another is a trap, so it waits for that half to become a slot too.
|
|
27
34
|
//
|
|
28
|
-
//
|
|
35
|
+
// A Feature's line is not a Ticket's result: it is a history the Authority built
|
|
36
|
+
// by folding Tickets into it, already on the remote, and it is pushed as it is,
|
|
37
|
+
// a fast-forward of what the remote holds. mason says so with `--kind line`.
|
|
38
|
+
//
|
|
39
|
+
// Runs in the work line. mason appends --id, --ref, --target and --kind.
|
|
29
40
|
|
|
30
41
|
const cwd = process.cwd();
|
|
31
42
|
const tokens = ownArgv(process.argv, PUBLISHER_FLAGS);
|
|
@@ -48,18 +59,108 @@ if (!existsSync(join(cwd, ".git"))) {
|
|
|
48
59
|
|
|
49
60
|
// C locale: git's words become the refusal a team reads on the tracker, and a
|
|
50
61
|
// machine's own language would put French or German in an English thread.
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
62
|
+
const env = { ...process.env, LC_ALL: "C" };
|
|
63
|
+
/** @param {...string} args */
|
|
64
|
+
const git = (...args) => spawnSync("git", ["-C", cwd, ...args], { encoding: "utf8", env });
|
|
65
|
+
/** @param {{ stderr?: string | null, stdout?: string | null }} run */
|
|
66
|
+
const text = (run) => `${run.stderr ?? ""}${run.stdout ?? ""}`.trim().slice(-1000);
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Runs git; a git that cannot run at all ends the slot, anything else is the caller's to read.
|
|
70
|
+
* @param {...string} args
|
|
71
|
+
*/
|
|
72
|
+
function run(...args) {
|
|
73
|
+
const done = git(...args);
|
|
74
|
+
if (done.error !== undefined) {
|
|
75
|
+
writeDiagnostic(`git publisher cannot run git: ${done.error.message}\n`);
|
|
76
|
+
process.exit(1);
|
|
77
|
+
}
|
|
78
|
+
return done;
|
|
58
79
|
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* @param {string} what
|
|
83
|
+
* @param {{ stderr?: string | null, stdout?: string | null }} [done]
|
|
84
|
+
* @returns {never}
|
|
85
|
+
*/
|
|
86
|
+
function refuse(what, done) {
|
|
87
|
+
emitPublishRefusal(
|
|
88
|
+
`Could not publish ${ref}: ${what}${done === undefined ? "" : `: ${text(done)}`}`,
|
|
89
|
+
);
|
|
90
|
+
process.exit(0);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const kind = take(process.argv, "--kind") ?? "ticket";
|
|
94
|
+
if (kind === "line") {
|
|
95
|
+
const pushedLine = run("push", remote, `refs/heads/${ref}:refs/heads/${ref}`);
|
|
96
|
+
if (pushedLine.status !== 0) {
|
|
97
|
+
refuse("the push was refused", pushedLine);
|
|
98
|
+
}
|
|
99
|
+
emitPublication(ref);
|
|
100
|
+
process.exit(0);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const target = take(process.argv, "--target");
|
|
104
|
+
const id = take(process.argv, "--id");
|
|
105
|
+
if (target === undefined || target.trim().length === 0) {
|
|
106
|
+
refuse("it was given no --target to build on");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// The line's tip, as the remote holds it: what the Submission is a change to.
|
|
110
|
+
const fetched = run("fetch", "--quiet", remote, target);
|
|
111
|
+
if (fetched.status !== 0) {
|
|
112
|
+
refuse(`cannot read ${target} from ${remote}`, fetched);
|
|
113
|
+
}
|
|
114
|
+
const tip = run("rev-parse", "FETCH_HEAD");
|
|
115
|
+
const tipOid = tip.stdout.trim();
|
|
116
|
+
if (tip.status !== 0 || tipOid.length === 0) {
|
|
117
|
+
refuse(`cannot read ${target}`, tip);
|
|
118
|
+
}
|
|
119
|
+
const tree = run("rev-parse", `${ref}^{tree}`);
|
|
120
|
+
if (tree.status !== 0) {
|
|
121
|
+
refuse(`${ref} is not a branch of this work line`, tree);
|
|
122
|
+
}
|
|
123
|
+
const treeOid = tree.stdout.trim();
|
|
124
|
+
|
|
125
|
+
// What this slot published before: the work line keeps it under `refs/published/`.
|
|
126
|
+
// A name that holds anything else moved under us, and is left for the push to
|
|
127
|
+
// reject.
|
|
128
|
+
const recordName = `refs/published/${ref}`;
|
|
129
|
+
let before;
|
|
130
|
+
const recorded = run("rev-parse", "--verify", "--quiet", recordName).stdout.trim();
|
|
131
|
+
const remoteRef = run("ls-remote", remote, `refs/heads/${ref}`);
|
|
132
|
+
const remoteOid = remoteRef.stdout.trim().split(/\s+/)[0] ?? "";
|
|
133
|
+
if (recorded.length > 0 && remoteRef.status === 0 && remoteOid === recorded) {
|
|
134
|
+
before = recorded;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Nothing new: the same tree on the same line, already there.
|
|
138
|
+
if (
|
|
139
|
+
before !== undefined &&
|
|
140
|
+
run("rev-parse", `${before}^{tree}`).stdout.trim() === treeOid &&
|
|
141
|
+
run("merge-base", "--is-ancestor", tipOid, before).status === 0
|
|
142
|
+
) {
|
|
143
|
+
emitPublication(ref);
|
|
62
144
|
process.exit(0);
|
|
63
145
|
}
|
|
64
146
|
|
|
147
|
+
const parents = [...new Set([before, tipOid].filter((oid) => oid !== undefined))];
|
|
148
|
+
const made = run(
|
|
149
|
+
"commit-tree",
|
|
150
|
+
treeOid,
|
|
151
|
+
...parents.flatMap((oid) => ["-p", oid]),
|
|
152
|
+
"-m",
|
|
153
|
+
`Submission of ${id ?? ref}`,
|
|
154
|
+
);
|
|
155
|
+
const commit = made.stdout.trim();
|
|
156
|
+
if (made.status !== 0 || commit.length === 0) {
|
|
157
|
+
refuse("cannot make the commit", made);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const pushed = run("push", remote, `${commit}:refs/heads/${ref}`);
|
|
161
|
+
if (pushed.status !== 0) {
|
|
162
|
+
refuse("the push was refused", pushed);
|
|
163
|
+
}
|
|
164
|
+
run("update-ref", recordName, commit);
|
|
165
|
+
|
|
65
166
|
emitPublication(ref);
|