clearotron 0.3.0-beta.8 → 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.
@@ -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
  }
@@ -102,6 +102,45 @@ export function denylistFor({ paths, demo = false, env = {}, home }) {
102
102
  return paths.denylist ?? denylistPathFor(env, home);
103
103
  }
104
104
 
105
+ /**
106
+ * THE DEMO'S SIGNING SECRET, KEPT IN THE DEMO'S OWN BASE.
107
+ *
108
+ * The demo generated its signing secret per run and held it in memory only, so the key command its
109
+ * terminal printed could never sign a key its door would accept: run from a second terminal, as printed,
110
+ * it read the install's settings and refused (measured on a published beta, 2026-09-11). The secret now
111
+ * lives beside the demo's revocation list, mode 600, so `key issue --base <demo base>` reads the one the
112
+ * door was given, and removing the demo is still removing one directory.
113
+ */
114
+ export const demoTokenSecretPath = (base) => join(base, "token-secret");
115
+
116
+ /**
117
+ * The secret a demo's door signs with: the one its base already holds, or a new one written there.
118
+ * `io` is `{ read, write, mint }`; a read that finds nothing is the first start.
119
+ */
120
+ export function demoTokenSecret(base, io) {
121
+ const path = demoTokenSecretPath(base);
122
+ let held = "";
123
+ try { held = String(io.read(path) ?? "").trim(); } catch { /* not yet written */ }
124
+ if (held) return held;
125
+ const fresh = io.mint();
126
+ io.write(path, `${fresh}\n`);
127
+ return fresh;
128
+ }
129
+
130
+ /**
131
+ * The key command a start prints beside its client door — runnable exactly as printed.
132
+ *
133
+ * A demo's door admits the demo's own account, so the command names it rather than a placeholder a reader
134
+ * cannot type, and it names the demo's base so the verb reads that demo's secret and guest list. An
135
+ * install moved with `--base` is named too, for its guest list; the default install needs neither.
136
+ */
137
+ export function keyIssueCommand({ prefix = "", demo = false, user = null, base = null, defaultBase = null } = {}) {
138
+ const q = (d) => (/\s/.test(d) ? `"${d}"` : d);
139
+ const who = demo && user ? user : "<email>";
140
+ const where = base && (demo || base !== defaultBase) ? ` --base ${q(base)}` : "";
141
+ return `${prefix}clearotron key issue ${who}${where}`;
142
+ }
143
+
105
144
  /**
106
145
  * Create the denylist if it is absent, so the door is born consulting a file that exists.
107
146
  *