clearotron 0.3.0-beta.9 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/INSTALL.md CHANGED
@@ -295,6 +295,71 @@ keeps working throughout.
295
295
  provider, so it is safe to run on any machine that has the repository. "Safe anywhere" is about spend,
296
296
  not about where it will run — on a packaged tree it refuses, as above.
297
297
 
298
+ ## 2a. Removing it
299
+
300
+ An install is not one directory, so removing it is not one command. Everything it writes is listed here.
301
+ Read the list before you delete anything: one of these directories holds your reports.
302
+
303
+ **Stop it first.** If the product is running in a terminal, Ctrl-C in that terminal. If you installed the
304
+ background services, `clearotron stop` stops them and removes their unit files. `clearotron status` says
305
+ which of the two you have.
306
+
307
+ **The program.** Installed globally, `npm rm -g clearotron` removes it. Installed by `clearotron install`
308
+ (which is also what a demo run from `npx` leaves behind), it lives in two places under `~/.local`:
309
+
310
+ ```
311
+ ~/.local/lib/node_modules/clearotron
312
+ ~/.local/bin/clearotron
313
+ ```
314
+
315
+ **The settings, and the keys.** `~/.config/clearotron/.env` holds this install's signing secrets, so
316
+ deleting it invalidates every key it ever issued. Beside it: `~/.config/clearotron/token-denylist`, the
317
+ list of keys you revoked, and `~/.config/clearotron/running`, which records a product running in a
318
+ terminal and is rewritten on every start.
319
+
320
+ **Your work.** The install keeps it all under one directory, `~/trademark` by default — wherever
321
+ `CLEAROTRON_REPORTS_DIR`'s parent points if you moved it:
322
+
323
+ ```
324
+ ~/trademark/pool THE REPORTS. Every clearance this install delivered.
325
+ ~/trademark/workspace the working files of each run, including unfinished ones
326
+ ~/trademark/queue work ordered and not yet run
327
+ ~/trademark/outbox what the product prepared to send
328
+ ~/trademark/locks which runs are in flight
329
+ ~/trademark/config your companies, their projects and your saved searches
330
+ ~/trademark/config/recipes the saved searches themselves
331
+ ~/trademark/config/profiles the company profiles
332
+ ~/trademark/grants.json who may use this install
333
+ ~/trademark/portal-audit.log what the portal was asked to do
334
+ ~/trademark/portal-local-credential.json the sign-in this install minted for you
335
+ ```
336
+
337
+ **Keep the pool if you want the reports.** They are finished documents and nothing else reads them: move
338
+ `~/trademark/pool` somewhere of your own and delete the rest. Deleting the pool deletes the clearances.
339
+
340
+ **The demo, if you ran one.** `~/trademark-demo` holds the demo's own copy of everything above, including
341
+ its own copy of the program under `~/trademark-demo/program`. It is one directory to remove and it shares
342
+ nothing with an install.
343
+
344
+ **The background services, if you installed them.** `clearotron stop` removes the four unit files; if you
345
+ would rather see them, they are in `~/.config/systemd/user`:
346
+
347
+ ```
348
+ clearotron-portal.service
349
+ clearotron-mcp-face.service
350
+ clearotron-worker.service
351
+ clearotron-client-mcp.service
352
+ ```
353
+
354
+ **And `~/.env`, which only a background install writes.** A service inherits nothing from the terminal
355
+ that installed it, so `clearotron start --background` writes everything those services need into `~/.env`,
356
+ mode 600 — your register credential, your research key and the engine's settings among it. It is not the
357
+ same file as `~/.config/clearotron/.env`, which configures the product when you run it yourself. Delete
358
+ both, or you leave a file of credentials in your home for services that no longer exist.
359
+
360
+ Nothing else on the machine is ours: no system-wide files, no services outside your own user, and nothing
361
+ in `/etc`, `/usr` or `/var`.
362
+
298
363
  ## 3. Configuration (environment)
299
364
 
300
365
  Runtime configuration is by environment variable, and there are two ways to supply them.
package/README.md CHANGED
@@ -59,6 +59,9 @@ npx clearotron@beta install
59
59
  What each channel promises, and when a stable is cut: [docs/RELEASES.md](docs/RELEASES.md). If you are not
60
60
  sure, the first command is the one you want.
61
61
 
