@feigi/fleet-ctl 3.82.12 → 3.82.14

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,260 @@
1
+ #!/usr/bin/env node
2
+ // Run the Recipe's Check step and the Test entrypoint against the staged tree,
3
+ // and record that both passed — the record `ledger.mjs settle --from-report`
4
+ // requires before it settles an implementer's or a fix-applier's report.
5
+ //
6
+ // recipe-check.mjs --member <name> --testCmd <command> [--reports <dir>]
7
+ //
8
+ // Run from the member's worktree, after `git add` and before the commit and
9
+ // push, and again after any content change or rebase. Reached through the
10
+ // Resolver: `~/.fleet/bin/fleet-run recipe-check.mjs ...`.
11
+ //
12
+ // `--member` is the `impl-<N>[-x]` or `fix-pr-<M>[-x]` name the member was
13
+ // dispatched as. `--testCmd` is the Test entrypoint exactly as the member was
14
+ // handed it, a shell command run from the worktree root: the member's runner
15
+ // already goes through the suite-slot wrapper, so this script adds none of its
16
+ // own and never runs the suite a second time. The Check step is not an
17
+ // argument: it is read off the repository's Recipe cache, through
18
+ // derive-testcmd.sh, the one reader of it. `--reports` is the directory the
19
+ // member's report is written to, `<scratch>/reports`; omitted, the default
20
+ // scratch root's.
21
+ //
22
+ // Steps, in this order:
23
+ //
24
+ // 1 read the Check step and the Test entrypoint off the Recipe cache. A
25
+ // worktree with no usable Recipe has no Check step to run: exit 2,
26
+ // nothing run, nothing written. Settle reads the same cache from the
27
+ // ledger's repository, where derive-testcmd.sh's probes of the Test
28
+ // entrypoint can answer otherwise, so it skips its gate only where its
29
+ // own read refuses.
30
+ // 2 `git write-tree`: the tree id of the staged index. The commands run in
31
+ // the working tree, so it must hold exactly that tree: an unstaged change
32
+ // to a tracked file, or an untracked file no ignore rule covers, is a
33
+ // refusal, exit 2 — a green record would otherwise name a tree that never
34
+ // ran. Then the reports directory is created, before anything runs.
35
+ // 3 the Check step, `sh -c` from the worktree root. A Check step of
36
+ // `true` is recorded as green without being run.
37
+ // 4 the Test entrypoint, the same way — only when the Check step passed.
38
+ //
39
+ // The commands' own output passes through on stdout and stderr as it arrives.
40
+ //
41
+ // THE RECORD is `<reports>/<member>.check.json`, beside the member's report
42
+ // (report-files.mjs names the path and reads it back), written whole and
43
+ // atomically once both commands have settled:
44
+ //
45
+ // { member, tree, check: {command, ran, exitCode, pass, fail},
46
+ // test: {command, ran, exitCode, pass, fail} }
47
+ //
48
+ // tree the `git write-tree` of step 2. Settle compares it with the
49
+ // reported head's own tree, so a message-only amend keeps the
50
+ // record valid and a rebase onto a moved base, or any content
51
+ // change, does not.
52
+ // ran false for a Check step of `true`, and for a command skipped
53
+ // because the Check step failed (its exitCode is then null).
54
+ // exitCode the command's status, a signal's number plus 128.
55
+ // pass, fail the counts the runner's summary printed — a `# pass N` /
56
+ // `ℹ fail N` line, or `N passed`/`N failed`/`N passing`/`N
57
+ // failing` — null where it printed none.
58
+ //
59
+ // A record is written whenever a command ran and failed, so a red run
60
+ // replaces an older record rather than leaving it standing; it records the
61
+ // failure, and settle refuses on it. A record is a claim, no attestation: the
62
+ // member produces it, and nothing here stops a forged one.
63
+ //
64
+ // Exit status: 0 both commands passed and the record is written; 1 a command
65
+ // failed — the record says which, and the script's stderr names it; 2 a
66
+ // refusal that ran nothing and wrote nothing (usage, no usable Recipe, git
67
+ // could not be read, a working tree that is not the staged tree, a reports
68
+ // directory that could not be created); 75 the Test entrypoint printed exit
69
+ // 75 — the suite wrapper found no slot and launched nothing, which is no test
70
+ // result — so no record is written, and the same command is to be run again.
71
+
72
+ import { spawn, spawnSync } from "node:child_process";
73
+ import { mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
74
+ import { constants as osConstants } from "node:os";
75
+ import { dirname, join } from "node:path";
76
+ import { fileURLToPath } from "node:url";
77
+ import { makeDie, defineFlags, writeAll } from "./arg.mjs";
78
+ import { FleetDirUnresolvable } from "./fleet-dir.mjs";
79
+ import { gitEnv } from "./git-env.mjs";
80
+ import { isCLI } from "./is-cli.mjs";
81
+ import { parseMember } from "./ledger-grammar.mjs";
82
+ import { checkRecordPathIn, defaultScratch, reportsDir } from "./report-files.mjs";
83
+
84
+ const NAME = "recipe-check";
85
+ const USAGE = "usage: recipe-check.mjs --member <name> --testCmd <command> [--reports <dir>]";
86
+ const DERIVE = join(dirname(fileURLToPath(import.meta.url)), "derive-testcmd.sh");
87
+ const GIT_TIMEOUT_MS = 30_000;
88
+ // What the suite wrapper exits with when it found no slot and launched nothing.
89
+ const NO_SLOT = 75;
90
+ // The tail of a command's output kept for the counts: a runner prints its
91
+ // summary last.
92
+ const TAIL_BYTES = 256 * 1024;
93
+
94
+ const ANSI = /\x1b\[[0-9;]*[A-Za-z]/g;
95
+
96
+ /** `{pass, fail}` the output's summary printed, each null where it printed no
97
+ * count. The last count of a kind wins: a summary follows the lines it sums. */
98
+ export function parseCounts(output) {
99
+ const text = output.replace(ANSI, "");
100
+ const last = (patterns) => {
101
+ for (const re of patterns) {
102
+ const all = [...text.matchAll(re)];
103
+ if (all.length > 0) return Number(all.at(-1)[1]);
104
+ }
105
+ return null;
106
+ };
107
+ const count = (word, loose) => last([new RegExp(`^[ \\t]*(?:#|ℹ)[ \\t]+${word}[ \\t]+(\\d+)[ \\t]*$`, "gm"), loose]);
108
+ return {
109
+ pass: count("pass", /\b(\d+)[ \t]+(?:passed|passing)\b/g),
110
+ fail: count("fail", /\b(\d+)[ \t]+(?:failed|failing)\b/g),
111
+ };
112
+ }
113
+
114
+ async function cli() {
115
+ const die = makeDie(NAME);
116
+ const { arg, sweep, stray } = defineFlags(die, { flags: { member: "value", testCmd: "value", reports: "value" } });
117
+ sweep();
118
+ stray();
119
+ const member = arg("member");
120
+ const testCmd = arg("testCmd");
121
+ let reports = arg("reports");
122
+ if (member === null || testCmd === null) die(USAGE);
123
+ const parsed = parseMember(member);
124
+ if (parsed === null || (parsed.family !== "impl" && parsed.family !== "fix-pr")) {
125
+ die(`--member '${member}' is not an impl-<N>[-x] or fix-pr-<M>[-x] name — ${USAGE}`);
126
+ }
127
+ if (testCmd.trim() === "") die(`--testCmd is empty — ${USAGE}`);
128
+ if (reports === null) {
129
+ try {
130
+ reports = reportsDir(defaultScratch());
131
+ } catch (e) {
132
+ if (!(e instanceof FleetDirUnresolvable)) throw e;
133
+ die(`no --reports, and the scratch root could not be resolved — ${e.message}`);
134
+ }
135
+ }
136
+
137
+ const env = gitEnv();
138
+ // A reader that did not answer: the refusal names it, its exit status and
139
+ // its own reason.
140
+ const noAnswer = (what, r) => {
141
+ const why = (r.error?.message ?? r.stderr ?? "").trim();
142
+ die(`${what} did not answer${r.status === null ? "" : ` (exit ${r.status})`}${why ? `: ${why}` : ""}; nothing run, nothing written`);
143
+ };
144
+ const gitOut = (what, args) => {
145
+ const r = spawnSync("git", args, { encoding: "utf8", env, timeout: GIT_TIMEOUT_MS });
146
+ if (r.status !== 0) noAnswer(what, r);
147
+ return r.stdout.trim();
148
+ };
149
+ const top = gitOut("git rev-parse --show-toplevel", ["rev-parse", "--show-toplevel"]);
150
+
151
+ // 1: the Recipe. Exit 1 is derive-testcmd.sh's refusal about the cache; any
152
+ // other failure is a reader that could not run.
153
+ const field = (name) => {
154
+ const r = spawnSync("sh", [DERIVE, top, name], { encoding: "utf8", env, timeout: GIT_TIMEOUT_MS });
155
+ if (r.status === 0) return r.stdout.replace(/\n$/, "");
156
+ if (r.status === 1) {
157
+ die(`this worktree has no usable Recipe, so there is no Check step to run; settle reads the Recipe from the ledger's repository and gates the report wherever that read finds one; nothing run, nothing written — ${(r.stderr ?? "").trim()}`);
158
+ }
159
+ noAnswer(`derive-testcmd.sh ${name}`, r);
160
+ };
161
+ const checkCmd = field("check");
162
+ field("test");
163
+
164
+ // 2: the staged tree, and a working tree that holds exactly it.
165
+ const tree = gitOut("git write-tree", ["write-tree"]);
166
+ const strays = [
167
+ ...gitOut("git diff --name-only", ["-C", top, "diff", "--name-only"]).split("\n"),
168
+ ...gitOut("git ls-files --others --exclude-standard", ["-C", top, "ls-files", "--others", "--exclude-standard", "--directory"]).split("\n"),
169
+ ].filter((p) => p !== "");
170
+ if (strays.length > 0) {
171
+ const shown = strays.slice(0, 10).join(", ") + (strays.length > 10 ? `, and ${strays.length - 10} more` : "");
172
+ die(`the working tree is not the staged tree — unstaged or untracked: ${shown}. The commands run in the working tree and the record names the staged tree, so stage, remove or ignore these first; nothing run, nothing written`);
173
+ }
174
+ try {
175
+ mkdirSync(reports, { recursive: true });
176
+ } catch (e) {
177
+ die(`could not create ${reports}: ${e.code ?? e.message}; nothing run, nothing written`);
178
+ }
179
+
180
+ // 3 and 4: the commands. A reader that closes this script's stdout or
181
+ // stderr early stops only the passthrough: the commands still run to their
182
+ // end and the record is still written. Any other stream error stays fatal.
183
+ for (const stream of [process.stdout, process.stderr]) {
184
+ stream.on("error", (e) => {
185
+ if (e.code !== "EPIPE") throw e;
186
+ });
187
+ }
188
+ const check = checkCmd.trim() === "true"
189
+ ? { command: checkCmd, ran: false, exitCode: 0, pass: null, fail: null }
190
+ : await runCommand(checkCmd, top, env);
191
+ const test = check.exitCode === 0
192
+ ? await runCommand(testCmd, top, env)
193
+ : { command: testCmd, ran: false, exitCode: null, pass: null, fail: null };
194
+
195
+ // process.exitCode, never process.exit(): the command's own output is still
196
+ // draining through async pipe writes, and exit discards what is queued.
197
+ const say = (line) => writeAll(2, `\n${NAME}: ${line}\n`);
198
+ if (test.exitCode === NO_SLOT) {
199
+ say(`the Test entrypoint exited ${NO_SLOT}: no suite slot was free and nothing was launched — run the same command again; no record written`);
200
+ process.exitCode = NO_SLOT;
201
+ return;
202
+ }
203
+
204
+ const path = checkRecordPathIn(reports, member);
205
+ const tmp = `${path}.${process.pid}.tmp`;
206
+ try {
207
+ writeFileSync(tmp, `${JSON.stringify({ member, tree, check, test })}\n`);
208
+ renameSync(tmp, path);
209
+ } catch (e) {
210
+ rmSync(tmp, { force: true });
211
+ die(`could not write ${path}: ${e.code ?? e.message}`);
212
+ }
213
+
214
+ if (check.exitCode !== 0) {
215
+ say(`the Check step exited ${check.exitCode}; red record written to ${path}, so settle refuses it — fix the cause and run again`);
216
+ process.exitCode = 1;
217
+ } else if (test.exitCode !== 0) {
218
+ say(`the Test entrypoint exited ${test.exitCode}; red record written to ${path}, so settle refuses it — fix the cause and run again`);
219
+ process.exitCode = 1;
220
+ } else {
221
+ say(`green on tree ${tree}; record written to ${path}`);
222
+ }
223
+ }
224
+
225
+ if (isCLI(import.meta.url)) await cli();
226
+
227
+ // One command, `sh -c` from `cwd`, its output passed through and its tail kept
228
+ // for the counts. A signal that stops it is its number plus 128.
229
+ function runCommand(command, cwd, env) {
230
+ return new Promise((resolve) => {
231
+ let tail = "";
232
+ const keep = (chunk) => {
233
+ tail = (tail + chunk).slice(-TAIL_BYTES);
234
+ };
235
+ const child = spawn("sh", ["-c", command], { cwd, env, stdio: ["ignore", "pipe", "pipe"] });
236
+ const handlers = ["SIGTERM", "SIGINT", "SIGHUP"].map((sig) => [sig, () => child.kill(sig)]);
237
+ for (const [sig, h] of handlers) process.on(sig, h);
238
+ child.stdout.setEncoding("utf8");
239
+ child.stderr.setEncoding("utf8");
240
+ child.stdout.on("data", (d) => {
241
+ keep(d);
242
+ process.stdout.write(d);
243
+ });
244
+ child.stderr.on("data", (d) => {
245
+ keep(d);
246
+ process.stderr.write(d);
247
+ });
248
+ const done = (exitCode) => {
249
+ for (const [sig, h] of handlers) process.off(sig, h);
250
+ const { pass, fail } = parseCounts(tail);
251
+ resolve({ command, ran: true, exitCode, pass, fail });
252
+ };
253
+ // A spawn that never started (a missing `sh`) is the command not passing.
254
+ child.on("error", (e) => {
255
+ process.stderr.write(`${NAME}: could not start sh: ${e.message}\n`);
256
+ done(127);
257
+ });
258
+ child.on("close", (code, signal) => done(code ?? 128 + (osConstants.signals[signal] ?? 0)));
259
+ });
260
+ }
@@ -9,6 +9,10 @@
9
9
  // SCHEMAS. The review runner and every member outside those four families
10
10
  // have no schema, so no file to look up.
11
11
  //
12
+ // The Check-step record beside a report, `<reports>/<member>.check.json`, is
13
+ // looked up here the same way: recipe-check.mjs writes it, and this module
14
+ // only names where it lives and reads it back.
15
+ //
12
16
  // This module only looks files up: it writes none, and creates no scratch
13
17
  // root.
14
18
 
@@ -32,6 +36,66 @@ export const reportsDir = (scratch) => join(scratch, "reports");
32
36
 
33
37
  export const reportPath = (scratch, member) => join(reportsDir(scratch), `${member}.json`);
34
38
 
39
+ /** Where `member`'s Check-step record sits in reports directory `dir`: the
40
+ * sibling of its report, `<dir>/<member>.check.json`. recipe-check.mjs writes
41
+ * it, `ledger.mjs settle --from-report` reads it. */
42
+ export const checkRecordPathIn = (dir, member) => join(dir, `${member}.check.json`);
43
+
44
+ const HEX_TREE = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
45
+ const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
46
+
47
+ // One command's half of a record: the command string, whether it ran (a Check
48
+ // step of `true` is recorded as green without running), the exit status it
49
+ // ended with — null when it never ran because an earlier one failed — and
50
+ // the pass/fail counts its output printed, null where the runner printed none.
51
+ function commandViolation(path, c) {
52
+ if (!isObject(c)) return `${path}: not an object`;
53
+ if (typeof c.command !== "string" || c.command === "") return `${path}.command: not a non-empty string`;
54
+ if (typeof c.ran !== "boolean") return `${path}.ran: not a boolean`;
55
+ if (c.exitCode !== null && !Number.isInteger(c.exitCode)) return `${path}.exitCode: not an integer or null`;
56
+ for (const k of ["pass", "fail"]) {
57
+ if (c[k] !== null && !(Number.isInteger(c[k]) && c[k] >= 0)) return `${path}.${k}: not a non-negative integer or null`;
58
+ }
59
+ return null;
60
+ }
61
+
62
+ // A file's text as `{text}`, or the answer both readers below give for a file
63
+ // that is not there (`absent`) or cannot be read (`invalid`).
64
+ function readAt(path) {
65
+ try {
66
+ return { text: readFileSync(path, "utf8") };
67
+ } catch (e) {
68
+ if (e.code === "ENOENT" || e.code === "ENOTDIR") return { state: "absent", path };
69
+ return { state: "invalid", path, violation: `$: unreadable (${e.code ?? e.message})` };
70
+ }
71
+ }
72
+
73
+ /** The Check-step record of `member` in reports directory `dir`, the way
74
+ * readReportIn() answers for a report: `{state: "ok", path, record}`,
75
+ * `{state: "invalid", path, violation}` for a file that exists and is
76
+ * unreadable, not JSON, or the wrong shape, or `{state: "absent", path}`.
77
+ * The record is `{member, tree, check, test}`, `tree` the `git write-tree`
78
+ * of the index the two commands ran against and `check`/`test` each
79
+ * `{command, ran, exitCode, pass, fail}`. */
80
+ export function readCheckRecordIn(dir, member) {
81
+ const path = checkRecordPathIn(dir, member);
82
+ const read = readAt(path);
83
+ if (read.text === undefined) return read;
84
+ let record;
85
+ try {
86
+ record = JSON.parse(read.text);
87
+ } catch (e) {
88
+ return { state: "invalid", path, violation: `$: not JSON (${e.message})` };
89
+ }
90
+ if (!isObject(record)) return { state: "invalid", path, violation: "$: not an object" };
91
+ if (record.member !== member) return { state: "invalid", path, violation: `$.member: ${JSON.stringify(record.member)} is not ${member}` };
92
+ if (typeof record.tree !== "string" || !HEX_TREE.test(record.tree)) {
93
+ return { state: "invalid", path, violation: "$.tree: not a full lowercase hex tree id" };
94
+ }
95
+ const violation = commandViolation("$.check", record.check) ?? commandViolation("$.test", record.test);
96
+ return violation === null ? { state: "ok", path, record } : { state: "invalid", path, violation };
97
+ }
98
+
35
99
  /** The report file of `member` under `scratch`, read and checked against its
36
100
  * family's schema: `{state: "ok", path, report}`, `{state: "invalid", path,
37
101
  * violation}` for a file that exists and is unreadable, not JSON, or the
@@ -46,14 +110,9 @@ export function readReportIn(dir, member) {
46
110
  const family = reportFamily(member);
47
111
  if (family === null) throw new Error(`no report schema for '${member}'`);
48
112
  const path = join(dir, `${member}.json`);
49
- let text;
50
- try {
51
- text = readFileSync(path, "utf8");
52
- } catch (e) {
53
- if (e.code === "ENOENT" || e.code === "ENOTDIR") return { state: "absent", path };
54
- return { state: "invalid", path, violation: `$: unreadable (${e.code ?? e.message})` };
55
- }
56
- const parsed = parseReport(text);
113
+ const read = readAt(path);
114
+ if (read.text === undefined) return read;
115
+ const parsed = parseReport(read.text);
57
116
  const violation = parsed.violation ?? validate(SCHEMAS[family], parsed.value);
58
117
  return violation === null ? { state: "ok", path, report: parsed.value } : { state: "invalid", path, violation };
59
118
  }
@@ -94,8 +94,7 @@ Come back here. The sizing path hands off; this step always follows.
94
94
 
95
95
  ```bash
96
96
  git fetch origin && git rebase origin/main # rebase, never merge main in
97
- <test command> # the Recipe's Test entrypoint; re-run after rebasing
98
- <check command> # the Recipe's Check step; re-run after rebasing
97
+ ~/.fleet/bin/fleet-run recipe-check.mjs --member <your name> --testCmd "<test command>" --reports <scratch>/reports # re-run after any content change or rebase
99
98
  git push --force-with-lease -u origin HEAD
100
99
  gh pr create --base main --body "…
101
100
 
@@ -103,6 +102,17 @@ Closes #N"
103
102
  gh pr edit --add-label <patch|minor|major> # own command, own exit status
104
103
  ```
105
104
 
105
+ `recipe-check.mjs` runs the Recipe's Check step, then `<test command>` — the
106
+ Recipe's Test entrypoint, as you were handed it, which runs through the suite
107
+ slot already — against the staged tree, and records both results beside your
108
+ report; the controller refuses a report whose head's tree has no green record.
109
+ `<your name>` is the `impl-<N>[-x]` you were dispatched as. The record is keyed
110
+ to the tree, so run it after `git add` and before you commit, and again after
111
+ any content change or rebase: a message-only amend keeps it valid, a changed
112
+ tree does not. A non-zero exit is not a pass, and exit 75 is no test result —
113
+ no slot was free and nothing ran, so run it again. A solo session has no report
114
+ to settle against: it runs the Check step and the Test entrypoint themselves.
115
+
106
116
  **A red Check step is yours to judge.** Read what it reports and fix every
107
117
  finding in a file the PR changes, including lines you did not edit — CI checks
108
118
  whole files. Never edit a file the PR does not otherwise change: a finding there