clearotron 0.3.2-beta.10 → 0.3.2-beta.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/CONTRIBUTING.md +1 -0
  2. package/INSTALL.md +49 -46
  3. package/README.md +8 -7
  4. package/bin/onboard.mjs +44 -13
  5. package/bin/start.mjs +31 -6
  6. package/build-info.json +2 -2
  7. package/docs/architecture/05-config-governance.md +1 -1
  8. package/driver/CHANGELOG.md +400 -388
  9. package/driver/engine/mcp/recording-server.mjs +20 -0
  10. package/driver/package.json +1 -1
  11. package/driver/phase0.mjs +16 -7
  12. package/driver/pipeline.mjs +48 -1
  13. package/driver/portal-local-auth.mjs +14 -4
  14. package/driver/portal-service.mjs +12 -3
  15. package/driver/publish/attr.mjs +36 -0
  16. package/driver/publish/index.mjs +10 -10
  17. package/driver/publish/parse.mjs +1 -1
  18. package/driver/publish/render.mjs +9 -9
  19. package/driver/publish/xlsx.mjs +7 -1
  20. package/driver/queue-watch-verdict.mjs +1 -1
  21. package/driver/suite-census.json +59 -23
  22. package/driver/unit-inventory.mjs +7 -7
  23. package/mcp-server/CHANGELOG.md +23 -19
  24. package/mcp-server/lib/audit-view.mjs +4 -4
  25. package/mcp-server/lib/trace.mjs +1 -1
  26. package/mcp-server/package.json +1 -1
  27. package/package.json +2 -2
  28. package/portal-ui/package.json +1 -1
  29. package/providers/jx/README.md +2 -2
  30. package/providers/jx-subclass/README.md +1 -1
  31. package/providers/oauth-mcp-bridge/CHANGELOG.md +4 -0
  32. package/providers/oauth-mcp-bridge/package.json +1 -1
  33. package/scripts/README.md +5 -10
  34. package/scripts/citation-line-check.mjs +2 -2
  35. package/scripts/e2e.mjs +8 -8
  36. package/scripts/generated-files-are-current.mjs +35 -4
  37. package/scripts/live-surface-check.mjs +17 -17
  38. package/scripts/release-code-scanning-check.mjs +114 -0
  39. package/scripts/release-visible-check.mjs +117 -0
  40. package/shared/invocation.mjs +2 -2
  41. package/shared/register-selection.mjs +4 -1
  42. package/shared/root-doc-commands.mjs +10 -4
  43. package/shared/scope.mjs +2 -2
