clearotron 0.2.1 → 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 (45) hide show
  1. package/INSTALL.md +5 -4
  2. package/bin/example.mjs +9 -5
  3. package/bin/onboard.mjs +19 -19
  4. package/bin/stop.mjs +65 -3
  5. package/build-info.json +2 -2
  6. package/driver/CHANGELOG.md +21 -0
  7. package/driver/declination-call.mjs +32 -0
  8. package/driver/driver.config.mjs +20 -0
  9. package/driver/engine/mcp/recording-server.mjs +4 -0
  10. package/driver/gateway.mjs +8 -3
  11. package/driver/knockout-assess-record.mjs +5 -1
  12. package/driver/package.json +1 -1
  13. package/driver/pipeline.mjs +83 -2
  14. package/driver/predelivery-lint.mjs +22 -4
  15. package/driver/publish/knockout.mjs +12 -6
  16. package/driver/publish/render-knockout.mjs +133 -23
  17. package/driver/publish/report-data.mjs +13 -3
  18. package/driver/record-carry.mjs +2 -2
  19. package/driver/reference-score.mjs +1 -1
  20. package/driver/result-noun-fields.mjs +7 -0
  21. package/driver/skills/knockout-assess/SKILL.md +10 -4
  22. package/driver/stages-knockout.mjs +1 -1
  23. package/driver/stages.mjs +1 -1
  24. package/driver/suite-census.json +66 -18
  25. package/driver/unit-inventory.mjs +47 -0
  26. package/driver/unit-state-verdict.mjs +8 -8
  27. package/driver/verify-knockout.mjs +9 -1
  28. package/driver/whatif-memo-run.mjs +45 -4
  29. package/mcp-server/CHANGELOG.md +2 -0
  30. package/mcp-server/lib/brief.mjs +15 -0
  31. package/mcp-server/lib/driver.mjs +6 -0
  32. package/mcp-server/lib/knockout.mjs +435 -0
  33. package/mcp-server/lib/scrub.mjs +1 -1
  34. package/mcp-server/package.json +1 -1
  35. package/mcp-server/server.mjs +69 -4
  36. package/package.json +1 -1
  37. package/portal-ui/package.json +1 -1
  38. package/providers/oauth-mcp-bridge/CHANGELOG.md +2 -0
  39. package/providers/oauth-mcp-bridge/package.json +1 -1
  40. package/scripts/drain-preflight.mjs +2 -2
  41. package/scripts/freeze-example-run.mjs +3 -3
  42. package/scripts/headless-page.mjs +51 -2
  43. package/scripts/live-surface-check.mjs +86 -17
  44. package/scripts/render-check.mjs +61 -2
  45. package/scripts/deploy-test.sh +0 -309
@@ -47,7 +47,7 @@ import { join, dirname, relative, basename } from "node:path";
47
47
  import { createHash } from "node:crypto";
48
48
  import { driverDir } from "../shared/driver-dir.mjs"; //
49
49
  import { tmpdir } from "node:os";
50
- import { fileURLToPath } from "node:url";
50
+ import { fileURLToPath, pathToFileURL } from "node:url";
51
51
 
52
52
  const REPO = join(dirname(fileURLToPath(import.meta.url)), "..");
53
53
 
