clearotron 0.3.2-beta.6 → 0.3.2-beta.7

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.
@@ -0,0 +1,246 @@
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-duplicate-notes.mjs — refuse a release note that sits in `.changeset/` and `.changeset/pre/` at
5
+ // once, because the next cut would publish its text a second time.
6
+ //
7
+ // node scripts/release-duplicate-notes.mjs
8
+ //
9
+ // Exit 0 when no note is in both places · 1 when one is, each named with the cut that consumed it · 2 when
10
+ // it could not look.
11
+ //
12
+ // ── WHAT GOES WRONG, MEASURED ──────────────────────────────────────────────────────────────────────
13
+ //
14
+ // A pre-release consumes a note by MOVING it from `.changeset/` into `.changeset/pre/`, where it waits so
15
+ // the eventual stable can list every change since the last stable. A branch that carries its own copy of
16
+ // work already on main can put that same filename back at the top level. The tree then holds both, and
17
+ // nothing refuses it: the next cut consumes the top-level copy again and republishes its sentence on the
18
+ // releases page — the surface a stranger reads to decide whether to upgrade.
19
+ //
20
+ // Seen on a report pack's head, 2026-09-16: ten notes consumed by a beta at 17:32Z were re-added three
21
+ // minutes later by a branch commit. No run would have failed.
22
+ //
23
+ // ── WHY THE CONSUMING CUT COMES FROM GIT AND NOT FROM pre.json ─────────────────────────────────────
24
+ //
25
+ // It would be natural to read the consuming release out of `.changeset/pre.json`. It is not there. In
26
+ // changesets 3.x that file carries `{ mode, tag }` and nothing else — no list of consumed notes and no
27
+ // initial versions — so the only record of WHICH cut took a note is the commit that added it under
28
+ // `pre/`. That commit is the version commit, and its subject names the release. A guard that read
29
+ // `pre.json` for this would find an absent field and report nothing, which is the failure this comment
30
+ // exists to stop somebody re-introducing.
31
+ //
32
+ // ── THE FLOOR ──────────────────────────────────────────────────────────────────────────────────────
33
+ //
34
+ // An empty `.changeset/` is the ordinary state straight after a cut, and an empty `pre/` is the ordinary
35
+ // state on a tree that has never pre-released. Neither is a defect, but a run that enumerated nothing
36
+ // because it was pointed at the wrong place must not read as a pass, so the count of what was compared is
37
+ // printed on success and a missing `.changeset/` is a refusal rather than a clean answer.
38
+ import { readdirSync, existsSync, readFileSync } from "node:fs";
39
+ import { join, dirname } from "node:path";
40
+ import { fileURLToPath } from "node:url";
41
+ import { execFileSync } from "node:child_process";
42
+ import { isEntrypoint } from "../shared/is-entrypoint.mjs";
43
+
44
+ const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
45
+
46
+ const mdIn = (dir, { keepReadme = false } = {}) =>
47
+ readdirSync(dir, { withFileTypes: true })
48
+ .filter((e) => e.isFile() && e.name.endsWith(".md") && (keepReadme || e.name !== "README.md"))
49
+ .map((e) => e.name)
50
+ .sort();
51
+
52
+ /** The notes present in BOTH `.changeset/` and `.changeset/pre/`, by file name. */
53
+ export function duplicateNotes(root = ROOT) {
54
+ const dir = join(root, ".changeset");
55
+ if (!existsSync(dir)) return { error: `${dir} does not exist — this is not a tree that carries release notes` };
56
+ const preDir = join(dir, "pre");
57
+ const waiting = mdIn(dir);
58
+ const consumed = existsSync(preDir) ? mdIn(preDir, { keepReadme: true }) : [];
59
+ const inPre = new Set(consumed);
60
+ return { duplicates: waiting.filter((n) => inPre.has(n)), waiting: waiting.length, consumed: consumed.length };
61
+ }
62
+
63
+ /**
64
+ * For every note now under `pre/`, the commit that most recently PUT IT THERE.
65
+ *
66
+ * ONE WALK OF `.changeset/`, NOT ONE QUERY PER FILE, and that is a correctness fix rather than a saving.
67
+ * `git log --diff-filter=A -- .changeset/pre/<name>` looks right and lies in two ways. A cut consumes a
68
+ * note by MOVING it, which git records as a rename and not an add. And a path-limited log simplifies
69
+ * history: for a note that went into `pre/`, back out, and in again — which is exactly what the note that
70
+ * prompted this guard did — it reports the FIRST creation and never mentions the release that consumed it.
71
+ * Measured 2026-09-17: the query named `911b0b4 The report a client opens, redrawn` for a note the beta.6
72
+ * version commit had just consumed, so the guard refused a tree that was correct.
73
+ *
74
+ * `--full-history` stops the simplification, `-M` makes the rename legible, and `--name-status` says where
75
+ * each file LANDED. The first entry naming a destination under `pre/` is the most recent one, because the
76
+ * log is newest-first.
77
+ *
78
+ * THE TWO FLAGS ARE NOT EQUALLY GUARDED, and that is written down rather than left to be discovered.
79
+ * Removing `-M` reds the arm for this, because ignoring renames is the original defect and a linear
80
+ * fixture reproduces it. Removing `--full-history` reds NOTHING, because the simplification it defeats
81
+ * needs a MERGE in the history and the fixture repository has none — the real case was this repository
82
+ * with main merged into a branch. So that flag is carried on a measurement rather than on a passing
83
+ * test, and deleting it because nothing goes red would restore a defect no arm here can see.
84
+ */
85
+ export function consumedProvenance(root = ROOT) {
86
+ let out;
87
+ try {
88
+ out = execFileSync("git",
89
+ ["log", "--full-history", "-M", "--name-status", "--format=@@%h%x00%an%x00%s", "--", ".changeset/"],
90
+ { cwd: root, encoding: "utf8", maxBuffer: 32 * 1024 * 1024, stdio: ["ignore", "pipe", "ignore"] });
91
+ } catch {
92
+ return new Map(); // no history to read — the caller reports that, it does not condemn anything
93
+ }
94
+ const seen = new Map();
95
+ let commit = null;
96
+ for (const line of out.split("\n")) {
97
+ if (line.startsWith("@@")) {
98
+ const [hash, author, subject] = line.slice(2).split("\0");
99
+ commit = { hash, author, subject };
100
+ continue;
101
+ }
102
+ if (!commit || !line.trim()) continue;
103
+ const parts = line.split("\t");
104
+ const dest = parts[parts.length - 1]; // A → the path; R → the destination
105
+ const m = /^\.changeset\/pre\/(.+)$/.exec(dest);
106
+ if (!m) continue;
107
+ if (!seen.has(m[1])) seen.set(m[1], commit); // newest-first, so the first sighting is the latest
108
+ }
109
+ return seen;
110
+ }
111
+
112
+ /** The commit that most recently put this note under `pre/`, or null when history cannot answer. */
113
+ export function addedBy(name, root = ROOT) {
114
+ return consumedProvenance(root).get(name) ?? null;
115
+ }
116
+
117
+ /** The commit that consumed this note, as a line for a reader. */
118
+ export function consumedBy(name, root = ROOT) {
119
+ const a = addedBy(name, root);
120
+ return a ? `${a.hash} ${a.subject}` : null;
121
+ }
122
+
123
+ // A NOTE ARRIVES UNDER `pre/` ONE WAY ONLY: a cut moves it there after publishing it. The author and the
124
+ // subject of that commit are how it is recognised, and both are the release pipeline's own doing rather
125
+ // than a convention anybody types.
126
+ const VERSION_AUTHOR = "github-actions[bot]";
127
+
128
+ // THE VERSION COMMIT HAS TWO NAMES, AND THIS CHECK RUNS WHERE BOTH ARE LIVE.
129
+ //
130
+ // On `main` it reads "Release 0.3.2-beta.6", because the squash that merges the version pull request
131
+ // takes the pull request's title. ON THE VERSION BRANCH ITSELF it reads whatever the cut named it, and
132
+ // the cut runs this same check before the merge — so a discriminator built from main's history alone
133
+ // refuses every version pull request, calling the notes it has just consumed misfiled by the very commit
134
+ // that consumed them. Measured 2026-09-17: it blocked a beta cut on its first live run, naming both
135
+ // waiting notes against `Cut a version (beta)`.
136
+ //
137
+ // So the branch-side subject is DERIVED from the workflow that writes it rather than restated here. A
138
+ // restated copy is a second place to keep in step, and it is exactly the copy that was missing.
139
+ const RELEASED_SUBJECT = /^Release\s/;
140
+
141
+ /** The subject the cut gives its own commit, read from the workflow that sets it. */
142
+ export function cutCommitSubject(root = ROOT) {
143
+ try {
144
+ const wf = readFileSync(join(root, ".github", "workflows", "release.yml"), "utf8");
145
+ return /^\s*commit-message:\s*['"]([^'"]+)['"]/m.exec(wf)?.[1] ?? null;
146
+ } catch {
147
+ return null;
148
+ }
149
+ }
150
+
151
+ /** Did a release put this note here — under either of the two names that commit goes by? */
152
+ export function isVersionCommit(commit, cutSubject) {
153
+ if (commit.author !== VERSION_AUTHOR) return false;
154
+ if (RELEASED_SUBJECT.test(commit.subject)) return true;
155
+ return Boolean(cutSubject) && commit.subject.startsWith(cutSubject);
156
+ }
157
+
158
+ /**
159
+ * Notes sitting in the consumed pile that no cut put there — written straight into `.changeset/pre/`.
160
+ *
161
+ * Such a note is born consumed: the versioning tool filters `pre/` ids out of its count while the tree is
162
+ * in pre-release mode, so the note is never eligible for a release and nothing refuses it. It then lands
163
+ * in the eventual stable's changelog as though a pre-release had already carried it. Measured
164
+ * 2026-09-17: one note in this state, holding the whole report redesign, published by no release at all.
165
+ *
166
+ * NEEDS HISTORY. A shallow clone cannot say which commit added a file, and answering "misfiled" from a
167
+ * missing answer would condemn every note on a depth-1 checkout. Those are returned as `unknown` and the
168
+ * caller decides; `main` refuses to give a verdict when it could not look at any of them.
169
+ */
170
+ export function misfiledNotes(root = ROOT) {
171
+ const preDir = join(root, ".changeset", "pre");
172
+ if (!existsSync(preDir)) return { misfiled: [], checked: 0, unknown: [] };
173
+ const provenance = consumedProvenance(root);
174
+ const cutSubject = cutCommitSubject(root);
175
+ const misfiled = [], unknown = [];
176
+ for (const name of mdIn(preDir, { keepReadme: true })) {
177
+ const added = provenance.get(name);
178
+ if (!added) { unknown.push(name); continue; }
179
+ if (!isVersionCommit(added, cutSubject)) misfiled.push({ name, ...added });
180
+ }
181
+ return { misfiled, unknown, cutSubject,
182
+ checked: mdIn(preDir, { keepReadme: true }).length - unknown.length };
183
+ }
184
+
185
+ export function placementMain(root = ROOT) {
186
+ const preDir = join(root, ".changeset", "pre");
187
+ if (!existsSync(preDir)) {
188
+ console.log("release-duplicate-notes: no .changeset/pre/ in this tree — nothing has been consumed yet.");
189
+ return 0;
190
+ }
191
+ const { misfiled, unknown, cutSubject } = misfiledNotes(root);
192
+ const total = mdIn(preDir, { keepReadme: true }).length;
193
+ if (total && !cutSubject) {
194
+ console.error("release-duplicate-notes: could not look — the release workflow's `commit-message:` could "
195
+ + "not be read, and that is half of what tells a release's own commit from a hand-filed note. Judging "
196
+ + "on the other half alone is what refused a correct version pull request once already.");
197
+ return 2;
198
+ }
199
+ if (total && unknown.length === total) {
200
+ console.error("release-duplicate-notes: could not look — no commit could be found for any of the "
201
+ + `${total} note(s) under .changeset/pre/. This check reads history to tell a note a cut consumed `
202
+ + "from one written straight into the consumed pile, so it needs a full clone (fetch-depth: 0).");
203
+ return 2;
204
+ }
205
+ if (misfiled.length) {
206
+ for (const m of misfiled) {
207
+ console.error(`::error file=.changeset/pre/${m.name}::${m.name} sits in .changeset/pre/, the pile a cut `
208
+ + `moves a note into AFTER publishing it, but it was put there by \`${m.hash} ${m.subject}\` rather than `
209
+ + "by a release. A note written straight into pre/ is never counted, never published, and then appears "
210
+ + "in the next stable's changelog as though a pre-release had carried it. Move it to .changeset/.");
211
+ }
212
+ console.error(`\nrelease-duplicate-notes: ${misfiled.length} note(s) in the consumed pile that no cut consumed`
213
+ + `${unknown.length ? `, and ${unknown.length} whose history could not be read` : ""}.`);
214
+ return 1;
215
+ }
216
+ console.log(`release-duplicate-notes: every one of the ${total - unknown.length} consumed note(s) was put there `
217
+ + `by a release${unknown.length ? `; ${unknown.length} could not be read` : ""}.`);
218
+ return 0;
219
+ }
220
+
221
+ export function main(root = ROOT) {
222
+ const read = duplicateNotes(root);
223
+ if (read.error) {
224
+ console.error(`release-duplicate-notes: could not look — ${read.error}.`);
225
+ return 2;
226
+ }
227
+ if (read.duplicates.length) {
228
+ for (const name of read.duplicates) {
229
+ const by = consumedBy(name, root);
230
+ console.error(`::error file=.changeset/${name}::${name} is in .changeset/ and in .changeset/pre/ at once. `
231
+ + `It was already consumed by ${by ? `\`${by}\`` : "an earlier cut (no commit found that added it under pre/)"}, `
232
+ + "so the next cut would publish its text a second time. Delete the copy at the top level — the one "
233
+ + "under pre/ is the record the stable release reads.");
234
+ }
235
+ console.error(`\nrelease-duplicate-notes: ${read.duplicates.length} note(s) counted twice, `
236
+ + `of ${read.waiting} waiting and ${read.consumed} already consumed.`);
237
+ return 1;
238
+ }
239
+ console.log(`release-duplicate-notes: no note is counted twice — ${read.waiting} waiting, `
240
+ + `${read.consumed} already consumed by an earlier cut.`);
241
+ return 0;
242
+ }
243
+
244
+ if (isEntrypoint(import.meta.url)) {
245
+ process.exitCode = process.argv.includes("--placement") ? placementMain() : main();
246
+ }
@@ -38,18 +38,42 @@ export const REPOSITORY = "CordilleraSarl/clearotron";
38
38
  *
39
39
  * A job is "publishing" if its own block runs `npm publish`. Comments are already stripped by the caller.
40
40
  */
41
- export function publishingJobs(live) {
42
- const out = [];
41
+ export function jobBlocks(live) {
42
+ const out = new Map();
43
43
  const starts = [...live.matchAll(/^ {2}([A-Za-z_][A-Za-z0-9_-]*):$/gm)];
44
44
  for (let i = 0; i < starts.length; i++) {
45
45
  const from = starts[i].index;
46
46
  const to = i + 1 < starts.length ? starts[i + 1].index : live.length;
47
- const block = live.slice(from, to);
48
- if (/\bnpm\s+publish\b/.test(block)) out.push([starts[i][1], block]);
47
+ out.set(starts[i][1], live.slice(from, to));
49
48
  }
50
49
  return out;
51
50
  }
52
51
 
52
+ export function publishingJobs(live) {
53
+ return [...jobBlocks(live)].filter(([, block]) => /\bnpm\s+publish\b/.test(block));
54
+ }
55
+
56
+ /**
57
+ * The ONE job permitted to hold a registry credential, and the secret it must come from.
58
+ *
59
+ * Owner ruling 2026-09-17. Deprecating a published version is a write the OIDC exchange cannot make: the
60
+ * short-lived credential npm mints for a trusted publish is scoped to publishing, and eleven deprecations
61
+ * attempted with it came back 404 while reads succeeded. So this one job reads a granular token, created
62
+ * by hand and held as a repository secret, into NODE_AUTH_TOKEN for its own step.
63
+ *
64
+ * WHAT THE RULING DID NOT CHANGE, and what the checks below hold: publishing stays credential-less. A
65
+ * credential anywhere else in this file is the thing this guard was written for and is still refused. The
66
+ * exemption is one named job, it may not publish, its credential must come from the named secret, and it
67
+ * must be scoped to a step rather than to the job — a job-level `env:` would hand the token to every step
68
+ * in it, including this guard.
69
+ */
70
+ export const CREDENTIALLED_JOB = "deprecate";
71
+ export const DEPRECATE_SECRET = "NPM_DEPRECATE_TOKEN";
72
+
73
+ /** The only two lines in this file permitted to name a credential or a registry, spelled exactly. */
74
+ export const PERMITTED_CREDENTIAL_LINE = `NODE_AUTH_TOKEN: \${{ secrets.${DEPRECATE_SECRET} }}`;
75
+ export const PERMITTED_REGISTRY_LINE = "registry-url: https://registry.npmjs.org";
76
+
53
77
  /** Credential spellings that would let this repository publish without the OIDC exchange. */
54
78
  export const CREDENTIAL_TOKENS = Object.freeze([
55
79
  "NPM_TOKEN",
@@ -68,12 +92,46 @@ export function refusals({ workflow, rootPkg }) {
68
92
  // scanner that reads its own prose as a finding refuses the thing it is describing.
69
93
  const live = workflow.split("\n").filter((l) => !/^\s*#/.test(l)).join("\n");
70
94
 
95
+ // THE EXEMPTION IS TWO EXACT LINES, NOT A REGION, and the difference is not pedantry — it was measured.
96
+ // Exempting the whole `deprecate` job looked equivalent and was not: that job is the LAST in the file,
97
+ // so its block runs to end-of-file, and every plant this guard's own arms append at the end landed
98
+ // inside the exemption. Four credential spellings went from refused to accepted in one edit, and the
99
+ // arm caught it. So one occurrence of each permitted line is removed and everything else is scanned:
100
+ // a second copy, a different spelling, or the same line in another job all stay in what is scanned.
101
+ const deprecateBlock = jobBlocks(live).get(CREDENTIALLED_JOB) ?? "";
102
+ let rest = live;
103
+ for (const line of [PERMITTED_CREDENTIAL_LINE, PERMITTED_REGISTRY_LINE]) {
104
+ if (!deprecateBlock.includes(line)) continue; // permitted only where the ruling put it
105
+ const at = rest.indexOf(line);
106
+ if (at !== -1) rest = rest.slice(0, at) + rest.slice(at + line.length);
107
+ }
108
+
71
109
  for (const tok of CREDENTIAL_TOKENS) {
72
- if (live.includes(tok)) add(`the release workflow carries a registry credential (${tok})`);
110
+ if (rest.includes(tok)) add(`the release workflow carries a registry credential (${tok})`);
73
111
  }
74
112
  // `registry-url:` on setup-node writes an .npmrc that authenticates with NODE_AUTH_TOKEN. Trusted
75
113
  // publishing needs no registry configured at all, so its presence means somebody is wiring a token.
76
- if (/registry-url:/.test(live)) add("the release workflow configures a registry to authenticate against");
114
+ if (/registry-url:/.test(rest)) add("the release workflow configures a registry to authenticate against");
115
+
116
+ // ── AND THE PERMITTED JOB IS HELD TO THE TERMS OF ITS OWN EXEMPTION ─────────────────────────────
117
+ //
118
+ // An exemption nobody checks is a hole. These are the conditions the ruling was given under, and each
119
+ // one is a way the exemption could quietly become general.
120
+ if (deprecateBlock) {
121
+ if (/\bnpm\s+publish\b/.test(deprecateBlock)) {
122
+ add(`job \`${CREDENTIALLED_JOB}\` publishes, and it is the one job allowed to hold a credential — `
123
+ + "the two must never be the same job");
124
+ }
125
+ if (deprecateBlock.includes("NODE_AUTH_TOKEN") && !deprecateBlock.includes(`secrets.${DEPRECATE_SECRET}`)) {
126
+ add(`job \`${CREDENTIALLED_JOB}\` takes its credential from something other than \`secrets.${DEPRECATE_SECRET}\``);
127
+ }
128
+ // Job-level `env:` sits at four spaces; a step's sits at eight. The distinction is the whole point:
129
+ // a job-level block hands the token to every step, this guard included.
130
+ if (/^ {4}env:/m.test(deprecateBlock) && deprecateBlock.includes("NODE_AUTH_TOKEN")) {
131
+ add(`job \`${CREDENTIALLED_JOB}\` holds its credential at job level, so every step in it gets the token; `
132
+ + "it belongs on the one step that deprecates");
133
+ }
134
+ }
77
135
 
