clearotron 0.3.3 → 0.4.0-beta.1

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 (142) hide show
  1. package/.env.example +9 -0
  2. package/INSTALL.md +1 -14
  3. package/bin/brandowner.mjs +5 -5
  4. package/bin/connect.mjs +4 -4
  5. package/bin/onboard.mjs +5 -7
  6. package/bin/start.mjs +20 -5
  7. package/bin/update.mjs +6 -1
  8. package/build-info.json +2 -2
  9. package/docs/architecture/04-configuration-reference.md +2 -6
  10. package/driver/CHANGELOG.md +28 -0
  11. package/driver/ask-ledger.mjs +2 -2
  12. package/driver/band-shape.mjs +8 -8
  13. package/driver/blind-frame-model.mjs +1 -1
  14. package/driver/common-law-receipts.mjs +2 -2
  15. package/driver/commonlaw-carry.mjs +2 -2
  16. package/driver/company-bundle.mjs +11 -18
  17. package/driver/connotation-search.mjs +4 -4
  18. package/driver/contract-e3-backlog.mjs +3 -3
  19. package/driver/declination-call.mjs +1 -1
  20. package/driver/declination-tool.mjs +1 -1
  21. package/driver/dev-portal.mjs +1 -1
  22. package/driver/door-call-verdict.mjs +27 -0
  23. package/driver/driver.config.mjs +17 -11
  24. package/driver/e2e/README.md +1 -1
  25. package/driver/engine/mcp/clarivate-server.mjs +2 -1
  26. package/driver/engine/mcp/corsearch-server.mjs +1 -0
  27. package/driver/engine/mcp/euipo-server.mjs +1 -0
  28. package/driver/engine/mcp/free-tier-server.mjs +1 -1
  29. package/driver/engine/mcp/gather-config.mjs +1 -1
  30. package/driver/engine/mcp/perplexity-server.mjs +1 -1
  31. package/driver/engine/mcp/recording-server.mjs +1 -1
  32. package/driver/engine/mcp/signa-server.mjs +1 -0
  33. package/driver/engine/mcp/supplemental.mjs +1 -1
  34. package/driver/engine/mcp/uspto-local-server.mjs +1 -0
  35. package/driver/enqueue-schema.mjs +2 -2
  36. package/driver/feedback-issues.mjs +1 -1
  37. package/driver/feedback-store.mjs +1 -1
  38. package/driver/findings-model.mjs +3 -3
  39. package/driver/flag-snapshot.mjs +1 -1
  40. package/driver/floor-duty.mjs +2 -2
  41. package/driver/form-neighbourhood.mjs +47 -15
  42. package/driver/frame-diff-model.mjs +3 -3
  43. package/driver/gateway.mjs +5 -5
  44. package/driver/jx-lanes.mjs +1 -1
  45. package/driver/known-conflicts.mjs +18 -0
  46. package/driver/log.mjs +2 -2
  47. package/driver/package.json +1 -1
  48. package/driver/pipeline-knockout.mjs +129 -95
  49. package/driver/pipeline.mjs +69 -32
  50. package/driver/placement-carry.mjs +2 -2
  51. package/driver/placement-form.mjs +1 -1
  52. package/driver/portal-mcp-client.mjs +1 -1
  53. package/driver/portal-request-origin.mjs +79 -0
  54. package/driver/portal-service.mjs +45 -17
  55. package/driver/predelivery-lint.mjs +10 -10
  56. package/driver/profile-page.html +9 -13
  57. package/driver/profile-service.mjs +25 -13
  58. package/driver/profiles.mjs +17 -4
  59. package/driver/progress.mjs +1 -1
  60. package/driver/provider-usage.mjs +24 -1
  61. package/driver/publish/index.mjs +17 -5
  62. package/driver/publish/knockout.mjs +3 -2
  63. package/driver/publish/render-knockout.mjs +1 -1
  64. package/driver/publish/render.mjs +14 -3
  65. package/driver/publish/search-depth.mjs +4 -2
  66. package/driver/recall-reconciliation.mjs +1 -1
  67. package/driver/record-carry.mjs +6 -6
  68. package/driver/recording-agreement.mjs +2 -2
  69. package/driver/reference-score.mjs +27 -27
  70. package/driver/register-count.mjs +56 -1
  71. package/driver/register-digest-record.mjs +1 -1
  72. package/driver/register-plan.mjs +5 -5
  73. package/driver/register-records.mjs +10 -1
  74. package/driver/registry-fidelity.mjs +4 -4
  75. package/driver/repair-composers.mjs +6 -6
  76. package/driver/run-economics.mjs +8 -19
  77. package/driver/screen-gate.mjs +1 -1
  78. package/driver/skills/blind-frame/SKILL.md +2 -2
  79. package/driver/skills/clearance-common-law/SKILL.md +2 -2
  80. package/driver/skills/clearance-common-law/perplexity-prompts.md +4 -4
  81. package/driver/skills/clearance-register/digest.md +1 -1
  82. package/driver/skills/clearance-register/unit.md +1 -1
  83. package/driver/skills/clearance-search/report-prose.md +5 -5
  84. package/driver/skills/clearance-search/synthesis-rules.md +4 -4
  85. package/driver/skills/clearance-variants/SKILL.md +2 -2
  86. package/driver/skills/clearance-variants/transliteration-scripts.md +1 -1
  87. package/driver/skills/frame-diff/SKILL.md +2 -2
  88. package/driver/skills/knockout-assess/SKILL.md +9 -9
  89. package/driver/skills/matter-frame/watchlist-reference.md +1 -1
  90. package/driver/skills/narrative-refutation/SKILL.md +2 -2
  91. package/driver/skills/placement-inquiry/SKILL.md +1 -1
  92. package/driver/stage-context.mjs +4 -4
  93. package/driver/stages.mjs +23 -15
  94. package/driver/suite-census.json +138 -42
  95. package/driver/systemd/clearotron-client-mcp.service +24 -0
  96. package/driver/systemd/clearotron-mcp-face.service +24 -0
  97. package/driver/systemd/clearotron-portal.service +24 -0
  98. package/driver/systemd/clearotron-worker.service +24 -0
  99. package/driver/tokens.mjs +26 -17
  100. package/driver/turnaround-bands.mjs +1 -1
  101. package/driver/unit-inventory.mjs +3 -3
  102. package/driver/variant-manifest-model.mjs +1 -1
  103. package/driver/verify.mjs +2 -2
  104. package/driver/whatif-memo-run.mjs +1 -1
  105. package/mcp-server/CHANGELOG.md +8 -0
  106. package/mcp-server/lib/audit.mjs +9 -2
  107. package/mcp-server/lib/http-handler.mjs +7 -3
  108. package/mcp-server/mint-token.mjs +8 -6
  109. package/mcp-server/package.json +1 -1
  110. package/mcp-server/server.mjs +11 -1
  111. package/package.json +1 -1
  112. package/portal-ui/dist/assets/{index-GBbbyQxc.js → index-D_O_55vK.js} +59 -9
  113. package/portal-ui/dist/index.html +1 -1
  114. package/portal-ui/package.json +1 -1
  115. package/providers/_shared/README.md +1 -1
  116. package/providers/_shared/answer-memory.mjs +199 -0
  117. package/providers/_shared/ledger-path.mjs +1 -1
  118. package/providers/_shared/ledger.mjs +47 -5
  119. package/providers/_shared/script-form.mjs +24 -5
  120. package/providers/_shared/term-shape.mjs +5 -5
  121. package/providers/clarivate/src/capabilities.js +11 -0
  122. package/providers/clarivate/src/core.js +140 -13
  123. package/providers/jx-subclass/lookup.mjs +1 -1
  124. package/providers/oauth-mcp-bridge/CHANGELOG.md +8 -0
  125. package/providers/oauth-mcp-bridge/package.json +1 -1
  126. package/providers/signa/src/capabilities.js +24 -0
  127. package/providers/signa/src/core.js +66 -0
  128. package/scripts/deprecate-below.mjs +114 -2
  129. package/scripts/freeze-example-run.mjs +1 -1
  130. package/scripts/live-surface-check.mjs +11 -2
  131. package/scripts/release-entry-catch-up.mjs +211 -0
  132. package/scripts/release-note-required.mjs +102 -6
  133. package/scripts/release-rehearsal-version.mjs +60 -0
  134. package/scripts/release-sbom.mjs +104 -0
  135. package/scripts/release-visible-check.mjs +7 -5
  136. package/scripts/score.mjs +3 -3
  137. package/shared/brand.mjs +1 -1
  138. package/shared/client-door.mjs +15 -8
  139. package/shared/driver-dir.mjs +20 -9
  140. package/shared/names-in-force.mjs +2 -0
  141. package/shared/scope.mjs +25 -10
  142. package/shared/store-in-repo.mjs +38 -17