@@ -123,7 +123,7 @@ const FROZEN_DIRS = [
123
123
  //
124
124
  // research/ IS REQUIRED AND THE PROOF IS WHAT FOUND IT. publish/knockout.mjs
125
125
  // traces every finding citation back to the run's own research payload — `research/<mark>.md`, read from
126
- // the workspace (knockout.mjs:317) — and REFUSES the publish when a citation cannot be traced. The first
126
+ // the workspace (publish/knockout.mjs:317) — and REFUSES the publish when a citation cannot be traced. The first
127
127
  // knockout freeze copied nine files, left research/ behind, and the republish proof threw:
128
128
  //
129
129
  // knockout publish REFUSED: 2 finding citation(s) could not be traced to this run's own research
@@ -506,7 +506,7 @@ const poolFull = join(scratch, "full");
506
506
  const poolFrozen = join(scratch, "frozen");
507
507
  const meta = { runId, codename, customerKey, template };
508
508
 
509
- const { republishRun } = await import(join(REPO, "driver", "publish", "report-registry.mjs"));
509
+ const { republishRun } = await import(pathToFileURL(join(REPO, "driver", "publish", "report-registry.mjs")).href);
510
510
  const publishInto = async (pool, dir) => {
511
511
  mkdirSync(pool, { recursive: true });
512
512
  return republishRun({ runId, meta, pool, poolUrl: "", runDir: dir, skipRegen: true });
@@ -36,6 +36,12 @@ import { execFileSync } from "node:child_process";
36
36
 
37
37
  /** Chrome's own error pages live under this scheme. Nothing a real document is served from does. */
38
38
  export const CHROME_ERROR_SCHEME = "chrome-error:";
39
+ /** What Chrome shows before it has navigated. Not a document, and not a wrong one. */
40
+ export const START_PAGE = "about:blank";
41
+ /** How long `assertPageLoaded` will wait for the browser to leave its start page. */
42
+ export const NAVIGATION_GRACE_MS = 5000;
43
+ /** How often it asks, inside that grace. */
44
+ export const NAVIGATION_POLL_MS = 100;
39
45
 
40
46
  /**
41
47
  * PURE. Given what the page says about itself, is it the document we asked for?
@@ -74,6 +80,25 @@ export function pageVerdict({ href = "", expected = "", marker = null, markerNam
74
80
  + "captured is Chrome's interstitial, not the artefact — and that page carries an `<h1>`, a "
75
81
  + "`<title>` and a body, so a content check alone reads it as a success." };
76
82
  }
83
+ // ── THE BROWSER'S START PAGE IS NOT A WRONG DOCUMENT (tracker issue 273) ────────────────────────
84
+ //
85
+ // `about:blank` is what Chrome shows before it has navigated anywhere. Reaching the check below, it
86
+ // compares unequal to the expected URL and was reported as `wrong-document` — "a redirect, a stale tab
87
+ // or a second page target" — which is a finding about a page. It is not one. Nothing was ever loaded,
88
+ // so nothing about the target document has been measured either way.
89
+ //
90
+ // This mattered because it is what a LOADED BOX produces: three of these scripts launch Chrome with the
91
+ // URL as a command-line argument and cannot wait for a navigation event, so under load the address is
92
+ // read before the browser has moved. A real defect and a slow browser then arrived as the same message,
93
+ // and the arms that exist to catch a wrong page were the ones that fired.
94
+ if (said === START_PAGE || said.startsWith(`${START_PAGE}?`) || said.startsWith(`${START_PAGE}#`)) {
95
+ return { ok: false, kind: "not-navigated",
96
+ why: `chrome is on its start page (${said}) and never reached ${expected}. Nothing about that `
97
+ + "document has been measured, so this is not a finding about the page. TWO THINGS LOOK LIKE "
98
+ + "THIS and the address cannot tell them apart: a navigation that failed without saying so, and "
99
+ + "one that had not happened yet. That is why the caller waits before asking — a verdict of this "
100
+ + "kind means it waited and the browser never left the start page." };
101
+ }
77
102
  // NORMALISED ON BOTH SIDES. Chrome resolves and percent-encodes a `file://` argument, so a raw string
78
103
  // comparison fails on a path with a space and reports "the wrong document" about the right one.
79
104
  const norm = (u) => { try { return new URL(u).href; } catch { return String(u); } };
@@ -103,8 +128,32 @@ export function pageVerdict({ href = "", expected = "", marker = null, markerNam
103
128
  * handshake before this file existed and rewriting seven of them to share one is a bigger change than the
104
129
  * defect warrants. What they must share is the QUESTION.
105
130
  */
106
- export async function assertPageLoaded(evaluate, { expected, marker = null, markerName = "the page's own content", what = "this page", errorText = null } = {}) {
107
- const href = await evaluate("location.href");
131
+ export async function assertPageLoaded(evaluate, { expected, marker = null, markerName = "the page's own content", what = "this page", errorText = null,
132
+ graceMs = NAVIGATION_GRACE_MS, pollMs = NAVIGATION_POLL_MS,
133
+ sleep = (ms) => new Promise((r) => setTimeout(r, ms)), now = () => Date.now() } = {}) {
134
+ // ── WAIT FOR THE BROWSER TO LEAVE ITS START PAGE, THEN JUDGE (tracker issue 273) ─────────────────
135
+ //
136
+ // Three of these scripts launch Chrome with the URL as an argument and get no response to wait on, so
137
+ // the first read of `location.href` can land before the browser has moved. On an idle box it never
138
+ // does; under a full parallel suite it did, repeatedly, and the arms reported the start page as a
139
+ // wrong document.
140
+ //
141
+ // BOUNDED, AND THE BOUND IS THE POINT. This waits for the browser to become ready — it does not wait
142
+ // for the page to become correct. If the address is anything other than the start page it is judged
143
+ // immediately, so a genuinely wrong document still fails on the first read and fails as fast as it did
144
+ // before. Only the "nothing has happened yet" case costs time, and only up to the grace.
145
+ //
146
+ // When the grace runs out the verdict is `not-navigated`, which is a could-not-look and says so.
147
+ // Deadline arithmetic is the caller's to drive: `sleep` and `now` are injected so the exhausted path
148
+ // can be exercised without a browser and without waiting.
149
+ let href = await evaluate("location.href");
150
+ if (String(href ?? "") === START_PAGE) {
151
+ const until = now() + graceMs;
152
+ while (String(href ?? "") === START_PAGE && now() < until) {
153
+ await sleep(pollMs);
154
+ href = await evaluate("location.href");
155
+ }
156
+ }
108
157
  const found = marker == null ? null : Boolean(await evaluate(`Boolean(${marker})`));
109
158
  const verdict = pageVerdict({ href, expected, marker: found, markerName, errorText });
110
159
  if (!verdict.ok) {
@@ -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.
@@ -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
  }