@@ -60,6 +60,19 @@ export function treeState(root = ROOT) {
60
60
  * a path whose STATE changed — staged to modified, say — is reported as having moved, and the paths are
61
61
  * returned rather than a count, because two numbers agreeing is not the same as two sets agreeing.
62
62
  */
63
+ /**
64
+ * The `git status --porcelain` LINES that differ between two readings, status included. `movedPaths`
65
+ * names what moved; this keeps the two-character state, which is what says whether a path is tracked.
66
+ */
67
+ export function movedLines(before, after) {
68
+ const bag = (s) => { const m = new Map(); for (const l of String(s).split("\n")) if (l.trim()) m.set(l, (m.get(l) ?? 0) + 1); return m; };
69
+ const [b, a] = [bag(before), bag(after)];
70
+ const out = new Set();
71
+ for (const [l, n] of a) if ((b.get(l) ?? 0) !== n) out.add(l);
72
+ for (const [l, n] of b) if ((a.get(l) ?? 0) !== n) out.add(l);
73
+ return [...out];
74
+ }
75
+
63
76
  export function movedPaths(before, after) {
64
77
  const bag = (s) => {
65
78
  const m = new Map();
@@ -84,6 +97,7 @@ export function checkAll({ dir = HERE, root = ROOT, log = console.log, readTree
84
97
  const unreadable = [];
85
98
  const wrote = [];
86
99
  const unattributable = [];
100
+ const neighbours = [];
87
101
  for (const m of found) {
88
102
  // ── `--check` IS A CONTRACT, AND NOTHING WAS VERIFYING IT ──────────────────────────────────────
89
103
  //
@@ -125,14 +139,25 @@ export function checkAll({ dir = HERE, root = ROOT, log = console.log, readTree
125
139
  const out = ((r.stdout || "") + (r.stderr || "")).trim();
126
140
  if (before !== null && after !== null && before !== after) {
127
141
  const moved = movedPaths(before, after);
128
- if (before.trim() === "") {
142
+ // AN UNTRACKED PATH IS NOT A MINTER'S TO WRITE. A minter that ignores `--check` re-mints its own
143
+ // generated file, which is tracked, so its write reads as a MODIFIED path. A path that appears or
144
+ // goes as untracked (`??`) was written by something else — in the suite, the neighbour test running
145
+ // beside this one — and it touched nothing a minter reads. So it is named, with who it was not, and
146
+ // this minter's answer is read as it came back. Measured on a suite run: `mint-public-residue`
147
+ // printed "the backlog is current" and was named as having changed the tree, because a neighbour
148
+ // had just made a directory inside the checkout.
149
+ if (movedLines(before, after).every((l) => l.startsWith("?? "))) {
150
+ neighbours.push({ m, moved });
151
+ log(` (another process wrote ${moved.join(", ")} while ${m} ran — untracked, so not this minter's output)`);
152
+ } else if (before.trim() === "") {
129
153
  wrote.push({ m, out, moved });
130
154
  log(` WROTE ${m} (during --check): ${moved.join(", ") || "the tree moved"}`);
155
+ continue;
131
156
  } else {
132
157
  unattributable.push({ m, moved });
133
158
  log(` ? ${m} — the tree moved and this probe cannot say who moved it: ${moved.join(", ") || "paths unknown"}`);
159
+ continue;
134
160
  }
135
- continue;
136
161
  }
137
162
  // 0 is current, 1 is stale, anything else is a minter that could not look — reported separately,
138
163
  // because "I could not read the tree" and "the file is out of date" need different things done.
@@ -141,11 +166,11 @@ export function checkAll({ dir = HERE, root = ROOT, log = console.log, readTree
141
166
  unreadable.push({ m, out, code: r.status });
142
167
  log(` ? ${m} (exit ${r.status})`);
143
168
  }
144
- return { found, stale, unreadable, wrote, unattributable, empty: false, contractChecked: readTree() !== null };
169
+ return { found, stale, unreadable, wrote, unattributable, neighbours, empty: false, contractChecked: readTree() !== null };
145
170
  }
146
171
 
147
172
  function main() {
148
- const { found, stale, unreadable, wrote, unattributable, empty, contractChecked } = checkAll();
173
+ const { found, stale, unreadable, wrote, unattributable, neighbours, empty, contractChecked } = checkAll();
149
174
  if (empty) {
150
175
  console.error("generated-files-are-current: no scripts/mint-*.mjs found. Either they moved or the "
151
176
  + "naming changed — and a pass over nothing is not a pass.");
@@ -154,6 +179,12 @@ function main() {
154
179
  console.log(`\ngenerated-files-are-current: checked ${found.length} minter(s)`
155
180
  + (contractChecked ? "" : "; the tree could not be read, so nothing verified that `--check` changed nothing"));
156
181
 
182
+ // NAMED, NOT FAILED: the file, and that the writer was not the minter. The fix belongs to whatever
183
+ // wrote inside the checkout, and the wrapper's own no-writes check is the one that fails it.
184
+ for (const { m, moved } of neighbours ?? []) {
185
+ console.error(`\nnot ${m}: another process wrote ${moved.join(", ")} inside the checkout while it ran — `
186
+ + `untracked, so not a generated file; this check's answers stand.`);
187
+ }
157
188
  for (const { m, out } of [...wrote, ...stale, ...unreadable]) {
158
189
  console.error(`\n──── ${m} ────\n${out}`);
159
190
  }
@@ -11,14 +11,14 @@
11
11
  // because none of them is a property of the source — they are properties of the PROCESSES that are
12
12
  // running, the ENVIRONMENT they were started in, and the COMMIT they were started from:
13
13
  //
14
- // • #98 — the ops-MCP told every caller that three of five depths were "not switched on". They were
14
+ // • THE AVAILABILITY MISMATCH — the ops-MCP told every caller that three of five depths were "not switched on". They were
15
15
  // switched on; the portal said so on the same box at the same moment. The MCP unit had no
16
16
  // EnvironmentFile, so it read availability from an environment it did not have. The validation that
17
17
  // was supposed to catch this ran `describe_options` in a plain shell — the one environment that
18
18
  // makes it dishonest — and reported 10/10. A probe that talks to the RUNNING service cannot make
19
19
  // that mistake.
20
20
  //
21
- // • #83 — the ops-MCP had no CLEAROTRON_CUSTOMERS_DIR, silently fell back to the bundled demo roster, and
21
+ // • THE ROSTER FALLBACK — the ops-MCP had no CLEAROTRON_CUSTOMERS_DIR, silently fell back to the bundled demo roster, and
22
22
  // refused every real customer. Nothing anywhere compared the roster a door resolves against the
23
23
  // roster on disk.
24
24
  //
@@ -35,7 +35,7 @@
35
35
  // Stated plainly, because a check that quietly skips a surface is worse than one that says it did:
36
36
  //
37
37
  // ops-MCP loopback, TRADEMARK_MCP_AUTH_DISABLED — fully reachable, two independent code paths
38
- // (describe_options → lib/options.mjs, plan_run → lib/plan.mjs). #98 lived in BOTH, so
38
+ // (describe_options → lib/options.mjs, plan_run → lib/plan.mjs). The availability mismatch lived in BOTH, so
39
39
  // comparing them to each other is necessary but NOT sufficient…
40
40
  // on-disk …which is why the load-bearing comparison is against the flag snapshot, recomputed
41
41
  // here through the ENGINE's own productAvailability(). That is the source of truth the
@@ -47,10 +47,10 @@
47
47
  //
48
48
  // ── it must call the doors AS A REAL CALLER DOES ─────────────────────────────────────────────────────
49
49
  //
50
- // Learned while writing this, and worth stating because it is the same trap as #98 wearing a different
50
+ // Learned while writing this, and worth stating because it is the same trap as the availability mismatch wearing a different
51
51
  // hat. A token-less loopback call to the ops face initialises an accounts-scoped session with NO
52
52
  // accounts: `list_profiles` answers `clients: 0` and `plan_run` refuses, because an empty grant covers
53
- // no account and not the neutral profile either. The first version of this script read that as a live #83 and was
53
+ // no account and not the neutral profile either. The first version of this script read that as a live roster fallback and was
54
54
  // WRONG — the deployment was fine; the caller was not. With the portal's own ops token the same call
55
55
  // answers `clients: 10`.
56
56
  //
@@ -97,7 +97,7 @@ import { treeOfRunning } from "../shared/checkout-move.mjs"; // …and
97
97
  import { findUnitFiles, unitFilePath } from "../driver/unit-files.mjs"; //
98
98
  import { managerGroupsVerdict } from "../driver/manager-groups-verdict.mjs"; //
99
99
  import { config } from "../driver/driver.config.mjs"; //
100
- import { probeQueueWatch, probeWorker, probeTimer } from "../driver/queue-watch-probe.mjs"; // · and, tracker issue 206, the units that say HOW this box drains
100
+ import { probeQueueWatch, probeWorker, probeTimer } from "../driver/queue-watch-probe.mjs"; // · and the units that say HOW this box drains
101
101
  import { doorPostureVerdict } from "../mcp-server/door-posture.mjs"; // — a door whose mode came from another door's variables
102
102
  import { readDrainerStamp, drainerVerdict, defaultPpidOf } from "../driver/drainer-identity.mjs"; // — the process that EXECUTES runs
103
103
  import { readUpdaterStamp, updaterVerdict, resolveUpdaterStampPath, updaterAbsentHere, UPDATER_STAMP_BASENAME } from "../driver/updater-identity.mjs"; // — the mechanism that PLACES commits
@@ -105,7 +105,7 @@ import { claimerIsAlive } from "../driver/claim-liveness.mjs";
105
105
  import { processTable } from "../shared/process-table.mjs"; // — /proc is not the only box
106
106
  import { envFrom } from "../shared/env-aliases.mjs";
107
107
  import { gitTry, treeOf } from "../shared/tree-commit.mjs"; // — a packaged install has no git, and says its commit in build-info.json
108
- import { exitFor } from "../driver/surface-exit-verdict.mjs"; // — a could-not-look is not a drift, and they want different things done // — the name a reader is told to set is the one in force
108
+ import { exitFor } from "../driver/surface-exit-verdict.mjs"; // — a check that could not look is not a drift, and they want different things done // — the name a reader is told to set is the one in force
109
109
 
110
110
  const HERE = dirname(fileURLToPath(import.meta.url));
111
111
  const asJson = process.argv.includes("--json");
@@ -170,12 +170,12 @@ const PORTAL_URL = DOORS.portal.url;
170
170
  const CLIENT_MCP_URL = DOORS["client door"].url;
171
171
 
172
172
  // The bundled demo roster, as `list_profiles` REPORTS it. A door that resolves exactly this set is a
173
- // door with no CLEAROTRON_CUSTOMERS_DIR — #83. Compared as a set, not a count: a deployment may legitimately
173
+ // door with no CLEAROTRON_CUSTOMERS_DIR — the roster fallback. Compared as a set, not a count: a deployment may legitimately
174
174
  // have three customers.
175
175
  //
176
176
  // DERIVED FROM THE DIRECTORY THAT SHIPS IT, never written down here. As a hand-maintained triple this
177
177
  // went stale the day a fourth bundle landed: the set-equality could no longer match anything, so the
178
- // #83 detector returned PASS on the #83 condition — a guard that cannot match reports that it found
178
+ // roster-fallback detector returned PASS on the roster fallback itself — a guard that cannot match reports that it found
179
179
  // nothing wrong. The two earlier faults on this same constant failed CLOSED and someone investigated;
180
180
  // this one failed open. `generic` is excluded by the derivation, because `list_profiles` reports it as
181
181
  // `genericFallback` rather than as a member of `clients[]`.
@@ -203,7 +203,7 @@ const skip = (n, d) => record(n, "skip", d); // could not be reached — never
203
203
  * Two arms used to call `fail` while their own message said "This is a failure to look, never a pass".
204
204
  * The text was honest and the verdict was not: FAIL is what a genuine drift also produces, so a reader
205
205
  * could not tell "this box has drifted" from "I was unable to look" without reading to the end of the
206
- * message. A drift is fixed by redeploying; a could-not-look is fixed by pointing the check at something
206
+ * message. A drift is fixed by redeploying; a check that could not look is fixed by pointing the check at something
207
207
  * it can read. Until somebody does, nothing is known either way.
208
208
  *
209
209
  * IT IS NOT THE SAME AS AN ORDINARY SKIP, which is why this exists rather than reusing `skip`. Several
@@ -536,7 +536,7 @@ const wiredRegister = snapshot?.register?.provider ?? null;
536
536
  if (wiredRegister) pass("register wired", `${wiredRegister} · canCount=${snapshot.register?.canCount === true}`);
537
537
  else skip("register wired", "the snapshot carries no register block");
538
538
 
539
- // 3a. The roster a door RESOLVES, vs the roster on disk. #83: no CLEAROTRON_CUSTOMERS_DIR ⇒ the bundled demos
539
+ // 3a. The roster a door RESOLVES, vs the roster on disk. The roster fallback: no CLEAROTRON_CUSTOMERS_DIR ⇒ the bundled demos
540
540
  // ⇒ every real customer refused. Counts and set-identity only — never a customer name.
541
541
  // Runs BEFORE the availability checks because plan_run needs a profile to plan against, and taking
542
542
  // it from what the door itself resolved is what keeps a customer key out of this source file.
@@ -561,7 +561,7 @@ try {
561
561
  // clients: 0 → an accounts-scoped session with NO accounts, i.e. THIS CALLER is unscoped.
562
562
  // A statement about the probe, not the deployment. Never a failure.
563
563
  // clients: <demos> → the door resolved the BUNDLED roster, i.e. CLEAROTRON_CUSTOMERS_DIR is not reaching
564
- // the service. A statement about the deployment. This is #83.
564
+ // the service. A statement about the deployment. This is the roster fallback.
565
565
  // On the TEST instance the bundled roster is the CORRECT answer — CLEAROTRON_CUSTOMERS_DIR is deliberately
566
566
  // unset there so real client bundles never reach it. Same observation, opposite verdict, so the
567
567
  // instance has to say which it is rather than the check guessing from a hostname or a path.
@@ -570,10 +570,10 @@ try {
570
570
  // protected; inside this top-level-await script neither could be caught by a test.
571
571
  // The derivation THROWS rather than handing back an empty set, and the throw is caught here on its own
572
572
  // so it cannot be reported as a list_profiles fault. Either way it is never a pass: an unreadable
573
- // profiles directory means #83 cannot be ruled out, which is a could-not-look and says so.
573
+ // profiles directory means the roster fallback cannot be ruled out: the check could not look, and says so.
574
574
  let bundledDemos = null;
575
575
  try { bundledDemos = bundledDemoKeys({ profilesDir: BUNDLED_PROFILES_DIR }); }
576
- catch (e) { fail("roster resolves", `the bundled roster could not be derived, so #83 cannot be ruled out: ${e.message}`); }
576
+ catch (e) { fail("roster resolves", `the bundled roster could not be derived, so a door falling back to the bundled demo roster cannot be ruled out: ${e.message}`); }
577
577
 
578
578
  // THE DOOR ANSWERED FOR THIS KEY, so the comparison is made within this key's cap. Its claims are read
579
579
  // the way doctor and the brand-owner command read them. A key that cannot be read leaves the verdict
@@ -608,7 +608,7 @@ try {
608
608
  }
609
609
 
610
610
  // 3. THE LOAD-BEARING CHECK — every door's availability answer vs the engine's own, recomputed here
611
- // from the snapshot. This is #98: the MCP disagreed with the portal because it derived availability
611
+ // from the snapshot. This is the availability mismatch: the MCP disagreed with the portal because it derived availability
612
612
  // from its own process environment instead of from this file.
613
613
  let mcpOptions = null;
614
614
  try {
@@ -731,7 +731,7 @@ if (mcpOptions && built) {
731
731
  else pass("doors agree with the engine on product availability", `${seen.length} products, all matching: ${seen.join(", ")}`);
732
732
 
733
733
  // 3b. describe_options and plan_run are two independent code paths (lib/options.mjs, lib/plan.mjs).
734
- // #98 lived in both, so agreement here is necessary-but-not-sufficient — 3 above is the real test.
734
+ // The availability mismatch lived in both, so agreement here is necessary-but-not-sufficient — 3 above is the real test.
735
735
  //
736
736
  // plan_run needs an explicit profileKey: an accounts-scoped session refuses without one. The key
737
737
  // comes from what the door ITSELF resolved (below), so no customer is ever hardcoded here.
@@ -863,7 +863,7 @@ else {
863
863
  // to an unprobed posture — a failure to look, never a pass.
864
864
  posture: { worker: probeWorker(), timer: probeTimer() },
865
865
  });
866
- // — AND THE VERDICT'S OWN could-not-look MARKER, carried through. The verdict distinguishes a
866
+ // — AND THE VERDICT'S OWN MARKER FOR A CHECK THAT COULD NOT LOOK, carried through. The verdict distinguishes a
867
867
  // process table it could not read from one that answered; dropping that here would put the
868
868
  // distinction back where it was, one layer down.
869
869
  record("the process that executes runs is on the deployed commit", v.state, v.message, v.blocked === true);
@@ -0,0 +1,114 @@
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
+ // release-code-scanning-check.mjs — is the commit about to be published as a stable one code scanning
5
+ // has finished reading, with nothing left open?
6
+ //
7
+ // node scripts/release-code-scanning-check.mjs --sha <commit> --repo <owner/name>
8
+ // [--ref refs/heads/main] [--timeout 1200] [--interval 30]
9
+ //
10
+ // Exit 0: code scanning has a completed analysis of <sha> for every language it analyses on <ref>, and
11
+ // no alert on <ref> is open. Exit 1: alerts are open (each named), or the analysis of <sha> did not arrive
12
+ // within the bound. Exit 2: could not look — the API refused, or the arguments were incomplete.
13
+ //
14
+ // ── WHY A STABLE, AND ONLY A STABLE ─────────────────────────────────────────────────────────────────
15
+ //
16
+ // Code scanning was on and reported alerts, and an integration merge landed with its check red: a scanner
17
+ // that can stay red without stopping anything is a notification. The owner's ruling (2026-09-18): the
18
+ // check is required on main, and a stable is published only over a clean result. Betas are not gated —
19
+ // they are how a fix reaches the people testing it. There is NO OVERRIDE: an open alert is fixed, or
20
+ // dismissed on the alert with a written reason, and then this passes.
21
+ //
22
+ // ── IT WAITS, BECAUSE THE ANALYSIS FOLLOWS THE MERGE ────────────────────────────────────────────────
23
+ //
24
+ // The commit a stable publishes is the version commit, and the version pull request merges itself
25
+ // moments before this runs; code scanning analyses that push in a few minutes (measured on the beta.10
26
+ // release commit: under five). So an analysis not there YET is waited for, bounded; an analysis never
27
+ // there fails the publish, because a verdict nobody reached is not a clean one.
28
+ //
29
+ // ── WHY THE OPEN ALERTS ARE READ ON THE BRANCH ──────────────────────────────────────────────────────
30
+ //
31
+ // Alerts belong to a ref, not to a commit: the open list for main describes main's latest analysis. So
32
+ // the check first requires that analysis to be of <sha> itself — if main has moved past the commit being
33
+ // published, the list describes a different tree and this refuses rather than answer about it.
34
+ import { execFileSync } from "node:child_process";
35
+ import { isEntrypoint } from "../shared/is-entrypoint.mjs";
36
+
37
+ const arg = (argv, flag, dflt = null) => { const i = argv.indexOf(flag); return i === -1 ? dflt : argv[i + 1]; };
38
+ const sleep = (ms) => new Promise((ok) => setTimeout(ok, ms));
39
+
40
+ /** `gh api` as JSON, paginated where asked. Throws on a refusal, which the caller turns into exit 2. */
41
+ function gh(path, { paginate = false } = {}) {
42
+ // Paginated pages come back as one element per line (`--jq '.[]'`), so no page boundary is ever
43
+ // reassembled by editing text.
44
+ const args = ["api", ...(paginate ? ["--paginate", "--jq", ".[]"] : []), path];
45
+ const out = execFileSync("gh", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: 1 << 26 });
46
+ return paginate ? out.split("\n").filter((l) => l.trim()).map((l) => JSON.parse(l)) : JSON.parse(out);
47
+ }
48
+
49
+ /**
50
+ * PURE. The verdict from what the API said.
51
+ * @param {object} o
52
+ * @param {Array<{commit_sha:string, category?:string, error?:string, created_at?:string}>} o.analyses on <ref>, newest first
53
+ * @param {Array<{number:number, rule?:{id?:string}, most_recent_instance?:{location?:{path?:string, start_line?:number}}}>} o.open
54
+ */
55
+ export function verdict({ sha, analyses, open }) {
56
+ const categories = [...new Set(analyses.map((a) => a.category ?? ""))];
57
+ if (!categories.length) return { state: "waiting", why: "code scanning has no analysis on this branch at all" };
58
+ const newest = new Map();
59
+ for (const a of analyses) if (!newest.has(a.category ?? "")) newest.set(a.category ?? "", a);
60
+ const notYet = categories.filter((c) => newest.get(c).commit_sha !== sha);
61
+ if (notYet.length) {
62
+ const moved = notYet.filter((c) => analyses.some((a) => (a.category ?? "") === c && a.commit_sha === sha));
63
+ if (moved.length) return { state: "moved", why: `the branch's newest ${moved.join(", ")} analysis is of another commit, so its alerts describe a different tree` };
64
+ return { state: "waiting", why: `no analysis of ${sha.slice(0, 8)} yet for ${notYet.join(", ")}` };
65
+ }
66
+ const failed = categories.filter((c) => newest.get(c).error);
67
+ if (failed.length) return { state: "failed", why: `the analysis of ${sha.slice(0, 8)} for ${failed.join(", ")} reported an error: ${newest.get(failed[0]).error}` };
68
+ if (open.length) return { state: "open", why: `${open.length} alert(s) are open`, open };
69
+ return { state: "clean", why: `${categories.length} analysis categor${categories.length === 1 ? "y" : "ies"} of ${sha.slice(0, 8)} complete, no alert open` };
70
+ }
71
+
72
+ async function main() {
73
+ const argv = process.argv.slice(2);
74
+ const sha = arg(argv, "--sha"), repo = arg(argv, "--repo");
75
+ const ref = arg(argv, "--ref", "refs/heads/main");
76
+ const timeoutSec = Number(arg(argv, "--timeout", "1200")), intervalSec = Number(arg(argv, "--interval", "30"));
77
+ if (!/^[0-9a-f]{40}$/.test(sha ?? "") || !/^[\w.-]+\/[\w.-]+$/.test(repo ?? "") || !(intervalSec > 0) || !Number.isFinite(timeoutSec)) {
78
+ console.error("release-code-scanning-check: needs --sha <40-character commit> and --repo <owner/name>. Could not look.");
79
+ process.exit(2);
80
+ }
81
+ const deadline = Date.now() + timeoutSec * 1000;
82
+ for (;;) {
83
+ let v;
84
+ try {
85
+ const analyses = gh(`repos/${repo}/code-scanning/analyses?ref=${encodeURIComponent(ref)}&tool_name=CodeQL&per_page=100`);
86
+ const open = gh(`repos/${repo}/code-scanning/alerts?ref=${encodeURIComponent(ref)}&state=open&per_page=100`, { paginate: true });
87
+ v = verdict({ sha, analyses, open });
88
+ } catch (e) {
89
+ console.error(`release-code-scanning-check: the code-scanning API could not be read: ${String(e.stderr ?? e.message).split("\n")[0]}. `
90
+ + "Could not look, which is not a clean scan.");
91
+ process.exit(2);
92
+ }
93
+ console.log(`release-code-scanning-check: at ${Math.floor(Date.now() / 1000)} — ${v.why}.`);
94
+ if (v.state === "clean") return;
95
+ if (v.state === "open") {
96
+ console.error(`\nA stable is not published over open code-scanning alerts. Fix each, or dismiss it on the alert with a written reason:`);
97
+ for (const a of v.open.slice(0, 25)) {
98
+ const l = a.most_recent_instance?.location ?? {};
99
+ console.error(` #${a.number} ${a.rule?.id ?? "?"} ${l.path ?? "?"}:${l.start_line ?? "?"}`);
100
+ }
101
+ if (v.open.length > 25) console.error(` … and ${v.open.length - 25} more`);
102
+ process.exit(1);
103
+ }
104
+ if (v.state === "moved" || v.state === "failed") { console.error(`\nrelease-code-scanning-check: ${v.why}. Not published.`); process.exit(1); }
105
+ if (Date.now() + intervalSec * 1000 > deadline) {
106
+ console.error(`\nrelease-code-scanning-check: after ${timeoutSec}s, ${v.why}. A verdict nobody reached is not a `
107
+ + "clean one, so the stable is not published. Re-run the job once the analysis has finished.");
108
+ process.exit(1);
109
+ }
110
+ await sleep(intervalSec * 1000);
111
+ }
112
+ }
113
+
114
+ if (isEntrypoint(import.meta.url)) main();
@@ -0,0 +1,117 @@
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
+ // release-visible-check.mjs — can a stranger's npm client get the version this run just published?
5
+ //
6
+ // node scripts/release-visible-check.mjs --version <v> --tag <dist-tag> --tarball <file>
7
+ // [--timeout 900] [--interval 15] [--registry https://registry.npmjs.org] [--name clearotron]
8
+ //
9
+ // Exit 0: the registry serves <v>, the <dist-tag> names it, and the tarball it serves is byte for byte
10
+ // the one at <file> — the bytes this run published. Exit 1: not within the bound, or the registry serves
11
+ // different bytes; the caller creates no release entry. Exit 2: could not look — bad arguments, or the
12
+ // local tarball cannot be read.
13
+ //
14
+ // ── WHY THIS RUNS BEFORE THE RELEASE ENTRY ─────────────────────────────────────────────────────────
15
+ //
16
+ // `npm publish` returning is the registry accepting the upload, not the registry serving it. Measured on
17
+ // 0.3.2-beta.10, 2026-09-18: the release entry went up at 19:47:40Z and the workflow finished three
18
+ // seconds later, while an unauthenticated `npm view clearotron@0.3.2-beta.10` answered E404 and the beta
19
+ // tag still named beta.9 until about 19:56Z. Nine minutes in which the releases page named a version
20
+ // nobody could install, and a green run reported it done on evidence no user could see. So the entry
21
+ // waits for the registry to answer a stranger, and a registry that never does leaves no entry at all.
22
+ //
23
+ // ── UNAUTHENTICATED ON PURPOSE ──────────────────────────────────────────────────────────────────────
24
+ //
25
+ // Plain requests with no credential and no npm configuration. The publishing job holds a publish
26
+ // credential, and a read made with it can see what the public cannot yet — which is the gap this
27
+ // exists to close. No cache-busting query either: a stranger's client does not add one.
28
+ //
29
+ // ── AND THE BYTES, NOT ONLY THE NAME ────────────────────────────────────────────────────────────────
30
+ //
31
+ // A version document can answer while the tarball behind it is not the one this run built. So the
32
+ // tarball the registry serves is fetched, as `npm pack <name>@<version>` would fetch it, and its sha512
33
+ // is compared both with the registry's own recorded integrity and with the local file this run
34
+ // uploaded. Either disagreeing is a refusal, not a warning.
35
+ import { readFileSync } from "node:fs";
36
+ import { createHash } from "node:crypto";
37
+ import { isEntrypoint } from "../shared/is-entrypoint.mjs";
38
+
39
+ const arg = (argv, flag, dflt = null) => { const i = argv.indexOf(flag); return i === -1 ? dflt : argv[i + 1]; };
40
+ const sri = (bytes) => `sha512-${createHash("sha512").update(bytes).digest("base64")}`;
41
+ const sleep = (ms) => new Promise((ok) => setTimeout(ok, ms));
42
+
43
+ /** One look at the registry, as a stranger. Returns what it served and nothing it did not. */
44
+ export async function lookOnce({ registry, name, version, tag }) {
45
+ const seen = { version: null, tagged: null, integrity: null, tarball: null, error: null };
46
+ try {
47
+ const v = await fetch(`${registry}/${encodeURIComponent(name)}/${encodeURIComponent(version)}`, { headers: { accept: "application/json" } });
48
+ if (v.ok) {
49
+ const doc = await v.json();
50
+ seen.version = doc.version ?? null;
51
+ seen.integrity = doc.dist?.integrity ?? null;
52
+ seen.tarball = doc.dist?.tarball ?? null;
53
+ }
54
+ const t = await fetch(`${registry}/-/package/${encodeURIComponent(name)}/dist-tags`, { headers: { accept: "application/json" } });
55
+ if (t.ok) seen.tagged = (await t.json())?.[tag] ?? null;
56
+ } catch (e) { seen.error = String(e?.message ?? e); }
57
+ return seen;
58
+ }
59
+
60
+ /** PURE. Is what a look saw the published version, under its tag? */
61
+ export function visible(seen, { version }) {
62
+ return seen.version === version && seen.tagged === version && typeof seen.tarball === "string";
63
+ }
64
+
65
+ async function main() {
66
+ const argv = process.argv.slice(2);
67
+ const version = arg(argv, "--version"), tag = arg(argv, "--tag"), tarballPath = arg(argv, "--tarball");
68
+ const registry = String(arg(argv, "--registry", "https://registry.npmjs.org")).replace(/\/+$/, "");
69
+ const name = arg(argv, "--name", "clearotron");
70
+ const timeoutSec = Number(arg(argv, "--timeout", "900")), intervalSec = Number(arg(argv, "--interval", "15"));
71
+ if (!version || !tag || !tarballPath || !Number.isFinite(timeoutSec) || !Number.isFinite(intervalSec) || intervalSec <= 0) {
72
+ console.error("release-visible-check: needs --version, --tag and --tarball, and a positive --interval. Could not look.");
73
+ process.exit(2);
74
+ }
75
+ let local;
76
+ try { local = sri(readFileSync(tarballPath)); }
77
+ catch (e) { console.error(`release-visible-check: cannot read the published tarball ${tarballPath}: ${e.message}. Could not look.`); process.exit(2); }
78
+
79
+ const started = Date.now(), deadline = started + timeoutSec * 1000;
80
+ const epoch = () => Math.floor(Date.now() / 1000);
81
+ let seen;
82
+ for (;;) {
83
+ seen = await lookOnce({ registry, name, version, tag });
84
+ const waited = Math.round((Date.now() - started) / 1000);
85
+ console.log(`release-visible-check: at ${epoch()} (+${waited}s) the registry answers ${name}@${version} → `
86
+ + `${seen.version ?? "nothing"}; ${tag} → ${seen.tagged ?? "nothing"}${seen.error ? ` (${seen.error})` : ""}`);
87
+ if (visible(seen, { version })) break;
88
+ if (Date.now() + intervalSec * 1000 > deadline) {
89
+ console.error(`\nrelease-visible-check: after ${waited}s an unauthenticated client still cannot get ${name}@${version} `
90
+ + `under \`${tag}\`. The publish was accepted and is not yet served, so no release entry is created: `
91
+ + "the page would name a version nobody can install. The tag is already written, so the pipeline "
92
+ + "knows this version is published; re-run this job to create the entry once the registry serves it.");
93
+ process.exit(1);
94
+ }
95
+ await sleep(intervalSec * 1000);
96
+ }
97
+
98
+ let served;
99
+ try {
100
+ const r = await fetch(seen.tarball);
101
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
102
+ served = sri(Buffer.from(await r.arrayBuffer()));
103
+ } catch (e) {
104
+ console.error(`release-visible-check: the registry names ${seen.tarball} and it could not be fetched: ${e.message}. No release entry.`);
105
+ process.exit(1);
106
+ }
107
+ if (served !== local || (seen.integrity && seen.integrity !== local)) {
108
+ console.error(`release-visible-check: the registry serves DIFFERENT BYTES for ${name}@${version}.\n`
109
+ + ` published by this run: ${local}\n served: ${served}\n recorded integrity: ${seen.integrity ?? "none"}\n`
110
+ + "No release entry is created for a version whose install is not the build this run verified.");
111
+ process.exit(1);
112
+ }
113
+ console.log(`release-visible-check: a stranger can install ${name}@${version} under \`${tag}\`, `
114
+ + `and the tarball served is the one this run published (${served.slice(0, 22)}…).`);
115
+ }
116
+
117
+ if (isEntrypoint(import.meta.url)) main();
@@ -141,8 +141,8 @@ export function installRoute(dir = INSTALL_DIR) {
141
141
  * <prefix>/lib/node_modules/clearotron ← this module's install directory
142
142
  * <prefix>/bin/clearotron ← the executable npm links for it
143
143
  *
144
- * Measured on the reporter's box: `/home/hardening-b/.npm-global/lib/node_modules/clearotron` and
145
- * `/home/hardening-b/.npm-global/bin/clearotron`. A LOCAL install — `<project>/node_modules/clearotron`,
144
+ * Measured on a reader's machine: `/home/<you>/.npm-global/lib/node_modules/clearotron` and
145
+ * `/home/<you>/.npm-global/bin/clearotron`. A LOCAL install — `<project>/node_modules/clearotron`,
146
146
  * no `lib` segment — is deliberately NOT matched: npm links those into `<project>/node_modules/.bin`,
147
147
  * which is not on anybody's PATH, and `standFrom` already gives that case its correct answer.
148
148
  *
@@ -40,6 +40,9 @@ import {
40
40
  USPTO_ARCHIVE_GB, USPTO_INDEX_GB, USPTO_INGEST_GB_PER_HOUR, USPTO_DAILY_TOPUP_MB,
41
41
  usptoBuildHours, usptoProvisionGB,
42
42
  } from "./uspto-index-size.mjs";
43
+ // THE BUILD COMMAND AS THIS INSTALL IS REACHED. It named `npm run sync:uspto`, a script only a checkout has,
44
+ // so a reader who installed the package was handed a command with nothing behind it.
45
+ import { reachableCommand } from "./invocation.mjs";
43
46
 
44
47
  const SIGNA_SIGNUP = [
45
48
  "1. Create an account at https://signa.so/ and open the API section — it is self-serve: no sales",
@@ -64,7 +67,7 @@ const USPTO_WARNINGS = [
64
67
  ` · provision ~${usptoProvisionGB()} GB free — the archives are deleted as they are ingested, so they never all exist at once`,
65
68
  "The EU register needs NONE of this and works as soon as your EUIPO credentials are in.",
66
69
  "A USPTO API key needs an ID.me identity verification, which is a real-world identity check.",
67
- "Build it with: npm run sync:uspto (resumable — an interrupted build picks up where it stopped)",
70
+ `Build it with: ${reachableCommand("sync")} (resumable — an interrupted build picks up where it stopped)`,
68
71
  ];
69
72
 
70
73
 
@@ -20,7 +20,8 @@
20
20
  // A bare `clearotron <command>` is command-not-found (exit 127, measured on a real clone) unless the
21
21
  // document has already put the binary on `PATH`. Two ways it can have done that:
22
22
  //
23
- // · an `npm install -g clearotron` line ABOVE the site — line-based, because a reader executing a
23
+ // · an install line ABOVE the site — `npm install -g clearotron`, or `npx clearotron install` (either
24
+ // channel), which places the command in `~/.local/bin` — line-based, because a reader executing a
24
25
  // quickstart top to bottom has not run a later line yet;
25
26
  // · the paragraph is ABOUT the `PATH` shim `clearotron install` writes, where the short form is the
26
27
  // thing being explained and `npx` would make the document contradict itself.
@@ -32,8 +33,13 @@
32
33
  export const BARE = /(?<!npx )\bclearotron (?=[a-z])/;
33
34
  /** The form that works from a fresh clone, and the anti-vacuity signal that the scan is reading. */
34
35
  export const NPX = /npx clearotron [a-z]/g;
35
- /** The line that puts the binary on `PATH` for everything below it. */
36
- export const GLOBAL_INSTALL = /npm install -g clearotron/;
36
+ /**
37
+ * The line that puts the binary on `PATH` for everything below it. `npx clearotron install` counts as well
38
+ * as npm's global form: after it, every documented command uses the launcher it installed rather than a
39
+ * fresh `npx`, which resolves whichever channel npm picks at that moment — the stable, for a reader who
40
+ * installed the beta (owner ruling, 2026-09-18).
41
+ */
42
+ export const GLOBAL_INSTALL = /npm install -g clearotron|npx clearotron(?:@[\w.-]+)? install\b/;
37
43
  /** The paragraph that is ABOUT the shim, where the short form is the point. */
38
44
  export const SHIM_PARAGRAPH = /short form|on your `PATH`|stop typing/;
39
45
 
@@ -81,5 +87,5 @@ export function unreachableBareSites(docs) {
81
87
  export function sentenceFor(s) {
82
88
  return `${s.file}:${s.line} uses the bare \`clearotron\` form with nothing above it that put the binary `
83
89
  + "on `PATH`. For a reader who cloned, that is command-not-found (exit 127, measured) — either write "
84
- + "it as `npx clearotron`, or give the document an `npm install -g clearotron` line before this point.";
90
+ + "it as `npx clearotron`, or give the document an install line before this point.";
85
91
  }
package/shared/scope.mjs CHANGED
@@ -874,8 +874,8 @@ export function resolveScope({ local = false, innerToken = null, email = null, f
874
874
  //
875
875
  // buildJob REQUIRES a forwarder ("…the requester/reply-routing key that rides the delivery packet
876
876
  // (docs/DELIVERY.md)") and plan_run builds the same job start_run does, so a preview without one died on
877
- // a message naming an internal doc — on the FREE call every connecting principal makes first. #53 fixed
878
- // that for the account branch only; ops and internal hit the same wall. Fixed here rather than in
877
+ // a message naming an internal doc — on the FREE call every connecting principal makes first. The first fix
878
+ // covered the account branch only; ops and internal hit the same wall. Fixed here rather than in
879
879
  // buildJob because the answer is the SESSION's: who is asking is a fact of the verified identity.
880
880
  //
881
881
  // `args.forwarder ||` first: ops and internal are trusted principals routing on someone else's behalf (an