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 +65 -0
- package/README.md +3 -0
- package/bin/onboard.mjs +1 -1
- package/bin/status.mjs +1 -1
- package/build-info.json +2 -2
- package/docs/RELEASES.md +1 -1
- package/driver/CHANGELOG.md +34 -0
- package/driver/package.json +1 -1
- package/driver/suite-census.json +19 -7
- package/driver/unit-environment.mjs +1 -1
- package/mcp-server/CHANGELOG.md +8 -0
- package/mcp-server/package.json +1 -1
- package/package.json +1 -1
- package/portal-ui/dist/assets/{index-qes7gLpM.js → index-CwPAS0we.js} +3 -2
- package/portal-ui/dist/index.html +1 -1
- package/portal-ui/package.json +1 -1
- package/providers/oauth-mcp-bridge/CHANGELOG.md +8 -0
- package/providers/oauth-mcp-bridge/package.json +1 -1
- package/scripts/import-cycle-check.mjs +468 -0
- package/scripts/release-install-check.mjs +51 -2
- package/scripts/release-notes-lint.mjs +2 -2
- package/scripts/release-version.mjs +3 -3
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 "../
|
|
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 "../
|
|
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
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.
|
package/driver/CHANGELOG.md
CHANGED
|
@@ -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
|
package/driver/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "clearotron-driver",
|
|
3
3
|
"private": true,
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "0.3.0
|
|
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": {
|
package/driver/suite-census.json
CHANGED
|
@@ -647,7 +647,7 @@
|
|
|
647
647
|
},
|
|
648
648
|
"a-release-note-is-written-for-its-reader.test.mjs": {
|
|
649
649
|
"tests": 9,
|
|
650
|
-
"asserts":
|
|
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":
|
|
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":
|
|
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":
|
|
4838
|
-
"asserts":
|
|
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":
|
|
5862
|
-
"asserts":
|
|
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 "
|
|
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 = "-";
|
package/mcp-server/CHANGELOG.md
CHANGED
package/mcp-server/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trademark-artifacts-mcp",
|
|
3
|
-
"version": "0.3.0
|
|
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
|
@@ -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-
|
|
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>
|
package/portal-ui/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "portal-ui",
|
|
3
3
|
"private": true,
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "0.3.0
|
|
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": {
|
|
@@ -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
|
-
|
|
120
|
-
|
|
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
|
}
|