@@ -0,0 +1,211 @@
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-entry-catch-up.mjs — finish a release npm was still validating when the publish job stopped waiting.
5
+ //
6
+ // node scripts/release-entry-catch-up.mjs [--repo owner/name] [--bound-hours 6] [--dry-run]
7
+ //
8
+ // WHY THIS EXISTS. npm now accepts a publish before it serves it ("Your package is being processed and may
9
+ // take a few minutes to become available"), and the processing is not bounded. The publish job waits for
10
+ // an unauthenticated client to get the exact bytes it uploaded before it creates the release entry, and
11
+ // that wait has to end somewhere. Measured 2026-09-23: 0.3.3-beta.1 was served eleven minutes after npm
12
+ // accepted it; the 0.3.3 stable was still "Validating" on npmjs.com more than fifty minutes after, the
13
+ // job gave up at its bound, and no entry was ever created. GitHub went on naming 0.3.2 as the latest
14
+ // release while the stable was already published. Nothing ran again to finish it.
15
+ //
16
+ // So a tagged version with no entry is PENDING, not failed, and the scheduled run finishes it: once the
17
+ // registry serves the bytes the publish job kept, the entry is created from the changelog, exactly as the
18
+ // publish job would have created it. Past a bound with the version still not served, it says so in red.
19
+ //
20
+ // THE SAME CHECK, ON THE SAME BYTES. The publish job keeps the tarball it uploaded as an artifact named
21
+ // `published-<version>`, and this hands that file to release-visible-check.mjs, so an entry is only ever
22
+ // created over bytes the registry serves and this pipeline published. A tagged version with no kept bytes
23
+ // (one published before this existed) is reported, never guessed at.
24
+ //
25
+ // THE RELEASE RUN'S ARTIFACT, NEVER ANY ARTIFACT OF THAT NAME. Any run in the repository can upload an
26
+ // artifact called `published-<version>`, a fork's pull request included, and GitHub lists same-named
27
+ // artifacts highest id first, so a name alone takes the last upload: its parts list would ride on the
28
+ // official entry, and its tarball would report a sound release as different bytes. An artifact is used only
29
+ // when the run that made it is this repository's release workflow, on `main`, at the tagged commit or
30
+ // behind it. Behind it is the ordinary case for a cut: its publish runs on the commit it was dispatched
31
+ // from, and the version commit it tags lands on `main` after (0.3.3-beta.1: run on `1d7722ac`, tag on
32
+ // `d571d0ee`).
33
+ //
34
+ // Exit 0: nothing to do, the entry was created, or the version is pending inside the bound.
35
+ // Exit 1: the registry serves different bytes, the version is still not served past the bound, or its bytes
36
+ // cannot be found to check against.
37
+ // Exit 2: could not look.
38
+ import { execFileSync, spawnSync } from "node:child_process";
39
+ import { mkdtempSync, readFileSync, readdirSync, writeFileSync, rmSync } from "node:fs";
40
+ import { tmpdir } from "node:os";
41
+ import { join, dirname } from "node:path";
42
+ import { fileURLToPath } from "node:url";
43
+ import { isEntrypoint } from "../shared/is-entrypoint.mjs";
44
+
45
+ const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
46
+
47
+ /**
48
+ * What to do about the version at main's head. PURE, so every branch is driven without a registry.
49
+ * @param {{ tagged: boolean, entryExists: boolean, keptBytes: boolean, visibleExit: number|null,
50
+ * ageSec: number|null, boundSec: number }} s
51
+ * @returns {{ action: "none"|"create"|"wait"|"report", exit: 0|1|2, why: string }}
52
+ */
53
+ export function decide(s) {
54
+ if (!s.tagged) return { action: "none", exit: 0, why: "not tagged: nothing has been published to finish" };
55
+ if (s.entryExists) return { action: "none", exit: 0, why: "the release entry already exists" };
56
+ if (!s.keptBytes) {
57
+ return { action: "report", exit: 1,
58
+ why: "tagged with no release entry, and no kept bytes to check the registry against: create the entry by hand after checking what the registry serves" };
59
+ }
60
+ if (s.visibleExit === 0) return { action: "create", exit: 0, why: "the registry serves the published bytes" };
61
+ // DIFFERENT BYTES ARE NEVER PENDING. The registry serves this version and it is not the build that was
62
+ // published, so it is red at once, however young the tag.
63
+ if (s.visibleExit === 3) {
64
+ return { action: "report", exit: 1, why: "the registry serves DIFFERENT BYTES for this version than the publish job kept" };
65
+ }
66
+ if (s.visibleExit === 1) {
67
+ if (s.ageSec != null && s.ageSec > s.boundSec) {
68
+ return { action: "report", exit: 1,
69
+ why: `tagged ${Math.round(s.ageSec / 3600)} h ago and the registry still does not serve these bytes` };
70
+ }
71
+ return { action: "wait", exit: 0, why: "npm has not served it yet; the next scheduled run looks again" };
72
+ }
73
+ return { action: "report", exit: 2, why: "the registry check could not look" };
74
+ }
75
+
76
+ export const RELEASE_WORKFLOW = ".github/workflows/release.yml";
77
+ const RELEASE_EVENTS = new Set(["push", "workflow_dispatch", "schedule"]);
78
+
79
+ /**
80
+ * Why a kept artifact is NOT the release run's own, or null when it is. PURE. Called first with the
81
+ * artifact alone, which reads only what the artifact list already says, so a fork's upload costs no further
82
+ * call; then with the run that made it and how that run's commit relates to the tagged one (the compare
83
+ * API's `status` from the run's commit to the tag: "identical" or "ahead" means at the tag or behind it).
84
+ * @param {{ artifact: object, repoId: number, run?: object, relation?: string }} s
85
+ * @returns {string|null}
86
+ */
87
+ export function notTheReleaseRun({ artifact, repoId, run, relation }) {
88
+ const w = artifact.workflow_run ?? {};
89
+ if (w.repository_id !== repoId || w.head_repository_id !== repoId) return "it was made by a run from another repository, such as a fork's pull request";
90
+ if (w.head_branch !== "main") return `it was made by a run on ${w.head_branch ? `\`${w.head_branch}\`` : "no branch"}, not on main`;
91
+ if (run === undefined) return null;
92
+ if (run.id !== w.id) return "the run read back is not the run the artifact names";
93
+ if (run.path !== RELEASE_WORKFLOW) return `it was made by ${run.path ?? "an unnamed workflow"}, not the release workflow`;
94
+ if (!RELEASE_EVENTS.has(run.event)) return `it was made by a ${run.event ?? "nameless"} run, which never publishes`;
95
+ if (run.head_repository?.id !== repoId || run.head_branch !== "main") return "the run that made it is not on this repository's main";
96
+ if (relation !== "identical" && relation !== "ahead") return `it was made on a commit that is not the tagged commit or behind it (${relation ?? "unknown"})`;
97
+ return null;
98
+ }
99
+
100
+ const arg = (argv, flag, dflt = null) => { const i = argv.indexOf(flag); return i === -1 ? dflt : argv[i + 1]; };
101
+ const gh = (args, opts = {}) => execFileSync("gh", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], ...opts });
102
+
103
+ // Whether the entry exists. Only GitHub's own "release not found" is a no; any other failure throws.
104
+ function entryFor(tag, repo) {
105
+ const r = spawnSync("gh", ["release", "view", tag, "--repo", repo, "--json", "tagName"], { encoding: "utf8" });
106
+ if (r.status === 0) return true;
107
+ if (/release not found/i.test(r.stderr ?? "")) return false;
108
+ throw Object.assign(new Error(`could not read the release entry for ${tag}`), { stderr: r.stderr || r.error?.message });
109
+ }
110
+
111
+ // The first unexpired `published-<version>` artifact the release run made, or null. Every other one is
112
+ // named in a warning with the reason it was refused. EVERY PAGE OF THE LIST IS READ, NOT THE FIRST: any
113
+ // run can upload an artifact of that name, so a hundred uploads made after the release run's would push
114
+ // its own off a single page, and the version would be reported as having no kept bytes.
115
+ function keptArtifact(repo, version, tagSha) {
116
+ const repoId = Number(gh(["api", `repos/${repo}`, "--jq", ".id"]).trim());
117
+ for (let page = 1, seen = 0, total = 1; seen < total; page++) {
118
+ const list = JSON.parse(gh(["api", `repos/${repo}/actions/artifacts?name=published-${version}&per_page=100&page=${page}`]));
119
+ const artifacts = list.artifacts ?? [];
120
+ if (!artifacts.length) break; // the count named more than the pages hold: nothing further to read
121
+ total = list.total_count ?? 0;
122
+ seen += artifacts.length;
123
+ for (const artifact of artifacts.filter((x) => !x.expired && x.name === `published-${version}`)) {
124
+ let why = notTheReleaseRun({ artifact, repoId });
125
+ if (!why) {
126
+ const run = JSON.parse(gh(["api", `repos/${repo}/actions/runs/${artifact.workflow_run.id}`]));
127
+ const relation = gh(["api", `repos/${repo}/compare/${run.head_sha}...${tagSha}`, "--jq", ".status"]).trim();
128
+ why = notTheReleaseRun({ artifact, repoId, run, relation });
129
+ }
130
+ if (!why) return artifact;
131
+ console.log(`::warning::release-entry-catch-up: refused artifact ${artifact.id} named published-${version}: ${why}.`);
132
+ }
133
+ }
134
+ return null;
135
+ }
136
+
137
+ function main() {
138
+ try { return catchUp(process.argv.slice(2)); }
139
+ catch (e) {
140
+ // A GitHub call that failed is a check that could not look, never the verdict "past the bound".
141
+ console.log(`::error::release-entry-catch-up could not finish: ${String(e.stderr || e.message).trim().split("\n")[0]}`);
142
+ return 2;
143
+ }
144
+ }
145
+
146
+ function catchUp(argv) {
147
+ const repo = arg(argv, "--repo", process.env.GITHUB_REPOSITORY);
148
+ const boundSec = Number(arg(argv, "--bound-hours", "6")) * 3600;
149
+ const dry = argv.includes("--dry-run");
150
+ if (!repo || !(boundSec > 0)) { console.error("release-entry-catch-up: needs --repo (or GITHUB_REPOSITORY) and a positive --bound-hours. Could not look."); return 2; }
151
+
152
+ const version = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8")).version;
153
+ const tag = `v${version}`;
154
+ const prerelease = version.includes("-");
155
+
156
+ // THE EXACT REF, as the publish job asks it: a prefix read would take v0.3.1 for v0.3.1-beta.0.
157
+ let tagged;
158
+ try { tagged = gh(["api", `repos/${repo}/git/ref/tags/${tag}`, "--jq", ".ref"]).trim() === `refs/tags/${tag}`; }
159
+ catch (e) { if (/HTTP 404/.test(String(e.stderr))) tagged = false; else { console.error(`release-entry-catch-up: could not read the tag ${tag}: ${String(e.stderr || e.message).trim()}`); return 2; } }
160
+ const entryExists = tagged ? entryFor(tag, repo) : false;
161
+
162
+ let keptBytes = false, visibleExit = null, ageSec = null, work = null, sbom = null;
163
+ if (tagged && !entryExists) {
164
+ const [tagSha, when] = gh(["api", `repos/${repo}/commits/${tag}`, "--jq", `.sha + " " + .commit.committer.date`]).trim().split(" ");
165
+ ageSec = Math.max(0, (Date.now() - Date.parse(when)) / 1000);
166
+ const a = keptArtifact(repo, version, tagSha);
167
+ if (a) {
168
+ work = mkdtempSync(join(tmpdir(), "entry-catch-up-"));
169
+ writeFileSync(join(work, "kept.zip"), execFileSync("gh", ["api", `repos/${repo}/actions/artifacts/${a.id}/zip`],
170
+ { maxBuffer: 256 * 1024 * 1024, stdio: ["ignore", "pipe", "pipe"] }));
171
+ execFileSync("unzip", ["-q", "-o", join(work, "kept.zip"), "-d", join(work, "kept")]);
172
+ const kept = readdirSync(join(work, "kept"));
173
+ const tgz = kept.find((f) => f.endsWith(".tgz"));
174
+ const cdx = kept.find((f) => f.endsWith(".cdx.json"));
175
+ if (cdx) sbom = join(work, "kept", cdx);
176
+ if (tgz) {
177
+ keptBytes = true;
178
+ const r = spawnSync(process.execPath, [join(ROOT, "scripts", "release-visible-check.mjs"), "--version", version,
179
+ "--tag", prerelease ? "beta" : "latest", "--tarball", join(work, "kept", tgz), "--timeout", "60", "--interval", "15"],
180
+ { encoding: "utf8" });
181
+ process.stdout.write(r.stdout ?? ""); process.stderr.write(r.stderr ?? "");
182
+ visibleExit = r.status;
183
+ }
184
+ }
185
+ }
186
+
187
+ const d = decide({ tagged, entryExists, keptBytes, visibleExit, ageSec, boundSec });
188
+ console.log(`release-entry-catch-up: ${tag}: ${d.why}.`);
189
+ if (d.action === "create") {
190
+ if (dry) { console.log(`release-entry-catch-up: --dry-run, so no entry is created for ${tag}.`); }
191
+ else {
192
+ // THE SAME ENTRY THE PUBLISH JOB WRITES: the changelog's section for this version, flagged as a
193
+ // pre-release for a beta, and GitHub's own notes only when the changelog has no section.
194
+ const notes = spawnSync(process.execPath, [join(ROOT, "scripts", "release-notes-for.mjs"), version], { encoding: "utf8" }).stdout ?? "";
195
+ // The parts list the publish job kept beside the bytes rides as an asset, as it does on an entry the
196
+ // publish job writes itself. A version kept before the list existed has none, and gets none.
197
+ const args = ["release", "create", tag, ...(sbom ? [sbom] : []), "--repo", repo, "--verify-tag", "--title", tag];
198
+ if (notes.trim()) { const f = join(work ?? mkdtempSync(join(tmpdir(), "entry-notes-")), "notes.md"); writeFileSync(f, notes); args.push("--notes-file", f); }
199
+ else args.push("--generate-notes");
200
+ if (prerelease) args.push("--prerelease");
201
+ gh(args);
202
+ console.log(`release-entry-catch-up: created the release entry for ${tag}${prerelease ? " as a pre-release" : ""}.`);
203
+ }
204
+ }
205
+ if (d.exit === 1) console.log(`::error::${tag}: ${d.why}.`);
206
+ else if (d.action === "wait") console.log(`::notice::${tag}: ${d.why}.`);
207
+ if (work) rmSync(work, { recursive: true, force: true });
208
+ return d.exit;
209
+ }
210
+
211
+ if (isEntrypoint(import.meta.url)) process.exitCode = main();
@@ -101,6 +101,27 @@
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.
104
125
  import { execFileSync } from "node:child_process";
105
126
  import { readFileSync } from "node:fs";
106
127
  import { join, dirname } from "node:path";
@@ -163,6 +184,51 @@ export const NEVER_A_NOTE = [/(^|\/)CHANGELOG\.md$/, /(^|\/)package(-lock)?\.jso
163
184
  const NOTE_LINE = /^\s*Release-note:[ \t]*(?<value>.*?)[ \t]*$/gim;
164
185
  /** A note named in a `Release-note:` line: `.changeset/<name>.md`, or the bare `<name>.md`. */
165
186
  const NAMED_NOTE = /^(?:\.changeset\/)?(?<name>[\w.-]+\.md)$/;
187
+ /** `Release-note-for: <sha> none — <reason>`: a later commit answering a bare `none` (see the header). */
188
+ const ANSWER_LINE = /^[ \t]*Release-note-for:[ \t]*(?<rest>.*?)[ \t]*$/gim;
189
+ const ANSWER_HEAD = /^(?<sha>[0-9a-f]{7,40})(?<tail>(?:[ \t].*)?)$/i;
190
+
191
+ /**
192
+ * PURE. Every `Release-note-for:` line in the range, judged. `isAncestor(a, b)` says whether commit `a` is
193
+ * an ancestor of commit `b`; without one, range order stands in for it (the range is read oldest first).
194
+ * @returns {{answers: Map<string, {by:string, subject:string, reason:string}>,
195
+ * refused: Array<{sha:string, subject:string, text:string, problem:string}>}}
196
+ */
197
+ export function readAnswers(commits = [], isAncestor = null) {
198
+ const refused = [];
199
+ const claims = new Map();
200
+ commits.forEach((c, at) => {
201
+ for (const m of String(c.message ?? "").matchAll(ANSWER_LINE)) {
202
+ const text = m[0].trim();
203
+ const bad = (problem) => refused.push({ sha: c.sha, subject: c.subject, text, problem });
204
+ const head = ANSWER_HEAD.exec(m.groups.rest);
205
+ if (!head) { bad("names no commit: the form is `Release-note-for: <sha> none — <reason>`"); continue; }
206
+ const none = NO_NOTE.exec(`Release-note:${head.groups.tail}`);
207
+ if (!none) { bad("does not say `none` straight after one sha: one line answers one commit"); continue; }
208
+ if (!none.groups?.reason) { bad("gives no reason, and the reason is the whole point of the line"); continue; }
209
+ const prefix = head.groups.sha.toLowerCase();
210
+ const hits = commits.filter((k) => String(k.sha).toLowerCase().startsWith(prefix));
211
+ if (!hits.length) { bad(`names ${prefix}, which is not a commit in this range`); continue; }
212
+ if (hits.length > 1) { bad(`names ${prefix}, which matches ${hits.length} commits in this range`); continue; }
213
+ const target = hits[0];
214
+ const later = target.sha !== c.sha
215
+ && (isAncestor ? isAncestor(target.sha, c.sha) : commits.indexOf(target) < at);
216
+ if (!later) { bad(`names ${prefix}, which is not an earlier commit on this commit's own history`); continue; }
217
+ if (!claims.has(target.sha)) claims.set(target.sha, []);
218
+ claims.get(target.sha).push({ by: c.sha, subject: c.subject, text, reason: none.groups.reason });
219
+ }
220
+ });
221
+ const answers = new Map();
222
+ for (const [sha, list] of claims) {
223
+ if (list.length > 1) {
224
+ for (const a of list) refused.push({ sha: a.by, subject: a.subject, text: a.text,
225
+ problem: `is one of ${list.length} answers naming ${sha.slice(0, 7)}; one commit takes one answer` });
226
+ continue;
227
+ }
228
+ answers.set(sha, list[0]);
229
+ }
230
+ return { answers, refused };
231
+ }
166
232
 
