clearotron 0.2.0 → 0.3.0-beta.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.
Files changed (70) hide show
  1. package/.env.example +52 -0
  2. package/INSTALL.md +9 -7
  3. package/README.md +2 -1
  4. package/bin/example.mjs +97 -32
  5. package/bin/onboard.mjs +58 -20
  6. package/bin/start.mjs +7 -0
  7. package/bin/stop.mjs +65 -3
  8. package/build-info.json +2 -2
  9. package/docs/RELEASES.md +6 -4
  10. package/docs/architecture/04-configuration-reference.md +1 -1
  11. package/driver/CHANGELOG.md +51 -0
  12. package/driver/ask-ledger.mjs +69 -1
  13. package/driver/declination-call.mjs +32 -0
  14. package/driver/driver.config.mjs +20 -0
  15. package/driver/engine/mcp/recording-server.mjs +4 -0
  16. package/driver/gateway.mjs +8 -3
  17. package/driver/knockout-assess-record.mjs +5 -1
  18. package/driver/package.json +1 -1
  19. package/driver/pipeline.mjs +123 -3
  20. package/driver/predelivery-lint.mjs +23 -5
  21. package/driver/publish/knockout.mjs +12 -6
  22. package/driver/publish/render-knockout.mjs +133 -23
  23. package/driver/publish/report-data.mjs +13 -3
  24. package/driver/publish/seed-pool.mjs +24 -9
  25. package/driver/record-carry.mjs +139 -0
  26. package/driver/reference-score.mjs +53 -3
  27. package/driver/reference-strip-signatures.mjs +68 -0
  28. package/driver/register-digest-record.mjs +31 -1
  29. package/driver/repairs.mjs +1 -1
  30. package/driver/result-noun-fields.mjs +7 -0
  31. package/driver/skills/knockout-assess/SKILL.md +10 -4
  32. package/driver/stages-knockout.mjs +1 -1
  33. package/driver/stages.mjs +1 -1
  34. package/driver/suite-census.json +108 -30
  35. package/driver/unit-inventory.mjs +47 -0
  36. package/driver/unit-state-verdict.mjs +8 -8
  37. package/driver/verify-knockout.mjs +9 -1
  38. package/driver/verify.mjs +2 -2
  39. package/driver/whatif-memo-run.mjs +45 -4
  40. package/mcp-server/CHANGELOG.md +10 -0
  41. package/mcp-server/lib/brief.mjs +15 -0
  42. package/mcp-server/lib/driver.mjs +6 -0
  43. package/mcp-server/lib/knockout.mjs +435 -0
  44. package/mcp-server/lib/scrub.mjs +1 -1
  45. package/mcp-server/lib/whatif.mjs +10 -1
  46. package/mcp-server/package.json +1 -1
  47. package/mcp-server/server.mjs +69 -4
  48. package/package.json +1 -1
  49. package/portal-ui/package.json +1 -1
  50. package/providers/oauth-mcp-bridge/CHANGELOG.md +10 -0
  51. package/providers/oauth-mcp-bridge/package.json +1 -1
  52. package/scripts/ai-page-render-check.mjs +2 -1
  53. package/scripts/clearances-render-check.mjs +2 -1
  54. package/scripts/drain-preflight.mjs +2 -2
  55. package/scripts/env-audit.mjs +20 -0
  56. package/scripts/freeze-example-run.mjs +3 -3
  57. package/scripts/headless-page.mjs +274 -0
  58. package/scripts/home-render-check.mjs +2 -1
  59. package/scripts/live-surface-check.mjs +86 -17
  60. package/scripts/mint-reference-strip-backlog.mjs +41 -0
  61. package/scripts/release-await-cut.mjs +95 -7
  62. package/scripts/release-version-pr-checks.mjs +25 -1
  63. package/scripts/render-check.mjs +61 -2
  64. package/scripts/report-frame-check.mjs +12 -0
  65. package/scripts/report-screenshot.mjs +62 -2
  66. package/scripts/revisit-render-check.mjs +3 -2
  67. package/scripts/score.mjs +14 -0
  68. package/shared/access-audience.mjs +215 -0
  69. package/shared/tracked-files.mjs +31 -0
  70. package/scripts/deploy-test.sh +0 -309
@@ -90,7 +90,9 @@ import { unitsActiveVerdict } from "../driver/unit-state-verdict.mjs";
90
90
  import { deploymentBox } from "../shared/deployment-box.mjs"; // — extracted; one allowlist, two readers
91
91
  import { unitFileDriftVerdict } from "../driver/unit-file-drift.mjs"; //
92
92
  import { placeholdersIn, resolveValues, renderUnit } from "../driver/systemd/render-units.mjs"; //
93
- import { CHECKED_UNITS, unitInventoryVerdict, serviceCommitVerdict, unitWorkingDirectory } from "../driver/unit-inventory.mjs"; // · -bundle ·
93
+ import { CHECKED_UNITS, unitInventoryVerdict, serviceCommitVerdict, unitWorkingDirectory, unitClone } from "../driver/unit-inventory.mjs"; // · -bundle ·
94
+ import { entrypointOf } from "../driver/systemd/install-census.mjs"; // the ONE ExecStart parser — a unit says which module it runs
95
+ import { treeOfRunning } from "../shared/checkout-move.mjs"; // …and the live argv says which tree that module came from
94
96
  import { findUnitFiles, unitFilePath } from "../driver/unit-files.mjs"; //
95
97
  import { managerGroupsVerdict } from "../driver/manager-groups-verdict.mjs"; //
96
98
  import { config } from "../driver/driver.config.mjs"; //
