clearotron 0.4.0-beta.0 → 0.4.0-beta.2

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 (293) hide show
  1. package/.env.example +9 -0
  2. package/INSTALL.md +4 -31
  3. package/README.md +5 -5
  4. package/bin/brandowner.mjs +1 -1
  5. package/bin/onboard.mjs +18 -74
  6. package/bin/start.mjs +58 -14
  7. package/bin/update.mjs +10 -2
  8. package/build-info.json +2 -2
  9. package/demo/full-country-search/run/_driver/search-policy.json +1 -1
  10. package/demo/full-country-search/run/status.json +1 -1
  11. package/demo/global-preliminary-search/run/_driver/search-policy.json +1 -1
  12. package/demo/global-preliminary-search/run/status.json +1 -1
  13. package/demo/knockout-search/run/_driver/search-policy.json +1 -1
  14. package/demo/knockout-search/run/status.json +1 -1
  15. package/demo/multi-country-focus-search/run/_driver/search-policy.json +1 -1
  16. package/demo/multi-country-focus-search/run/status.json +1 -1
  17. package/docs/README.md +1 -0
  18. package/docs/SECURITY-OWASP.md +298 -0
  19. package/docs/SECURITY.md +18 -13
  20. package/docs/architecture/02-architecture.md +5 -5
  21. package/docs/architecture/03-run-lifecycle.md +28 -29
  22. package/docs/architecture/04-configuration-reference.md +5 -10
  23. package/docs/architecture/05-config-governance.md +5 -7
  24. package/docs/architecture/06-operations-runbook.md +0 -12
  25. package/docs/architecture/07-quality-and-audit.md +12 -13
  26. package/docs/architecture/09-security-and-data.md +9 -4
  27. package/driver/CHANGELOG.md +70 -0
  28. package/driver/ask-ledger.mjs +4 -2
  29. package/driver/authority-trees.mjs +90 -8
  30. package/driver/band-shape.mjs +13 -13
  31. package/driver/blind-frame-model.mjs +1 -1
  32. package/driver/case-law-ledger.mjs +19 -3
  33. package/driver/claim-liveness.mjs +30 -1
  34. package/driver/clearance-variants-record.mjs +10 -3
  35. package/driver/common-law-receipts.mjs +64 -14
  36. package/driver/commonlaw-carry.mjs +2 -2
  37. package/driver/compare.mjs +2 -2
  38. package/driver/connotation-search.mjs +33 -104
  39. package/driver/contract-arm2-baseline.json +6 -5
  40. package/driver/contract-e3-backlog.mjs +62 -62
  41. package/driver/contract-vocabulary.mjs +38 -31
  42. package/driver/coverage-form-io.mjs +13 -1
  43. package/driver/coverage-form.mjs +42 -4
  44. package/driver/coverage-ledger.mjs +3 -5
  45. package/driver/cross-check-wait.mjs +43 -0
  46. package/driver/declination-call.mjs +34 -10
  47. package/driver/declination-tool.mjs +27 -15
  48. package/driver/deferral-row.mjs +139 -0
  49. package/driver/degraded-parts.mjs +377 -0
  50. package/driver/deliver-trigger.sh +1 -1
  51. package/driver/demo-container.mjs +4 -1
  52. package/driver/dev-portal.mjs +5 -5
  53. package/driver/dispatch-record.mjs +2 -2
  54. package/driver/driver.config.mjs +193 -96
  55. package/driver/e2e/README.md +1 -1
  56. package/driver/effective-scope.mjs +58 -2
  57. package/driver/engine/anthropic-agent.mjs +103 -27
  58. package/driver/engine/auth.mjs +2 -2
  59. package/driver/engine/child-record.mjs +11 -0
  60. package/driver/engine/cli-version.mjs +4 -3
  61. package/driver/engine/common.mjs +17 -2
  62. package/driver/engine/deny-authority-write.mjs +3 -1
  63. package/driver/engine/engine-env.mjs +174 -0
  64. package/driver/engine/engine-spawn.mjs +179 -0
  65. package/driver/engine/mcp/band-server.mjs +1 -1
  66. package/driver/engine/mcp/clarivate-server.mjs +2 -1
  67. package/driver/engine/mcp/codex-config.mjs +112 -3
  68. package/driver/engine/mcp/corsearch-server.mjs +1 -0
  69. package/driver/engine/mcp/declination-server.mjs +9 -8
  70. package/driver/engine/mcp/dispositions-server.mjs +2 -1
  71. package/driver/engine/mcp/euipo-server.mjs +1 -0
  72. package/driver/engine/mcp/fetch-server.mjs +14 -11
  73. package/driver/engine/mcp/free-tier-server.mjs +1 -1
  74. package/driver/engine/mcp/gather-config.mjs +23 -11
  75. package/driver/engine/mcp/perplexity-server.mjs +97 -17
  76. package/driver/engine/mcp/probe-server.mjs +43 -21
  77. package/driver/engine/mcp/public-fetch.mjs +195 -0
  78. package/driver/engine/mcp/recording-server.mjs +79 -14
  79. package/driver/engine/mcp/signa-server.mjs +1 -0
  80. package/driver/engine/mcp/supplemental.mjs +14 -14
  81. package/driver/engine/mcp/unit-note-server.mjs +50 -1
  82. package/driver/engine/mcp/uspto-local-server.mjs +1 -0
  83. package/driver/engine/openai-agent.mjs +109 -28
  84. package/driver/engine/probe.mjs +152 -19
  85. package/driver/enqueue-schema.mjs +5 -5
  86. package/driver/feedback-issues.mjs +1 -1
  87. package/driver/feedback-store.mjs +1 -1
  88. package/driver/findings-model.mjs +67 -16
  89. package/driver/flag-snapshot.mjs +1 -1
  90. package/driver/floor-duty.mjs +23 -7
  91. package/driver/form-neighbourhood.mjs +52 -17
  92. package/driver/frame-diff-model.mjs +3 -3
  93. package/driver/framework-method.mjs +304 -0
  94. package/driver/gateway.mjs +49 -21
  95. package/driver/hand-off-exits.mjs +129 -0
  96. package/driver/jx-lanes.mjs +1 -1
  97. package/driver/jx.mjs +8 -5
  98. package/driver/knockout-assess-record.mjs +7 -1
  99. package/driver/knockout-frame-record.mjs +96 -34
  100. package/driver/knockout-review-record.mjs +7 -2
  101. package/driver/log.mjs +2 -2
  102. package/driver/matter-frame-record.mjs +31 -2
  103. package/driver/methodology-witness.mjs +6 -3
  104. package/driver/named-band.mjs +1 -1
  105. package/driver/owner-use-check.mjs +45 -10
  106. package/driver/package.json +1 -1
  107. package/driver/partial-payload-baseline.json +4 -0
  108. package/driver/phase0.mjs +2 -2
  109. package/driver/pipeline-knockout.mjs +256 -129
  110. package/driver/pipeline.mjs +417 -614
  111. package/driver/placement-carry.mjs +22 -3
  112. package/driver/placement-form-io.mjs +18 -4
  113. package/driver/placement-form.mjs +53 -4
  114. package/driver/placement-union.mjs +32 -5
  115. package/driver/portal-report.mjs +3 -3
  116. package/driver/portal-service.mjs +6 -5
  117. package/driver/portal-static.mjs +11 -18
  118. package/driver/predelivery-lint.mjs +10 -10
  119. package/driver/profile-page.html +5 -5
  120. package/driver/profiles.mjs +5 -5
  121. package/driver/progress.mjs +6 -4
  122. package/driver/provider-usage.mjs +25 -2
  123. package/driver/publish/index.mjs +97 -25
  124. package/driver/publish/knockout.mjs +136 -14
  125. package/driver/publish/profiles-page.mjs +2 -0
  126. package/driver/publish/publish-inputs.mjs +10 -4
  127. package/driver/publish/render-knockout.mjs +48 -21
  128. package/driver/publish/render.mjs +13 -8
  129. package/driver/publish/report-data.mjs +2 -0
  130. package/driver/publish/search-depth.mjs +77 -4
  131. package/driver/publish/templates/report.css +1 -1
  132. package/driver/publish/xlsx.mjs +34 -15
  133. package/driver/reasoning-tripwires.mjs +2 -160
  134. package/driver/recall-receipt.mjs +27 -0
  135. package/driver/recall-reconciliation.mjs +1 -1
  136. package/driver/record-carry.mjs +14 -14
  137. package/driver/recording-agreement.mjs +2 -2
  138. package/driver/reference-score.mjs +169 -47
  139. package/driver/register-count.mjs +61 -4
  140. package/driver/register-digest-record.mjs +14 -1
  141. package/driver/register-plan.mjs +161 -80
  142. package/driver/register-records.mjs +37 -4
  143. package/driver/registration-scripts.mjs +22 -0
  144. package/driver/registry-fidelity.mjs +4 -4
  145. package/driver/repair-composers.mjs +23 -7
  146. package/driver/repair-contract.mjs +1 -1
  147. package/driver/repairs.mjs +5 -2
  148. package/driver/reviewer-open-points.mjs +33 -0
  149. package/driver/rule-shape.mjs +8 -8
  150. package/driver/run-economics.mjs +3 -3
  151. package/driver/run-integrity.mjs +2 -2
  152. package/driver/runner.mjs +17 -5
  153. package/driver/scope-facts.mjs +89 -12
  154. package/driver/score-redaction.mjs +247 -0
  155. package/driver/screen-gate.mjs +1 -1
  156. package/driver/search-policy.mjs +17 -0
  157. package/driver/skills/README.md +2 -2
  158. package/driver/skills/blind-frame/SKILL.md +2 -2
  159. package/driver/skills/clearance-common-law/SKILL.md +29 -36
  160. package/driver/skills/clearance-common-law/perplexity-prompts.md +8 -30
  161. package/driver/skills/clearance-register/SKILL.md +15 -16
  162. package/driver/skills/clearance-register/digest.md +15 -31
  163. package/driver/skills/clearance-register/register-recipes.md +14 -56
  164. package/driver/skills/clearance-register/unit.md +37 -48
  165. package/driver/skills/clearance-search/SKILL.md +6 -8
  166. package/driver/skills/clearance-search/delivery-contract.md +0 -7
  167. package/driver/skills/clearance-search/firm-wide-reasoning.md +5 -4
  168. package/driver/skills/clearance-search/phase2-execution.md +5 -6
  169. package/driver/skills/clearance-search/report-prose.md +5 -5
  170. package/driver/skills/clearance-search/risk-framework-triage.md +2 -2
  171. package/driver/skills/clearance-search/synthesis-rules.md +9 -9
  172. package/driver/skills/clearance-search/template-formatting.md +2 -2
  173. package/driver/skills/clearance-variants/SKILL.md +8 -8
  174. package/driver/skills/clearance-variants/transliteration-scripts.md +2 -2
  175. package/driver/skills/frame-diff/SKILL.md +2 -2
  176. package/driver/skills/knockout-assess/SKILL.md +40 -10
  177. package/driver/skills/knockout-frame/SKILL.md +42 -10
  178. package/driver/skills/matter-frame/SKILL.md +12 -5
  179. package/driver/skills/matter-frame/watchlist-reference.md +1 -1
  180. package/driver/skills/narrative-refutation/SKILL.md +5 -3
  181. package/driver/skills/placement-inquiry/SKILL.md +5 -1
  182. package/driver/stage-context.mjs +27 -6
  183. package/driver/stages-knockout.mjs +92 -79
  184. package/driver/stages.mjs +111 -68
  185. package/driver/stray-artifacts.mjs +9 -5
  186. package/driver/suite-census.json +696 -150
  187. package/driver/synthesis-record.mjs +26 -4
  188. package/driver/systemd/README.md +5 -6
  189. package/driver/systemd/clearotron-client-mcp.service +24 -0
  190. package/driver/systemd/clearotron-mcp-face.service +24 -0
  191. package/driver/systemd/clearotron-portal.service +24 -0
  192. package/driver/systemd/clearotron-worker.service +29 -0
  193. package/driver/tokens.mjs +10 -9
  194. package/driver/turnaround-bands.mjs +1 -1
  195. package/driver/unit-inventory.mjs +24 -43
  196. package/driver/variant-manifest-model.mjs +22 -3
  197. package/driver/verify-knockout.mjs +163 -4
  198. package/driver/verify.mjs +42 -16
  199. package/driver/web-grid.mjs +150 -0
  200. package/driver/whatif-memo-run.mjs +1 -1
  201. package/driver/withheld-families.mjs +113 -4
  202. package/driver/worker-heartbeat.mjs +13 -2
  203. package/mcp-server/CHANGELOG.md +8 -0
  204. package/mcp-server/README.md +1 -1
  205. package/mcp-server/lib/knockout.mjs +1 -1
  206. package/mcp-server/lib/ops.mjs +10 -10
  207. package/mcp-server/lib/scrub.mjs +1 -1
  208. package/mcp-server/lib/trace.mjs +3 -2
  209. package/mcp-server/package.json +1 -1
  210. package/mcp-server/server.mjs +3 -3
  211. package/package.json +1 -1
  212. package/portal-ui/dist/assets/{index-DVtz44vH.js → index-BPAUjcI0.js} +3 -2
  213. package/portal-ui/dist/assets/{index-5CCwiJG7.css → index-D2wrw9cH.css} +18 -2
  214. package/portal-ui/dist/assets/plus-jakarta-sans-BUCHxqJ-.woff2 +0 -0
  215. package/portal-ui/dist/index.html +2 -15
  216. package/portal-ui/package.json +1 -1
  217. package/providers/_shared/README.md +1 -1
  218. package/providers/_shared/answer-memory.mjs +199 -0
  219. package/providers/_shared/enumerate.mjs +155 -50
  220. package/providers/_shared/execute-plan.mjs +62 -16
  221. package/providers/_shared/ledger-path.mjs +1 -1
  222. package/providers/_shared/ledger.mjs +48 -6
  223. package/providers/_shared/plan-guards.mjs +7 -0
  224. package/providers/_shared/script-form.mjs +24 -5
  225. package/providers/_shared/term-shape.mjs +5 -5
  226. package/providers/clarivate/src/capabilities.js +11 -0
  227. package/providers/clarivate/src/core.js +193 -18
  228. package/providers/corsearch/README.md +1 -2
  229. package/providers/corsearch/src/core.js +2 -2
  230. package/providers/jx-subclass/lookup.mjs +1 -1
  231. package/providers/oauth-mcp-bridge/CHANGELOG.md +8 -0
  232. package/providers/oauth-mcp-bridge/README.md +8 -7
  233. package/providers/oauth-mcp-bridge/bridge.mjs +21 -5
  234. package/providers/oauth-mcp-bridge/package.json +1 -1
  235. package/providers/oauth-mcp-bridge/systemd/courtlistener-mcp.service +1 -1
  236. package/providers/oauth-mcp-bridge/warm-server.mjs +16 -5
  237. package/providers/perplexity/src/core.js +246 -21
  238. package/providers/signa/src/capabilities.js +24 -0
  239. package/providers/signa/src/core.js +93 -25
  240. package/scripts/README.md +2 -1
  241. package/scripts/added-reference-check.mjs +2 -1
  242. package/scripts/backup-recall-stores.mjs +5 -5
  243. package/scripts/demo-evidence.mjs +2 -1
  244. package/scripts/deprecate-below.mjs +114 -2
  245. package/scripts/drive-env-check.mjs +2 -1
  246. package/scripts/e2e-first-time.mjs +469 -0
  247. package/scripts/e2e-scenario-ops.mjs +441 -0
  248. package/scripts/e2e.mjs +104 -16
  249. package/scripts/env-audit.mjs +8 -0
  250. package/scripts/freeze-example-run.mjs +7 -7
  251. package/scripts/generated-files-are-current.mjs +2 -1
  252. package/scripts/hand-off-exits-probe.mjs +75 -0
  253. package/scripts/import-cycle-check.mjs +2 -1
  254. package/scripts/merge-shape-check.mjs +2 -1
  255. package/scripts/package-size-budget.mjs +2 -1
  256. package/scripts/record-carry-probe.mjs +1 -1
  257. package/scripts/release-entry-catch-up.mjs +77 -8
  258. package/scripts/release-install-check.mjs +4 -1
  259. package/scripts/release-note-required.mjs +139 -9
  260. package/scripts/release-rehearsal-version.mjs +60 -0
  261. package/scripts/release-sbom.mjs +104 -0
  262. package/scripts/release-visible-check.mjs +7 -5
  263. package/scripts/render-check.mjs +5 -2
  264. package/scripts/report-offline-render-check.mjs +169 -0
  265. package/scripts/score.mjs +79 -8
  266. package/scripts/strip-titles-and-attributions.mjs +2 -1
  267. package/scripts/strip-tracker-citations.mjs +2 -1
  268. package/scripts/test-full.mjs +2 -1
  269. package/scripts/test-run.mjs +10 -3
  270. package/scripts/third-party-notices.mjs +3 -1
  271. package/scripts/travelling-predicates.mjs +1 -1
  272. package/scripts/writing-standard-check.mjs +2 -1
  273. package/shared/brand-fonts.mjs +59 -0
  274. package/shared/brand.mjs +6 -6
  275. package/shared/browser-temp-root.mjs +3 -2
  276. package/shared/doctrine-overlay.mjs +1 -1
  277. package/shared/driver-dir.mjs +47 -11
  278. package/shared/fonts/OFL-fira-code.txt +93 -0
  279. package/shared/fonts/OFL-plus-jakarta-sans.txt +93 -0
  280. package/shared/fonts/README.md +37 -0
  281. package/shared/fonts/fira-code.woff2 +0 -0
  282. package/shared/fonts/plus-jakarta-sans.woff2 +0 -0
  283. package/shared/names-in-force.mjs +2 -0
  284. package/shared/npm-cli.mjs +23 -0
  285. package/shared/os-advice.mjs +14 -2
  286. package/shared/path-seps.mjs +42 -0
  287. package/shared/process-table.mjs +71 -3
  288. package/shared/reference-guard-classes.mjs +30 -6
  289. package/shared/root-doc-commands.mjs +6 -2
  290. package/shared/running-start.mjs +24 -7
  291. package/shared/scope.mjs +2 -2
  292. package/shared/wsl.mjs +1 -1
  293. package/driver/known-conflicts.mjs +0 -327
