@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
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
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// The end of a command's output, for a Gate's report: what a person or an agent needs to read
|
|
2
|
+
// to see why a check is red.
|
|
3
|
+
//
|
|
4
|
+
// Test runners and CLIs redraw a progress line (a spinner, "[✓] Pulling schema...") thousands of
|
|
5
|
+
// times. Kept as bytes, those redraws push the real error out of the tail the report is cut from,
|
|
6
|
+
// and the report says nothing but the spinner. So the output is cleaned line by line as it
|
|
7
|
+
// arrives, before anything is dropped for size: escape codes removed, a line redrawn with "\r"
|
|
8
|
+
// reduced to its last state, spinner and progress lines dropped.
|
|
9
|
+
|
|
10
|
+
// CSI sequences (colors, erase line, cursor moves) and OSC sequences (titles, links).
|
|
11
|
+
const ESC = String.fromCharCode(0x1b);
|
|
12
|
+
const BEL = String.fromCharCode(0x07);
|
|
13
|
+
const ESCAPES = new RegExp(
|
|
14
|
+
`${ESC}\\[[0-9;?]*[ -/]*[@-~]|${ESC}\\][^${BEL}${ESC}]*(?:${BEL}|${ESC}\\\\)`,
|
|
15
|
+
"g",
|
|
16
|
+
);
|
|
17
|
+
// A braille glyph is a spinner frame; "[✓] …..." is the line a spinner settles on.
|
|
18
|
+
const SPINNER_FRAME = /[⠀-⣿]/;
|
|
19
|
+
const SPINNER_DONE = /\[[✓✔]\].*\.\.\.\s*$/;
|
|
20
|
+
|
|
21
|
+
/** One line, cleaned; null when it is only progress noise. */
|
|
22
|
+
export function cleanLine(/** @type {string} */ raw) {
|
|
23
|
+
const redrawn = raw.split("\r").filter((part) => part.trim().length > 0);
|
|
24
|
+
const last = (redrawn.length > 0 ? redrawn[redrawn.length - 1] : "") ?? "";
|
|
25
|
+
const line = last.replace(ESCAPES, "").trimEnd();
|
|
26
|
+
if (SPINNER_FRAME.test(line) || SPINNER_DONE.test(line)) {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
return line;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {number} maxChars what `text()` returns at most; older lines are dropped first.
|
|
34
|
+
*/
|
|
35
|
+
export function createOutputTail(maxChars) {
|
|
36
|
+
let kept = "";
|
|
37
|
+
/** @type {Map<string, string>} */
|
|
38
|
+
const partial = new Map();
|
|
39
|
+
|
|
40
|
+
const add = (/** @type {string} */ raw) => {
|
|
41
|
+
const line = cleanLine(raw);
|
|
42
|
+
if (line === null) {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
kept += `${line}\n`;
|
|
46
|
+
if (kept.length > maxChars * 2) {
|
|
47
|
+
kept = kept.slice(kept.length - maxChars);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
/** Feed a chunk of one stream; each stream keeps its own unfinished line. */
|
|
53
|
+
push(/** @type {string} */ stream, /** @type {Buffer | string} */ chunk) {
|
|
54
|
+
const lines = ((partial.get(stream) ?? "") + chunk.toString()).split("\n");
|
|
55
|
+
partial.set(stream, lines.pop() ?? "");
|
|
56
|
+
lines.forEach(add);
|
|
57
|
+
},
|
|
58
|
+
/** The cleaned end of the output, cut on a line boundary. */
|
|
59
|
+
text() {
|
|
60
|
+
for (const rest of partial.values()) {
|
|
61
|
+
if (rest.length > 0) {
|
|
62
|
+
add(rest);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
partial.clear();
|
|
66
|
+
const cut = kept.length > maxChars ? kept.slice(kept.length - maxChars) : kept;
|
|
67
|
+
return (kept.length > maxChars ? cut.slice(cut.indexOf("\n") + 1) : cut).trim();
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bluewombat/slots",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Shipped mason slots: Builders, Gates, publishers, and refreshers a Project names in its config.",
|
|
6
6
|
"keywords": [
|
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
},
|
|
20
20
|
"exports": {
|
|
21
21
|
"./builders/*": "./builders/*",
|
|
22
|
-
"./
|
|
22
|
+
"./ticket/*": "./ticket/*",
|
|
23
|
+
"./feature/*": "./feature/*",
|
|
23
24
|
"./agents/*": "./agents/*",
|
|
24
25
|
"./gates/*": "./gates/*",
|
|
25
26
|
"./publishers/*": "./publishers/*",
|
|
@@ -28,7 +29,8 @@
|
|
|
28
29
|
},
|
|
29
30
|
"files": [
|
|
30
31
|
"builders",
|
|
31
|
-
"
|
|
32
|
+
"ticket",
|
|
33
|
+
"feature",
|
|
32
34
|
"agents",
|
|
33
35
|
"lib",
|
|
34
36
|
"gates",
|
|
@@ -50,7 +52,7 @@
|
|
|
50
52
|
"test": "tsx --test $(find test -name '*.test.ts')"
|
|
51
53
|
},
|
|
52
54
|
"dependencies": {
|
|
53
|
-
"@bluewombat/slot-kit": "^0.
|
|
54
|
-
"@pinkpuma/deck": "1.
|
|
55
|
+
"@bluewombat/slot-kit": "^0.11.0",
|
|
56
|
+
"@pinkpuma/deck": "1.1.0"
|
|
55
57
|
}
|
|
56
58
|
}
|
package/refreshers/git.mjs
CHANGED
|
@@ -23,6 +23,12 @@ import {
|
|
|
23
23
|
// fast-forward has been written to by somebody else, and that is a person's
|
|
24
24
|
// problem, not a merge to attempt.
|
|
25
25
|
//
|
|
26
|
+
// A Feature's line cut from the final branch that has no commit of its own is
|
|
27
|
+
// only a stale copy of it: when `--base` names that branch and the line is an
|
|
28
|
+
// ancestor of it, the line is fast-forwarded to it, here and on the remote, so
|
|
29
|
+
// a Ticket starts from the code as it now is. A line with commits of its own
|
|
30
|
+
// is left alone.
|
|
31
|
+
//
|
|
26
32
|
// The remote is `origin`, like the git Publisher's. Takes no option.
|
|
27
33
|
|
|
28
34
|
const cwd = process.cwd();
|
|
@@ -53,6 +59,34 @@ if (fetched.status !== 0) {
|
|
|
53
59
|
process.exit(0);
|
|
54
60
|
}
|
|
55
61
|
|
|
62
|
+
const base = take(process.argv, "--base");
|
|
63
|
+
let goal = `origin/${target}`;
|
|
64
|
+
if (base !== undefined && base.trim().length > 0 && base !== target) {
|
|
65
|
+
const fetchedBase = git(["fetch", "origin", `+refs/heads/${base}:refs/remotes/origin/${base}`]);
|
|
66
|
+
if (fetchedBase.status !== 0) {
|
|
67
|
+
emitRefreshRefusal(
|
|
68
|
+
`Cannot fetch origin/${base} from ${cwd}: ${fetchedBase.stderr.trim().slice(-500)}`,
|
|
69
|
+
);
|
|
70
|
+
process.exit(0);
|
|
71
|
+
}
|
|
72
|
+
const behindBase =
|
|
73
|
+
git(["merge-base", "--is-ancestor", `origin/${target}`, `origin/${base}`]).status === 0;
|
|
74
|
+
if (
|
|
75
|
+
behindBase &&
|
|
76
|
+
git(["rev-parse", `origin/${target}`]).stdout !== git(["rev-parse", `origin/${base}`]).stdout
|
|
77
|
+
) {
|
|
78
|
+
// A plain push: never forced, so it only ever adds history.
|
|
79
|
+
const pushed = git(["push", "origin", `refs/remotes/origin/${base}:refs/heads/${target}`]);
|
|
80
|
+
if (pushed.status !== 0) {
|
|
81
|
+
emitRefreshRefusal(
|
|
82
|
+
`Cannot move origin/${target} up to origin/${base}: ${pushed.stderr.trim().slice(-500)}`,
|
|
83
|
+
);
|
|
84
|
+
process.exit(0);
|
|
85
|
+
}
|
|
86
|
+
goal = `origin/${base}`;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
56
90
|
const current = git(["symbolic-ref", "--short", "HEAD"]);
|
|
57
91
|
if (current.status !== 0 || current.stdout.trim() !== target) {
|
|
58
92
|
const exists = git(["rev-parse", "--verify", "--quiet", `refs/heads/${target}`]).status === 0;
|
|
@@ -65,7 +99,7 @@ if (current.status !== 0 || current.stdout.trim() !== target) {
|
|
|
65
99
|
}
|
|
66
100
|
}
|
|
67
101
|
|
|
68
|
-
const merged = git(["merge", "--ff-only",
|
|
102
|
+
const merged = git(["merge", "--ff-only", goal]);
|
|
69
103
|
if (merged.status !== 0) {
|
|
70
104
|
// The one way a copy stops fast-forwarding is a commit made into it that
|
|
71
105
|
// the reference does not have. The copy is the system's, not a person's:
|
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
} from "@bluewombat/slot-kit";
|
|
16
16
|
import { attemptOf } from "../lib/fill.mjs";
|
|
17
17
|
|
|
18
|
-
const NAME = "
|
|
18
|
+
const NAME = "ticket fix";
|
|
19
19
|
|
|
20
20
|
const DEFAULT_TEMPLATE = `You are a coding agent working unattended in a checkout of the project.
|
|
21
21
|
|
|
@@ -60,7 +60,7 @@ A person reads that line and decides. It is a correct answer, not a failure.
|
|
|
60
60
|
|
|
61
61
|
const report = (take(process.argv, "--report") ?? "").trim();
|
|
62
62
|
if (report.length === 0) {
|
|
63
|
-
emitFailure("fail-blocking", "The
|
|
63
|
+
emitFailure("fail-blocking", "The ticket fix command needs --report.");
|
|
64
64
|
process.exit(1);
|
|
65
65
|
}
|
|
66
66
|
|
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
} from "@bluewombat/slot-kit";
|
|
16
16
|
import { attemptOf } from "../lib/fill.mjs";
|
|
17
17
|
|
|
18
|
-
const NAME = "
|
|
18
|
+
const NAME = "ticket validate";
|
|
19
19
|
|
|
20
20
|
const DEFAULT_TEMPLATE = `You are a coding agent working unattended in a checkout of the project.
|
|
21
21
|
|
|
@@ -67,7 +67,7 @@ const report = (take(process.argv, "--report") ?? "").trim();
|
|
|
67
67
|
if (report.length > 0) {
|
|
68
68
|
emitFailure(
|
|
69
69
|
"fail-blocking",
|
|
70
|
-
"
|
|
70
|
+
"ticket.validate is a read-only review; it does not take --report — that is ticket.fix's job.",
|
|
71
71
|
);
|
|
72
72
|
process.exit(1);
|
|
73
73
|
}
|
|
@@ -149,7 +149,7 @@ function answer(run, bin, name) {
|
|
|
149
149
|
// next round gets the same prompt again.
|
|
150
150
|
emitFailure(
|
|
151
151
|
"fail-retryable",
|
|
152
|
-
`
|
|
152
|
+
`ticket.validate found no MASON_VERDICT: line in the answer: ${
|
|
153
153
|
said.length > 0 ? said.slice(0, 500) : "(no answer)"
|
|
154
154
|
}`,
|
|
155
155
|
);
|
|
@@ -160,9 +160,7 @@ function answer(run, bin, name) {
|
|
|
160
160
|
const why = verdict.slice(refused[0].length).trim();
|
|
161
161
|
emitFailure(
|
|
162
162
|
"fail-retryable",
|
|
163
|
-
why.length > 0
|
|
164
|
-
? why
|
|
165
|
-
: "assembly.validate refused the assembled ticket, with no reason given.",
|
|
163
|
+
why.length > 0 ? why : "ticket.validate refused the assembled ticket, with no reason given.",
|
|
166
164
|
);
|
|
167
165
|
process.exit(1);
|
|
168
166
|
}
|
|
@@ -171,7 +169,7 @@ function answer(run, bin, name) {
|
|
|
171
169
|
}
|
|
172
170
|
emitFailure(
|
|
173
171
|
"fail-retryable",
|
|
174
|
-
`
|
|
172
|
+
`ticket.validate's MASON_VERDICT: line was neither VALIDATED nor REFUSED: ${verdict.slice(0, 500)}`,
|
|
175
173
|
);
|
|
176
174
|
process.exit(1);
|
|
177
175
|
}
|