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.
- package/bin/update.mjs +24 -0
- package/build-info.json +2 -2
- package/driver/CHANGELOG.md +7 -0
- package/driver/connotation-search.mjs +45 -0
- package/driver/contract-e3-backlog.mjs +4 -4
- package/driver/contract-vocabulary.mjs +17 -17
- package/driver/gateway.mjs +6 -3
- package/driver/package.json +1 -1
- package/driver/pipeline.mjs +191 -3
- package/driver/publish/templates/report.css +10 -1
- package/driver/result-noun-fields.mjs +5 -2
- package/driver/suite-census.json +22 -10
- package/driver/verify.mjs +23 -5
- package/mcp-server/CHANGELOG.md +4 -0
- package/mcp-server/http-server.mjs +4 -0
- package/mcp-server/lib/audit.mjs +11 -1
- package/mcp-server/lib/http-handler.mjs +6 -2
- package/mcp-server/package.json +1 -1
- package/mcp-server/server.mjs +1 -1
- package/package.json +1 -1
- package/portal-ui/package.json +1 -1
- package/providers/clarivate/src/capabilities.js +5 -5
- package/providers/clarivate/src/core.js +1 -1
- package/providers/corsearch/src/core.js +2 -2
- package/providers/oauth-mcp-bridge/CHANGELOG.md +4 -0
- package/providers/oauth-mcp-bridge/package.json +1 -1
- package/providers/perplexity/src/core.js +3 -3
- package/providers/signa/src/capabilities.js +5 -6
- package/providers/signa/src/core.js +1 -1
- package/scripts/release-duplicate-notes.mjs +246 -0
- package/scripts/release-publish-guard.mjs +64 -6
- package/scripts/report-print-check.mjs +194 -0
- package/shared/driver-dir.mjs +1 -1
|
@@ -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
|
|
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
|
-
|
|
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 (
|
|
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(
|
|
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(/"/g, '"').replace(/'/g, "'")
|
|
84
|
+
.replace(/</g, "<").replace(/>/g, ">").replace(/&/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();
|
package/shared/driver-dir.mjs
CHANGED
|
@@ -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:
|
|
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 ────────────────────────────────────────────────────────────
|