78
136
  // A publish without provenance is a publish nobody can trace back to a commit — which is the whole
79
137
  // reason the owner's ruling moved from a human publish to a CI one.
@@ -0,0 +1,194 @@
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
+ // What does the EXPORTED PDF actually show? Ask a browser under print media, not a regex.
5
+ //
6
+ // node scripts/report-print-check.mjs [--keep]
7
+ //
8
+ // ── WHY THIS EXISTS ─────────────────────────────────────────────────────────────────────────────────
9
+ //
10
+ // The exported PDF is the client-facing document, and it is the one rendering of a report that nothing
11
+ // else in this repository can see. The frozen hash pins the renderer's bytes; the render tests read its
12
+ // HTML. Neither can tell you that a control which exists to be CLICKED is being printed onto paper,
13
+ // because on screen it is correct and only the print block decides otherwise.
14
+ //
15
+ // Two folds have shipped mis-ruled. A hero caption's fold printed an inert label and a right-pointing
16
+ // COLLAPSED marker above text that was already fully expanded. A "What was searched" heading printed its
17
+ // arrow the same way. In both cases the commit that added the fold checked the half it thought about —
18
+ // the caption's text was carried correctly — and shipped the half it did not.
19
+ //
20
+ // ── THE INVARIANT, AND WHY IT IS NOT A LIST OF NAMES ────────────────────────────────────────────────
21
+ //
22
+ // `report.css`'s print block has two mechanisms for <details>, and its own comment states the rule. PURE
23
+ // TOGGLES hide their summary line. Summaries that CARRY content — region rows, secondary groups, section
24
+ // titles — print as static headers with the marker blanked. Every <details> the renderers emit must be
25
+ // ruled by one or the other.
26
+ //
27
+ // A test naming a class in the hide list would be the tautology this repository keeps shipping: it would
28
+ // go green the day somebody adds a sixth disclosure and forgets it, which is precisely the failure. So
29
+ // this walks EVERY <details> a real rendered page contains and requires each one that reaches paper to be
30
+ // ruled by a mechanism. A new disclosure fails this until somebody decides which it is.
31
+ //
32
+ // ── HOW IT ASKS ─────────────────────────────────────────────────────────────────────────────────────
33
+ //
34
+ // `@media print{` is rewritten to `@media all{`, so the print block applies to the live layout tree and
35
+ // every computed style is the one the export would use. A probe script appended to the page walks the
36
+ // disclosures and writes what it found into the DOM, which `--dump-dom` hands back. Two floors sit under
37
+ // that: a page with no print block at all is a refusal rather than a clean sweep, and a probe that never
38
+ // ran is a refusal rather than an empty list of problems.
39
+ //
40
+ // Needs `google-chrome`, and MUST NOT run under a virtual-memory ulimit — Chrome dies under one.
41
+ import { execFileSync } from "node:child_process";
42
+ import { readFileSync, writeFileSync, readdirSync, existsSync, rmSync, mkdtempSync, copyFileSync } from "node:fs";
43
+ import { tmpdir } from "node:os";
44
+ import { join, dirname } from "node:path";
45
+ import { fileURLToPath } from "node:url";
46
+ import { isEntrypoint } from "../shared/is-entrypoint.mjs";
47
+ import { browserRun } from "../shared/browser-temp-root.mjs";
48
+ import { buildFixturePool } from "./render-check.mjs";
49
+
50
+ const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
51
+
52
+ /** Appended to the page: it walks the disclosures and leaves its answer in the DOM for --dump-dom. */
53
+ export const PROBE = `
54
+ <script>
55
+ (function () {
56
+ var out = [];
57
+ document.querySelectorAll('details').forEach(function (d) {
58
+ var sm = d.querySelector(':scope > summary');
59
+ if (!sm) { out.push({ cls: d.className || '(none)', noSummary: true }); return; }
60
+ // Does it reach paper at all? An ancestor with display:none means no, and a disclosure that never
61
+ // prints cannot print a control.
62
+ var hidden = false, n = d;
63
+ while (n && n !== document.body) { if (getComputedStyle(n).display === 'none') { hidden = true; break; } n = n.parentElement; }
64
+ var sc = getComputedStyle(sm);
65
+ out.push({
66
+ cls: d.className || '(none)',
67
+ renders: !hidden,
68
+ summaryDisplay: sc.display,
69
+ before: getComputedStyle(sm, '::before').content,
70
+ listStyle: sc.listStyleType,
71
+ text: sm.textContent.replace(/\\s+/g, ' ').trim().slice(0, 48),
72
+ });
73
+ });
74
+ var p = document.createElement('pre');
75
+ p.id = 'PRINT-PROBE';
76
+ p.textContent = JSON.stringify(out);
77
+ document.body.appendChild(p);
78
+ })();
79
+ </script>
80
+ `;
81
+
82
+ const unescapeDom = (s) => s
83
+ .replace(/&quot;/g, '"').replace(/&#39;/g, "'")
84
+ .replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&");
85
+
86
+ /** A disclosure is ruled when its summary is hidden, or when it prints with no marker of any kind. */
87
+ export function ruledBy(row) {
88
+ if (row.summaryDisplay === "none") return "hidden";
89
+ const before = String(row.before ?? "");
90
+ const blanked = before === "" || before === "none" || before === '""' || before === "normal";
91
+ const noNativeMarker = row.listStyle === "none";
92
+ return blanked && noNativeMarker ? "static-header" : null;
93
+ }
94
+
95
+ /** Every <details> on one rendered page, as the export would draw it. */
96
+ export function measure(name, html, work, env) {
97
+ const printed = html.replace(/@media print\{/g, "@media all{");
98
+ if (printed === html) return { error: `${name}: no @media print block — the instrument measured nothing` };
99
+ const file = join(work, `${name}.print.html`);
100
+ writeFileSync(file, printed + PROBE);
101
+ let dom;
102
+ try {
103
+ dom = execFileSync("google-chrome", [
104
+ "--headless=new", "--disable-gpu", "--no-sandbox", "--hide-scrollbars",
105
+ "--host-resolver-rules=MAP * ~NOTFOUND, EXCLUDE 127.0.0.1",
106
+ `--user-data-dir=${join(work, `chrome-${name}`)}`, "--virtual-time-budget=8000",
107
+ "--dump-dom", `file://${file}`,
108
+ ], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], maxBuffer: 64 * 1024 * 1024, env: env });
109
+ } catch (e) {
110
+ return { error: `${name}: the browser did not run (${String(e.message).split("\n")[0]})` };
111
+ }
112
+ const m = dom.match(/<pre id="PRINT-PROBE">([\s\S]*?)<\/pre>/);
113
+ if (!m) return { error: `${name}: the probe never ran — nothing was measured` };
114
+ return { rows: JSON.parse(unescapeDom(m[1])) };
115
+ }
116
+
117
+ /** Every published report in a pool, with the stylesheet each one needs beside it. */
118
+ export function pagesIn(pool, work) {
119
+ const pages = [];
120
+ for (const run of readdirSync(pool)) {
121
+ const html = join(pool, run, "report.html");
122
+ if (!existsSync(html)) continue;
123
+ for (const f of readdirSync(join(pool, run))) {
124
+ if (f.endsWith(".css")) copyFileSync(join(pool, run, f), join(work, f));
125
+ }
126
+ pages.push([run, readFileSync(html, "utf8")]);
127
+ }
128
+ return pages;
129
+ }
130
+
131
+ export function main() {
132
+ const keep = process.argv.includes("--keep");
133
+ // `keep` is the handle that takes this run's root OUT of the exit sweep. Destructuring only the root
134
+ // and the environment is what defeats a --keep flag silently: the flag still parses, the sweep still
135
+ // runs, and nothing in the check's own output changes. The membership arm catches exactly that.
136
+ const { root: work, env: chromeEnv, keep: keepRoot } = browserRun("report-print-check-");
137
+ const pool = buildFixturePool(mkdtempSync(join(tmpdir(), "print-check-pool-")));
138
+ let failures = 0;
139
+ const fail = (m) => { failures += 1; console.error(` ✕ ${m}`); };
140
+ try {
141
+ const pages = pagesIn(pool, work);
142
+ // THE FLOOR ON THE POPULATION. An empty pool reports nothing wrong, which is what a broken fixture
143
+ // looks like from here. A check that measured no page is a could-not-look, never a pass.
144
+ if (!pages.length) {
145
+ console.error("report-print-check: the fixture pool published no report — nothing was measured.");
146
+ return 2;
147
+ }
148
+ let ruled = 0;
149
+ const byMechanism = { hidden: 0, "static-header": 0 };
150
+ for (const [name, html] of pages) {
151
+ const got = measure(name, html, work, chromeEnv);
152
+ if (got.error) { fail(got.error); continue; }
153
+ const printed = got.rows.filter((r) => r.renders);
154
+ console.log(`${name}: ${got.rows.length} disclosure(s), ${printed.length} of them printed`);
155
+ for (const r of got.rows) {
156
+ if (r.noSummary) { fail(`${name}: a <details class="${r.cls}"> has no <summary> — nothing rules how it prints`); continue; }
157
+ if (!r.renders) continue;
158
+ const how = ruledBy(r);
159
+ if (how) { ruled += 1; byMechanism[how] = (byMechanism[how] ?? 0) + 1; continue; }
160
+ fail(`${name}: <details class="${r.cls}"> ("${r.text}") prints its control — summary display `
161
+ + `${r.summaryDisplay}, marker ${r.before}, list-style ${r.listStyle}. On paper that is a label and `
162
+ + "an arrow over content that is already fully expanded. Rule it in report.css's print block: hide "
163
+ + "the summary if it is a pure toggle, or blank its marker if the summary carries content.");
164
+ }
165
+ }
166
+ // AND THE SECOND FLOOR. Every page answering "no disclosures" is indistinguishable from a probe that
167
+ // walked an empty document, so the run must have ruled at least one.
168
+ if (!failures && !ruled) {
169
+ console.error("report-print-check: no printed disclosure was found on any page. Either the reports "
170
+ + "stopped carrying them or the probe read the wrong document; both are a could-not-look.");
171
+ return 2;
172
+ }
173
+ if (failures) {
174
+ console.error(`\nreport-print-check: ${failures} disclosure(s) reach paper ruled by neither mechanism.`);
175
+ return 1;
176
+ }
177
+ // THE BREAKDOWN IS THE POINT OF PRINTING IT. "How many still draw a marker" is a question a reader
178
+ // will ask of a rendered report, and the answer is none: the hidden ones draw nothing because their
179
+ // summary is not painted at all, and the static headers draw nothing because their marker is blanked.
180
+ // Recording which mechanism ruled how many turns that from an inference into a number.
181
+ console.log(`report-print-check: ${ruled} printed disclosure(s), every one ruled by the print block — `
182
+ + `${byMechanism.hidden} by hiding the summary (pure toggles), `
183
+ + `${byMechanism["static-header"]} as static headers with the marker blanked.`);
184
+ return 0;
185
+ } finally {
186
+ // Out of the exit sweep first, or --keep promises a directory that goes at exit anyway — the flag
187
+ // parses, the removal it guards still runs, and nothing in the output says so.
188
+ if (keep) keepRoot();
189
+ if (keep) console.log(` kept: ${work} (and the fixture pool at ${pool})`);
190
+ else { rmSync(pool, { recursive: true, force: true }); rmSync(work, { recursive: true, force: true }); }
191
+ }
192
+ }
193
+
194
+ if (isEntrypoint(import.meta.url)) process.exitCode = main();
@@ -35,7 +35,7 @@
35
35
  // its own population needs an exception list — which would rebuild this issue's defect inside its fix:
36
36
  //
37
37
  // · `driver/stage-freshness.mjs` creates a CHILD, `join(runDir, "_driver", STAMP_DIR)`.
38
- // · `driver/pipeline.mjs:15757 shadowDir` passes a shadow dispatch sandbox under `_experiments/`, not a run
38
+ // · `driver/pipeline.mjs:15945 shadowDir` passes a shadow dispatch sandbox under `_experiments/`, not a run
39
39
  // directory. It is a run-dir-SHAPED base, which is why the parameter is `base` and not `runDir`.
40
40
  //
41
41
  // ── WHAT THIS DELIBERATELY DOES NOT DO ────────────────────────────────────────────────────────────