@@ -234,14 +236,20 @@ function serviceClones() {
234
236
  const out = [];
235
237
  let reached = 0, lastErr = null;
236
238
  for (const u of units) {
237
- let wd = null, active = null, type = null, since = null;
239
+ let wd = null, active = null, type = null, since = null, mainPid = null, fragment = null;
238
240
  try {
239
241
  // — `Type` and `StateChangeTimestamp` ride along on a call that was already being made. Both
240
242
  // are for the MESSAGE, never for the verdict: Type tells a reader whether an `activating` unit is a
241
243
  // oneshot mid-fire or a service mid-restart, and the timestamp lets a human judge a long
242
244
  // `activating` that this check deliberately does not judge (see driver/unit-state-verdict.mjs).
245
+ //
246
+ // `MainPID` and `FragmentPath` ride along for the SECOND way to attribute a unit to a checkout,
247
+ // below. Same call, no extra round trip. Deliberately NOT `ExecStart`: systemd renders it
248
+ // unexpanded — `argv[]=/usr/bin/node ${CLEAROTRON_CHECKOUT_DIR}/driver/runner.mjs` — so the one
249
+ // field that looks like it names the tree is the one field that does not.
243
250
  const shown = execFileSync("systemctl", ["--user", "show", u,
244
- "-p", "WorkingDirectory", "-p", "ActiveState", "-p", "Type", "-p", "StateChangeTimestamp"],
251
+ "-p", "WorkingDirectory", "-p", "ActiveState", "-p", "Type", "-p", "StateChangeTimestamp",
252
+ "-p", "MainPID", "-p", "FragmentPath"],
245
253
  { encoding: "utf8", env, stdio: ["ignore", "pipe", "pipe"] });
246
254
  // `show` answers for a unit that does not exist too (ActiveState=inactive), so a PARSED answer is
247
255
  // proof the bus was reachable — which is exactly the fact the old catch destroyed.
@@ -252,19 +260,73 @@ function serviceClones() {
252
260
  if (k === "ActiveState") active = v.join("=") || null;
253
261
  if (k === "Type") type = v.join("=") || null;
254
262
  if (k === "StateChangeTimestamp") since = v.join("=") || null;
263
+ if (k === "MainPID") mainPid = v.join("=") || null;
264
+ if (k === "FragmentPath") fragment = v.join("=") || null;
255
265
  }
256
266
  } catch (e) { lastErr = String(e?.stderr || e?.message || e).replace(/\s+/g, " ").trim().slice(0, 160); }
257
- // — an inactive unit reporting nothing is NOT a gap in the population; there is genuinely
258
- // nothing to compare, and `unreadable: null` says so. Only the two branches below are gaps.
259
- if (!wd) { out.push({ unit: u, active, type, since, clone: null, head: null, unreadable: null }); continue; }
260
- const parsed = unitWorkingDirectory(wd);
261
- if (!parsed.path) { out.push({ unit: u, active, type, since, clone: null, head: null, unreadable: parsed.why }); continue; }
262
- const top = gitTry(parsed.path, "rev-parse", "--show-toplevel");
263
- if (!top.ok) { out.push({ unit: u, active, type, since, clone: null, head: null, unreadable: `git could not read ${parsed.path}: ${top.err}` }); continue; }
264
- const root = top.out;
265
- const head = gitTry(root, "rev-parse", "HEAD");
266
- out.push({ unit: u, active, type, since, clone: root, head: head.ok ? head.out : null,
267
- unreadable: head.ok ? null : `git could not read HEAD in ${root}: ${head.err}` });
267
+
268
+ // ── ATTRIBUTION ONE: THE DECLARATION ────────────────────────────────────────────────────────────
269
+ let declaredTree = null, declaredWhy = null;
270
+ if (!wd) declaredWhy = "the unit reported no WorkingDirectory";
271
+ else {
272
+ const parsed = unitWorkingDirectory(wd);
273
+ if (!parsed.path) declaredWhy = parsed.why;
274
+ else {
275
+ const top = gitTry(parsed.path, "rev-parse", "--show-toplevel");
276
+ if (top.ok) declaredTree = top.out;
277
+ else declaredWhy = `git could not read ${parsed.path}: ${top.err}`;
278
+ }
279
+ }
280
+
281
+ // ── ATTRIBUTION TWO: WHAT THE PROCESS IS ACTUALLY RUNNING ───────────────────────────────────────
282
+ //
283
+ // The unit file names its entrypoint as `${CLEAROTRON_CHECKOUT_DIR}/<module>`; the LIVE process was
284
+ // started with that expanded, and `/proc/<pid>/cmdline` carries the resolved absolute path. So the
285
+ // unit file says WHICH module to look for and the process says WHERE it came from. Both readers
286
+ // already exist and are tested — this composes them rather than parsing anything new.
287
+ let runningTree = null, runningWhy = null;
288
+ const pid = Number(mainPid);
289
+ if (!pid) runningWhy = "the unit reported no MainPID, so no running process could be read";
290
+ else if (!fragment) runningWhy = `pid ${pid} is running but the unit reported no FragmentPath, so nothing says which module to look for`;
291
+ else {
292
+ let unitText = null;
293
+ try { unitText = readFileSync(fragment, "utf8"); }
294
+ catch (e) { runningWhy = `the unit file ${fragment} could not be read: ${String(e?.message ?? e).slice(0, 80)}`; }
295
+ if (unitText !== null) {
296
+ const { rel, unreadable } = entrypointOf(unitText);
297
+ if (!rel) runningWhy = unreadable;
298
+ else {
299
+ let cmdline = null;
300
+ try { cmdline = readFileSync(`/proc/${pid}/cmdline`, "utf8"); }
301
+ catch (e) { runningWhy = `/proc/${pid}/cmdline could not be read: ${String(e?.message ?? e).slice(0, 80)}`; }
302
+ if (cmdline !== null) {
303
+ const { tree, why } = treeOfRunning(cmdline, rel);
304
+ if (!tree) runningWhy = why;
305
+ else {
306
+ const top = gitTry(tree, "rev-parse", "--show-toplevel");
307
+ if (top.ok) runningTree = top.out;
308
+ else runningWhy = `git could not read ${tree}, which pid ${pid} is running from: ${top.err}`;
309
+ }
310
+ }
311
+ }
312
+ }
313
+ }
314
+
315
+ const chosen = unitClone({ declaredTree, runningTree, declaredWhy, runningWhy });
316
+ // — an inactive unit that reported nothing is NOT a gap in the population; there is genuinely
317
+ // nothing to compare, and `unreadable: null` says so. A unit that IS running and could not be
318
+ // attributed is a gap, and the reason names both halves.
319
+ if (!chosen.clone) {
320
+ const idle = !wd && !pid;
321
+ out.push({ unit: u, active, type, since, clone: null, head: null, source: null,
322
+ unreadable: idle ? null : chosen.why });
323
+ continue;
324
+ }
325
+ const head = gitTry(chosen.clone, "rev-parse", "HEAD");
326
+ out.push({ unit: u, active, type, since, clone: chosen.clone, source: chosen.source,
327
+ disagreement: chosen.disagreement,
328
+ head: head.ok ? head.out : null,
329
+ unreadable: head.ok ? null : `git could not read HEAD in ${chosen.clone}: ${head.err}` });
268
330
  }
