clearotron 0.3.2-beta.10 → 0.3.2-beta.12

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 (74) hide show
  1. package/.env.example +1 -1
  2. package/CONTRIBUTING.md +1 -0
  3. package/INSTALL.md +49 -46
  4. package/README.md +8 -7
  5. package/bin/clearotron.mjs +14 -0
  6. package/bin/connect.mjs +68 -3
  7. package/bin/key.mjs +6 -1
  8. package/bin/onboard.mjs +51 -14
  9. package/bin/passphrase.mjs +4 -2
  10. package/bin/start.mjs +43 -9
  11. package/build-info.json +2 -2
  12. package/docs/architecture/05-config-governance.md +1 -1
  13. package/driver/CHANGELOG.md +416 -388
  14. package/driver/contract-vocabulary.mjs +5 -5
  15. package/driver/engine/mcp/recording-server.mjs +21 -1
  16. package/driver/engine/mcp/supplemental.mjs +22 -5
  17. package/driver/knockout-next-step.mjs +72 -0
  18. package/driver/named-band.mjs +1 -1
  19. package/driver/package.json +1 -1
  20. package/driver/phase0.mjs +16 -7
  21. package/driver/pipeline-knockout.mjs +26 -0
  22. package/driver/pipeline.mjs +48 -1
  23. package/driver/portal-local-auth.mjs +14 -4
  24. package/driver/portal-report.mjs +21 -2
  25. package/driver/portal-service.mjs +20 -11
  26. package/driver/publish/attr.mjs +36 -0
  27. package/driver/publish/index.mjs +10 -10
  28. package/driver/publish/parse.mjs +1 -1
  29. package/driver/publish/render-knockout.mjs +14 -9
  30. package/driver/publish/render.mjs +9 -9
  31. package/driver/publish/xlsx.mjs +7 -1
  32. package/driver/queue-watch-verdict.mjs +1 -1
  33. package/driver/register-availability.mjs +1 -1
  34. package/driver/register-plan.mjs +81 -11
  35. package/driver/result-noun-fields.mjs +3 -1
  36. package/driver/stages-knockout.mjs +1 -1
  37. package/driver/suite-census.json +104 -38
  38. package/driver/unit-inventory.mjs +7 -7
  39. package/driver/verify-knockout.mjs +0 -27
  40. package/mcp-server/CHANGELOG.md +27 -19
  41. package/mcp-server/lib/audit-view.mjs +4 -4
  42. package/mcp-server/lib/trace.mjs +1 -1
  43. package/mcp-server/package.json +1 -1
  44. package/package.json +2 -2
  45. package/portal-ui/dist/assets/{index-CtvwLCti.css → index-7Lq-dXDV.css} +12 -9
  46. package/portal-ui/dist/assets/{index-DXSRxPV_.js → index-w8GFZftk.js} +110 -69
  47. package/portal-ui/dist/index.html +2 -2
  48. package/portal-ui/package.json +1 -1
  49. package/providers/jx/README.md +2 -2
  50. package/providers/jx-subclass/README.md +1 -1
  51. package/providers/oauth-mcp-bridge/CHANGELOG.md +8 -0
  52. package/providers/oauth-mcp-bridge/package.json +1 -1
  53. package/scripts/README.md +5 -10
  54. package/scripts/ask-ai-render-check.mjs +26 -1
  55. package/scripts/citation-line-check.mjs +2 -2
  56. package/scripts/dead-names.mjs +17 -16
  57. package/scripts/e2e.mjs +8 -8
  58. package/scripts/env-audit.mjs +6 -0
  59. package/scripts/generated-files-are-current.mjs +35 -4
  60. package/scripts/live-surface-check.mjs +17 -17
  61. package/scripts/release-code-scanning-check.mjs +114 -0
  62. package/scripts/release-visible-check.mjs +117 -0
  63. package/scripts/repo-writes.mjs +42 -0
  64. package/scripts/report-sections-render-check.mjs +245 -0
  65. package/scripts/report-theme-render-check.mjs +31 -3
  66. package/scripts/settings-render-check.mjs +10 -7
  67. package/scripts/test-run.mjs +63 -4
  68. package/shared/client-door.mjs +20 -0
  69. package/shared/invocation.mjs +2 -2
  70. package/shared/parent-watch.mjs +33 -0
  71. package/shared/register-selection.mjs +4 -1
  72. package/shared/root-doc-commands.mjs +10 -4
  73. package/shared/running-start.mjs +14 -3
  74. package/shared/scope.mjs +24 -5
@@ -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();
@@ -119,3 +119,45 @@ export function explainRepoWrites(rows) {
119
119
  " touching the tree to tell the two apart.)",
120
120
  ];
121
121
  }
122
+
123
+ // ── the home the run executes as ────────────────────────────────────────────────────────────────────
124
+ //
125
+ // THE SAME RULE, ONE FOLDER OUT. The suite is documented as offline with no side effects, and a fresh
126
+ // clone's run left `~/trademark/telemetry/trademark-mcp-access.jsonl` behind (measured 2026-09-19, one
127
+ // line per run): a test spawned the stdio server with the real home, and `doctor` on that machine then
128
+ // reported the client door's access log as being written. These are the product's own folders under a
129
+ // home: the pool, workspace, queue and telemetry, and the settings, records and revocation list. A run
130
+ // leaves them as it found them. The rest of a home — npm's cache, a browser's profile — belongs to the
131
+ // tools a run uses, and is theirs to write.
132
+
133
+ /** The product's own folders under a home, relative to it. */
134
+ export const HOME_DATA = Object.freeze(["trademark", join(".config", "clearotron")]);
135
+
136
+ /** Every path under the home's product folders, stamped. A folder that does not exist is not walked. */
137
+ export function snapshotHome(home) {
138
+ const out = new Map();
139
+ for (const rel of HOME_DATA) {
140
+ const p = join(resolve(home), rel);
141
+ let isDir = false;
142
+ try { isDir = lstatSync(p).isDirectory(); } catch { continue; }
143
+ if (!isDir) { out.set(p, "not-a-directory"); continue; }
144
+ out.set(p, "dir");
145
+ for (const [k, v] of snapshotRepo(p)) out.set(k, v);
146
+ }
147
+ return out;
148
+ }
149
+
150
+ /** What a reader is told when a run wrote under the home's product folders. */
151
+ export function explainHomeWrites(rows, home) {
152
+ return [
153
+ "",
154
+ `[test-run] THIS RUN WROTE UNDER THE HOME IT RAN AS (${home}), in the product's own folders:`,
155
+ ...rows,
156
+ "",
157
+ " A test that starts a product command hands it a HOME under its own temp directory. Without one, the",
158
+ " command reads and writes the pool, queue, telemetry and settings of whoever runs the suite.",
159
+ "",
160
+ " (A service of this account writing there while the run was in flight prints this too. Run again",
161
+ " with HOME set to an empty temp directory to tell the two apart.)",
162
+ ];
163
+ }