@@ -101,10 +101,46 @@
101
101
  // A deletion that gives no reason still refuses, and names the deleting commit as where the reason goes.
102
102
  // The move a pre-release makes — `.changeset/<name>.md` deleted and `.changeset/pre/<name>.md` added in
103
103
  // one commit — is not a withdrawal: the note still reaches a reader, and a version commit is that shape.
104
+ // ── A LATER COMMIT MAY ANSWER A BARE `none` FOR ONE NAMED COMMIT ───────────────────────────────────────
105
+ //
106
+ // A commit carried `Release-note: none` with no reason and was merged into an integration branch before
107
+ // anyone read it, and a second branch was already built on that merge. The only fix this check offered was
108
+ // the commit's own message, so the answer was a rewrite of a shared branch. A later commit may therefore
109
+ // answer for it, in its own message:
110
+ //
111
+ // Release-note-for: <sha> none — <why a reader would see no difference>
112
+ //
113
+ // Held tight, because a loose one excuses by accident:
114
+ // - one line names one commit, by its full sha or a prefix of at least 7 characters, resolved against
115
+ // THIS range's commits only. A prefix that matches none of them, or several, is refused;
116
+ // - the named commit must be an ANCESTOR of the answering one (git ancestry, never timestamps), and a
117
+ // commit cannot answer for itself;
118
+ // - the reason is read by NO_NOTE itself, so the two forms cannot drift. Empty, or a dash, is refused;
119
+ // - one commit takes one answer: two lines naming the same commit are both refused, even when they agree;
120
+ // - an answer naming a commit that owes none (it ships no code, carries a note, or gives its own reason)
121
+ // is refused as stale. An answer ignored in silence reads as though it did something.
122
+ // Every answer taken is printed with the sha that gave it, and every refused answer fails the check and
123
+ // names the commit that carries it. The answering commit is read like any other: one that ships no code,
124
+ // such as an empty commit, owes nothing of its own.
125
+ //
126
+ // ── …AND MAY ANSWER A LINE THAT PROMISED A NOTE, BY NAMING THE NOTE ─────────────────────────────────────
127
+ //
128
+ // Five commits on one design said `Release-note: to follow`, because the note's sentence was the owner's
129
+ // to write, and a second and third branch were built on them before the sentence existed. A sentence in a
130
+ // `Release-note:` line is refused, rightly: it reaches no reader. The only fix was again a rewrite of shared
131
+ // branches. So a later commit may answer such a commit by NAMING the note that answers for it:
132
+ //
133
+ // Release-note-for: <sha> .changeset/<name>.md
134
+ //
135
+ // under every rule above (one line, one earlier commit on its own history, one answer each), and one more:
136
+ // the range must add that note, exactly as a `Release-note:` line naming a note is read. It answers a
137
+ // commit whose own line is a sentence or a bare `none`. A commit that says nothing is answered by any note
138
+ // in the range already, so naming one for it is refused as stale.
104
139
  import { execFileSync } from "node:child_process";