62
+ Removing it later: **[INSTALL.md §2a](INSTALL.md#2a-removing-it)** lists every path an install writes, and
63
+ says which one holds your reports so you can keep them deliberately.
64
+
62
65
  ## Quick start
63
66
 
64
67
  With it installed, check what it found before it does anything. `doctor` only reads — it writes nothing,
package/bin/onboard.mjs CHANGED
@@ -116,7 +116,7 @@ import { isEntrypoint } from "../shared/is-entrypoint.mjs"; // — one entry-p
116
116
  // one synopsis reader for every verb that prints one.
117
117
  import { usageBlock } from "../shared/usage-block.mjs";
118
118
  import { invoke } from "../shared/invocation.mjs"; // — name a command the reader can actually type
119
- import { parseEnvFile } from "../driver/systemd/render-units.mjs"; // — ONE KEY=value reader; a second copy would drift from what systemd actually reads
119
+ import { parseEnvFile } from "../shared/env-file-merge.mjs"; // — ONE KEY=value reader, taken from a leaf: the unit renderer re-exports this same function, and it is a COMMAND, so importing it from here would put a command in this binary's graph
120
120
  import { unitEnvironment, unitValue, couldNotDetermine } from "../driver/unit-environment.mjs"; // — F34: claim about the UNITS only from the units' own environment
121
121
 
122
122
  /**
package/bin/status.mjs CHANGED
@@ -15,7 +15,7 @@ import { join } from "node:path";
15
15
  import { homedir } from "node:os";
16
16
  import { execFileSync } from "node:child_process";
17
17
  import { BACKGROUND_UNITS, resolvePorts } from "./start.mjs";
18
- import { parseEnvFile } from "../driver/systemd/render-units.mjs"; // ONE KEY=value reader — what systemd actually reads
18
+ import { parseEnvFile } from "../shared/env-file-merge.mjs"; // ONE KEY=value reader — what systemd actually reads, taken from the leaf the unit renderer re-exports it from
19
19
  import { CLIENT_DOOR_UNIT, clientDoorPort } from "../shared/client-door.mjs";
20
20
  import { invoke } from "../shared/invocation.mjs";
21
21
  import { readRunning, probe } from "../shared/running-start.mjs";
package/build-info.json CHANGED
@@ -1,4 +1,4 @@
1
1
  {
2
- "commit": "8f26558701f6930799f7d8f724ab2ea91e302103",
3
- "version": "0.3.0-beta.9"
2
+ "commit": "cd86797b655d82bc021e2ada72cbacdc261174dc",
3
+ "version": "0.3.0"
4
4
  }
package/docs/RELEASES.md CHANGED
@@ -54,5 +54,5 @@ Downgrading is an ordinary install of the older version. Your configuration and
54
54
  by either — they live outside the package — so moving between channels is not a migration.
55
55
 
56
56
  Every version's notes are on the [releases page](https://github.com/CordilleraSarl/clearotron/releases)
57
- and in `CHANGELOG.md`, grouped New / Fixed / For operators. A beta's notes are the same notes; the stable
57
+ and in `CHANGELOG.md`, grouped Before you upgrade / New / Fixed / For operators. A beta's notes are the same notes; the stable
58
58
  that follows aggregates all of them into one entry.
@@ -1,5 +1,39 @@
1
1
  # clearotron-driver
2
2
 
3
+ ## 0.3.0
4
+
5
+ ### Minor Changes
6
+
7
+ - d2afa5e: Before you upgrade: Name every person who uses your installation in the file that lists who may sign in, or the portal will not start. That file is in your install folder.
8
+ - d2afa5e: New: Switch between companies from any screen, and set a new one up in the browser.
9
+ - d2afa5e: New: Add people from the portal, and give each the right to run clearances, to manage people and companies, or both.
10
+ - d2afa5e: New: Every register finding in a report links to the trade mark office's own page for that record.
11
+ - d2afa5e: New: A clearance records any territory it could not cover, so a partial search never reads as a complete one.
12
+ - d2afa5e: New: Connect Clearotron to your own AI assistant — Claude Code, Codex, or ChatGPT — on your computer or a shared server.
13
+ - d2afa5e: New: A saved search is available in every company, in the portal and in your AI assistant.
14
+ - d2afa5e: New: Check a risk framework for errors before it rates a clearance, and point a company at its own.
15
+ - d2afa5e: New: Group companies under an organisation, each with its own daily limit on clearances.
16
+ - d2afa5e: New: Clearotron installs to a fixed location, so the commands it prints and your AI assistant's connection keep working.
17
+ - d2afa5e: New: `clearotron update` moves an installation, beta included, to the current release.
18
+
19
+ ### Patch Changes
20
+
21
+ - d2afa5e: Fixed: You sign in, open your companies, and run your first clearance on a new installation.
22
+ - d2afa5e: Fixed: The demo reads and writes only its own companies, reports, and saved searches.
23
+ - d2afa5e: Fixed: `clearotron doctor` checks what a clearance needs, so an installation it clears can run one.
24
+ - d2afa5e: Fixed: A key you paste at a yes-or-no question stays off the screen and out of your command history.
25
+ - d2afa5e: Fixed: Clearotron stays signed in to a paid Codex plan when Codex renews the sign-in.
26
+ - d2afa5e: Fixed: The dashboard shows only the chosen company's clearances, and keeps the filters on screen.
27
+ - d2afa5e: Fixed: The Stop button ends a run before its report goes out, and its dialog says what stopping does.
28
+ - d2afa5e: Fixed: A knockout report names the filings behind each finding, and marks the reviewer's notes as reference.
29
+
30
+ ## 0.3.0-beta.10
31
+
32
+ ### Patch Changes
33
+
34
+ - fd3a4f7: Fixed: A portal address that does not exist now says so wherever it is. Addresses under the admin path used to show the Global config screen.
35
+ - fd3a4f7: New: The install guide now says how to remove Clearotron, naming every path it writes and which one holds your reports.
36
+
3
37
  ## 0.3.0-beta.9
4
38
 
5
39
  ### Patch Changes
@@ -2,7 +2,7 @@
2
2
  "name": "clearotron-driver",
3
3
  "private": true,
4
4
  "type": "module",
5
- "version": "0.3.0-beta.9",
5
+ "version": "0.3.0",
6
6
  "license": "AGPL-3.0-only",
7
7
  "description": "Deterministic driver for the trademark clearance workflow: orchestration in code (fan-out, fan-in barrier, gating, retries); the model does judgment leaves only, through a reasoning CLI spawned per stage.",
8
8
  "engines": {
@@ -647,7 +647,7 @@
647
647
  },
648
648
  "a-release-note-is-written-for-its-reader.test.mjs": {
649
649
  "tests": 9,
650
- "asserts": 63,
650
+ "asserts": 66,
651
651
  "skips": 0,
652
652
  "todos": 0
653
653
  },
@@ -1319,7 +1319,7 @@
1319
1319
  },
1320
1320
  "client-door.test.mjs": {
1321
1321
  "tests": 38,
1322
- "asserts": 161,
1322
+ "asserts": 162,
1323
1323
  "skips": 1,
1324
1324
  "todos": 0
1325
1325
  },
@@ -2799,6 +2799,12 @@
2799
2799
  "skips": 0,
2800
2800
  "todos": 0
2801
2801
  },
2802
+ "nothing-a-command-awaits-imports-it-back.test.mjs": {
2803
+ "tests": 16,
2804
+ "asserts": 49,
2805
+ "skips": 0,
2806
+ "todos": 0
2807
+ },
2802
2808
  "numeric-setting-refuses.test.mjs": {
2803
2809
  "tests": 15,
2804
2810
  "asserts": 35,
@@ -4235,7 +4241,7 @@
4235
4241
  },
4236
4242
  "start-refuses-on-a-box-that-has-the-services.test.mjs": {
4237
4243
  "tests": 6,
4238
- "asserts": 17,
4244
+ "asserts": 20,
4239
4245
  "skips": 0,
4240
4246
  "todos": 0
4241
4247
  },
@@ -4665,6 +4671,12 @@
4665
4671
  "skips": 0,
4666
4672
  "todos": 0
4667
4673
  },
4674
+ "the-install-guide-says-how-to-remove-it.test.mjs": {
4675
+ "tests": 4,
4676
+ "asserts": 11,
4677
+ "skips": 0,
4678
+ "todos": 0
4679
+ },
4668
4680
  "the-install-names-the-auth-values-it-needs.test.mjs": {
4669
4681
  "tests": 9,
4670
4682
  "asserts": 25,
@@ -4834,8 +4846,8 @@
4834
4846
  "todos": 0
4835
4847
  },
4836
4848
  "the-published-manifest-is-not-the-repos-manifest.test.mjs": {
4837
- "tests": 18,
4838
- "asserts": 65,
4849
+ "tests": 21,
4850
+ "asserts": 82,
4839
4851
  "skips": 1,
4840
4852
  "todos": 0
4841
4853
  },
@@ -5858,8 +5870,8 @@
5858
5870
  "todos": 0
5859
5871
  },
5860
5872
  "nav.test.ts": {
5861
- "tests": 12,
5862
- "asserts": 57,
5873
+ "tests": 13,
5874
+ "asserts": 67,
5863
5875
  "skips": 0,
5864
5876
  "todos": 0
5865
5877
  },
@@ -35,7 +35,7 @@
35
35
  // PURE. It opens no file and knows no path: the caller injects the unit text and the env-file text, so
36
36
  // every branch — including the ones a developer box cannot produce — is drivable from a test.
37
37
 
38
- import { parseEnvFile } from "./systemd/render-units.mjs";
38
+ import { parseEnvFile } from "../shared/env-file-merge.mjs"; // the leaf the unit renderer re-exports it from; this module stays free of the command
39
39
 
40
40
  /** systemd's own name for "load this file, and do not fail if it is missing". */
41
41
  const OPTIONAL = "-";
@@ -1,5 +1,13 @@
1
1
  # trademark-artifacts-mcp
2
2
 
3
+ ## 0.3.0
4
+
5
+ No changes in this release.
6
+
7
+ ## 0.3.0-beta.10
8
+
9
+ No changes in this release.
10
+
3
11
  ## 0.3.0-beta.9
4
12
 
5
13
  No changes in this release.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trademark-artifacts-mcp",
3
- "version": "0.3.0-beta.9",
3
+ "version": "0.3.0",
4
4
  "license": "AGPL-3.0-only",
5
5
  "private": true,
6
6
  "description": "MCP server to interrogate clearotron trademark-clearance runs — list/read artifacts, trace the full decision flow, telemetry/cost, coverage, single-run search, and a gated single-step what-if. Imports the clearotron-driver read-only; touches no driver/template/deploy files.",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "clearotron",
3
3
  "type": "module",
4
- "version": "0.3.0-beta.9",
4
+ "version": "0.3.0",
5
5
  "license": "AGPL-3.0-only",
6
6
  "repository": {
7
7
  "type": "git",
@@ -11008,7 +11008,8 @@ var NAV = [
11008
11008
  label: "Clearance",
11009
11009
  path: "/portal/result",
11010
11010
  icon: "layers",
11011
- hidden: true
11011
+ hidden: true,
11012
+ subtree: true
11012
11013
  },
11013
11014
  {
11014
11015
  id: "about",
@@ -11107,7 +11108,7 @@ function avatarMenuFor(who, entries = NAV) {
11107
11108
  }
11108
11109
  function screenForPath(path, who, entries = NAV) {
11109
11110
  const clean = path.replace(/[?#].*$/, "").replace(/\/+$/, "") || "/portal";
11110
- const hits = flatten(routableFor(who, entries)).filter((e) => clean === e.path || clean.startsWith(e.path + "/"));
11111
+ const hits = flatten(routableFor(who, entries)).filter((e) => clean === e.path || e.subtree === true && clean.startsWith(e.path + "/"));
11111
11112
  if (!hits.length) return null;
11112
11113
  return hits.reduce((a, b) => b.path.length > a.path.length ? b : a);
11113
11114
  }
@@ -49,7 +49,7 @@
49
49
  -->
50
50
  <link rel="preconnect" href="https://api.fontshare.com" crossorigin />
51
51
  <link href="https://api.fontshare.com/v2/css?f[]=satoshi@400,500,700,900&display=swap" rel="stylesheet" />
52
- <script type="module" crossorigin src="/portal/assets/index-qes7gLpM.js"></script>
52
+ <script type="module" crossorigin src="/portal/assets/index-CwPAS0we.js"></script>
53
53
  <link rel="stylesheet" crossorigin href="/portal/assets/index-Cv-E_agg.css">
54
54
  </head>
55
55
  <body>
@@ -2,7 +2,7 @@
2
2
  "name": "portal-ui",
3
3
  "private": true,
4
4
  "type": "module",
5
- "version": "0.3.0-beta.9",
5
+ "version": "0.3.0",
6
6
  "license": "AGPL-3.0-only",
7
7
  "description": "The unified trademark portal UI. One address, one login: who you are decides what you see. Built as a static bundle, served by driver/portal-service.mjs — the browser never reaches profile-service or recipe-service.",
8
8
  "engines": {
@@ -1,5 +1,13 @@
1
1
  # trademark-oauth-mcp-bridge
2
2
 
3
+ ## 0.3.0
4
+
5
+ No changes in this release.
6
+
7
+ ## 0.3.0-beta.10
8
+
9
+ No changes in this release.
10
+
3
11
  ## 0.3.0-beta.9
4
12
 
5
13
  No changes in this release.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trademark-oauth-mcp-bridge",
3
- "version": "0.3.0-beta.9",
3
+ "version": "0.3.0",
4
4
  "license": "AGPL-3.0-only",
5
5
  "private": true,
6
6
  "description": "OAuth 2.1 MCP stdio bridge used by the engine's case-law gather stage (courtlistener / legaldatahunter).",
@@ -0,0 +1,468 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright 2026 Cordillera Sàrl. Additional terms under section 7 of the AGPL-3.0 apply — see ADDITIONAL-TERMS.md
3
+ //
4
+ // import-cycle-check.mjs — nothing a command awaits may import that command back.
5
+ //
6
+ // THE FAILURE THIS EXISTS FOR, AND WHY NOTHING ELSE CATCHES IT.
7
+ //
8
+ // A module can be both a module and a command. When such a module carries a TOP-LEVEL await, anything it
9
+ // reaches while that await is still settling must not import it back, because the import asks for a module
10
+ // that is mid-evaluation. The request never resolves. Node prints "Detected unsettled top-level await" and
11
+ // names the line of the await — not the import that closed the loop, and not the file that added it.
12
+ //
13
+ // Measured 2026-09-12: one static `import { parseEnvFile } from "../driver/systemd/render-units.mjs"` in
14
+ // `bin/start.mjs` stopped 21 install and unit-placement arms at once. `render-units.mjs --apply` IS the
15
+ // documented server install, so the defect refused to install rather than misbehaving quietly. Lint, the
16
+ // pattern guards, the portal build and the packaged-bytes guard all passed around it; only arms that run
17
+ // the command for real caught it, on the third push.
18
+ //
19
+ // The same shape is already in the tree once: `bin/onboard.mjs` carries a top-level `await runCli()` and
20
+ // reaches `bin/start.mjs` from inside it, so a static import of onboard from start takes `doctor` down.
21
+ // That one had a bespoke arm naming one file pair. This check holds the property for the whole class.
22
+ //
23
+ // WHAT COUNTS AS AN EDGE, AND WHY THE OBVIOUS RULE IS WRONG.
24
+ //
25
+ // A static import always counts: importing a module evaluates it. A DYNAMIC import counts only where it is
26
+ // awaited on the top-level await's own call path — `writeInstallEnv`'s imports are on it; the register
27
+ // table at `bin/start.mjs` and the renderer it fetches when placing units are not, because a dynamic
28
+ // import inside a function closes no load-time loop and both are deliberate.
29
+ //
30
+ // A rule of "no command is imported by a binary" would refuse correct call sites. Following every dynamic
31
+ // import regardless of where it sits reports cycles that do not exist and would demand the repair that
32
+ // CAUSES this bug. Neither is the property. The property is the sentence at the top of this file.
33
+
34
+ import { readFileSync, readdirSync, statSync } from "node:fs";
35
+ import { join, dirname, normalize, sep } from "node:path";
36
+ import { fileURLToPath } from "node:url";
37
+
38
+ const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
39
+ const ROOTS = ["bin", "driver", "shared", "scripts"];
40
+ const SKIP = new Set(["node_modules", ".git", "dist", "coverage"]);
41
+
42
+ /**
43
+ * Entry points that carry a top-level await must number at least this many, or the scanner has stopped
44
+ * recognising its subject. A FLOOR ON THE POPULATION, not on the matches: an empty class reads exactly
45
+ * like a clean one, and this class is invisible failures by definition.
46
+ *
47
+ * SET JUST UNDER WHAT THE TREE CARRIES, which is 18. A floor far below the real count is not a floor:
48
+ * an earlier draft read brace depth per line, and a floor of 12 would have passed that regression
49
+ * without a word (found in review).
50
+ *
51
+ * IT READ 22 BEFORE, AND THAT NUMBER WAS WRONG — do not restore it. A function whose signature carries
52
+ * an object-literal default, `async function f(ctx, opts = {}) {`, had its body read as module scope,
53
+ * so ordinary awaits inside four commands counted as top-level. Each departure was checked rather than
54
+ * assumed: the awaits in `connect`, `brandowner`, `drain-preflight` and `pool-admin` all sit inside
55
+ * named functions, and the command `pipeline.mjs` — which this check briefly accused of a cycle — exits
56
+ * on a usage message rather than hanging. The two that must never leave are here: the unit renderer
57
+ * (`if (APPLY) … await …`) and the wizard (`await runCli()` in a module-scope else).
58
+ */
59
+ export const POPULATION_FLOOR = 16;
60
+
61
+ /** Reached during a top-level await and absent from every live tree. Named, never skipped in silence:
62
+ * `cut/` is withheld from the public repository, and `cut-archive` is deliberately not overlaid either,
63
+ * so this path resolves nowhere this check will ever run. An absence nobody declared is a could-not-look. */
64
+ export const KNOWN_ABSENT = Object.freeze(["cut/packed-artifact.mjs"]);
65
+
66
+ /**
67
+ * Comments, string bodies and regex literals removed, line count preserved, so what remains is code.
68
+ *
69
+ * ONE LEFT-TO-RIGHT PASS, NOT A STACK OF REPLACEMENTS. This was five independent regexes, and their
70
+ * order is what broke it: a `//` inside a string is eaten as a comment, the quote that followed then
71
+ * pairs with the wrong one, and from there the file is read out of phase. The symptom was this very
72
+ * scanner reporting ITSELF as awaiting at the top level, off a template in its own error output — the
73
+ * word `await` inside a template whose opening backtick had been swallowed upstream (found in review by
74
+ * printing the lines it claimed, rather than reasoning about which construct was to blame).
75
+ *
76
+ * Regex literals are blanked too: `/await\s+.../` carries the token `await` between two non-word
77
+ * characters, and no boundary test on the word alone can tell that from code.
78
+ */
79
+ export function blank(src) {
80
+ const text = String(src ?? "");
81
+ const out = new Array(text.length);
82
+ // Where a `/` starts a regex rather than divides: after an operator, a comma, an opening bracket, or
83
+ // nothing at all. Division follows a value — an identifier, a number, or a closing bracket.
84
+ const regexCanStart = (prev) => prev === "" || "([{,;:=!&|?+-*%~^<>".includes(prev) || /\breturn|typeof|of|in|case\b/.test(prev);
85
+ let i = 0, lastSignificant = "";
86
+ const keep = (n) => { for (let k = 0; k < n; k++, i++) out[i] = text[i]; };
87
+ const hide = (n, ch) => { for (let k = 0; k < n; k++, i++) out[i] = text[i] === "\n" ? "\n" : ch; };
88
+ while (i < text.length) {
89
+ const c = text[i], next = text[i + 1];
90
+ if (c === "/" && next === "/") { let j = i; while (j < text.length && text[j] !== "\n") j++; hide(j - i, " "); continue; }
91
+ if (c === "/" && next === "*") { let j = i + 2; while (j < text.length && !(text[j] === "*" && text[j + 1] === "/")) j++; hide(Math.min(j + 2, text.length) - i, " "); continue; }
92
+ if (c === '"' || c === "'") {
93
+ let j = i + 1; while (j < text.length && text[j] !== c) { if (text[j] === "\\") j++; j++; }
94
+ hide(Math.min(j + 1, text.length) - i, "_"); lastSignificant = "x"; continue;
95
+ }
96
+ if (c === "`") {
97
+ // A template ends at its own backtick; `${ … }` may hold code, and this blanks that too — nothing
98
+ // inside a template can open a load-time import, so losing it costs this check nothing.
99
+ let j = i + 1, depth = 0;
100
+ while (j < text.length) {
101
+ if (text[j] === "\\") { j += 2; continue; }
102
+ if (text[j] === "$" && text[j + 1] === "{") { depth++; j += 2; continue; }
103
+ if (text[j] === "}" && depth) { depth--; j++; continue; }
104
+ if (text[j] === "`" && !depth) break;
105
+ j++;
106
+ }
107
+ hide(Math.min(j + 1, text.length) - i, "_"); lastSignificant = "x"; continue;
108
+ }
109
+ if (c === "/" && regexCanStart(lastSignificant)) {
110
+ let j = i + 1, inClass = false, closed = false;
111
+ while (j < text.length && text[j] !== "\n") {
112
+ if (text[j] === "\\") { j += 2; continue; }
113
+ if (text[j] === "[") inClass = true;
114
+ else if (text[j] === "]") inClass = false;
115
+ else if (text[j] === "/" && !inClass) { closed = true; break; }
116
+ j++;
117
+ }
118
+ if (closed) { hide(j + 1 - i, "_"); lastSignificant = "x"; continue; }
119
+ }
120
+ if (!/\s/.test(c)) lastSignificant = c;
121
+ keep(1);
122
+ }
123
+ return out.join("");
124
+ }
125
+
126
+ /**
127
+ * Lines carrying an await with NO function-opened brace around them.
128
+ *
129
+ * Depth alone is not the test. `bin/onboard.mjs` awaits inside a module-scope `if`/`else`, which is a
130
+ * genuine top-level await at brace depth one, and a depth-zero scan would miss the module that proves this
131
+ * class exists. So each open brace remembers whether the line that opened it looked like a function.
132
+ */
133
+ /** Blocks that are not functions, so an `await` inside one is still the module's own. */
134
+ const CONTROL = new Set(["if", "for", "while", "switch", "catch", "do", "with", "else", "try", "finally"]);
135
+
136
+ /**
137
+ * Did a function open this brace? Read structurally, never by looking back over text.
138
+ *
139
+ * A TEXT WINDOW GETS THIS WRONG ON THE COMMONEST IDIOM IN THIS TREE. The first version split the
140
+ * preceding text on `[;{}]` and asked whether the last piece said `function` — and
141
+ * `async function runDigest(ctx, opts = {}) {` contains a brace pair IN ITS PARAMETER LIST, so the
142
+ * window reset and the body brace saw only `) `. Every function with an object-literal default had its
143
+ * body read as module scope, and `driver/pipeline.mjs` reported 84 top-level awaits that are ordinary
144
+ * awaits inside functions — a cycle the check then claimed on a tree that runs perfectly (found by
145
+ * driving the command it named: it exits 2 on a usage message, not 13 on a hang).
146
+ *
147
+ * So: step over the parameter list by matching parentheses, then read the name in front of it.
148
+ */
149
+ export function opensFunction(text, at) {
150
+ let j = at - 1;
151
+ const skipSpace = () => { while (j >= 0 && /\s/.test(text[j])) j--; };
152
+ skipSpace();
153
+ if (j >= 1 && text[j] === ">" && text[j - 1] === "=") return true; // `=> {`
154
+ if (j >= 0 && text[j] === ")") {
155
+ let depth = 0;
156
+ for (; j >= 0; j--) {
157
+ if (text[j] === ")") depth++;
158
+ else if (text[j] === "(") { depth--; if (!depth) break; }
159
+ }
160
+ j--; skipSpace();
161
+ let end = j;
162
+ while (j >= 0 && /[\w$]/.test(text[j])) j--;
163
+ const name = text.slice(j + 1, end + 1);
164
+ return !CONTROL.has(name); // `f(…) {` but not `if (…) {`
165
+ }
166
+ let k = j, word = "";
167
+ while (k >= 0 && /[\w$]/.test(text[k])) { word = text[k] + word; k--; }
168
+ return word === "class" || (word !== "" && !CONTROL.has(word) && /\bclass\b/.test(text.slice(Math.max(0, k - 20), k + 1)));
169
+ }
170
+
171
+ export function topLevelAwaitLines(src) {
172
+ const text = blank(src);
173
+ const opened = [];
174
+ const hits = new Set();
175
+ let line = 0;
176
+ for (let i = 0; i < text.length; i++) {
177
+ const ch = text[i];
178
+ if (ch === "\n") { line++; continue; }
179
+ if (ch === "{") { opened.push(opensFunction(text, i)); continue; }
180
+ if (ch === "}") { opened.pop(); continue; }
181
+ // THE TOKEN, NOT THE WORD. `blank()` removes comments and string bodies but not regex literals, and
182
+ // this very file carries `/await\s+([A-Za-z_$][\w$]*)\s*\(/` — which has `await` between two
183
+ // non-word characters, so a boundary test alone counted this scanner as awaiting at its own top
184
+ // level (found in review by reading the population it reported). A real `await` is followed by
185
+ // whitespace or an open parenthesis; inside that regex it is followed by a backslash.
186
+ if (ch === "a" && text.startsWith("await", i) && !/[\w$/\\]/.test(text[i - 1] ?? " ") && /[\s(]/.test(text[i + 5] ?? "")) {
187
+ if (!opened.some(Boolean)) hits.add(line);
188
+ }
189
+ }
190
+ return [...hits];
191
+ }
192
+
193
+ /** The whole argument expression of every `import(...)`, parentheses counted rather than split on a comma. */
194
+ export function importArguments(text) {
195
+ const src = String(text ?? "");
196
+ const out = [];
197
+ for (const m of src.matchAll(/\bimport\s*\(/g)) {
198
+ const open = m.index + m[0].length - 1;
199
+ let depth = 0;
200
+ for (let j = open; j < src.length; j++) {
201
+ const c = src[j];
202
+ if (c === "(") depth++;
203
+ else if (c === ")") { depth--; if (!depth) { out.push(src.slice(open + 1, j)); break; } }
204
+ }
205
+ }
206
+ return out;
207
+ }
208
+
209
+ /**
210
+ * The literal chunks of a specifier, whatever shape it is written in.
211
+ *
212
+ * THREE SHAPES, AND A READER THAT KNOWS ONLY THE FIRST IS BLIND TO THE CASE THAT BIT US. `bin/onboard.mjs`
213
+ * reaches `bin/start.mjs` through `pathToFileURL(join(REPO, "bin", "start.mjs")).href` — computed, not
214
+ * quoted. A literal-only scan finds nothing there and reports a complete walk. An argument yielding no
215
+ * fragment at all is neither safe nor a specifier this check understood: it is reported, not assumed.
216
+ */
217
+ export function fragmentsOf(arg) {
218
+ const src = String(arg ?? "");
219
+ const out = [];
220
+ for (const m of src.matchAll(/["']([^"']+)["']/g)) out.push(m[1]);
221
+ for (const m of src.matchAll(/`([^`]*)`/g))
222
+ for (const chunk of m[1].split(/\$\{[^}]*\}/)) if (chunk.trim()) out.push(chunk);
223
+ return out;
224
+ }
225
+
226
+ /** A repo-relative module path, or null when the fragments do not spell one (a bare package, `node:`, a
227
+ * data URL). Only paths this repository owns can close a cycle inside it. */
228
+ export function resolveSpecifier(fragments, from) {
229
+ if (!fragments.length) return null;
230
+ const joined = fragments.join("/").replace(/\/+/g, "/").split("?")[0];
231
+ if (/^node:/.test(joined)) return null;
232
+ if (!/\.(mjs|js|cjs)$/.test(joined)) return null;
233
+ const p = joined.startsWith(".") ? join(dirname(from), joined) : joined.replace(/^\//, "");
234
+ return normalize(p).split(sep).join("/");
235
+ }
236
+
237
+ /**
238
+ * Every specifier that is evaluated when this module is imported.
239
+ *
240
+ * TWO FORMS, AND READING ONLY THE FIRST MAKES THIS CHECK BLIND TO THE THING IT IS FOR. `import x from "y"`
241
+ * and `export { a } from "y"` carry `from`; a SIDE-EFFECT import — `import "y";` — does not, and it
242
+ * evaluates the module just the same. Four binaries in this tree open with one. Found in review by
243
+ * planting `import "…/render-units.mjs";` into the leaf that renderer loads from inside its top-level
244
+ * await: this check reported nothing and exited 0 while the command itself exited 13 on the unsettled
245
+ * await. Same failure, same pair, silently passed.
246
+ *
247
+ * A dynamic `import("y")` is not matched here and must not be: the parenthesis is what distinguishes it,
248
+ * and it is an edge only where the await reaches it.
249
+ */
250
+ export function staticSpecifiers(src) {
251
+ const text = String(src ?? "");
252
+ const out = [];
253
+ for (const m of text.matchAll(/(?:^|\n)\s*(?:import|export)\s[^;]*?from\s*["']([^"']+)["']/g)) out.push(m[1]);
254
+ for (const m of text.matchAll(/(?:^|\n)\s*import\s*["']([^"']+)["']/g)) out.push(m[1]);
255
+ return [...new Set(out)];
256
+ }
257
+
258
+ /** The body of a named async function or arrow, by brace matching. */
259
+ export function bodyOf(src, name) {
260
+ const text = String(src ?? "");
261
+ const re = new RegExp(`(?:async\\s+function\\s+${name}\\s*\\(|(?:const|let|var)\\s+${name}\\s*=\\s*async\\s*(?:\\([^)]*\\)|[A-Za-z_$][\\w$]*)\\s*=>)`, "m");
262
+ const m = re.exec(text);
263
+ if (!m) return null;
264
+ const open = text.indexOf("{", m.index);
265
+ if (open < 0) return null;
266
+ let depth = 0;
267
+ for (let j = open; j < text.length; j++) {
268
+ const c = text[j];
269
+ if (c === "{") depth++;
270
+ else if (c === "}") { depth--; if (!depth) return text.slice(open, j + 1); }
271
+ }
272
+ return null;
273
+ }
274
+
275
+ const awaitedCalls = (text) =>
276
+ [...String(text ?? "").matchAll(/await\s+([A-Za-z_$][\w$]*)\s*\(/g)].map((m) => m[1]).filter((n) => n !== "import");
277
+
278
+ /**
279
+ * The reader, and the two answers it must keep apart.
280
+ *
281
+ * A FILE THAT IS NOT THERE IS A FACT; A FILE THAT WOULD NOT READ IS A COULD-NOT-LOOK. One `catch` returning
282
+ * nothing collapses them, and then a file too large for the read buffer is filed as absent and quietly left
283
+ * out of the walk — which is what happened while this check was being built, over a module that exists.
284
+ */
285
+ export function makeReader(read = readFileSync) {
286
+ const missing = [];
287
+ const unreadable = [];
288
+ const cache = new Map();
289
+ const readSource = (rel) => {
290
+ if (cache.has(rel)) return cache.get(rel);
291
+ let out = null;
292
+ try {
293
+ out = read(join(ROOT, rel), "utf8");
294
+ } catch (e) {
295
+ if (e?.code === "ENOENT" || e?.code === "ENOTDIR") missing.push(rel);
296
+ else unreadable.push(`${rel} (${e?.code ?? e?.message ?? "unknown"})`);
297
+ }
298
+ cache.set(rel, out);
299
+ return out;
300
+ };
301
+ return { readSource, missing, unreadable };
302
+ }
303
+
304
+ /** Every `.mjs` under the roots this repository owns. */
305
+ export function moduleFiles(root = ROOT, roots = ROOTS, list = readdirSync, stat = statSync) {
306
+ const out = [];
307
+ const walk = (rel) => {
308
+ let entries;
309
+ try { entries = list(join(root, rel), { withFileTypes: true }); } catch { return; }
310
+ for (const e of entries) {
311
+ if (SKIP.has(e.name)) continue;
312
+ const child = rel ? `${rel}/${e.name}` : e.name;
313
+ const isDir = e.isDirectory?.() ?? stat(join(root, child)).isDirectory();
314
+ if (isDir) walk(child);
315
+ else if (e.name.endsWith(".mjs")) out.push(child);
316
+ }
317
+ };
318
+ for (const r of roots) walk(r);
319
+ return out.sort();
320
+ }
321
+
322
+ /** A module that decides whether it was run rather than imported. */
323
+ export const isEntryPointSource = (src) => /isEntrypoint\(|import\.meta\.url === /.test(String(src ?? ""));
324
+
325
+ /**
326
+ * What an entry point reaches WHILE its top-level await is settling.
327
+ *
328
+ * Seeds are the calls awaited at the top level and any `import()` written there. From each seed we take the
329
+ * function of that name in the same file and follow what it awaits, to a fixed point — the call path, not
330
+ * the whole module.
331
+ */
332
+ export function reachedDuringTopLevelAwait(entry, src) {
333
+ const seeds = new Set();
334
+ const args = [];
335
+ const lines = String(src ?? "").split("\n");
336
+ for (const i of topLevelAwaitLines(src)) {
337
+ for (const c of awaitedCalls(lines[i])) seeds.add(c);
338
+ for (const a of importArguments(lines[i])) args.push(a);
339
+ }
340
+ const walked = new Set();
341
+ const queue = [...seeds];
342
+ while (queue.length) {
343
+ const name = queue.shift();
344
+ if (walked.has(name)) continue;
345
+ walked.add(name);
346
+ const body = bodyOf(src, name);
347
+ if (!body) continue;
348
+ for (const a of importArguments(body)) args.push(a);
349
+ for (const c of awaitedCalls(body)) if (!walked.has(c)) queue.push(c);
350
+ }
351
+ const modules = new Set();
352
+ const unresolved = [];
353
+ for (const a of args) {
354
+ const frags = fragmentsOf(a);
355
+ if (!frags.length) { unresolved.push(a.trim().replace(/\s+/g, " ").slice(0, 70)); continue; }
356
+ const r = resolveSpecifier(frags, entry);
357
+ if (r) modules.add(r);
358
+ }
359
+ return { modules, unresolved, awaited: [...seeds] };
360
+ }
361
+
362
+ /** Everything evaluated when `start` is imported: itself, and the static closure beneath it. */
363
+ export function staticClosureOf(start, readSource) {
364
+ const seen = new Set();
365
+ const queue = [start];
366
+ while (queue.length) {
367
+ const p = queue.shift();
368
+ if (seen.has(p)) continue;
369
+ seen.add(p);
370
+ const src = readSource(p);
371
+ if (!src) continue;
372
+ for (const spec of staticSpecifiers(src)) {
373
+ if (!spec.startsWith(".")) continue;
374
+ queue.push(normalize(join(dirname(p), spec)).split(sep).join("/"));
375
+ }
376
+ }
377
+ return seen;
378
+ }
379
+
380
+ /** The whole check, as data. Callers decide what to print and what to exit. */
381
+ export function scan({ read = readFileSync, list = readdirSync } = {}) {
382
+ const { readSource, missing, unreadable } = makeReader(read);
383
+ const files = moduleFiles(ROOT, ROOTS, list);
384
+ const entries = files.filter((f) => isEntryPointSource(readSource(f)));
385
+ const withTopLevelAwait = [];
386
+ const violations = [];
387
+ const unresolved = [];
388
+ for (const entry of entries) {
389
+ const src = readSource(entry);
390
+ if (!src) continue;
391
+ const reached = reachedDuringTopLevelAwait(entry, src);
392
+ if (!topLevelAwaitLines(src).length) continue;
393
+ withTopLevelAwait.push(entry);
394
+ for (const u of reached.unresolved) unresolved.push(`${entry}: ${u}`);
395
+ for (const mod of reached.modules) {
396
+ for (const inClosure of staticClosureOf(mod, readSource)) {
397
+ const src2 = readSource(inClosure);
398
+ if (!src2) continue;
399
+ for (const spec of staticSpecifiers(src2)) {
400
+ if (!spec.startsWith(".")) continue;
401
+ const target = normalize(join(dirname(inClosure), spec)).split(sep).join("/");
402
+ if (target === entry) violations.push({ entry, importer: inClosure, reached: mod });
403
+ }
404
+ }
405
+ }
406
+ }
407
+ const undeclaredAbsent = [...new Set(missing)].filter((m) => !KNOWN_ABSENT.includes(m));
408
+ return { scanned: files.length, entries, withTopLevelAwait, violations, unresolved,
409
+ missing: [...new Set(missing)], undeclaredAbsent, unreadable: [...new Set(unreadable)] };
410
+ }
411
+
412
+ if (import.meta.url === `file://${process.argv[1]}` || (process.argv[1] ?? "").endsWith("import-cycle-check.mjs")) {
413
+ let r;
414
+ try { r = scan(); }
415
+ catch (e) {
416
+ console.error(`import-cycle-check: could not read the tree (${e?.code ?? e?.message}). Nothing was checked.`);
417
+ process.exit(2);
418
+ }
419
+
420
+ console.log(`import-cycle-check: ${r.scanned} modules, ${r.entries.length} of them commands, `
421
+ + `${r.withTopLevelAwait.length} of those awaiting at the top level.`);
422
+
423
+ // COULD NOT LOOK — never a pass, and three separate ways to get there.
424
+ if (r.withTopLevelAwait.length < POPULATION_FLOOR) {
425
+ console.error(`import-cycle-check: only ${r.withTopLevelAwait.length} commands await at the top level, `
426
+ + `and ${POPULATION_FLOOR} is the floor. The scanner has stopped recognising its subject — read it `
427
+ + "before trusting this run.");
428
+ process.exit(2);
429
+ }
430
+ if (r.unreadable.length) {
431
+ console.error("import-cycle-check: these files would not read, so the walk below is incomplete:");
432
+ for (const u of r.unreadable) console.error(` ${u}`);
433
+ process.exit(2);
434
+ }
435
+ if (r.undeclaredAbsent.length) {
436
+ console.error("import-cycle-check: these modules are reached during a top-level await and are not in the "
437
+ + "tree, and nothing here declares them absent:");
438
+ for (const m of r.undeclaredAbsent) console.error(` ${m}`);
439
+ console.error("\nAdd it to KNOWN_ABSENT with the reason, or fix the path. An absence nobody declared is "
440
+ + "a module this check silently stopped following.");
441
+ process.exit(2);
442
+ }
443
+ if (r.unresolved.length) {
444
+ console.error("import-cycle-check: these dynamic imports have a specifier this check could not read, so "
445
+ + "what they reach is unknown:");
446
+ for (const u of r.unresolved) console.error(` ${u}`);
447
+ process.exit(2);
448
+ }
449
+
450
+ if (!r.violations.length) {
451
+ console.log("import-cycle-check: nothing any of them awaits imports it back.");
452
+ process.exit(0);
453
+ }
454
+
455
+ console.error("");
456
+ console.error("These commands are imported back by something they reach while their top-level await is settling:");
457
+ for (const v of r.violations) console.error(` ${v.entry} <= ${v.importer} (reached via ${v.reached})`);
458
+ console.error("");
459
+ console.error("Run as a command, each of these hangs: the import asks for a module that is still being");
460
+ console.error("evaluated, so it never resolves. Node reports an unsettled top-level await naming the await,");
461
+ console.error("not this import, and the command installs or runs nothing.");
462
+ console.error("");
463
+ console.error("The repair is to move what the importer wanted into a module that imports nothing, and to");
464
+ console.error("re-export it from the command if its other readers should keep one spelling. Making the");
465
+ console.error("import dynamic inside a function also closes the loop, but it leaves a command in a");
466
+ console.error("binary's graph for the next reader to tidy back up.");
467
+ process.exit(1);
468
+ }
@@ -73,6 +73,30 @@ import { isEntrypoint } from "../shared/is-entrypoint.mjs";
73
73
  * malformed tarball are deliberately absent: those ARE about the bytes, and calling them could-not-look
74
74
  * would let a genuinely broken package publish.
75
75
  */
76
+ /**
77
+ * Did npm say anything of its own, or is this only the runner reporting a non-zero exit?
78
+ *
79
+ * `execFileSync` composes "Command failed: <the command>" and appends whatever the child wrote. With npm
80
+ * silenced — `npm run -s` and `npm --silent` export `npm_config_loglevel=silent` to every child — npm
81
+ * writes nothing at all, and that first line is the whole of it. Every judgement below reads npm's words,
82
+ * so with none to read there is nothing to judge: not a refusal, not a clearance, an absence.
83
+ *
84
+ * The test is deliberately crude and errs towards SPOKE: any content beyond the command echo counts, so a
85
+ * message this function has never seen is treated as npm talking rather than as silence. The direction
86
+ * matters — reading silence as a refusal publishes a verdict nobody made, and that is the defect this
87
+ * exists for; reading speech as silence only costs a re-run. PURE.
88
+ */
89
+ export function npmSpoke(said) {
90
+ const text = String(said ?? "").trim();
91
+ if (!text) return false;
92
+ const rest = text
93
+ .split("\n")
94
+ .filter((l) => l.trim() && !/^Command failed:/i.test(l.trim()))
95
+ .join("")
96
+ .trim();
97
+ return rest.length > 0;
98
+ }
99
+
76
100
  export function looksLikeCouldNotLook(said) {
77
101
  return /\b(ENOTCACHED|ENOTFOUND|EAI_AGAIN|ETIMEDOUT|ECONNRESET|ECONNREFUSED|ENETUNREACH|EAGAIN|ENOSPC|ENOENT|ERR_SOCKET_TIMEOUT)\b/i
78
102
  .test(String(said ?? ""))
@@ -116,10 +140,28 @@ export function installsAsADependency(tarballPath, { keep = false, timeoutMs = 9
116
140
  `${JSON.stringify({ name: "clearotron-install-check-consumer", version: "1.0.0", private: true }, null, 2)}\n`);
117
141
 
118
142
  try {
119
- execFileSync("npm", ["install", abs, "--no-audit", "--no-fund"],
120
- { cwd: consumer, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: timeoutMs });
143
+ // NPM IS TOLD TO SPEAK, RATHER THAN INHERITING WHETHER IT MAY. This check reads npm's own words —
144
+ // it is the only thing that tells a reader WHY an install failed — and `npm run -s`, which is how a
145
+ // suite or a release script is often started, exports `npm_config_loglevel=silent` to everything
146
+ // beneath it. Silenced, npm exits non-zero and prints nothing, and this file then reports a refusal
147
+ // it could not read as a statement about the artefact. Measured 2026-09-12: the same three tests
148
+ // red under `npm run -s` and green without it, on the same tree, in three seconds either way.
149
+ execFileSync("npm", ["install", abs, "--no-audit", "--no-fund", "--loglevel", "error"],
150
+ { cwd: consumer, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: timeoutMs,
151
+ env: { ...process.env, npm_config_loglevel: "error" } });
121
152
  } catch (e) {
122
153
  const said = `${e?.stderr ?? ""}`.trim() || `${e?.stdout ?? ""}`.trim() || `${e?.message ?? e}`;
154
+ // AN ANSWER NOBODY CAN READ IS NOT A VERDICT ON THE BYTES. When npm says nothing of its own — both
155
+ // streams empty, so `said` is the runner's "Command failed: npm install …" and nothing else — this
156
+ // file used to call it a refusal and exit 1, which is a statement that the artefact is broken made
157
+ // out of an absence. It is a could-not-look: the install did not get far enough to answer.
158
+ if (!npmSpoke(said)) {
159
+ return { ok: false, couldNotLook: true, missingBins: [], installed: null,
160
+ why: "npm exited non-zero and said nothing of its own, so there is no refusal to read:\n\n"
161
+ + `${said}\n\nThis says nothing about the artefact. Run the check again with npm allowed to `
162
+ + "speak — `npm run -s` and `npm --silent` pass `npm_config_loglevel=silent` to everything "
163
+ + "beneath them — and it will either refuse with npm's reason or clear these bytes." };
164
+ }
123
165
  if (looksLikeCouldNotLook(said)) {
124
166
  return { ok: false, couldNotLook: true, missingBins: [], installed: null,
125
167
  why: `npm could not complete an install here for a reason that is not about these bytes:\n\n${said}` };
@@ -176,6 +218,13 @@ function main() {
176
218
  process.exit(2);
177
219
  }
178
220
 
221
+ // ── THE ORDER OF THESE TWO BLOCKS IS THE CORRECTNESS, NOT EITHER CONDITION ──────────────────────────
222
+ //
223
+ // Every could-not-look return also carries `ok: false`, so both blocks match the same result and the
224
+ // first one wins. Tested in this order, an absence exits 2 and never reaches the refusal below. Swapped,
225
+ // it exits 1 — a statement that somebody's package is broken, made out of an answer nobody could read —
226
+ // and nothing in either block looks wrong on its own. What holds it is the arm that drives this command
227
+ // and asserts the code, whose message names the consequence rather than the number.
179
228
  if (r.couldNotLook) {
180
229
  console.error(` COULD NOT LOOK (exit 2): ${r.why}\n`);
181
230
  console.error(" This says nothing about the artefact. It has not been cleared and it has not been "
@@ -26,7 +26,7 @@ import { lineFindings, sourceDirectories, userDocs } from "./plain-language-rule
26
26
  const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
27
27
 
28
28
  /** The three groups a note belongs to on the page. User-facing first, as the contract orders them. */
29
- export const GROUPS = ["New", "Fixed", "For operators"];
29
+ export const GROUPS = ["Before you upgrade", "New", "Fixed", "For operators"];
30
30
 
31
31
  /** At most this many words in a sentence. The contract's number, not a tuned one. */
32
32
  export const MAX_WORDS = 25;
@@ -155,7 +155,7 @@ export function findings(text, {
155
155
 
156
156
  // The group, which is how the page is ordered. Not part of the owner's contract text — it is the
157
157
  // mechanism that delivers its rule 5 — so it is checked first and named as itself.
158
- const group = /^(New|Fixed|For operators):\s/.exec(body)?.[1];
158
+ const group = /^(Before you upgrade|New|Fixed|For operators):\s/.exec(body)?.[1];
159
159
  if (!group) {
160
160
  out.push({
161
161
  line: bodyStart + 1,
@@ -30,7 +30,7 @@ import { isEntrypoint } from "../shared/is-entrypoint.mjs";
30
30
  const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
31
31
  const GROUP = ["driver", "mcp-server", "portal-ui", "providers/oauth-mcp-bridge"];
32
32
  /** The page's order, user-facing first, from the owner's contract. */
33
- export const GROUPS = ["New", "Fixed", "For operators"];
33
+ export const GROUPS = ["Before you upgrade", "New", "Fixed", "For operators"];
34
34
  const readJson = (p) => JSON.parse(readFileSync(p, "utf8"));
35
35
 
36
36
  /** PURE. Where `version` is already out: tagged in this checkout, published on the registry, both, or neither. */
@@ -126,7 +126,7 @@ export function notesOf(section) {
126
126
  if (!lines) return;
127
127
  const text = lines.join(" ");
128
128
  lines = null;
129
- const own = /^(New|Fixed|For operators):\s/.exec(text)?.[1];
129
+ const own = /^(Before you upgrade|New|Fixed|For operators):\s/.exec(text)?.[1];
130
130
  if (own) current = own;
131
131
  note.push(own || !current ? text : `${current}: ${text}`);
132
132
  };
@@ -177,7 +177,7 @@ export function group(bullets) {
177
177
  const groups = Object.fromEntries(GROUPS.map((g) => [g, []]));
178
178
  const ungrouped = [];
179
179
  for (const b of bullets) {
180
- const m = /^(New|Fixed|For operators):\s+(.*)$/s.exec(b);
180
+ const m = /^(Before you upgrade|New|Fixed|For operators):\s+(.*)$/s.exec(b);
181
181
  if (!m) { ungrouped.push(b); continue; }
182
182
  groups[m[1]].push(m[2].trim());
183
183
  }