@bluewombat/slots 0.9.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.
@@ -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
- if (stageOf(process.argv) === "assembly") {
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/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.9.0",
3
+ "version": "0.10.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
- "./assembly/*": "./assembly/*",
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
- "assembly",
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.9.0",
54
- "@pinkpuma/deck": "1.0.0"
55
+ "@bluewombat/slot-kit": "^0.10.0",
56
+ "@pinkpuma/deck": "1.1.0"
55
57
  }
56
58
  }