105
140
  import { readFileSync } from "node:fs";
106
141
  import { join, dirname } from "node:path";
107
142
  import { fileURLToPath } from "node:url";
143
+ import { isEntrypoint } from "../shared/is-entrypoint.mjs";
108
144
 
109
145
  const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
110
146
 
@@ -163,6 +199,52 @@ export const NEVER_A_NOTE = [/(^|\/)CHANGELOG\.md$/, /(^|\/)package(-lock)?\.jso
163
199
  const NOTE_LINE = /^\s*Release-note:[ \t]*(?<value>.*?)[ \t]*$/gim;
164
200
  /** A note named in a `Release-note:` line: `.changeset/<name>.md`, or the bare `<name>.md`. */
165
201
  const NAMED_NOTE = /^(?:\.changeset\/)?(?<name>[\w.-]+\.md)$/;
202
+ /** `Release-note-for: <sha> none — <reason>`: a later commit answering a bare `none` (see the header). */
203
+ const ANSWER_LINE = /^[ \t]*Release-note-for:[ \t]*(?<rest>.*?)[ \t]*$/gim;
204
+ const ANSWER_HEAD = /^(?<sha>[0-9a-f]{7,40})(?<tail>(?:[ \t].*)?)$/i;
205
+
206
+ /**
207
+ * PURE. Every `Release-note-for:` line in the range, judged. `isAncestor(a, b)` says whether commit `a` is
208
+ * an ancestor of commit `b`; without one, range order stands in for it (the range is read oldest first).
209
+ * @returns {{answers: Map<string, {by:string, subject:string, reason:string}>,
210
+ * refused: Array<{sha:string, subject:string, text:string, problem:string}>}}
211
+ */
212
+ export function readAnswers(commits = [], isAncestor = null) {
213
+ const refused = [];
214
+ const claims = new Map();
215
+ commits.forEach((c, at) => {
216
+ for (const m of String(c.message ?? "").matchAll(ANSWER_LINE)) {
217
+ const text = m[0].trim();
218
+ const bad = (problem) => refused.push({ sha: c.sha, subject: c.subject, text, problem });
219
+ const head = ANSWER_HEAD.exec(m.groups.rest);
220
+ if (!head) { bad("names no commit: the form is `Release-note-for: <sha> none — <reason>` or `Release-note-for: <sha> .changeset/<name>.md`"); continue; }
221
+ const note = NAMED_NOTE.exec(head.groups.tail.trim())?.groups.name ?? null;
222
+ const none = note ? null : NO_NOTE.exec(`Release-note:${head.groups.tail}`);
223
+ if (!note && !none) { bad("does not say `none` or name a note straight after one sha: one line answers one commit"); continue; }
224
+ if (!note && !none.groups?.reason) { bad("gives no reason, and the reason is the whole point of the line"); continue; }
225
+ const prefix = head.groups.sha.toLowerCase();
226
+ const hits = commits.filter((k) => String(k.sha).toLowerCase().startsWith(prefix));
227
+ if (!hits.length) { bad(`names ${prefix}, which is not a commit in this range`); continue; }
228
+ if (hits.length > 1) { bad(`names ${prefix}, which matches ${hits.length} commits in this range`); continue; }
229
+ const target = hits[0];
230
+ const later = target.sha !== c.sha
231
+ && (isAncestor ? isAncestor(target.sha, c.sha) : commits.indexOf(target) < at);
232
+ if (!later) { bad(`names ${prefix}, which is not an earlier commit on this commit's own history`); continue; }
233
+ if (!claims.has(target.sha)) claims.set(target.sha, []);
234
+ claims.get(target.sha).push({ by: c.sha, subject: c.subject, text, ...(note ? { note } : { reason: none.groups.reason }) });
235
+ }
236
+ });
237
+ const answers = new Map();
238
+ for (const [sha, list] of claims) {
239
+ if (list.length > 1) {
240
+ for (const a of list) refused.push({ sha: a.by, subject: a.subject, text: a.text,
241
+ problem: `is one of ${list.length} answers naming ${sha.slice(0, 7)}; one commit takes one answer` });
242
+ continue;
243
+ }
244
+ answers.set(sha, list[0]);
245
+ }
246
+ return { answers, refused };
247
+ }
166
248
 
167
249
  const isNotePath = (p) => p.startsWith(".changeset/") && p.endsWith(".md") && !p.endsWith("README.md");
168
250
  const shipped = (paths, files) => paths.filter((p) => !NEVER_A_NOTE.some((re) => re.test(p)) && shipsCode(p, files));
@@ -181,12 +263,15 @@ const shipped = (paths, files) => paths.filter((p) => !NEVER_A_NOTE.some((re) =>
181
263
  * non-merge, oldest first; `added` and `deleted` are the paths the commit adds and removes, when the caller read them
182
264
  * @param {string[]} o.files the package's own `files` list
183
265
  * @param {string[]|null} [o.atHead] every path the head carries under `.changeset/`, when the caller read it
266
+ * @param {((a:string, b:string) => boolean)|null} [o.isAncestor] git ancestry for `Release-note-for:` answers; range order without it
184
267
  * @returns {{visible:string[], notes:string[], declined:Array<{sha:string, subject:string, reason:string}>,
185
268
  * withdrawals:Array<{sha:string, subject:string, note:string, by:string, reason:string}>,
269
+ * answered:Array<{sha:string, subject:string, by:string, reason:string}>,
270
+ * refusedAnswers:Array<{sha:string, subject:string, text:string, problem:string}>,
186
271
  * owed:Array<{sha:string, subject:string, why:"no-note"|"prose"|"bare-none"|"names-a-missing-note"|"note-withdrawn-unsaid",
187
272
  * paths?:string[], text?:string, by?:string}>}}
188
273
  */
189
- export function commitVerdicts({ commits = [], files = [], atHead = null } = {}) {
274
+ export function commitVerdicts({ commits = [], files = [], atHead = null, isAncestor = null } = {}) {
190
275
  // `added` and `atHead` are what the command reads from git; a caller that passes neither keeps the older
191
276
  // reading of every changed path, which is what a pure table of paths means.
192
277
  // MATCHED ON THE NAME, NOT THE PATH, BECAUSE A PRE-RELEASE MOVES A NOTE IT CONSUMES.
@@ -234,7 +319,9 @@ export function commitVerdicts({ commits = [], files = [], atHead = null } = {})
234
319
  });
235
320
  // AND A NAME THE HEAD STILL CARRIES WAS NEVER WITHDRAWN AT ALL: the release assembles what the head has.
236
321
  if (presentNames) for (const name of [...withdrawn.keys()]) if (presentNames.has(name)) withdrawn.delete(name);
237
- const declined = [], owed = [], withdrawals = [];
322
+ const declined = [], owed = [], withdrawals = [], answered = [];
323
+ const { answers, refused: refusedAnswers } = readAnswers(commits, isAncestor);
324
+ const used = new Set();
238
325
  for (const [i, c] of commits.entries()) {
239
326
  const ships = shipped(c.paths, files);
240
327
  if (!ships.length) continue; // nothing a reader could see in this commit
@@ -243,11 +330,26 @@ export function commitVerdicts({ commits = [], files = [], atHead = null } = {})
243
330
  const named = values.map((v) => NAMED_NOTE.exec(v)?.groups.name).filter(Boolean);
244
331
  const prose = values.filter((v) => !/^none\b/i.test(v) && !NAMED_NOTE.test(v));
245
332
  const at = { sha: c.sha, subject: c.subject };
246
- if (prose.length && !addsNote) { owed.push({ ...at, why: "prose", text: prose[0] }); continue; }
333
+ // A LATER COMMIT'S ANSWER NAMING A NOTE: taken for a sentence line or a bare `none`, when the range adds it.
334
+ const byNote = (answer) => {
335
+ used.add(c.sha);
336
+ if (!noteNames.has(answer.note)) { owed.push({ ...at, why: "names-a-missing-note", text: answer.note, by: answer.by }); return; }
337
+ answered.push({ ...at, by: answer.by, note: answer.note, said: prose[0] ?? "none" });
338
+ };
339
+ if (prose.length && !addsNote) {
340
+ const answer = answers.get(c.sha);
341
+ if (answer?.note) byNote(answer);
342
+ else owed.push({ ...at, why: "prose", text: prose[0] });
343
+ continue;
344
+ }
247
345
  const none = NO_NOTE.exec(String(c.message ?? ""));
248
346
  if (none) {
249
347
  const reason = none.groups?.reason;
250
- if (reason) declined.push({ ...at, reason }); else owed.push({ ...at, why: "bare-none" });
348
+ const answer = reason ? null : answers.get(c.sha);
349
+ if (reason) declined.push({ ...at, reason });
350
+ else if (answer?.note) byNote(answer);
351
+ else if (answer) { answered.push({ ...at, by: answer.by, reason: answer.reason }); used.add(c.sha); }
352
+ else owed.push({ ...at, why: "bare-none" });
251
353
  continue;
252
354
  }
253
355
  if (addsNote) continue; // it carries its own note
@@ -268,7 +370,18 @@ export function commitVerdicts({ commits = [], files = [], atHead = null } = {})
268
370
  if (notes.length) continue; // a note in the range answers for it
269
371
  owed.push({ ...at, why: "no-note", paths: ships });
270
372
  }
271
- return { visible, notes, declined, withdrawals, owed };
373
+ // AN ANSWER THAT ANSWERED NOTHING IS REFUSED, NOT IGNORED (see the header).
374
+ const owing = new Set(owed.map((o) => o.sha));
375
+ for (const [sha, a] of answers) {
376
+ if (used.has(sha)) continue;
377
+ refusedAnswers.push({ sha: a.by, subject: a.subject, text: a.text,
378
+ problem: owing.has(sha)
379
+ ? (a.note
380
+ ? `names ${sha.slice(0, 7)}, whose own line this answer does not reach: a note answers only a sentence or a bare \`none\` in the named commit`
381
+ : `names ${sha.slice(0, 7)}, which owes a note this line cannot give: an answer covers only a bare \`none\``)
382
+ : `names ${sha.slice(0, 7)}, which owes no answer: it ships no code, carries a note, or gives its own reason` });
383
+ }
384
+ return { visible, notes, declined, withdrawals, owed, answered, refusedAnswers };
272
385
  }
273
386
 
274
387
  const argAfter = (flag) => {
@@ -333,7 +446,9 @@ function main() {
333
446
  + "history is never read.");
334
447
  return;
335
448
  }
336
- const { visible, notes, declined, withdrawals, owed } = commitVerdicts({ commits, files, atHead });
449
+ // ANCESTRY FROM GIT, NEVER TIMESTAMPS: an answer must come from a commit the named one is an ancestor of.
450
+ const isAncestor = (a, b) => { try { git("merge-base", "--is-ancestor", a, b); return true; } catch { return false; } };
451
+ const { visible, notes, declined, withdrawals, owed, answered, refusedAnswers } = commitVerdicts({ commits, files, atHead, isAncestor });
337
452
  console.log(`release-note-required: ${changed.length} changed file(s) against ${base}`
338
453
  + `${head === "HEAD" ? "" : ` (head ${head})`}; `
339
454
  + `${visible.length} ship as code; ${notes.length} release note(s) in the range; ${commits.length} commit(s) read`
@@ -352,7 +467,20 @@ function main() {
352
467
  for (const w of withdrawals) {
353
468
  console.log(` ${w.sha.slice(0, 7)} answered with ${w.note}, which ${w.by.slice(0, 7)} withdrew: ${w.reason}`);
354
469
  }
355
- if (!owed.length) return;
470
+ for (const a of answered) {
471
+ console.log(a.note
472
+ ? ` ${a.sha.slice(0, 7)} said ${a.said === "none" ? "a bare `none`" : `"${a.said}"`}, which ${a.by.slice(0, 7)} answers with ${a.note}`
473
+ : ` ${a.sha.slice(0, 7)} said a bare \`none\`, which ${a.by.slice(0, 7)} answers: ${a.reason}`);
474
+ }
475
+ if (!owed.length && !refusedAnswers.length) return;
476
+ if (refusedAnswers.length) {
477
+ console.error(`\n${refusedAnswers.length} Release-note-for answer(s) refused:\n`);
478
+ for (const r of refusedAnswers) {
479
+ console.error(` ${r.sha.slice(0, 7)} ${r.subject}`);
480
+ console.error(` "${r.text.slice(0, 120)}" ${r.problem}.`);
481
+ }
482
+ }
483
+ if (!owed.length) process.exit(1);
356
484
 
357
485
  console.error(`\n${owed.length} commit(s) that ship as code owe a release note:\n`);
358
486
  for (const o of owed) {
@@ -373,7 +501,9 @@ function main() {
373
501
  + "\n release too, say that in THAT commit — `Release-note: none — <why>` — which is the newest of"
374
502
  + "\n the two and the one that can still be amended.");
375
503
  } else {
376
- console.error(` names ${o.text} as its note, and this range adds no note by that name.`);
504
+ console.error(o.by
505
+ ? ` is answered by ${o.by.slice(0, 7)} with ${o.text}, and this range adds no note by that name.`
506
+ : ` names ${o.text} as its note, and this range adds no note by that name.`);
377
507
  }
378
508
  }
379
509
  console.error("\nSomebody installing Clearotron reads the releases page to decide whether to upgrade. Add a"
@@ -384,4 +514,4 @@ function main() {
384
514
  process.exit(1);
385
515
  }
386
516
 
387
- if (import.meta.url === `file://${process.argv[1]}`) main();
517
+ if (isEntrypoint(import.meta.url)) main();
@@ -0,0 +1,60 @@
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-rehearsal-version.mjs — what a rehearsal publishes: the version a beta cut would publish now.
5
+ //
6
+ // node scripts/release-rehearsal-version.mjs # stdout: publish_dry_run=true|false; the rest on stderr
7
+ //
8
+ // WHY A REHEARSAL DOES NOT PUBLISH MAIN'S VERSION. Main's version is the one already out on almost every
9
+ // day: a cut stamps it and publishes it, and it stays on main until the next cut. npm checks the registry
10
+ // even on `npm publish --dry-run`, so a rehearsal of main's own version answered "You cannot publish over
11
+ // the previously published versions" and went red (measured 2026-09-23 on 0.3.3), reading as a broken
12
+ // pipeline when nothing was wrong. It passed only between a cut and its publish, when nobody rehearses.
13
+ //
14
+ // So a rehearsal stamps the tree the way a `cut: beta` dispatch does, with release-version.mjs itself, and
15
+ // dry-runs that version: the one a cut made now would publish. The stamp lives in the job's own checkout
16
+ // and is never committed.
17
+ //
18
+ // WITH NO NOTE PENDING THERE IS NOTHING TO CUT, and a cut would refuse for exactly that reason. The
19
+ // version is then main's, which is out, so the publish dry run is skipped and says why, while every check
20
+ // before it still runs on those bytes. A note that is pending and still cannot be cut is a real failure,
21
+ // and the rehearsal fails with the version script's own refusal.
22
+ //
23
+ // Exit 0: stamped, or nothing to cut. Exit 1: the version script refused. Exit 2: could not look.
24
+ import { spawnSync } from "node:child_process";
25
+ import { join, dirname } from "node:path";
26
+ import { fileURLToPath } from "node:url";
27
+ import { isEntrypoint } from "../shared/is-entrypoint.mjs";
28
+ import { countNotes } from "./release-pre-gate.mjs";
29
+
30
+ const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
31
+
32
+ /** PURE. What a rehearsal does with the notes it finds pending at the top of `.changeset/`. */
33
+ export function rehearsalPlan({ pendingNotes }) {
34
+ if (!Number.isInteger(pendingNotes) || pendingNotes < 0) return { action: "could-not-look" };
35
+ return pendingNotes > 0 ? { action: "stamp-next-beta" } : { action: "nothing-to-cut" };
36
+ }
37
+
38
+ export function main(root = ROOT) {
39
+ let pendingNotes;
40
+ try { pendingNotes = countNotes(join(root, ".changeset")); }
41
+ catch (e) { console.error(`release-rehearsal-version: could not read .changeset/: ${e.message}. Could not look.`); return 2; }
42
+ const plan = rehearsalPlan({ pendingNotes });
43
+ if (plan.action === "could-not-look") { console.error("release-rehearsal-version: could not count the pending notes. Could not look."); return 2; }
44
+ if (plan.action === "nothing-to-cut") {
45
+ console.error("release-rehearsal-version: no release note is pending, so there is nothing to cut and main's version is the one already out.");
46
+ console.log("publish_dry_run=false");
47
+ return 0;
48
+ }
49
+ console.error(`release-rehearsal-version: ${pendingNotes} note(s) pending; stamping the version a \`cut: beta\` would publish.`);
50
+ // The version script's own output goes to stderr, so stdout carries the one line the workflow keeps.
51
+ const r = spawnSync(process.execPath, [join(root, "scripts", "release-version.mjs"), "--cut=beta"], { cwd: root, stdio: ["ignore", 2, 2] });
52
+ if (r.status !== 0) {
53
+ console.error(`release-rehearsal-version: the version script refused (exit ${r.status ?? r.signal}), so a beta cut made now would refuse too.`);
54
+ return 1;
55
+ }
56
+ console.log("publish_dry_run=true");
57
+ return 0;
58
+ }
59
+
60
+ if (isEntrypoint(import.meta.url)) process.exitCode = main();
@@ -0,0 +1,104 @@
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-sbom.mjs — the parts list attached to every release, written from package-lock.json.
5
+ //
6
+ // node scripts/release-sbom.mjs [--out <file>] # CycloneDX 1.5 JSON; stdout when --out is absent
7
+ //
8
+ // WHY NOT `npm sbom`. It refuses this tree: `ESBOMPROBLEMS: invalid: uuid@11.1.1 … required by
9
+ // exceljs@4.4.0`, on a clean `npm ci` as well, and `npm ls` flags the same edge. That is npm's reading of
10
+ // the `uuid` override on the bundled `exceljs`, and an exact-version override is flagged the same way
11
+ // (measured 2026-09-23 on npm 10.9.8). The reference tool, `@cyclonedx/cyclonedx-npm`, would add a
12
+ // dependency that runs inside a release job. The lock already names every package, its version, its
13
+ // integrity and its licence, which is what a parts list is.
14
+ //
15
+ // WHAT IS LISTED. Every package the lock installs that is not dev-only: the runtime dependencies and
16
+ // everything they pull in, and the bundled ones this package carries inside its tarball. A consumer's
17
+ // npm resolves the unbundled ones again at install time, so the list is what this release was built and
18
+ // tested with, which is what an SBOM of a library can honestly say. Workspace links are this package's
19
+ // own files, not parts, and are left out; a vendored package is listed by its path in this tree.
20
+ //
21
+ // THE SAME LOCK GIVES THE SAME BYTES. No timestamp and no serial number: both are optional in the spec,
22
+ // and either would make two runs over one lock differ for no reason a reader can use.
23
+ import { readFileSync, writeFileSync } from "node:fs";
24
+ import { join, dirname } from "node:path";
25
+ import { fileURLToPath } from "node:url";
26
+ import { isEntrypoint } from "../shared/is-entrypoint.mjs";
27
+
28
+ const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
29
+
30
+ /** `pkg:npm/<name>@<version>`, with a scope's `@` encoded as the purl spec asks. */
31
+ export const purlOf = (name, version) => `pkg:npm/${name.startsWith("@") ? `%40${name.slice(1)}` : name}@${version}`;
32
+
33
+ /** An npm integrity string (`sha512-<base64>`) as CycloneDX hashes, hex-encoded. */
34
+ export function hashesOf(integrity) {
35
+ const out = [];
36
+ for (const part of String(integrity ?? "").split(/\s+/).filter(Boolean)) {
37
+ const m = /^(sha512|sha384|sha256|sha1)-(.+)$/.exec(part);
38
+ if (!m) continue;
39
+ const alg = { sha512: "SHA-512", sha384: "SHA-384", sha256: "SHA-256", sha1: "SHA-1" }[m[1]];
40
+ out.push({ alg, content: Buffer.from(m[2], "base64").toString("hex") });
41
+ }
42
+ return out;
43
+ }
44
+
45
+ /**
46
+ * The CycloneDX document for a parsed lockfile and the root manifest. PURE.
47
+ * @param {{ lock: object, manifest: object }} input
48
+ */
49
+ export function sbomFor({ lock, manifest }) {
50
+ const byRef = new Map();
51
+ const workspaces = new Set((manifest.workspaces ?? []).map((w) => w.replace(/\/+$/, "")));
52
+ for (const [path, entry] of Object.entries(lock.packages ?? {})) {
53
+ if (!path.startsWith("node_modules/") && !path.includes("/node_modules/")) continue; // the root and link targets
54
+ if (entry.dev) continue; // dev-only tools
55
+ // A LINK TO A WORKSPACE is this package's own code. A link to anything else is code this package
56
+ // carries in its own tree, such as a vendored replacement, and it is listed by where it lives, with no
57
+ // npm purl: that would name the registry's package of the same name, which it is not.
58
+ if (entry.link) {
59
+ const target = lock.packages?.[entry.resolved] ?? {};
60
+ if (workspaces.has(entry.resolved) || !target.version || target.dev) continue;
61
+ const name = path.slice(path.lastIndexOf("node_modules/") + "node_modules/".length);
62
+ const ref = `file:${entry.resolved}`;
63
+ const vendored = { type: "library", "bom-ref": ref, name, version: target.version,
64
+ properties: [{ name: "clearotron:vendored-path", value: entry.resolved }] };
65
+ if (typeof target.license === "string" && target.license) vendored.licenses = [{ expression: target.license }];
66
+ byRef.set(ref, vendored);
67
+ continue;
68
+ }
69
+ const name = entry.name ?? path.slice(path.lastIndexOf("node_modules/") + "node_modules/".length);
70
+ if (!entry.version) continue;
71
+ const ref = purlOf(name, entry.version);
72
+ if (byRef.has(ref)) continue;
73
+ const component = { type: "library", "bom-ref": ref, name, version: entry.version, purl: ref };
74
+ const hashes = hashesOf(entry.integrity);
75
+ if (hashes.length) component.hashes = hashes;
76
+ if (typeof entry.license === "string" && entry.license) component.licenses = [{ expression: entry.license }];
77
+ byRef.set(ref, component);
78
+ }
79
+ const components = [...byRef.values()].sort((a, b) => a["bom-ref"].localeCompare(b["bom-ref"]));
80
+ const root = { type: "library", "bom-ref": purlOf(manifest.name, manifest.version), name: manifest.name, version: manifest.version,
81
+ purl: purlOf(manifest.name, manifest.version) };
82
+ if (manifest.license) root.licenses = [{ expression: manifest.license }];
83
+ return { bomFormat: "CycloneDX", specVersion: "1.5", version: 1, metadata: { component: root }, components };
84
+ }
85
+
86
+ function main() {
87
+ const argv = process.argv.slice(2);
88
+ const i = argv.indexOf("--out");
89
+ const out = i === -1 ? null : argv[i + 1];
90
+ if (i !== -1 && !out) { console.error("release-sbom: --out needs a file."); return 2; }
91
+ let lock, manifest;
92
+ try {
93
+ lock = JSON.parse(readFileSync(join(ROOT, "package-lock.json"), "utf8"));
94
+ manifest = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8"));
95
+ } catch (e) { console.error(`release-sbom: could not read the lock or the manifest: ${e.message}. Could not look.`); return 2; }
96
+ const doc = sbomFor({ lock, manifest });
97
+ if (!doc.components.length) { console.error("release-sbom: the lock lists no runtime package, so there is no parts list to write. Refusing an empty one."); return 1; }
98
+ const text = JSON.stringify(doc, null, 2) + "\n";
99
+ if (out) { writeFileSync(out, text); console.log(`release-sbom: ${doc.components.length} part(s) of ${manifest.name}@${manifest.version} written to ${out}.`); }
100
+ else process.stdout.write(text);
101
+ return 0;
102
+ }
103
+
104
+ if (isEntrypoint(import.meta.url)) process.exitCode = main();
@@ -7,9 +7,11 @@
7
7
  // [--timeout 900] [--interval 15] [--registry https://registry.npmjs.org] [--name clearotron]
8
8
  //
9
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.
10
+ // the one at <file> — the bytes this run published. Exit 1: not served within the bound, which is PENDING:
11
+ // npm accepts a publish before it serves it. Exit 3: the registry serves DIFFERENT BYTES for <v>, which is
12
+ // never pending and must go red at once, so it has a code of its own; it used to share 1, and a caller
13
+ // that waits on 1 then waited six hours on a wrong tarball. Exit 2: could not look — bad arguments, or the
14
+ // local tarball cannot be read. In every case but 0 the caller creates no release entry.
13
15
  //
14
16
  // ── WHY THIS RUNS BEFORE THE RELEASE ENTRY ─────────────────────────────────────────────────────────
15
17
  //
@@ -89,7 +91,7 @@ async function main() {
89
91
  console.error(`\nrelease-visible-check: after ${waited}s an unauthenticated client still cannot get ${name}@${version} `
90
92
  + `under \`${tag}\`. The publish was accepted and is not yet served, so no release entry is created: `
91
93
  + "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.");
94
+ + "knows this version is published; the scheduled entry job creates the entry once the registry serves it.");
93
95
  process.exit(1);
94
96
  }
95
97
  await sleep(intervalSec * 1000);
@@ -108,7 +110,7 @@ async function main() {
108
110
  console.error(`release-visible-check: the registry serves DIFFERENT BYTES for ${name}@${version}.\n`
109
111
  + ` published by this run: ${local}\n served: ${served}\n recorded integrity: ${seen.integrity ?? "none"}\n`
110
112
  + "No release entry is created for a version whose install is not the build this run verified.");
111
- process.exit(1);
113
+ process.exit(3);
112
114
  }
113
115
  console.log(`release-visible-check: a stranger can install ${name}@${version} under \`${tag}\`, `
114
116
  + `and the tarball served is the one this run published (${served.slice(0, 22)}…).`);
@@ -288,9 +288,12 @@ window.addEventListener('message',function(e){var d=e.data;
288
288
  // Every wait below is a real wait for a named condition, and each one RECORDS which way it ended. A
289
289
  // deadline that fires is a fact about the page, not a silent substitute for the condition.
290
290
  function after(ms,cb){setTimeout(cb,ms);}
291
+ // QUIET NEEDS SOMETHING TO GO QUIET. Timed from the shell's own start, 400ms with no post passed on a
292
+ // starved runner before the frame had posted once (0 height posts, content 0), and the read found the
293
+ // frame's default height. Every passing run has at least one post; none by the cap is named for itself.
291
294
  function whenQuiet(ms,cap,cb){var t0=Date.now();(function w(){
292
- if(Date.now()-lastHeightAt>=ms)return cb('quiet');
293
- if(Date.now()-t0>=cap)return cb('still-posting');
295
+ if(st.heightMsgs>0&&Date.now()-lastHeightAt>=ms)return cb('quiet');
296
+ if(Date.now()-t0>=cap)return cb(st.heightMsgs?'still-posting':'no-height-post');
294
297
  after(25,w);})();}
295
298
  function whenLaidOut(cb){var done=false,how='';
296
299
  var go=function(w){if(done)return;done=true;how=w;cb(w);};
@@ -0,0 +1,169 @@
1
+ #!/usr/bin/env node
2
+ // SPDX-License-Identifier: AGPL-3.0-only
3
+ // Copyright 2026 Cordillera Sàrl. Additional terms under section 7 of the AGPL-3.0 apply — see ADDITIONAL-TERMS.md
4
+ //
5
+ // report-offline-render-check.mjs — opening a report contacts no one, measured in a real browser.
6
+ // node scripts/report-offline-render-check.mjs [--keep]
7
+ //
8
+ // A report used to fetch its typefaces from two font services, so opening one told them the reader's
9
+ // address and that a report had been opened. It now carries its fonts inside itself. This opens every
10
+ // report the demo publishes, and every pool index beside them, and records EVERY request each page makes:
11
+ // • from disk, as a report opened from an email or a download is;
12
+ // • served over HTTP with the portal's own report policy, as the portal frames it.
13
+ // A request to any other machine fails the check. So does a report whose embedded faces did not load, or
14
+ // a policy violation while it was drawn.
15
+ //
16
+ // WHAT THE REQUESTS ARE READ FROM. Chrome's own record of what the page asked for (Network.requestWillBeSent
17
+ // on the page's session), not what reached a server: the network is blocked, so a request that was made
18
+ // fails where it would have left, and it is still recorded. Chrome's background traffic is not the page's
19
+ // and is not on this session.
20
+ //
21
+ // THE CONTROLS, because a blind instrument reads exactly like a clean page. Before any real page is
22
+ // judged, one report is opened with a font service's stylesheet put back in its head, and the check must
23
+ // see that request; and one with its embedded faces taken out, and the check must see no face loaded. If
24
+ // either control passes, nothing measured after it means anything, and the check stops with exit 2.
25
+ import { spawn, spawnSync } from "node:child_process";
26
+ import { createServer } from "node:http";
27
+ import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
28
+ import { extname, join, dirname, relative } from "node:path";
29
+ import { tmpdir } from "node:os";
30
+ import { fileURLToPath, pathToFileURL } from "node:url";
31
+ import { reapOnExit } from "../shared/reap-on-exit.mjs";
32
+ import { browserRun } from "../shared/browser-temp-root.mjs";
33
+ import { reportCsp } from "../driver/portal-static.mjs";
34
+ import { TEXT_FACE, CODE_FACE, REPORT_FONT_STYLE } from "../shared/brand-fonts.mjs";
35
+
36
+ const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
37
+ const keep = process.argv.includes("--keep");
38
+ const TYPES = { ".html": "text/html; charset=utf-8", ".css": "text/css; charset=utf-8", ".js": "text/javascript", ".json": "application/json", ".svg": "image/svg+xml", ".png": "image/png" };
39
+
40
+ // ── THE PAGES ────────────────────────────────────────────────────────────────────────────────────────
41
+ const pool = mkdtempSync(join(tmpdir(), "offline-check-pool-"));
42
+ const published = spawnSync(process.execPath, [join(ROOT, "bin", "example.mjs"), "--once", "--pool", pool], { encoding: "utf8" });
43
+ if (published.status !== 0) { console.error(`the demo publisher failed:\n${published.stderr || published.stdout}`); process.exit(2); }
44
+ const pages = [];
45
+ const walk = (d) => { for (const n of readdirSync(d)) { const p = join(d, n); if (statSync(p).isDirectory()) walk(p); else if (n.endsWith(".html")) pages.push(p); } };
46
+ walk(pool);
47
+ const reports = pages.filter((p) => p.endsWith("/report.html"));
48
+ if (reports.length !== 4) { console.error(`the demo published ${reports.length} reports, not one per product (4) — nothing to measure`); process.exit(2); }
49
+
50
+ // The two controls, written beside a real report so its relative paths still resolve.
51
+ const sample = readFileSync(reports[0], "utf8");
52
+ if (!sample.includes(REPORT_FONT_STYLE)) { console.error("the sample report does not carry the embedded faces, so the controls cannot be built"); process.exit(2); }
53
+ const leak = join(dirname(reports[0]), "control-leak.html");
54
+ writeFileSync(leak, sample.replace("</title>", '</title><link href="https://api.fontshare.com/v2/css?f[]=satoshi@400&display=swap" rel="stylesheet">'));
55
+ const bare = join(dirname(reports[0]), "control-no-faces.html");
56
+ writeFileSync(bare, sample.replace(REPORT_FONT_STYLE, ""));
57
+
58
+ // ── THE SERVER: the pool as the portal would serve a report, with the portal's report policy ─────────
59
+ const server = createServer((req, res) => {
60
+ const file = join(pool, decodeURIComponent(new URL(req.url, "http://x").pathname));
61
+ if (!file.startsWith(pool) || !existsSync(file) || !statSync(file).isFile()) { res.writeHead(404); return res.end(); }
62
+ const headers = { "content-type": TYPES[extname(file)] ?? "application/octet-stream" };
63
+ if (file.endsWith(".html")) headers["content-security-policy"] = reportCsp();
64
+ res.writeHead(200, headers);
65
+ res.end(readFileSync(file));
66
+ });
67
+ await new Promise((r) => server.listen(0, "127.0.0.1", r));
68
+ const origin = `http://127.0.0.1:${server.address().port}`;
69
+
70
+ // ── THE BROWSER ──────────────────────────────────────────────────────────────────────────────────────
71
+ const { root: browserRoot, profile, env: chromeEnv, keep: keepRoot } = browserRun("report-offline-check-");
72
+ if (keep) keepRoot();
73
+ const chrome = spawn("google-chrome", [
74
+ "--headless=new", "--disable-gpu", "--no-sandbox", "--hide-scrollbars",
75
+ // Nothing leaves the machine. A request the page makes still appears in the record below and fails
76
+ // where it would have left; that is how a leak is caught without one being made.
77
+ "--host-resolver-rules=MAP * ~NOTFOUND, EXCLUDE 127.0.0.1",
78
+ `--user-data-dir=${profile}`, "--window-size=1280,900", "--remote-debugging-port=0", "about:blank",
79
+ ], { stdio: ["ignore", "ignore", "pipe"], detached: true, env: chromeEnv });
80
+ reapOnExit(chrome);
81
+ const wsUrl = await new Promise((resolve, reject) => {
82
+ let devtools = "";
83
+ const t = setTimeout(() => reject(new Error(`chrome reported no devtools endpoint in 60s:\n${devtools || "(nothing)"}`)), 60000);
84
+ chrome.stderr.on("data", (c) => { devtools += c; const m = devtools.match(/ws:\/\/[^\s]+/); if (m) { clearTimeout(t); resolve(m[0]); } });
85
+ });
86
+ const ws = new WebSocket(wsUrl);
87
+ let id = 0;
88
+ const pending = new Map();
89
+ let requested = [];
90
+ ws.addEventListener("message", (e) => {
91
+ const m = JSON.parse(e.data);
92
+ if (m.method === "Network.requestWillBeSent") requested.push(m.params.request.url);
93
+ if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id); }
94
+ });
95
+ await new Promise((r) => ws.addEventListener("open", r));
96
+ const send = (method, params = {}, sessionId) => new Promise((r) => { const i = ++id; pending.set(i, r); ws.send(JSON.stringify({ id: i, method, params, ...(sessionId ? { sessionId } : {}) })); });
97
+ const { result: targets } = await send("Target.getTargets");
98
+ const target = targets.targetInfos.find((t) => t.type === "page");
99
+ const sessionId = (await send("Target.attachToTarget", { targetId: target.targetId, flatten: true })).result.sessionId;
100
+ const cmd = (method, params) => send(method, params, sessionId);
101
+ const value = async (expr) => (await cmd("Runtime.evaluate", { expression: expr, awaitPromise: true, returnByValue: true })).result?.result?.value ?? null;
102
+ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
103
+ await cmd("Page.enable");
104
+ await cmd("Network.enable");
105
+ // Before any script of the page's own, so a violation raised while the head is parsed is not missed.
106
+ await cmd("Page.addScriptToEvaluateOnNewDocument", { source: "window.__violations=[];document.addEventListener('securitypolicyviolation',function(e){window.__violations.push(e.violatedDirective+' '+e.blockedURI)});" });
107
+
108
+ /** Open one page and report what it asked for, which faces it drew with, and any policy it broke. */
109
+ async function open(url) {
110
+ requested = [];
111
+ await cmd("Page.navigate", { url });
112
+ const end = Date.now() + 15000;
113
+ while (Date.now() < end && (await value("document.readyState")) !== "complete") await wait(100);
114
+ await value("document.fonts.ready.then(() => true)");
115
+ await wait(300);
116
+ const faces = await value("JSON.stringify([...document.fonts].map((f) => ({ family: f.family.replace(/[\"']/g, ''), status: f.status })))");
117
+ const violations = await value("JSON.stringify(window.__violations || [])");
118
+ const away = requested.filter((u) => /^https?:/i.test(u) && !u.startsWith(origin));
119
+ return { away, faces: JSON.parse(faces ?? "[]"), violations: JSON.parse(violations ?? "[]"), requested: requested.length };
120
+ }
121
+ const loaded = (faces, family) => faces.some((f) => f.family === family && f.status === "loaded");
122
+
123
+ let failures = 0;
124
+ const fail = (m) => { failures += 1; console.error(` ✕ ${m}`); };
125
+ let exit = 0;
126
+ try {
127
+ // ── THE CONTROLS FIRST ──────────────────────────────────────────────────────────────────────────────
128
+ const leaked = await open(pathToFileURL(leak).href);
129
+ if (!leaked.away.some((u) => URL.canParse(u) && new URL(u).hostname === "api.fontshare.com")) {
130
+ console.error("report-offline-render-check: a report with a font service put back made no request this check could see. The instrument is blind; nothing below would mean anything.");
131
+ exit = 2;
132
+ }
133
+ const noFaces = await open(pathToFileURL(bare).href);
134
+ if (exit === 0 && (loaded(noFaces.faces, TEXT_FACE) || loaded(noFaces.faces, CODE_FACE))) {
135
+ console.error("report-offline-render-check: a report with its embedded faces taken out still reports them loaded. The font reading is blind; nothing below would mean anything.");
136
+ exit = 2;
137
+ }
138
+ if (exit === 0) {
139
+ console.log(`controls: a planted font stylesheet was caught (${leaked.away.length} request(s)), and a report without its faces loaded none`);
140
+ // ── EVERY PAGE ───────────────────────────────────────────────────────────────────────────────────
141
+ const runs = [];
142
+ for (const page of pages.filter((p) => p !== leak && p !== bare)) {
143
+ const rel = relative(pool, page);
144
+ runs.push([`${rel} (from disk)`, pathToFileURL(page).href, page.endsWith("/report.html")]);
145
+ if (page.endsWith("/report.html")) runs.push([`${rel} (served, report policy)`, `${origin}/${rel}`, true]);
146
+ }
147
+ for (const [name, url, isReport] of runs) {
148
+ const got = await open(url);
149
+ if (!got.requested) { fail(`${name}: the page made no request at all, not even for itself — it was not opened`); continue; }
150
+ for (const u of got.away) fail(`${name}: asked another machine for ${u}`);
151
+ if (!loaded(got.faces, TEXT_FACE)) fail(`${name}: the ${TEXT_FACE} face it carries did not load (${JSON.stringify(got.faces)})`);
152
+ if (isReport && !loaded(got.faces, CODE_FACE)) fail(`${name}: the ${CODE_FACE} face it carries did not load (${JSON.stringify(got.faces)})`);
153
+ for (const v of got.violations) fail(`${name}: broke its content policy: ${v}`);
154
+ if (!got.away.length && !got.violations.length) {
155
+ const size = url.startsWith("file:") ? ` · ${statSync(fileURLToPath(url)).size} bytes` : "";
156
+ console.log(`${name}: no other machine asked, ${got.faces.filter((f) => f.status === "loaded").map((f) => f.family).join(" + ")} loaded${size}`);
157
+ }
158
+ }
159
+ if (failures) { console.error(`\nreport-offline-render-check: ${failures} problem(s).`); exit = 1; }
160
+ else console.log(`\nreport-offline-render-check: ${runs.length} page opening(s), none asked another machine for anything.`);
161
+ }
162
+ } finally {
163
+ try { ws.close(); } catch { /* the browser may already be gone */ }
164
+ try { process.kill(-chrome.pid, "SIGTERM"); } catch { /* already exited */ }
165
+ server.close();
166
+ if (keep) console.log(` kept: ${browserRoot} and the pool at ${pool}`);
167
+ else rmSync(pool, { recursive: true, force: true });
168
+ }
169
+ process.exit(exit);