167
233
  const isNotePath = (p) => p.startsWith(".changeset/") && p.endsWith(".md") && !p.endsWith("README.md");
168
234
  const shipped = (paths, files) => paths.filter((p) => !NEVER_A_NOTE.some((re) => re.test(p)) && shipsCode(p, files));
@@ -181,12 +247,15 @@ const shipped = (paths, files) => paths.filter((p) => !NEVER_A_NOTE.some((re) =>
181
247
  * non-merge, oldest first; `added` and `deleted` are the paths the commit adds and removes, when the caller read them
182
248
  * @param {string[]} o.files the package's own `files` list
183
249
  * @param {string[]|null} [o.atHead] every path the head carries under `.changeset/`, when the caller read it
250
+ * @param {((a:string, b:string) => boolean)|null} [o.isAncestor] git ancestry for `Release-note-for:` answers; range order without it
184
251
  * @returns {{visible:string[], notes:string[], declined:Array<{sha:string, subject:string, reason:string}>,
185
252
  * withdrawals:Array<{sha:string, subject:string, note:string, by:string, reason:string}>,
253
+ * answered:Array<{sha:string, subject:string, by:string, reason:string}>,
254
+ * refusedAnswers:Array<{sha:string, subject:string, text:string, problem:string}>,
186
255
  * owed:Array<{sha:string, subject:string, why:"no-note"|"prose"|"bare-none"|"names-a-missing-note"|"note-withdrawn-unsaid",
187
256
  * paths?:string[], text?:string, by?:string}>}}
188
257
  */
189
- export function commitVerdicts({ commits = [], files = [], atHead = null } = {}) {
258
+ export function commitVerdicts({ commits = [], files = [], atHead = null, isAncestor = null } = {}) {
190
259
  // `added` and `atHead` are what the command reads from git; a caller that passes neither keeps the older
191
260
  // reading of every changed path, which is what a pure table of paths means.
192
261
  // MATCHED ON THE NAME, NOT THE PATH, BECAUSE A PRE-RELEASE MOVES A NOTE IT CONSUMES.
@@ -234,7 +303,9 @@ export function commitVerdicts({ commits = [], files = [], atHead = null } = {})
234
303
  });
235
304
  // AND A NAME THE HEAD STILL CARRIES WAS NEVER WITHDRAWN AT ALL: the release assembles what the head has.
236
305
  if (presentNames) for (const name of [...withdrawn.keys()]) if (presentNames.has(name)) withdrawn.delete(name);
237
- const declined = [], owed = [], withdrawals = [];
306
+ const declined = [], owed = [], withdrawals = [], answered = [];
307
+ const { answers, refused: refusedAnswers } = readAnswers(commits, isAncestor);
308
+ const used = new Set();
238
309
  for (const [i, c] of commits.entries()) {
239
310
  const ships = shipped(c.paths, files);
240
311
  if (!ships.length) continue; // nothing a reader could see in this commit
@@ -247,7 +318,10 @@ export function commitVerdicts({ commits = [], files = [], atHead = null } = {})
247
318
  const none = NO_NOTE.exec(String(c.message ?? ""));
248
319
  if (none) {
249
320
  const reason = none.groups?.reason;
250
- if (reason) declined.push({ ...at, reason }); else owed.push({ ...at, why: "bare-none" });
321
+ const answer = reason ? null : answers.get(c.sha);
322
+ if (reason) declined.push({ ...at, reason });
323
+ else if (answer) { answered.push({ ...at, by: answer.by, reason: answer.reason }); used.add(c.sha); }
324
+ else owed.push({ ...at, why: "bare-none" });
251
325
  continue;
252
326
  }
253
327
  if (addsNote) continue; // it carries its own note
@@ -268,7 +342,16 @@ export function commitVerdicts({ commits = [], files = [], atHead = null } = {})
268
342
  if (notes.length) continue; // a note in the range answers for it
269
343
  owed.push({ ...at, why: "no-note", paths: ships });
270
344
  }
271
- return { visible, notes, declined, withdrawals, owed };
345
+ // AN ANSWER THAT ANSWERED NOTHING IS REFUSED, NOT IGNORED (see the header).
346
+ const owing = new Set(owed.map((o) => o.sha));
347
+ for (const [sha, a] of answers) {
348
+ if (used.has(sha)) continue;
349
+ refusedAnswers.push({ sha: a.by, subject: a.subject, text: a.text,
350
+ problem: owing.has(sha)
351
+ ? `names ${sha.slice(0, 7)}, which owes a note this line cannot give: an answer covers only a bare \`none\``
352
+ : `names ${sha.slice(0, 7)}, which owes no answer: it ships no code, carries a note, or gives its own reason` });
353
+ }
354
+ return { visible, notes, declined, withdrawals, owed, answered, refusedAnswers };
272
355
  }
273
356
 
274
357
  const argAfter = (flag) => {
@@ -333,7 +416,9 @@ function main() {
333
416
  + "history is never read.");
334
417
  return;
335
418
  }
336
- const { visible, notes, declined, withdrawals, owed } = commitVerdicts({ commits, files, atHead });
419
+ // ANCESTRY FROM GIT, NEVER TIMESTAMPS: an answer must come from a commit the named one is an ancestor of.
420
+ const isAncestor = (a, b) => { try { git("merge-base", "--is-ancestor", a, b); return true; } catch { return false; } };
421
+ const { visible, notes, declined, withdrawals, owed, answered, refusedAnswers } = commitVerdicts({ commits, files, atHead, isAncestor });
337
422
  console.log(`release-note-required: ${changed.length} changed file(s) against ${base}`
338
423
  + `${head === "HEAD" ? "" : ` (head ${head})`}; `
339
424
  + `${visible.length} ship as code; ${notes.length} release note(s) in the range; ${commits.length} commit(s) read`
@@ -352,7 +437,18 @@ function main() {
352
437
  for (const w of withdrawals) {
353
438
  console.log(` ${w.sha.slice(0, 7)} answered with ${w.note}, which ${w.by.slice(0, 7)} withdrew: ${w.reason}`);
354
439
  }
355
- if (!owed.length) return;
440
+ for (const a of answered) {
441
+ console.log(` ${a.sha.slice(0, 7)} said a bare \`none\`, which ${a.by.slice(0, 7)} answers: ${a.reason}`);
442
+ }
443
+ if (!owed.length && !refusedAnswers.length) return;
444
+ if (refusedAnswers.length) {
445
+ console.error(`\n${refusedAnswers.length} Release-note-for answer(s) refused:\n`);
446
+ for (const r of refusedAnswers) {
447
+ console.error(` ${r.sha.slice(0, 7)} ${r.subject}`);
448
+ console.error(` "${r.text.slice(0, 120)}" ${r.problem}.`);
449
+ }
450
+ }
451
+ if (!owed.length) process.exit(1);
356
452
 
357
453
  console.error(`\n${owed.length} commit(s) that ship as code owe a release note:\n`);
358
454
  for (const o of owed) {
@@ -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)}…).`);
package/scripts/score.mjs CHANGED
@@ -435,7 +435,7 @@ function scoreOne(run, ref) {
435
435
  //
436
436
  // Refusing the unshaped pairing was only half of it. A gold set that gains a `counts` block usually
437
437
  // KEEPS its lawyer sheet — it is a real answer to a real matter and deleting it loses history — and
438
- // scoring both prints `LOST · TIKI PUNCH` beside the count that measures the product. That is the same
438
+ // scoring both prints `LOST · WAVO PUNCH` beside the count that measures the product. That is the same
439
439
  // structural zero the issue is about, one row smaller, and it lands in the bucket table a reader
440
440
  // scans first.
441
441
  //
@@ -644,7 +644,7 @@ function print(id, ref, run, s, delta, refPath) {
644
644
  console.log(renderCarryThrough(run.dir, { indent: " " }).lines.join("\n"));
645
645
 
646
646
  // — a record in two buckets is a defect in THIS TOOL, and it is printed as loudly as any score.
647
- // Silence here is what let `DELPHI GENETICS` sit in LOST and `DG DELPHI GENETICS` in NOISE on the same
647
+ // Silence here is what let `KORPHI GENETICS` sit in LOST and `DG KORPHI GENETICS` in NOISE on the same
648
648
  // page, and the recall number that reached the round handover was wrong for a week because nobody
649
649
  // reads two lists against each other.
650
650
  if (B.collisions?.length) {
@@ -656,7 +656,7 @@ function print(id, ref, run, s, delta, refPath) {
656
656
  //
657
657
  // These used to print under the line above, which tells the reader not to read the recall numbers.
658
658
  // One large filer anywhere in a matter therefore suppressed a whole run's measurement, and did: R2's
659
- // real 88% → 63% recall movement went unquoted on the regression issue because `Novartis AG` held one
659
+ // real 88% → 63% recall movement went unquoted on the regression issue because `Norvanta AG` held one
660
660
  // withheld reference mark and one surfaced non-reference mark. Those two rows are both true.
661
661
  //
662
662
  // Kept visible because a reader may still want the pairing, and deliberately WITHOUT a verdict: this
package/shared/brand.mjs CHANGED
@@ -718,7 +718,7 @@ export const CHROME_CSS = `
718
718
  thing. The three body elements that carried the class drop it too, and --wm-alpha goes with them:
719
719
  a token nothing reads is a knob somebody re-wires.
720
720
  NOTE FOR ANY FUTURE DECORATION ON body: the rule here pinned right:0 and never a negative inset,
721
- because right:-2% put 26px of horizontal scrollbar on a 1280px report (#485) and pointer-events:none
721
+ because right:-2% put 26px of horizontal scrollbar on a 1280px report and pointer-events:none
722
722
  does not exempt a box from scrollable overflow. That is a property of the position, not of this
723
723
  rule, and scripts/report-overflow-check.mjs still measures it. */
724
724
  /* Floating settings stack (theme + privacy) — pinned bottom-right, OUT of the nav bar. Icon-only: the toggle keeps its .tt-lbl/.anon-lbl text for screen readers but clips it visually.