269
331
  const probe = reached > 0
270
332
  ? { ok: true, why: null }
@@ -441,7 +503,12 @@ try {
441
503
  //
442
504
  // The arm above already travels the route the harness uses (`mcpToolCall` on `MCP_URL`, the same
443
505
  // client `scripts/e2e.mjs` enqueues through), so a door that 401s every caller ALREADY fails this
444
- // check and already fails the deploy — `scripts/deploy-test.sh` gates on this script's exit code.
506
+ // check and already fails the deploy — WHEN a deploy runs this script, which at the time of writing
507
+ // nothing does. `scripts/deploy-test.sh` gated on this script's exit code; that script is retired, and
508
+ // what replaced it was measured on the operations side and does NOT invoke this one. Nothing here is
509
+ // wrong: the check is a real instrument and its reasoning holds. It is simply not reached, and a caller
510
+ // is being wired back in. Until it is, read this as what the check is FOR, not as evidence that
511
+ // something enforces it.
445
512
  // That is the issue's first criterion, by its sanctioned second branch, and its third and fourth.
446
513
  //
447
514
  // What neither arm could say is WHY the door has the posture it has. This face reaches the auth-proxy
@@ -592,8 +659,10 @@ const { clones, probe: unitProbe } = serviceClones();
592
659
  const running = clones.filter((c) => c.head);
593
660
  const heads = [...new Set(running.map((c) => c.head))];
594
661
  // — THREE OUTCOMES, NOT TWO. This arm is the one whose entire purpose is to catch a service still
595
- // running an old bundle after a deploy, and deploy-test.sh runs it as the final gate on an instance that
596
- // deploys itself every hour. It had been degrading to `skip` with a reason that ASSERTED the deployment
662
+ // running an old bundle after a deploy. `deploy-test.sh` ran it as the final gate on an instance that
663
+ // deploys itself every hour; that script is retired and its replacement does not invoke this one, so at
664
+ // the time of writing nothing reaches this arm on a deploy — see the note above. It had been degrading to
665
+ // `skip` with a reason that ASSERTED the deployment
597
666
  // was not systemd --user — on a box where it is, and where the units are running.
598
667
  // · could not look → skip, naming the error. Not probed is not passed.
599
668
  // · looked, found none → skip, saying so. A genuinely non-systemd deployment lands here honestly.
@@ -0,0 +1,41 @@
1
+ #!/usr/bin/env node
2
+ // SPDX-License-Identifier: AGPL-3.0-only
3
+ // Copyright 2026 Cordillera Sàrl. Additional terms under section 7 of the AGPL-3.0 apply — see ADDITIONAL-TERMS.md
4
+ //
5
+ // Mints `driver/test/fixtures/reference-strip-backlog.json` — the per-file floor under the repair of
6
+ // the sentences the reference strip broke (tracker issue 185).
7
+ //
8
+ // Run it AFTER repairing lines, never to make a red arm green: the arm it feeds refuses any count that
9
+ // went UP, so re-minting is how a repair is recorded, not how a regression is absorbed.
10
+ //
11
+ // node scripts/mint-reference-strip-backlog.mjs [--check]
12
+ //
13
+ // `--check` re-derives and exits non-zero if the committed table disagrees with the tree, which is what
14
+ // CI runs. Without it, the table is rewritten.
15
+ import { readFileSync, writeFileSync } from "node:fs";
16
+ import { execFileSync } from "node:child_process";
17
+ import { join, dirname } from "node:path";
18
+ import { fileURLToPath } from "node:url";
19
+ import { SIGNATURES, censusOf } from "../driver/reference-strip-signatures.mjs";
20
+
21
+ const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
22
+ const TABLE = join(ROOT, "driver/test/fixtures/reference-strip-backlog.json");
23
+
24
+ const tracked = execFileSync("git", ["-C", ROOT, "ls-files"], { encoding: "utf8", maxBuffer: 1 << 28 })
25
+ .split("\n").filter(Boolean);
26
+ const minted = censusOf(ROOT, tracked, (f) => readFileSync(join(ROOT, f), "utf8"));
27
+
28
+ if (process.argv.includes("--check")) {
29
+ const have = JSON.parse(readFileSync(TABLE, "utf8"));
30
+ const a = JSON.stringify(have.files), b = JSON.stringify(minted.files);
31
+ if (a !== b || have.total !== minted.total) {
32
+ console.error("reference-strip backlog is STALE against the tree.");
33
+ console.error(` committed total ${have.total}, tree has ${minted.total}`);
34
+ console.error(" re-mint with: node scripts/mint-reference-strip-backlog.mjs");
35
+ process.exit(1);
36
+ }
37
+ console.log(`reference-strip backlog: current — ${minted.total} line(s) still to repair`);
38
+ } else {
39
+ writeFileSync(TABLE, JSON.stringify({ signatures: SIGNATURES.map((s) => s.name), ...minted }, null, 2) + "\n");
40
+ console.log(`minted ${TABLE}: ${minted.total} line(s) across ${Object.keys(minted.files).length} file(s)`);
41
+ }
@@ -54,11 +54,33 @@ import { cutDecision, versionAtHead, tagsHere } from "./release-cut-decision.mjs
54
54
  /**
55
55
  * Default bound: fifteen minutes at thirty-second steps. Both are arguments so an arm can drive the loop.
56
56
  *
57
- * FIFTEEN, and the job's `timeout-minutes` is 30 to contain it — a budget smaller than its own longest
58
- * step cancels the job at the moment it was about to publish, and a cancelled run reads as neither a
59
- * success nor a failure to anybody scanning the list.
57
+ * TWENTY-FIVE, RAISED FROM FIFTEEN (tracker issue 247), because this waits for the version pull
58
+ * request's OWN CI and that is what it must clear. Measured over the first three cuts, the wait held
59
+ * 552 s, 622 s and 686 s against a 900 s budget — rising every time, and the thing it waits on is the
60
+ * offline suite, which grows on purpose every time anybody adds an arm. The margin was one slow queue.
61
+ *
62
+ * AND THE FAILURE IS SILENT, which is why the margin has to be generous rather than adequate. Running
63
+ * out is a quiet exit 0 by design — an ordinary "nothing merged" must not read as a fault — so the
64
+ * first time this budget is exceeded, the release is simply stranded behind a green tick. There is no
65
+ * red to notice. The cron floor underneath would eventually publish it, which makes the silence worse
66
+ * rather than better: the version ships late, from a different run, with nobody told why.
67
+ *
68
+ * THE JOB'S `timeout-minutes` MUST EXCEED THIS, with room for the checkout and install above it. A
69
+ * budget smaller than its own longest step cancels the job at the moment it was about to publish, and a
70
+ * cancelled run reads as neither a success nor a failure to anybody scanning the list. The comment here
71
+ * used to say the job was capped at 30 while the job actually said 25 — harmless at a 15 minute wait,
72
+ * and exactly the sort of thing that stops being harmless when somebody raises one number and believes
73
+ * a sentence about the other. `MIN_JOB_MARGIN_MS` is what an arm holds the pair to now.
74
+ */
75
+ export const WAIT_MS = 25 * 60 * 1000;
76
+
77
+ /**
78
+ * How far the job's budget must exceed the wait's: enough for the checkout, the install and the pack
79
+ * that surround it. Five minutes, which is generous against the ~90 s those actually take, because the
80
+ * cost of being wrong in this direction is a cancelled publish and the cost of being wrong in the other
81
+ * is a runner held slightly longer.
60
82
  */
61
- export const WAIT_MS = 15 * 60 * 1000;
83
+ export const MIN_JOB_MARGIN_MS = 5 * 60 * 1000;
62
84
 
63
85
  /**
64
86
  * The bound, overridable for one caller only: the dry-run rehearsal.
@@ -111,6 +133,70 @@ export async function awaitCut({ refresh, read, sleep, waitMs = WAIT_MS, stepMs
111
133
 
112
134
  const git = (args) => execFileSync("git", args, { encoding: "utf8" });
113
135
 
136
+ /**
137
+ * One read of `main`: the version it carries, whether that version is tagged, and WHICH COMMIT said so.
138
+ *
139
+ * THE COMMIT IS READ IN THE SAME PASS AS THE VERSION, and that is the whole point of this function
140
+ * existing rather than being three calls at the call site (tracker issue 238). The job below used to
141
+ * check out `main` by name after this loop returned, so a commit landing in between — one that moves no
142
+ * version, an instrument fix with no note — was packed and published under a number whose changelog
143
+ * never described it. Nothing downstream could see it: the tip check compares VERSIONS, and the version
144
+ * had not moved. Naming the commit here is what makes the two ends of the pipeline talk about the same
145
+ * tree.
146
+ *
147
+ * No fetch happens between the two reads, so they cannot straddle one.
148
+ *
149
+ * EVERY READER IS AN ARGUMENT, and that is not decoration. An arm that injected only `run` still asked
150
+ * real git for the version and the tags, so it answered differently on a box where `main` is tagged than
151
+ * on a runner where the checkout has neither `origin/main` nor tags — green here, red there, for reasons
152
+ * that have nothing to do with what it was checking. This file's own suite header warns about exactly
153
+ * that shape, and one of these arms was written into it anyway.
154
+ */
155
+ export function versionBumpCommit({ version, ref = "origin/main", run = git, versionAt = versionAtHead, maxWalk = 100 }) {
156
+ const shas = run(["rev-list", "--first-parent", `-n${maxWalk}`, ref]).trim().split("\n").filter(Boolean);
157
+ let answer = null;
158
+ let sawTheChange = false;
159
+ for (const sha of shas) {
160
+ if (versionAt({ ref: sha }) !== version) { sawTheChange = true; break; }
161
+ // KEEP WALKING PAST THE FIRST MATCH. Every commit landing after the bump and before this read also
162
+ // carries the version — that is precisely the class of commit this exists to leave out — so the
163
+ // answer is the OLDEST consecutive one, not the newest.
164
+ answer = sha;
165
+ }
166
+ // A WALK THAT NEVER SAW THE VERSION CHANGE HAS NOT FOUND THE BUMP; it has run out of road. The oldest
167
+ // commit it happened to reach carries the version by coincidence of the window, and publishing that
168
+ // would ship a tree from before the release. An absence is a finding.
169
+ return sawTheChange ? answer : null;
170
+ }
171
+
172
+ export function readMain({ run = git, versionAt = versionAtHead, tags = tagsHere, decide = cutDecision } = {}) {
173
+ const sha = run(["rev-parse", "origin/main"]).trim();
174
+ // CHECKED BEFORE THE VERSION IS READ, so a checkout with no `origin/main` refuses here rather than
175
+ // going on to answer confidently about a tree it could not name. `git rev-parse` prints the name back
176
+ // when it cannot resolve it, so the failure looks like a value rather than like an error.
177
+ if (!/^[0-9a-f]{40}$/.test(sha)) {
178
+ throw new Error(`release-await-cut: \`git rev-parse origin/main\` answered "${sha.slice(0, 80)}", which is `
179
+ + "not a commit. The publish below checks out what this reports, so a name it cannot resolve must "
180
+ + "refuse here rather than resolve to something else there.");
181
+ }
182
+ const d = decide({ version: versionAt({ ref: "origin/main" }), tags: tags() });
183
+ // NOT THE TIP. `origin/main` is where the branch points in this pass, and a commit that landed after
184
+ // the version bump carries the same version — so the tip check downstream, which compares versions,
185
+ // passes on exactly the commit that made the tarball disagree with its changelog. The answer is the
186
+ // commit that MOVED the version, which is the version pull request's merge.
187
+ //
188
+ // Only asked when there is something to publish. On the ordinary "nothing merged" answer the tip is
189
+ // what a reader wants recorded, and there is no version whose bump could be looked for.
190
+ if (!d.cut) return { ...d, sha, tip: sha };
191
+ const bump = versionBumpCommit({ version: d.version, run, versionAt });
192
+ if (!bump) {
193
+ throw new Error(`release-await-cut: main carries ${d.version} but no commit in the last 100 could be `
194
+ + "found that moved it there. The publish below checks out what this reports, and reporting the "
195
+ + "branch tip instead would publish whatever else has landed since.");
196
+ }
197
+ return { ...d, sha: bump, tip: sha };
198
+ }
199
+
114
200
  function main() {
115
201
  const out = process.env.GITHUB_OUTPUT;
116
202
  const started = Date.now();
@@ -120,7 +206,7 @@ function main() {
120
206
  // tags never arrived answers "no tag" about every version there has ever been. That is the one wrong
121
207
  // answer this pipeline cannot afford, so it is refreshed on every pass rather than once at checkout.
122
208
  refresh: async () => { git(["fetch", "--no-tags", "--prune", "origin", "+refs/heads/main:refs/remotes/origin/main"]); git(["fetch", "--tags", "--force", "origin"]); },
123
- read: () => cutDecision({ version: versionAtHead({ ref: "origin/main" }), tags: tagsHere() }),
209
+ read: () => readMain(),
124
210
  sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
125
211
  now: () => Date.now() - started,
126
212
  }).catch((e) => {
@@ -137,7 +223,7 @@ function main() {
137
223
  + `(${String(e?.message ?? e).slice(0, 200)}). This is a failure to LOOK, not a finding that `
138
224
  + "nothing was cut — nothing downstream may treat it as one.");
139
225
  process.exitCode = 2;
140
- if (out) appendFileSync(out, "cut=false\nversion=\nlooked=false\n");
226
+ if (out) appendFileSync(out, "cut=false\nversion=\nsha=\nlooked=false\n");
141
227
  }).then((r) => {
142
228
  if (!r) return;
143
229
  const secs = Math.round(r.waitedMs / 1000);
@@ -148,7 +234,9 @@ function main() {
148
234
  // `looked` SEPARATES the two negatives above: a loop that ran and found nothing merged, from one
149
235
  // that could not read main at all. The publish job below requires `cut=true`, so neither publishes —
150
236
  // but a reader deciding whether a release went missing needs to know which of the two happened.
151
- if (out) appendFileSync(out, `cut=${r.cut ? "true" : "false"}\nversion=${r.version}\nlooked=true\n`);
237
+ // `sha` IS WRITTEN ON BOTH ANSWERS, not only on a cut. It records which commit this loop's verdict is
238
+ // about, so a run that published nothing can still be read back against the tree it looked at.
239
+ if (out) appendFileSync(out, `cut=${r.cut ? "true" : "false"}\nversion=${r.version}\nsha=${r.sha ?? ""}\nlooked=true\n`);
152
240
  });
153
241
  }
154
242
 
@@ -131,7 +131,31 @@ export function checksVerdict({ checkRuns = [], workflowRuns = [], policy = null
131
131
  * incident, and this is the difference between a false red on the version job — cheap, loud, retried by
132
132
  * the next push — and a red that means what it says.
133
133
  */
134
- export async function waitForChecks({ read, sleep, attempts = 32, everyMs = 15000 } = {}) {
134
+ /**
135
+ * This file's own window: 32 attempts at 15 s, or eight minutes.
136
+ *
137
+ * NAMED SO AN ARM CAN COUPLE THE JOB'S BUDGET TO IT (tracker issue 247). The `version` job's
138
+ * `timeout-minutes` has to contain THIS wait, and the arm that claimed to check that compared the job
139
+ * against `release-await-cut.mjs`'s constant instead — a different wait, in a different job, which this
140
+ * job never runs. It held by luck and would have failed the moment that unrelated number was raised.
141
+ */
142
+ export const CHECK_ATTEMPTS = 32;
143
+ export const CHECK_EVERY_MS = 15_000;
144
+ export const CHECKS_WINDOW_MS = CHECK_ATTEMPTS * CHECK_EVERY_MS;
145
+
146
+ /**
147
+ * What the `version` job spends around this wait: checkout, install, the changesets action that cuts the
148
+ * pull request, and the cut/awaited steps either side of it.
149
+ *
150
+ * This margin belongs to THIS file for the same reason the window does. `release-await-cut.mjs` exports a
151
+ * margin of its own with the same name-shape and a different subject — the checkout and install around the
152
+ * `awaited` job's wait. Coupling the version job's budget to that one repeats the very defect this issue
153
+ * closes, one size smaller: raising the other job's margin would move this job's requirement, about a job
154
+ * whose behaviour had not changed. Two jobs, two waits, two margins, and no number shared between them.
155
+ */
156
+ export const CHECKS_JOB_MARGIN_MS = 4 * 60 * 1000;
157
+
158
+ export async function waitForChecks({ read, sleep, attempts = CHECK_ATTEMPTS, everyMs = CHECK_EVERY_MS } = {}) {
135
159
  if (typeof read !== "function") throw new Error("release-version-pr-checks: waitForChecks needs a read()");
136
160
  let seen = null;
137
161
  for (let i = 1; i <= attempts; i++) {
@@ -64,7 +64,7 @@ import { execFileSync, spawn } from "node:child_process";
64
64
  import { createServer } from "node:http";
65
65
  import { basename, extname } from "node:path";
66
66
  import { join, dirname } from "node:path";
67
- import { fileURLToPath } from "node:url";
67
+ import { fileURLToPath, pathToFileURL } from "node:url";
68
68
  import { tmpdir } from "node:os";
69
69
  import { isEntrypoint } from "../shared/is-entrypoint.mjs"; // — one entry-point test, all spellings
70
70
  import { envFrom } from "../shared/env-aliases.mjs"; // — the name a reader is told to set is the one in force
@@ -447,12 +447,36 @@ async function measureAtZoom(zoom, deadlineMs = 90_000) {
447
447
  }
448
448
  }
449
449
 
450
+ /**
451
+ * PURE. Did the in-frame probe post, and if not, which of the two causes does the evidence name?
452
+ *
453
+ * SEPARATED SO EVERY BRANCH CAN BE DRIVEN. The whole finding here is a check that reported three failed
454
+ * measurements about values it never read, and an arm that could only reach this through a real browser on
455
+ * a starved runner would be the same shape one level up: untestable except by luck.
456
+ *
457
+ * `heightMsgs` is the discriminator and was already being collected. It counts messages from the report's
458
+ * OWN height bridge, which travels the same postMessage path as the probe — so non-zero means the frame
459
+ * loaded, ran scripts and reached this shell, and a missing probe is then the probe's problem. Zero means
460
+ * nothing arrived from inside at all, and the probe is not the thing to look at.
461
+ */
462
+ export function probeVerdict({ innerScrollbar, hOverflowPx, slackPx, heightMsgs = 0 } = {}) {
463
+ const missing = innerScrollbar === "no-probe" || hOverflowPx === "no-probe" || slackPx === null;
464
+ if (!missing) return { measured: true, cause: null, why: null };
465
+ return heightMsgs > 0
466
+ ? { measured: false, cause: "probe-only",
467
+ why: `${heightMsgs} height post(s) DID arrive, so the frame loaded and its scripts ran and reached `
468
+ + "this shell. The probe alone is missing — look at the probe, not at the report." }
469
+ : { measured: false, cause: "nothing-from-inside",
470
+ why: "No message of any kind arrived from inside the frame. The frame did not load, or its scripts "
471
+ + "did not run. The probe is not the thing to look at." };
472
+ }
473
+
450
474
  async function main() {
451
475
  const { pool: POOL, built } = resolvePool();
452
476
  const runId = pickRun(POOL);
453
477
  mkdirSync(WORK, { recursive: true });
454
478
 
455
- const { readReport } = await import(join(REPO, "driver", "portal-report.mjs"));
479
+ const { readReport } = await import(pathToFileURL(join(REPO, "driver", "portal-report.mjs")).href);
456
480
  const html = readReport(join(POOL, runId), { staff: true, poolRoot: POOL });
457
481
  // chrome.css carries 35KB of typography; without it the layout is not the one users see. What must be
458
482
  // true is that NO external stylesheet link survives into the measured document — either it was inlined,
@@ -473,12 +497,40 @@ async function main() {
473
497
  console.log(" Expect the sideways assertion to FAIL. A clean run here means the instrument is blind.\n");
474
498
  }
475
499
  let failures = 0;
500
+ // A COULD-NOT-LOOK IS NOT A FAILED MEASUREMENT (tracker issue 239). Counted apart from `failures`
501
+ // because the two mean different things to whoever reads the exit code: 1 says the layout is wrong,
502
+ // 2 says nothing was measured. Merging them is how a starved runner sends a reader to look at CSS.
503
+ let unmeasured = 0;
476
504
 
477
505
  for (const zoom of ZOOMS) {
478
506
  const measured = await measureAtZoom(zoom);
479
507
  if (!measured.ok) { console.log(` zoom ${zoom}: FAILED — ${measured.why}`); failures++; continue; }
480
508
  const r = measured.state;
481
509
 
510
+ // ── THE THREE ASSERTIONS BELOW READ THE IN-FRAME PROBE, AND IT MAY NEVER HAVE POSTED ────────────
511
+ //
512
+ // `no-probe` and `null` are not measurements that disagreed with the expectation — they are the
513
+ // expectation never being tested. Rendering them as `FAIL … (got "no-probe")` is a check that could
514
+ // not look, reported as a check that looked and disliked what it saw, and it sends a reader to the
515
+ // report's CSS where there is nothing to find. Measured on a starved runner: three assertions failed
516
+ // on a branch whose diff was comments, a documentation line and a new test file.
517
+ //
518
+ // `heightMsgs` separates the two causes and was already being collected. It counts messages from the
519
+ // report's OWN height bridge, which travels the same postMessage path as the probe: non-zero means
520
+ // the frame loaded, ran scripts and reached the parent, so a missing probe is the probe's problem.
521
+ // Zero means nothing from inside arrived at all, and the probe is not the thing to look at.
522
+ const probe = probeVerdict(r);
523
+ if (!probe.measured) {
524
+ unmeasured++;
525
+ console.log(` zoom ${zoom}: (ready by ${r.readyBy}, heights ${r.heightsQuiet}, `
526
+ + `settled after ${r.settleTries} × 25ms, ${r.heightMsgs} height post(s), ${r.probeMsgs} probe post(s))`);
527
+ console.log(` COULD NOT MEASURE — the in-frame probe never posted, so the report's own scrollbar,`);
528
+ console.log(` its sideways overflow and the frame's slack were not read at zoom ${zoom}.`);
529
+ console.log(` ${probe.why}`);
530
+ console.log(` ── raw state: ${JSON.stringify(r)}`);
531
+ continue;
532
+ }
533
+
482
534
  const checks = [
483
535
  ["no border stealing from the frame's viewport", r.borderSteals === 0, r.borderSteals],
484
536
  ["the report has NO scrollbar of its own", r.innerScrollbar === false, r.innerScrollbar],
@@ -540,6 +592,13 @@ async function main() {
540
592
  + "clean run of this check is not evidence the report is clean.");
541
593
  process.exit(caught ? 0 : 1);
542
594
  }
595
+ if (unmeasured) {
596
+ // EXIT 2, THE HOUSE MEANING FOR COULD-NOT-LOOK. It still stops CI — nothing is waved through — but it
597
+ // does not claim the layout was measured and found wrong.
598
+ console.error(`\nrender-check: nothing was measured at ${unmeasured} zoom level(s) — the in-frame probe `
599
+ + `never posted. This is a failure to LOOK, not a finding about the report.`);
600
+ process.exit(2);
601
+ }
543
602
  console.log(failures ? `\nrender-check: ${failures} FAILED` : "\nrender-check: all checks passed");
544
603
  process.exit(failures ? 1 : 0);
545
604
  }
@@ -22,6 +22,7 @@
22
22
  // Needs `google-chrome` (on the VM). Same mechanism as render-check.mjs: a page computes its verdict
23
23
  // and writes it into <title>, which --dump-dom hands back.
24
24
 
25
+ import { chromeErrorPage } from "./headless-page.mjs"; // tracker issue 227 — a dumped DOM can be chrome's own error document
25
26
  import { execFileSync } from "node:child_process";
26
27
  import { mkdtempSync, writeFileSync, rmSync, readFileSync } from "node:fs";
27
28
  import { tmpdir } from "node:os";
@@ -63,6 +64,17 @@ function main() {
63
64
  "--virtual-time-budget=8000", "--dump-dom", `file://${join(work, "verify.html")}`,
64
65
  ], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 120000 });
65
66
 
67
+ // ── IS THIS OUR PAGE, OR CHROME'S ERROR PAGE? (tracker issue 227) ───────────────────────────────
68
+ //
69
+ // `--dump-dom` prints whatever chrome ended up showing, and a file it could not read produces the
70
+ // interstitial — which has a `<title>` of its own. This was saved from reporting a pass only by the
71
+ // marker below being specific: the title has to parse as JSON with a `kept` array. That is luck, not
72
+ // a check, and the next person to loosen the parse removes it. Said out loud instead.
73
+ if (chromeErrorPage(out)) {
74
+ console.log(`FAILED — chrome could not open the page and dumped its own error document instead. `
75
+ + `Nothing below is about the report.`);
76
+ process.exit(1);
77
+ }
66
78
  const m = /<title>(.*?)<\/title>/s.exec(out);
67
79
  if (!m || m[1] === "pending") { console.log("FAILED — the page never reported"); process.exit(1); }
68
80
  const kept = JSON.parse(m[1]).kept;
@@ -19,6 +19,7 @@
19
19
  // receives, so it loads the brand webfonts the client's browser loads. Two scripts, two intents; do not
20
20
  // "fix" either to match the other.
21
21
  import { spawn } from "node:child_process";
22
+ import { assertPageLoaded, cjkCharsIn, cjkVerdict, fontsCovering } from "./headless-page.mjs"; // tracker issue 227 — did chrome open the report, or its own error page?
22
23
  import { mkdtempSync, writeFileSync, existsSync } from "node:fs";
23
24
  import { tmpdir } from "node:os";
24
25
  import { join, dirname, resolve } from "node:path";
@@ -29,6 +30,9 @@ const OUT = resolve(process.argv[3] ?? join(ROOT, "docs", "assets", "example-rep
29
30
  const WIDTH = 1280, HEIGHT = 1040;
30
31
  // The frame starts here rather than at the top of the document — see the scroll block below.
31
32
  const ANCHOR = process.argv.includes("--anchor") ? process.argv[process.argv.indexOf("--anchor") + 1] : "h1";
33
+ // WHAT ONLY A REPORT CARRIES. `h1` is the ANCHOR — where to start the frame — and it is on every HTML
34
+ // page including Chrome's error interstitial, so it cannot also be the proof that this IS a report.
35
+ const MARKER = process.argv.includes("--marker") ? process.argv[process.argv.indexOf("--marker") + 1] : "[data-run-id]";
32
36
 
33
37
  const src = process.argv[2];
34
38
  if (!src) { console.error("usage: node scripts/report-screenshot.mjs <report.html> [out.png]"); process.exit(2); }
@@ -69,6 +73,34 @@ const sessionId = sess.sessionId;
69
73
  const cmd = (method, params = {}) => new Promise((r) => { const n = ++id; pending.set(n, r); ws.send(JSON.stringify({ id: n, sessionId, method, params })); });
70
74
 
71
75
  await cmd("Page.enable");
76
+
77
+ // ── IS THIS THE REPORT, OR CHROME'S OWN ERROR PAGE? (tracker issue 227) ──────────────────────────────
78
+ //
79
+ // Chrome is launched with the file URL as an ARGUMENT, so there is no navigation response to check and
80
+ // nothing here ever asked. When the file could not be read, Chrome showed `ERR_ACCESS_DENIED` — a page
81
+ // with an `<h1>` — the anchor below resolved against it, the clip was taken, and this exited 0 having
82
+ // written 38 KB of grey error page over the README's example frame. The successful run and the failed
83
+ // one differed in the log by an anchor offset and a font count, neither of which was asserted on.
84
+ //
85
+ // `existsSync` above does not cover it: a file that EXISTS and cannot be READ passes that check and
86
+ // fails in Chrome. So does a file that is readable and is not a report.
87
+ //
88
+ // THE MARKER IS THE RUN ID, not a tag. `h1` is what the error page has; `[data-run-id]` is what only a
89
+ // rendered report has, and naming it in the log is what makes the success line say what it certified
90
+ // rather than "an h1 was found".
91
+ const evaluate = async (expression) => {
92
+ const r = await cmd("Runtime.evaluate", { expression, returnByValue: true });
93
+ return r?.result?.result?.value;
94
+ };
95
+ const loaded = await assertPageLoaded(evaluate, {
96
+ expected: `file://${page}`,
97
+ marker: `document.querySelector(${JSON.stringify(MARKER)})`,
98
+ markerName: `a report element (${MARKER})`,
99
+ what: "report-screenshot",
100
+ });
101
+ if (!loaded.ok) { chrome.kill(); process.exit(1); }
102
+ const runId = await evaluate(`(document.querySelector(${JSON.stringify(MARKER)})?.getAttribute("data-run-id") ?? "")`);
103
+
72
104
  // The fonts are the point of allowing the network at all, so wait for them rather than for a fixed
73
105
  // sleep: a timer long enough on this box is a timer too short on a slower one, and the failure is a
74
106
  // screenshot in the wrong typeface that nobody notices until it is in the README.
@@ -91,11 +123,39 @@ if (typeof top !== "number") {
91
123
  console.error(`report-screenshot: no element matched ${JSON.stringify(ANCHOR)} — nothing to anchor the frame to.`);
92
124
  chrome.kill(); process.exit(1);
93
125
  }
126
+ // ── CAN THIS BOX DRAW WHAT THE PAGE SAYS? (tracker issue 227) ───────────────────────────────────────
127
+ //
128
+ // The default demo product's report carries the mark's native-script renderings — ベンクリ, ベンコリ,
129
+ // ヴェンコリ — and they are load-bearing: the verdict sentence reads "A live Japanese class 9
130
+ // registration reading ベンクリ covers measuring and testing instruments". With no CJK-capable font
131
+ // those render as empty boxes, twice in the captured frame, and nothing said so.
132
+ //
133
+ // The wait for `document.fonts.ready` above was written against exactly this class — "the failure is a
134
+ // screenshot in the wrong typeface that nobody notices until it is in the README" — and solved the
135
+ // TYPEFACE half. This is the WRITING-SYSTEM half, in the same script.
136
+ //
137
+ // REFUSES rather than warns. This writes an image that goes into the README by hand; a warning on a
138
+ // terminal nobody is reading when the file is already written is the shape that produced the defect
139
+ // above it. `--allow-tofu` is there for a reader who genuinely wants the frame anyway and has been told
140
+ // what is in it.
141
+ const pageText = await evaluate("document.body ? document.body.innerText : ''");
142
+ const cjkChars = cjkCharsIn(pageText);
143
+ const glyphs = cjkVerdict({ cjkChars, covering: fontsCovering("ja"),
144
+ sample: (String(pageText).match(/[\u3040-\u30ff\u4e00-\u9fff]{2,8}/u) ?? [])[0] ?? "" });
145
+ if (!glyphs.ok && !process.argv.includes("--allow-tofu")) {
146
+ console.error(`report-screenshot: ${glyphs.why}`);
147
+ console.error(" Pass --allow-tofu to capture it anyway, knowing the frame is missing those glyphs.");
148
+ chrome.kill(); process.exit(1);
149
+ }
150
+ if (!glyphs.ok) console.error(`report-screenshot: WARNING — ${glyphs.why} Capturing anyway (--allow-tofu).`);
151
+
94
152
  const y = Math.max(top - 56, 0); // a little air above the title, so the page does not read as cropped
95
153
  const shot = await cmd("Page.captureScreenshot", { format: "png", captureBeyondViewport: true,
96
154
  clip: { x: 0, y, width: WIDTH, height: HEIGHT, scale: 1 } });
97
155
  if (!shot?.result?.data) { console.error(`report-screenshot: chrome returned no image. ${JSON.stringify(shot).slice(0, 300)}`); chrome.kill(); process.exit(1); }
98
156
  writeFileSync(OUT, Buffer.from(shot.result.data, "base64"));
99
- const loaded = await cmd("Runtime.evaluate", { expression: "document.fonts.size + ':' + [...document.fonts].filter(f=>f.status==='loaded').length", returnByValue: true });
157
+ const fonts = await cmd("Runtime.evaluate", { expression: "document.fonts.size + ':' + [...document.fonts].filter(f=>f.status==='loaded').length", returnByValue: true });
100
158
  chrome.kill();
101
- console.log(`report-screenshot: wrote ${OUT} (${WIDTH}x${HEIGHT}, anchor ${JSON.stringify(ANCHOR)} at y=${Math.round(y)}, fonts ${loaded?.result?.result?.value ?? "?"})`);
159
+ // NAMES WHAT IT CERTIFIED. "an h1 was found" is true of the error page this used to photograph; the
160
+ // run id is read out of the document and is the thing a reader can check against the report they meant.
161
+ console.log(`report-screenshot: wrote ${OUT} of run ${runId || "(no run id in the page)"} (${WIDTH}x${HEIGHT}, anchor ${JSON.stringify(ANCHOR)} at y=${Math.round(y)}, fonts ${fonts?.result?.result?.value ?? "?"}, ${glyphs.kind})`);
@@ -59,6 +59,7 @@
59
59
  // MUST NOT run as a user with a virtual-memory ulimit (`ulimit -v`) — Chrome dumps core under one. Run it
60
60
  // as a user with `ulimit -v unlimited`. The dbus/UPower errors Chrome prints on a headless box are noise.
61
61
 
62
+ import { navigateOrRefuse } from './headless-page.mjs' // tracker issue 227 — Page.navigate returns an errorText, and nothing read it
62
63
  import { createServer } from 'node:http'
63
64
  import { readFileSync, existsSync, writeFileSync, mkdtempSync, rmSync } from 'node:fs'
64
65
  import { join, extname, dirname } from 'node:path'
@@ -284,7 +285,7 @@ const record = {}
284
285
 
285
286
  // The shell, once. Everything after this is client-side.
286
287
  epoch = 'boot'
287
- await cmd('Page.navigate', { url: `${origin}/portal/home` })
288
+ await navigateOrRefuse(cmd, `${origin}/portal/home`, { what: 'revisit-render-check' })
288
289
  if (!(await settle())) say(false, 'the shell never went quiet within 12s — it is still requesting')
289
290
  const bootPath = await where()
290
291
  say(bootPath === '/portal/home', `the shell loaded on /portal/home (got ${bootPath})`)
@@ -375,7 +376,7 @@ await twice('clearances', () => clickNav('Clearances'), '/portal/clearances')
375
376
  // URL and the SECOND is the Back button, which is the revisit a reader actually performs and the one that
376
377
  // runs AppShell's popstate listener rather than its click funnel.
377
378
  epoch = 'result:1'
378
- await cmd('Page.navigate', { url: `${origin}/portal/result/${RUN_ID}` })
379
+ await navigateOrRefuse(cmd, `${origin}/portal/result/${RUN_ID}`, { what: 'revisit-render-check' })
379
380
  const rq1 = await settle()
380
381
  const rp1 = await where()
381
382
  const rt1 = await screenText()