clearotron 0.3.2-beta.7 → 0.3.2-beta.9
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/.env.example +58 -26
- package/CONTRIBUTING.md +8 -8
- package/INSTALL.md +148 -81
- package/README.md +3 -3
- package/SECURITY.md +3 -3
- package/bin/brandowner.mjs +3 -3
- package/bin/framework-preflight.mjs +1 -1
- package/bin/onboard.mjs +637 -216
- package/bin/start.mjs +151 -27
- package/bin/update.mjs +58 -11
- package/build-info.json +2 -2
- package/docs/DELIVERY.md +2 -1
- package/docs/INTAKE.md +1 -1
- package/docs/ONBOARDING.md +1 -1
- package/docs/architecture/03-run-lifecycle.md +6 -6
- package/docs/architecture/04-configuration-reference.md +32 -14
- package/docs/architecture/05-config-governance.md +23 -8
- package/docs/architecture/05-customer-profiles.md +2 -2
- package/docs/architecture/06-operations-runbook.md +3 -3
- package/docs/architecture/08-development-guide.md +6 -6
- package/docs/configuration.md +5 -5
- package/docs/decisions/0003-credential-model.md +1 -1
- package/docs/writing-standard.md +4 -0
- package/driver/CHANGELOG.md +124 -0
- package/driver/README.md +3 -3
- package/driver/band-size.mjs +59 -0
- package/driver/binding-layers.mjs +1 -1
- package/driver/citation-census.json +3 -3
- package/driver/{prelim-variants-record.mjs → clearance-variants-record.mjs} +24 -24
- package/driver/common-law-receipts.mjs +2 -2
- package/driver/company-bundle.mjs +3 -3
- package/driver/compose-read.mjs +8 -14
- package/driver/config-inventory.mjs +112 -9
- package/driver/consumption-ledger.mjs +2 -2
- package/driver/contract-arm2-baseline.json +2 -5
- package/driver/contract-dictation-registry.mjs +19 -19
- package/driver/contract-e3-backlog.mjs +43 -43
- package/driver/contract-e3-baseline.json +14 -14
- package/driver/contract-vocabulary.mjs +68 -27
- package/driver/deliver-trigger.sh +16 -16
- package/driver/demo-container.mjs +3 -3
- package/driver/dev-portal.mjs +3 -3
- package/driver/disposition-call.mjs +1 -1
- package/driver/door-gates.mjs +41 -7
- package/driver/doubt-ledger.mjs +2 -2
- package/driver/drainer-identity.mjs +34 -8
- package/driver/driver.config.mjs +367 -104
- package/driver/engine/CONTRACT.md +10 -3
- package/driver/engine/README.md +2 -2
- package/driver/engine/anthropic-agent.mjs +77 -21
- package/driver/engine/auth.mjs +129 -10
- package/driver/engine/jx-turn.mjs +7 -6
- package/driver/engine/mcp/README.md +1 -1
- package/driver/engine/mcp/dispositions-server.mjs +3 -3
- package/driver/engine/mcp/gather-config.mjs +9 -9
- package/driver/engine/mcp/perplexity-server.mjs +2 -2
- package/driver/engine/mcp/recording-server.mjs +18 -5
- package/driver/engine/openai-agent.mjs +4 -2
- package/driver/engine/probe.mjs +110 -23
- package/driver/enqueue-schema.mjs +6 -2
- package/driver/findings-model.mjs +6 -3
- package/driver/flag-snapshot.mjs +34 -8
- package/driver/form-neighbourhood.mjs +54 -7
- package/driver/framework.mjs +4 -4
- package/driver/gateway.mjs +36 -24
- package/driver/jx-lanes.mjs +23 -4
- package/driver/jx-units.mjs +7 -4
- package/driver/jx.mjs +34 -4
- package/driver/knockout-review-record.mjs +56 -4
- package/driver/known-conflicts.mjs +1 -1
- package/driver/matter-frame-record.mjs +90 -1
- package/driver/named-band.mjs +1 -1
- package/driver/ordinary-words.mjs +51 -0
- package/driver/outbox-backoff.mjs +31 -16
- package/driver/package.json +1 -1
- package/driver/partial-payload-baseline.json +2 -2
- package/driver/phase0.mjs +3 -3
- package/driver/pipeline-knockout.mjs +5 -5
- package/driver/pipeline.mjs +396 -81
- package/driver/placement-form.mjs +77 -1
- package/driver/placement-model.mjs +1 -1
- package/driver/portal-config-view.mjs +30 -1
- package/driver/portal-report.mjs +107 -6
- package/driver/portal-service.mjs +80 -14
- package/driver/portal-upstream.mjs +1 -1
- package/driver/predelivery-lint.mjs +12 -2
- package/driver/preserve-merge.mjs +3 -3
- package/driver/product-rows.mjs +2 -2
- package/driver/products.mjs +1 -1
- package/driver/profiles/README.md +3 -3
- package/driver/profiles/demo-brand-owner.json +2 -2
- package/driver/profiles.mjs +55 -17
- package/driver/progress.mjs +18 -8
- package/driver/provider-usage.mjs +8 -8
- package/driver/publish/index.mjs +154 -8
- package/driver/publish/knockout.mjs +39 -5
- package/driver/publish/pool-admin.mjs +1 -1
- package/driver/publish/publish-inputs.mjs +18 -2
- package/driver/publish/render-knockout.mjs +184 -31
- package/driver/publish/render.mjs +323 -93
- package/driver/publish/report-data.mjs +4 -1
- package/driver/publish/report-topbar.mjs +58 -0
- package/driver/publish/search-depth.mjs +133 -4
- package/driver/publish/templates/report.css +78 -4
- package/driver/publish/xlsx.mjs +20 -1
- package/driver/queue-order.mjs +2 -2
- package/driver/recording-agreement.mjs +1 -1
- package/driver/reference-score.mjs +1 -1
- package/driver/register-availability.mjs +2 -2
- package/driver/register-count.mjs +50 -5
- package/driver/register-coverage.mjs +161 -1
- package/driver/register-digest-record.mjs +236 -11
- package/driver/register-grant-vocabulary.mjs +1 -1
- package/driver/register-plan.mjs +189 -2
- package/driver/registry-fidelity.mjs +3 -3
- package/driver/repair-composers.mjs +1 -1
- package/driver/repair-contract.mjs +1 -1
- package/driver/replay-archive.mjs +6 -6
- package/driver/report-overview-record.mjs +2 -2
- package/driver/result-noun-fields.mjs +2 -2
- package/driver/run-economics.mjs +41 -10
- package/driver/run-requirements.mjs +173 -9
- package/driver/runner.mjs +5 -5
- package/driver/scope-facts.mjs +20 -5
- package/driver/scope-ledger.mjs +5 -5
- package/driver/search-policy.mjs +22 -12
- package/driver/skills/README.md +15 -15
- package/driver/skills/blind-frame/SKILL.md +2 -2
- package/driver/skills/case-law-citation/SKILL.md +4 -4
- package/driver/skills/case-law-citation/sources/eurlex.md +1 -1
- package/driver/skills/{prelim-common-law → clearance-common-law}/SKILL.md +22 -22
- package/driver/skills/{prelim-common-law → clearance-common-law}/perplexity-prompts.md +1 -1
- package/driver/skills/{prelim-register → clearance-register}/SKILL.md +10 -10
- package/driver/skills/{prelim-register → clearance-register}/digest.md +2 -2
- package/driver/skills/{prelim-register → clearance-register}/providers/README.md +1 -1
- package/driver/skills/{prelim-register → clearance-register}/providers/clarivate.md +37 -35
- package/driver/skills/{prelim-register → clearance-register}/providers/corsearch.md +20 -11
- package/driver/skills/{prelim-register → clearance-register}/providers/signa.md +5 -5
- package/driver/skills/{prelim-register → clearance-register}/register-recipes.md +3 -3
- package/driver/skills/{prelim-register → clearance-register}/status-rules.md +2 -2
- package/driver/skills/{prelim-register → clearance-register}/stealth-filer-indicators.md +1 -1
- package/driver/skills/{prelim-register → clearance-register}/unit.md +2 -2
- package/driver/skills/{prelim-search → clearance-search}/SKILL.md +31 -31
- package/driver/skills/{prelim-search → clearance-search}/delivery-contract.md +1 -1
- package/driver/skills/{prelim-search → clearance-search}/phase2-execution.md +18 -18
- package/driver/skills/{prelim-search → clearance-search}/synthesis-rules.md +7 -7
- package/driver/skills/{prelim-variants → clearance-variants}/SKILL.md +18 -18
- package/driver/skills/{prelim-variants → clearance-variants}/transliteration-scripts.md +5 -5
- package/driver/skills/frame-diff/SKILL.md +1 -1
- package/driver/skills/knockout-assess/SKILL.md +10 -7
- package/driver/skills/matter-frame/SKILL.md +3 -3
- package/driver/skills/narrative-refutation/SKILL.md +9 -9
- package/driver/skills/placement-inquiry/SKILL.md +5 -5
- package/driver/stage-context.mjs +1 -1
- package/driver/stages-knockout.mjs +4 -4
- package/driver/stages.mjs +65 -61
- package/driver/status-snapshot.mjs +2 -2
- package/driver/suite-census.json +340 -136
- package/driver/surface-exit-verdict.mjs +58 -0
- package/driver/systemd/README.md +9 -6
- package/driver/systemd/clearotron-worker.service +1 -1
- package/driver/terminal-clamp.mjs +109 -1
- package/driver/tokens.mjs +169 -3
- package/driver/unit-environment.mjs +42 -15
- package/driver/unit-inventory.mjs +19 -2
- package/driver/usage-ledger.mjs +1 -1
- package/driver/variant-manifest-model.mjs +4 -4
- package/driver/verify-knockout.mjs +27 -0
- package/driver/verify.mjs +94 -6
- package/driver/whatif-queue.mjs +1 -1
- package/driver/wordlists/en.txt +63906 -0
- package/mcp-server/CHANGELOG.md +8 -0
- package/mcp-server/README.md +1 -1
- package/mcp-server/lib/README.md +1 -1
- package/mcp-server/lib/options.mjs +8 -7
- package/mcp-server/lib/plan.mjs +18 -2
- package/mcp-server/lib/runs.mjs +1 -1
- package/mcp-server/lib/usage.mjs +3 -3
- package/mcp-server/lib/whatif.mjs +1 -1
- package/mcp-server/package.json +1 -1
- package/mcp-server/server.mjs +18 -1
- package/package.json +12 -11
- package/portal-ui/dist/assets/{index-CVOIvdhc.css → index-CtvwLCti.css} +207 -3
- package/portal-ui/dist/assets/{index-5UyqAyNM.js → index-EVaSo5-g.js} +1580 -527
- package/portal-ui/dist/index.html +2 -2
- package/portal-ui/package.json +1 -1
- package/providers/README.md +1 -1
- package/providers/_shared/enumerate.mjs +6 -6
- package/providers/_shared/execute-plan.mjs +3 -3
- package/providers/_shared/ledger.mjs +119 -5
- package/providers/_shared/provider-text.mjs +2 -2
- package/providers/_shared/screen.mjs +2 -2
- package/providers/_shared/script-form.mjs +3 -3
- package/providers/_shared/territory-codes.mjs +23 -3
- package/providers/clarivate/README.md +1 -1
- package/providers/clarivate/src/capabilities.js +12 -12
- package/providers/clarivate/src/core.js +37 -43
- package/providers/corsearch/README.md +1 -1
- package/providers/corsearch/src/capabilities.js +5 -5
- package/providers/corsearch/src/core.js +3 -3
- package/providers/jx/README.md +2 -1
- package/providers/jx/src/turn-envelope.mjs +8 -3
- package/providers/oauth-mcp-bridge/CHANGELOG.md +8 -0
- package/providers/oauth-mcp-bridge/package.json +1 -1
- package/providers/perplexity/src/core.js +1 -1
- package/providers/signa/README.md +1 -1
- package/providers/signa/src/capabilities.js +42 -49
- package/providers/signa/src/core.js +106 -29
- package/providers/uspto-local/README.md +1 -1
- package/providers/uspto-local/src/sync.js +1 -1
- package/scripts/README.md +1 -0
- package/scripts/ask-ai-render-check.mjs +127 -1
- package/scripts/authority-boundary-probe.mjs +8 -6
- package/scripts/backfill-started-at.mjs +2 -2
- package/scripts/census-merge-driver.mjs +33 -2
- package/scripts/citation-anchor-report.mjs +181 -0
- package/scripts/dead-names.mjs +1 -1
- package/scripts/deprecate-below.mjs +66 -8
- package/scripts/drain-preflight.mjs +1 -1
- package/scripts/e2e.mjs +174 -0
- package/scripts/env-audit.mjs +39 -6
- package/scripts/env-classify.mjs +20 -2
- package/scripts/freeze-example-run.mjs +61 -18
- package/scripts/generated-files-are-current.mjs +69 -4
- package/scripts/live-surface-check.mjs +124 -41
- package/scripts/markdown-link-check.mjs +1 -1
- package/scripts/merge-shape-check.mjs +242 -0
- package/scripts/mint-names-in-force.mjs +5 -5
- package/scripts/mint-offered-territories.mjs +72 -0
- package/scripts/mint-public-residue.mjs +2 -2
- package/scripts/mint-reference-strip-backlog.mjs +2 -2
- package/scripts/mint-suite-census.mjs +75 -2
- package/scripts/mint-writing-standard-backlog.mjs +2 -2
- package/scripts/purge-runs.mjs +7 -7
- package/scripts/reconcile-runs.mjs +2 -2
- package/scripts/release-approve-parked.mjs +20 -2
- package/scripts/release-await-cut.mjs +120 -1
- package/scripts/release-note-required.mjs +76 -8
- package/scripts/report-header-render-check.mjs +164 -0
- package/scripts/settings-render-check.mjs +75 -2
- package/scripts/test-full.mjs +96 -3
- package/scripts/test-run.mjs +10 -0
- package/shared/brand.mjs +27 -0
- package/shared/connect-clients.mjs +39 -11
- package/shared/deployment-box.mjs +7 -2
- package/shared/driver-dir.mjs +1 -1
- package/shared/env-aliases.mjs +1 -1
- package/shared/identifier-scan.mjs +65 -9
- package/shared/identifier-sentinels.mjs +22 -0
- package/shared/names-in-force.mjs +4 -2
- package/shared/offered-territories.json +738 -0
- package/shared/pre-rename-spellings.mjs +53 -0
- package/shared/reference-guard-classes.mjs +40 -2
- package/shared/stdio-connect.mjs +39 -4
- package/shared/tree-commit.mjs +48 -0
- /package/driver/skills/{prelim-register → clearance-register}/providers/euipo.md +0 -0
- /package/driver/skills/{prelim-register → clearance-register}/providers/free-tier.md +0 -0
- /package/driver/skills/{prelim-register → clearance-register}/providers/uspto-local.md +0 -0
- /package/driver/skills/{prelim-search → clearance-search}/field-doctrine-pharma.md +0 -0
- /package/driver/skills/{prelim-search → clearance-search}/firm-wide-reasoning.md +0 -0
- /package/driver/skills/{prelim-search → clearance-search}/report-prose.md +0 -0
- /package/driver/skills/{prelim-search → clearance-search}/risk-framework-demo.manifest.json +0 -0
- /package/driver/skills/{prelim-search → clearance-search}/risk-framework-demo.md +0 -0
- /package/driver/skills/{prelim-search → clearance-search}/risk-framework-triage.manifest.json +0 -0
- /package/driver/skills/{prelim-search → clearance-search}/risk-framework-triage.md +0 -0
- /package/driver/skills/{prelim-search → clearance-search}/risk-framework.manifest.json +0 -0
- /package/driver/skills/{prelim-search → clearance-search}/risk-framework.md +0 -0
- /package/driver/skills/{prelim-search → clearance-search}/template-formatting.md +0 -0
- /package/driver/skills/{prelim-search → clearance-search}/templates/email/generic.md +0 -0
- /package/driver/skills/{prelim-search → clearance-search}/templates/search-request-form.html +0 -0
- /package/driver/skills/{prelim-search → clearance-search}/worked-examples-demo.md +0 -0
- /package/driver/skills/{prelim-search → clearance-search}/worked-examples.md +0 -0
|
@@ -67,6 +67,23 @@
|
|
|
67
67
|
// the releases page is built from `.changeset/*.md`, and a line in a commit message reaches no reader. A
|
|
68
68
|
// line may instead NAME the note that answers for it, `Release-note: .changeset/<name>.md`, and that is read
|
|
69
69
|
// as an answer, provided the range adds that note.
|
|
70
|
+
//
|
|
71
|
+
// ── A NOTE THE RANGE WITHDRAWS, AND SAYS WHY, IS AN ANSWER ────────────────────────────────────────────
|
|
72
|
+
//
|
|
73
|
+
// A ruling took a beta-only change back out, and the commit that removed it removed the note that
|
|
74
|
+
// announced it too, so the stable changelog would not promise a ceiling the stable release does not have.
|
|
75
|
+
// That deletion is right, and it left two earlier commits naming a note the range no longer carries:
|
|
76
|
+
// refused, and unfixable, because the only fix the check offered was editing commits already pushed.
|
|
77
|
+
//
|
|
78
|
+
// So a note that a LATER commit in the same range DELETES counts as consumed for the commits that added
|
|
79
|
+
// or named it — provided the deleting commit says why, in its own `Release-note: none — <reason>` or in a
|
|
80
|
+
// note of its own. The reason is asked of the DELETING commit because it is the newest of the two, the
|
|
81
|
+
// only one that can still be amended, and the one that knows why the reader now hears nothing. Said, not
|
|
82
|
+
// silent: every commit excused this way is printed with the sha that withdrew its note and that reason.
|
|
83
|
+
//
|
|
84
|
+
// A deletion that gives no reason still refuses, and names the deleting commit as where the reason goes.
|
|
85
|
+
// The move a pre-release makes — `.changeset/<name>.md` deleted and `.changeset/pre/<name>.md` added in
|
|
86
|
+
// one commit — is not a withdrawal: the note still reaches a reader, and a version commit is that shape.
|
|
70
87
|
import { execFileSync } from "node:child_process";
|
|
71
88
|
import { readFileSync } from "node:fs";
|
|
72
89
|
import { join, dirname } from "node:path";
|
|
@@ -143,12 +160,14 @@ const shipped = (paths, files) => paths.filter((p) => !NEVER_A_NOTE.some((re) =>
|
|
|
143
160
|
* PURE. What each commit in a range owes, decided per commit (see the header).
|
|
144
161
|
*
|
|
145
162
|
* @param {object} o
|
|
146
|
-
* @param {Array<{sha:string, subject:string, message:string, paths:string[], added?:string[]}>} o.commits
|
|
147
|
-
* oldest first; `added`
|
|
163
|
+
* @param {Array<{sha:string, subject:string, message:string, paths:string[], added?:string[], deleted?:string[]}>} o.commits
|
|
164
|
+
* non-merge, oldest first; `added` and `deleted` are the paths the commit adds and removes, when the caller read them
|
|
148
165
|
* @param {string[]} o.files the package's own `files` list
|
|
149
166
|
* @param {string[]|null} [o.atHead] every path the head carries under `.changeset/`, when the caller read it
|
|
150
167
|
* @returns {{visible:string[], notes:string[], declined:Array<{sha:string, subject:string, reason:string}>,
|
|
151
|
-
*
|
|
168
|
+
* withdrawals:Array<{sha:string, subject:string, note:string, by:string, reason:string}>,
|
|
169
|
+
* owed:Array<{sha:string, subject:string, why:"no-note"|"prose"|"bare-none"|"names-a-missing-note"|"note-withdrawn-unsaid",
|
|
170
|
+
* paths?:string[], text?:string, by?:string}>}}
|
|
152
171
|
*/
|
|
153
172
|
export function commitVerdicts({ commits = [], files = [], atHead = null } = {}) {
|
|
154
173
|
// `added` and `atHead` are what the command reads from git; a caller that passes neither keeps the older
|
|
@@ -172,8 +191,34 @@ export function commitVerdicts({ commits = [], files = [], atHead = null } = {})
|
|
|
172
191
|
const notes = [...new Set(commits.flatMap(notesOf))];
|
|
173
192
|
const noteNames = new Set(notes.map((p) => p.split("/").pop()));
|
|
174
193
|
const visible = [...new Set(commits.flatMap((c) => shipped(c.paths, files)))];
|
|
175
|
-
const
|
|
176
|
-
|
|
194
|
+
const nameOf = (p) => p.split("/").pop();
|
|
195
|
+
// WHICH NOTES THIS RANGE TAKES BACK, AND WHETHER IT SAYS WHY. A commit that deletes a note path and
|
|
196
|
+
// adds one of the same name is MOVING it — what a pre-release cut does to every note it consumes — and
|
|
197
|
+
// a move is not a withdrawal. A name the head still carries was never withdrawn at all.
|
|
198
|
+
//
|
|
199
|
+
// THE DELETION THAT ANSWERS IS THE LAST ONE, because a note can go and come back. This note was
|
|
200
|
+
// written, consumed into `.changeset/pre/` by a beta cut, brought back at its old path by a merge of a
|
|
201
|
+
// main the cut had already taken it from, dropped again as a duplicate, and only then removed under the
|
|
202
|
+
// ruling. Taking the first deletion would have printed the duplicate sweep's reason — "removes
|
|
203
|
+
// duplicates of notes that already shipped" — against a commit the ruling is what excuses. Measured on
|
|
204
|
+
// the range, 2026-09-18.
|
|
205
|
+
const withdrawn = new Map();
|
|
206
|
+
commits.forEach((c, at) => {
|
|
207
|
+
const readded = new Set((c.added ?? []).filter(isNotePath).map(nameOf));
|
|
208
|
+
for (const p of (c.deleted ?? []).filter(isNotePath)) {
|
|
209
|
+
const name = nameOf(p);
|
|
210
|
+
if (readded.has(name)) continue;
|
|
211
|
+
const own = notesOf(c).map(nameOf);
|
|
212
|
+
const reason = NO_NOTE.exec(String(c.message ?? ""))?.groups?.reason
|
|
213
|
+
?? (own.length ? `the note ${own[0]} it wrote in its place` : null);
|
|
214
|
+
withdrawn.set(name, { sha: c.sha, subject: c.subject, reason, at });
|
|
215
|
+
}
|
|
216
|
+
for (const name of readded) withdrawn.delete(name); // a later copy puts the note back in the range
|
|
217
|
+
});
|
|
218
|
+
// AND A NAME THE HEAD STILL CARRIES WAS NEVER WITHDRAWN AT ALL: the release assembles what the head has.
|
|
219
|
+
if (presentNames) for (const name of [...withdrawn.keys()]) if (presentNames.has(name)) withdrawn.delete(name);
|
|
220
|
+
const declined = [], owed = [], withdrawals = [];
|
|
221
|
+
for (const [i, c] of commits.entries()) {
|
|
177
222
|
const ships = shipped(c.paths, files);
|
|
178
223
|
if (!ships.length) continue; // nothing a reader could see in this commit
|
|
179
224
|
const addsNote = notesOf(c).length > 0;
|
|
@@ -189,13 +234,24 @@ export function commitVerdicts({ commits = [], files = [], atHead = null } = {})
|
|
|
189
234
|
continue;
|
|
190
235
|
}
|
|
191
236
|
if (addsNote) continue; // it carries its own note
|
|
237
|
+
// THE NOTE THIS COMMIT ANSWERED WITH, TAKEN BACK BY A LATER ONE. Only a later commit: a name deleted
|
|
238
|
+
// before this commit named it was never this commit's answer.
|
|
239
|
+
const addedNames = [...new Set((c.added ?? c.paths).filter(isNotePath).map(nameOf))];
|
|
240
|
+
const takenBack = [...new Set([...addedNames, ...named])]
|
|
241
|
+
.map((n) => [n, withdrawn.get(n)]).filter(([, w]) => w && w.at > i);
|
|
242
|
+
const said = takenBack.find(([, w]) => w.reason);
|
|
243
|
+
if (said) { withdrawals.push({ ...at, note: said[0], by: said[1].sha, reason: said[1].reason }); continue; }
|
|
244
|
+
if (takenBack.length) {
|
|
245
|
+
owed.push({ ...at, why: "note-withdrawn-unsaid", text: takenBack[0][0], by: takenBack[0][1].sha });
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
192
248
|
const missing = named.filter((n) => !noteNames.has(n));
|
|
193
249
|
if (missing.length) { owed.push({ ...at, why: "names-a-missing-note", text: missing[0] }); continue; }
|
|
194
250
|
if (named.length) continue; // it names a note the range adds
|
|
195
251
|
if (notes.length) continue; // a note in the range answers for it
|
|
196
252
|
owed.push({ ...at, why: "no-note", paths: ships });
|
|
197
253
|
}
|
|
198
|
-
return { visible, notes, declined, owed };
|
|
254
|
+
return { visible, notes, declined, withdrawals, owed };
|
|
199
255
|
}
|
|
200
256
|
|
|
201
257
|
const argAfter = (flag) => {
|
|
@@ -232,6 +288,7 @@ function main() {
|
|
|
232
288
|
message: git("log", "-1", "--format=%B", sha),
|
|
233
289
|
paths: git("diff-tree", "--no-commit-id", "--name-only", "-r", "--root", sha).split("\n").filter(Boolean),
|
|
234
290
|
added: git("diff-tree", "--no-commit-id", "--name-only", "-r", "--root", "--diff-filter=A", sha).split("\n").filter(Boolean),
|
|
291
|
+
deleted: git("diff-tree", "--no-commit-id", "--name-only", "-r", "--root", "--diff-filter=D", sha).split("\n").filter(Boolean),
|
|
235
292
|
}));
|
|
236
293
|
atHead = git("ls-tree", "-r", "--name-only", head, "--", ".changeset/").split("\n").filter(Boolean);
|
|
237
294
|
} catch (e) {
|
|
@@ -245,13 +302,19 @@ function main() {
|
|
|
245
302
|
catch (e) { console.error(`release-note-required: cannot read the shipped file list: ${e.message}`); process.exit(2); }
|
|
246
303
|
if (!files.length) { console.error("release-note-required: package.json names no shipped files, so this cannot look"); process.exit(2); }
|
|
247
304
|
|
|
248
|
-
const { visible, notes, declined, owed } = commitVerdicts({ commits, files, atHead });
|
|
305
|
+
const { visible, notes, declined, withdrawals, owed } = commitVerdicts({ commits, files, atHead });
|
|
249
306
|
console.log(`release-note-required: ${changed.length} changed file(s) against ${base}`
|
|
250
307
|
+ `${head === "HEAD" ? "" : ` (head ${head})`}; `
|
|
251
|
-
+ `${visible.length} ship as code; ${notes.length} release note(s) in the range; ${commits.length} commit(s) read`
|
|
308
|
+
+ `${visible.length} ship as code; ${notes.length} release note(s) in the range; ${commits.length} commit(s) read`
|
|
309
|
+
+ `${withdrawals.length ? `; ${withdrawals.length} answered by a note the range withdrew` : ""}`);
|
|
252
310
|
// PER COMMIT, SAID AS PER COMMIT. "no note, declared on purpose" read as a verdict on the range, and beside
|
|
253
311
|
// a range that carries a note it told a reader skimming the output that none went out. Found in review.
|
|
254
312
|
for (const d of declined) console.log(` ${d.sha.slice(0, 7)} declines a note of its own, on purpose: ${d.reason}`);
|
|
313
|
+
// AN EXCUSED COMMIT IS PRINTED, WITH THE SHA THAT EXCUSED IT. A pass this check reached by reading
|
|
314
|
+
// another commit's decision is one a person may want to disagree with, and they cannot if it is silent.
|
|
315
|
+
for (const w of withdrawals) {
|
|
316
|
+
console.log(` ${w.sha.slice(0, 7)} answered with ${w.note}, which ${w.by.slice(0, 7)} withdrew: ${w.reason}`);
|
|
317
|
+
}
|
|
255
318
|
if (!owed.length) return;
|
|
256
319
|
|
|
257
320
|
console.error(`\n${owed.length} commit(s) that ship as code owe a release note:\n`);
|
|
@@ -267,6 +330,11 @@ function main() {
|
|
|
267
330
|
} else if (o.why === "bare-none") {
|
|
268
331
|
console.error(" says `Release-note: none` and gives no reason. The reason is the whole point of the"
|
|
269
332
|
+ "\n line — it is what a later reader uses to tell a considered decision from a skipped step.");
|
|
333
|
+
} else if (o.why === "note-withdrawn-unsaid") {
|
|
334
|
+
console.error(` answered with ${o.text}, which ${o.by.slice(0, 7)} deletes in this range without saying why.`
|
|
335
|
+
+ "\n A note the head does not carry reaches no reader. If the change it described went out of the"
|
|
336
|
+
+ "\n release too, say that in THAT commit — `Release-note: none — <why>` — which is the newest of"
|
|
337
|
+
+ "\n the two and the one that can still be amended.");
|
|
270
338
|
} else {
|
|
271
339
|
console.error(` names ${o.text} as its note, and this range adds no note by that name.`);
|
|
272
340
|
}
|
|
@@ -0,0 +1,164 @@
|
|
|
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
|
+
// report-header-render-check.mjs — the delivered document's own header, measured in a browser.
|
|
4
|
+
//
|
|
5
|
+
// node scripts/report-header-render-check.mjs [--keep]
|
|
6
|
+
//
|
|
7
|
+
// Two properties, both of which a reader met as defects on 2026-09-18 and neither of which any unit test
|
|
8
|
+
// can see, because every one of them asserts on the HTML STRING and both of these are layout:
|
|
9
|
+
//
|
|
10
|
+
// 1. ONE HEADER. The section breadcrumb is a row INSIDE `.rep-stickyhead`, and when the page is
|
|
11
|
+
// scrolled the header is still at the top of the viewport with the breadcrumb inside it. It used to
|
|
12
|
+
// be a sibling pinned at `top:var(--tb-h,52px)` — a variable this product sets nowhere — so it sat
|
|
13
|
+
// at a guessed offset, showed content through the slit between the two bars, and slid under the
|
|
14
|
+
// header whenever the guess was wrong.
|
|
15
|
+
// 2. THE BAND PILL CLEARS ITS HEADING. The pill hangs above the ladder and the heading sits above the
|
|
16
|
+
// pill, and at the LEFTMOST band the pill is drawn flush left — directly under "Overall risk". They
|
|
17
|
+
// overlapped, which is what a reader meets on a knockout whose band is the bottom rung.
|
|
18
|
+
//
|
|
19
|
+
// Driven at every rung of the ladder, not only the one a fixture happens to carry: the overlap is a
|
|
20
|
+
// property of the leftmost position, so a check that renders one band proves nothing about the band the
|
|
21
|
+
// next report lands on.
|
|
22
|
+
import { spawn } from 'node:child_process'
|
|
23
|
+
import { createServer } from 'node:http'
|
|
24
|
+
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
|
|
25
|
+
import { join } from 'node:path'
|
|
26
|
+
import { tmpdir } from 'node:os'
|
|
27
|
+
import { reapOnExit } from '../shared/reap-on-exit.mjs'
|
|
28
|
+
import { browserRun } from '../shared/browser-temp-root.mjs'
|
|
29
|
+
import { renderKnockoutHtml } from '../driver/publish/render-knockout.mjs'
|
|
30
|
+
|
|
31
|
+
const keep = process.argv.includes('--keep')
|
|
32
|
+
const WIDTH = 1280
|
|
33
|
+
|
|
34
|
+
// The ladder the house triage framework ships, so the rungs are the product's own and not invented here.
|
|
35
|
+
const FRAMEWORK = {
|
|
36
|
+
framework_key: 'house-triage',
|
|
37
|
+
title: 'House triage',
|
|
38
|
+
bands: [{ label: 'Blocking', tone: 'severe' }, { label: 'Medium', tone: 'medium' },
|
|
39
|
+
{ label: 'Manageable', tone: 'low' }, { label: 'Low', tone: 'minimal' }],
|
|
40
|
+
}
|
|
41
|
+
const BANDS = FRAMEWORK.bands.map((b) => b.label)
|
|
42
|
+
|
|
43
|
+
// A mark with the STRUCTURE the gauge card needs — a basis and factors are what make `readBlock` draw the
|
|
44
|
+
// card at all. The words are fixture words; nothing here is a finding.
|
|
45
|
+
const markAt = (band) => ({
|
|
46
|
+
name: 'EXAMPLEMARK',
|
|
47
|
+
rating: band,
|
|
48
|
+
classes: [9],
|
|
49
|
+
basis: 'One identical name in the same trade, and two that contain it.',
|
|
50
|
+
factors: ['An identical name is registered in class 9.', 'The trade is the same.'],
|
|
51
|
+
counterFactors: ['The register counts do not say who is using what.'],
|
|
52
|
+
mitigation: 'A narrower specification would move this.',
|
|
53
|
+
assessment: '## What the screen found\n\nOne identical name.\n\n## What happens next\n\nTake it to clearance.',
|
|
54
|
+
findings: [],
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
const docFor = (band) => renderKnockoutHtml(
|
|
58
|
+
{ marks: [markAt(band)], batch: { executiveSummary: 'One name screened.' } },
|
|
59
|
+
FRAMEWORK,
|
|
60
|
+
{ runId: 'fixture', overall: band, identity: { identity: 'Knockout search' }, issuedDate: '2026-09-18' },
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
const dir = mkdtempSync(join(tmpdir(), 'report-header-check-'))
|
|
64
|
+
for (const band of BANDS) writeFileSync(join(dir, `${band.toLowerCase()}.html`), docFor(band))
|
|
65
|
+
|
|
66
|
+
const server = createServer((req, res) => {
|
|
67
|
+
const name = decodeURIComponent((req.url ?? '/').split('?')[0]).replace(/^\//, '')
|
|
68
|
+
try {
|
|
69
|
+
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' })
|
|
70
|
+
res.end(docFor(BANDS.find((b) => b.toLowerCase() === name.replace(/\.html$/, '')) ?? BANDS[0]))
|
|
71
|
+
} catch { res.writeHead(404); res.end('no') }
|
|
72
|
+
})
|
|
73
|
+
await new Promise((r) => server.listen(0, '127.0.0.1', r))
|
|
74
|
+
const origin = `http://127.0.0.1:${server.address().port}`
|
|
75
|
+
|
|
76
|
+
const { profile, env: chromeEnv, keep: keepRoot } = browserRun('report-header-check-')
|
|
77
|
+
if (keep) keepRoot()
|
|
78
|
+
const chrome = spawn('google-chrome', [
|
|
79
|
+
'--headless=new', '--disable-gpu', '--no-sandbox', '--hide-scrollbars',
|
|
80
|
+
'--host-resolver-rules=MAP * ~NOTFOUND, EXCLUDE 127.0.0.1',
|
|
81
|
+
`--user-data-dir=${profile}`, `--window-size=${WIDTH},900`, '--remote-debugging-port=0', 'about:blank',
|
|
82
|
+
], { stdio: ['ignore', 'ignore', 'pipe'], detached: true, env: chromeEnv })
|
|
83
|
+
reapOnExit(chrome)
|
|
84
|
+
|
|
85
|
+
const wsUrl = await new Promise((resolve, reject) => {
|
|
86
|
+
let devtools = ''
|
|
87
|
+
const t = setTimeout(() => reject(new Error(`chrome reported no devtools endpoint in 60s:\n${devtools || '(nothing)'}`)), 60000)
|
|
88
|
+
chrome.stderr.on('data', (c) => { devtools += c; const m = devtools.match(/ws:\/\/[^\s]+/); if (m) { clearTimeout(t); resolve(m[0]) } })
|
|
89
|
+
})
|
|
90
|
+
const ws = new WebSocket(wsUrl)
|
|
91
|
+
let id = 0
|
|
92
|
+
const pending = new Map()
|
|
93
|
+
ws.addEventListener('message', (e) => { const m = JSON.parse(e.data); if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id) } })
|
|
94
|
+
await new Promise((r) => ws.addEventListener('open', r))
|
|
95
|
+
const send = (method, params = {}) => new Promise((r) => { const i = ++id; pending.set(i, r); ws.send(JSON.stringify({ id: i, method, params })) })
|
|
96
|
+
const { result: targets } = await send('Target.getTargets')
|
|
97
|
+
const page = targets.targetInfos.find((t) => t.type === 'page')
|
|
98
|
+
const { result: sess } = await send('Target.attachToTarget', { targetId: page.targetId, flatten: true })
|
|
99
|
+
const sessionId = sess.sessionId
|
|
100
|
+
const cmd = (method, params) => new Promise((r) => { const i = ++id; pending.set(i, r); ws.send(JSON.stringify({ id: i, sessionId, method, params })) })
|
|
101
|
+
const value = async (expr) => (await cmd('Runtime.evaluate', { expression: expr, awaitPromise: true, returnByValue: true })).result?.result?.value ?? null
|
|
102
|
+
|
|
103
|
+
// MEASURED OFF RECTANGLES, never off a class name: a rule that is present and overridden and a rule that
|
|
104
|
+
// works look identical to a selector, and the whole subject here is which one is true.
|
|
105
|
+
const PROBE = `(async () => {
|
|
106
|
+
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
|
107
|
+
window.scrollTo(0, 0); await sleep(60);
|
|
108
|
+
const head = document.querySelector('.rep-stickyhead');
|
|
109
|
+
const strip = document.querySelector('nav.strip');
|
|
110
|
+
const label = document.querySelector('.ko-gauge .label');
|
|
111
|
+
const pill = document.querySelector('.kpill');
|
|
112
|
+
if (!head || !strip) return { drawn: false, head: !!head, strip: !!strip };
|
|
113
|
+
const r = (el) => { const b = el.getBoundingClientRect(); return { top: b.top, bottom: b.bottom, left: b.left, height: b.height }; };
|
|
114
|
+
const out = {
|
|
115
|
+
drawn: true,
|
|
116
|
+
inside: head.contains(strip),
|
|
117
|
+
labelled: (strip.textContent || '').replace(/\\s+/g, ' ').trim(),
|
|
118
|
+
pill: pill ? r(pill) : null,
|
|
119
|
+
label: label ? r(label) : null,
|
|
120
|
+
pillText: pill ? (pill.textContent || '').trim() : null,
|
|
121
|
+
};
|
|
122
|
+
// Scroll well past the header and read again: what stays is what a reader keeps.
|
|
123
|
+
window.scrollTo(0, 1200); await sleep(120);
|
|
124
|
+
out.scrolled = { head: r(head), strip: r(strip), scrollY: window.scrollY };
|
|
125
|
+
return out;
|
|
126
|
+
})()`
|
|
127
|
+
|
|
128
|
+
const fail = []
|
|
129
|
+
const ok = []
|
|
130
|
+
for (const band of BANDS) {
|
|
131
|
+
await cmd('Page.navigate', { url: `${origin}/${band.toLowerCase()}.html` })
|
|
132
|
+
await new Promise((r) => setTimeout(r, 700))
|
|
133
|
+
const m = await value(PROBE)
|
|
134
|
+
const where = `band ${band}`
|
|
135
|
+
if (!m || !m.drawn) { fail.push(`${where}: the document drew no header or no breadcrumb (${JSON.stringify(m)})`); continue }
|
|
136
|
+
|
|
137
|
+
// 1 — one header
|
|
138
|
+
if (!m.inside) fail.push(`${where}: the breadcrumb is not inside .rep-stickyhead`)
|
|
139
|
+
else ok.push(`${where}: breadcrumb inside the header — "${m.labelled}"`)
|
|
140
|
+
|
|
141
|
+
const s = m.scrolled
|
|
142
|
+
if (!(s.scrollY > 300)) fail.push(`${where}: the page did not scroll (scrollY ${s.scrollY}) — the pin was not tested`)
|
|
143
|
+
else if (Math.abs(s.head.top) > 1) fail.push(`${where}: the header did not pin (top ${s.head.top.toFixed(1)} at scrollY ${s.scrollY})`)
|
|
144
|
+
else if (s.strip.top < s.head.top - 1 || s.strip.bottom > s.head.bottom + 1)
|
|
145
|
+
fail.push(`${where}: the breadcrumb left the header under scroll (strip ${s.strip.top.toFixed(1)}–${s.strip.bottom.toFixed(1)}, header ${s.head.top.toFixed(1)}–${s.head.bottom.toFixed(1)})`)
|
|
146
|
+
else ok.push(`${where}: header pinned at 0 with the breadcrumb inside it at scrollY ${s.scrollY}`)
|
|
147
|
+
|
|
148
|
+
// 2 — the band pill clears its heading
|
|
149
|
+
if (!m.pill || !m.label) fail.push(`${where}: the band card drew no pill or no heading`)
|
|
150
|
+
else if (m.pill.top < m.label.bottom)
|
|
151
|
+
fail.push(`${where}: the "${m.pillText}" pill overlaps the heading by ${(m.label.bottom - m.pill.top).toFixed(1)}px`)
|
|
152
|
+
else ok.push(`${where}: the "${m.pillText}" pill clears the heading by ${(m.pill.top - m.label.bottom).toFixed(1)}px`)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
for (const line of ok) console.log(` ok ${line}`)
|
|
156
|
+
for (const line of fail) console.log(` FAIL ${line}`)
|
|
157
|
+
try { ws.close() } catch { /* the browser is going anyway */ }
|
|
158
|
+
try { process.kill(-chrome.pid, 'SIGKILL') } catch { /* already gone */ }
|
|
159
|
+
server.close()
|
|
160
|
+
if (!keep) rmSync(dir, { recursive: true, force: true })
|
|
161
|
+
console.log(fail.length
|
|
162
|
+
? `\nreport-header-render-check: ${fail.length} measurement(s) failed.`
|
|
163
|
+
: `\nreport-header-render-check: ${ok.length} measurements, all clear.`)
|
|
164
|
+
process.exit(fail.length ? 1 : 0)
|
|
@@ -89,6 +89,20 @@ const server = createServer((req, res) => {
|
|
|
89
89
|
if (path === '/portal/admin/config' && !wantsPage) return json(adminConfig)
|
|
90
90
|
if (path === '/portal/api/about') return json(about)
|
|
91
91
|
if (path === '/portal/admin/roster' && !wantsPage) return json({ customers: [{ key: 'northwind', name: 'Northwind Foods' }] })
|
|
92
|
+
// THE PEOPLE SCREEN'S OWN DATA. Rows with real-length values on purpose: the defect this screen was
|
|
93
|
+
// filed for is a second column cut off at a phone's right edge, and a table of empty rows cannot show
|
|
94
|
+
// it. The permission phrases are the product's own words, the longest of them included.
|
|
95
|
+
if (path === '/portal/admin/access' && !wantsPage) {
|
|
96
|
+
return json({
|
|
97
|
+
note: '',
|
|
98
|
+
unknownAccounts: [],
|
|
99
|
+
people: [
|
|
100
|
+
{ email: 'rosa.venn@northwind.example', permissions: { run: true, manage: true }, accounts: '*', listed: true },
|
|
101
|
+
{ email: 'imani.oduya@northwind.example', permissions: { run: true, manage: false }, accounts: ['northwind'], listed: true },
|
|
102
|
+
{ email: 'peter.halvorsen@northwind.example', permissions: { run: false, manage: false }, accounts: ['northwind'], listed: true },
|
|
103
|
+
],
|
|
104
|
+
})
|
|
105
|
+
}
|
|
92
106
|
if (path === '/portal/login') {
|
|
93
107
|
res.writeHead(200, { 'content-type': 'text/html' })
|
|
94
108
|
return res.end(loginPage({ email: EMAIL, resetCommand: state.reset }))
|
|
@@ -176,11 +190,16 @@ async function setTheme(theme) {
|
|
|
176
190
|
|
|
177
191
|
// A FULL-HEIGHT VIEWPORT, not a beyond-viewport capture: a beyond-viewport capture paints the sticky rail
|
|
178
192
|
// and top bar where the 900px viewport put them.
|
|
179
|
-
async function capture(name) {
|
|
193
|
+
async function capture(name, width = 1280) {
|
|
180
194
|
if (!shotDir) return
|
|
181
195
|
await evalIn('window.scrollTo(0, 0)')
|
|
196
|
+
// THE WIDTH IS APPLIED BEFORE THE HEIGHT IS MEASURED. A narrow viewport reflows the page taller, and
|
|
197
|
+
// measuring first captures a page cut off at the fold — which is how a phone-width check ends up
|
|
198
|
+
// producing a picture that looks fine and shows half the screen.
|
|
199
|
+
await cmd('Emulation.setDeviceMetricsOverride', { width, height: 900, deviceScaleFactor: 1, mobile: width < 700 })
|
|
200
|
+
await sleep(250)
|
|
182
201
|
const h = await evalIn('Math.max(document.documentElement.scrollHeight, document.body.scrollHeight)') ?? 900
|
|
183
|
-
await cmd('Emulation.setDeviceMetricsOverride', { width
|
|
202
|
+
await cmd('Emulation.setDeviceMetricsOverride', { width, height: Math.max(900, h), deviceScaleFactor: 1, mobile: width < 700 })
|
|
184
203
|
await sleep(400)
|
|
185
204
|
const shot = await cmd('Page.captureScreenshot', { format: 'png' })
|
|
186
205
|
await cmd('Emulation.clearDeviceMetricsOverride', {})
|
|
@@ -544,6 +563,60 @@ for (const theme of ['light', 'dark']) {
|
|
|
544
563
|
state.reset = null
|
|
545
564
|
}
|
|
546
565
|
|
|
566
|
+
// ── People, at a desktop width and a phone's ────────────────────────────────────────────────────────
|
|
567
|
+
//
|
|
568
|
+
// THE SCREEN NOTHING COULD DRAW. It was reported as clipping its Permissions column at 400px "with no
|
|
569
|
+
// horizontal scroll offered", and the reading could not be settled from the source: the table sits in a
|
|
570
|
+
// wrapper set to scroll and carries a 760px floor, and both were already true at the sha the report was
|
|
571
|
+
// measured against. So either the scrollbar is an overlay one — real, invisible, and invisible in a
|
|
572
|
+
// capture — or something else clipped it and has since changed. No check reached this screen, so nobody
|
|
573
|
+
// could look. This is the looking.
|
|
574
|
+
//
|
|
575
|
+
// The rows carry real-length values deliberately. A table of empty rows cannot show a column being cut.
|
|
576
|
+
console.log('\nPeople, at 1280 and at 400:')
|
|
577
|
+
if (await open('/portal/people', "document.querySelector('table.data tbody tr')", 'People drew its table')) {
|
|
578
|
+
const peopleProbe = `(() => {
|
|
579
|
+
const wrap = document.querySelector('.table-wrap');
|
|
580
|
+
const table = document.querySelector('table.data');
|
|
581
|
+
const head = [...document.querySelectorAll('table.data thead th')].map((th) => th.innerText.trim());
|
|
582
|
+
const perm = [...document.querySelectorAll('table.data tbody tr')].map((tr) => tr.children[1]?.innerText.trim() ?? '');
|
|
583
|
+
return {
|
|
584
|
+
rows: document.querySelectorAll('table.data tbody tr').length,
|
|
585
|
+
head, perm,
|
|
586
|
+
// Does the wrapper actually have somewhere to scroll TO, and does it allow it?
|
|
587
|
+
scrollable: wrap ? wrap.scrollWidth > wrap.clientWidth + 1 : null,
|
|
588
|
+
overflowX: wrap ? getComputedStyle(wrap).overflowX : null,
|
|
589
|
+
wrapW: wrap ? Math.round(wrap.clientWidth) : null,
|
|
590
|
+
tableW: table ? Math.round(table.scrollWidth) : null,
|
|
591
|
+
// The page itself must NOT scroll sideways — that is the failure the clearances fix was about.
|
|
592
|
+
pageScrollsSideways: document.documentElement.scrollWidth > document.documentElement.clientWidth + 1,
|
|
593
|
+
};
|
|
594
|
+
})()`
|
|
595
|
+
|
|
596
|
+
for (const [label, width] of [['desktop', 1280], ['phone', 400]]) {
|
|
597
|
+
await cmd('Emulation.setDeviceMetricsOverride', { width, height: 900, deviceScaleFactor: 1, mobile: width < 700 })
|
|
598
|
+
await sleep(350)
|
|
599
|
+
const p = (await evalIn(peopleProbe)) ?? {}
|
|
600
|
+
ok(p.rows === 3, `${label}: the three people are drawn (saw ${p.rows})`)
|
|
601
|
+
// The heading is upper-cased by CSS, so innerText returns it that way — compare the word, not the casing.
|
|
602
|
+
ok(p.head?.[1]?.toLowerCase() === 'permissions', `${label}: the second column is still the permissions one (saw ${JSON.stringify(p.head)})`)
|
|
603
|
+
ok(p.perm?.every((t) => t.length > 0), `${label}: every row states an access (saw ${JSON.stringify(p.perm)})`)
|
|
604
|
+
// THE PROPERTY, not the pixel. A column wider than its wrapper is fine as long as the wrapper
|
|
605
|
+
// scrolls; it is a defect only when the overflow has nowhere to go, or when the PAGE takes it.
|
|
606
|
+
ok(p.overflowX === 'auto' || p.overflowX === 'scroll',
|
|
607
|
+
`${label}: the table's wrapper can scroll sideways (overflow-x: ${p.overflowX})`)
|
|
608
|
+
ok(p.pageScrollsSideways === false,
|
|
609
|
+
`${label}: the page itself does not scroll sideways — the table's overflow stays in the table`)
|
|
610
|
+
console.log(` ${label}: wrapper ${p.wrapW}px, table ${p.tableW}px, wrapper scrollable: ${p.scrollable}`)
|
|
611
|
+
await cmd('Emulation.clearDeviceMetricsOverride', {})
|
|
612
|
+
for (const theme of ['light', 'dark']) {
|
|
613
|
+
await setTheme(theme)
|
|
614
|
+
await capture(`people-${label}-${theme}`, width)
|
|
615
|
+
}
|
|
616
|
+
await setTheme('light')
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
547
620
|
// ── a browser whose storage refuses ──────────────────────────────────────────────────────────────────
|
|
548
621
|
//
|
|
549
622
|
// localStorage throws outright in a null-origin or locked-down document. The page must still draw, the
|
package/scripts/test-full.mjs
CHANGED
|
@@ -166,10 +166,89 @@ export function plan(root = ROOT, { providerFiles } = {}) {
|
|
|
166
166
|
return { corpora, faults };
|
|
167
167
|
}
|
|
168
168
|
|
|
169
|
+
/**
|
|
170
|
+
* ── SHARDING, AND WHY IT CANNOT SILENTLY DROP A FILE ──────────────────────────────────────────────
|
|
171
|
+
*
|
|
172
|
+
* The offline suite was the whole wall clock of CI: 12.8 minutes against 4.3 for the portal bundle and
|
|
173
|
+
* under one each for the other two jobs, which run beside it. Nearly all of that is `driver`, 925 of the
|
|
174
|
+
* ~980 test files in the tree, executed in one serial job. Agents write faster than that gate opens, so
|
|
175
|
+
* the gate — not the work — set the pace of the day.
|
|
176
|
+
*
|
|
177
|
+
* A shard is a SLICE OF THE SORTED FILE LIST TAKEN BY INDEX: shard i of n takes every file whose
|
|
178
|
+
* position satisfies `index % n === i - 1`. That is exhaustive and disjoint by construction — every
|
|
179
|
+
* index lands in exactly one shard, for any n — so the union of the shards is the whole list as a
|
|
180
|
+
* property of the arithmetic, not as something a check has to confirm afterwards. Round-robin rather
|
|
181
|
+
* than contiguous blocks because neighbouring files in a sorted list tend to be the same subsystem and
|
|
182
|
+
* to cost the same; interleaving spreads the slow ones.
|
|
183
|
+
*
|
|
184
|
+
* WHAT IS NOT SHARDED, and why that is stated rather than assumed. Only a corpus whose own test script
|
|
185
|
+
* is the plain `node --test test/*.test.mjs` shape can be expressed as an explicit file list and cut up
|
|
186
|
+
* this way. `portal-ui` runs a typecheck first and drives `.test.ts` through `--experimental-strip-types`,
|
|
187
|
+
* so it is not that shape: it runs WHOLE, on shard 1, and says so on every other shard rather than
|
|
188
|
+
* disappearing from them. Anything else that stops matching the shape falls into the same branch and is
|
|
189
|
+
* reported, never quietly skipped.
|
|
190
|
+
*/
|
|
191
|
+
const PLAIN_GLOB = /^node \.\.\/scripts\/test-run\.mjs node --test test\/\*\.test\.mjs$/;
|
|
192
|
+
|
|
193
|
+
export function shardSlice(list, i, n) {
|
|
194
|
+
return list.filter((_, idx) => idx % n === i - 1);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function workspaceTestFiles(root, ws) {
|
|
198
|
+
const dir = join(root, ws, "test");
|
|
199
|
+
if (!existsSync(dir)) return [];
|
|
200
|
+
return readdirSync(dir).filter((f) => f.endsWith(".test.mjs")).sort().map((f) => `${ws}/test/${f}`);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Is this workspace's `test:full` the plain shape we can express as a file list? */
|
|
204
|
+
function isShardable(root, ws) {
|
|
205
|
+
try {
|
|
206
|
+
const scripts = JSON.parse(readFileSync(join(root, ws, "package.json"), "utf8")).scripts ?? {};
|
|
207
|
+
let cmd = scripts["test:full"] ?? "";
|
|
208
|
+
// `test:full` is often just `npm run test`; follow that one hop.
|
|
209
|
+
const hop = cmd.match(/^npm run (\S+)$/);
|
|
210
|
+
if (hop) cmd = scripts[hop[1]] ?? "";
|
|
211
|
+
return PLAIN_GLOB.test(cmd.trim());
|
|
212
|
+
} catch { return false; }
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Rewrite the plan for one shard. Every corpus stays in the list and is reported; what changes is how
|
|
217
|
+
* many of its files THIS shard runs.
|
|
218
|
+
*/
|
|
219
|
+
export function applyShard(corpora, { i, n }, root = ROOT) {
|
|
220
|
+
return corpora.map((c) => {
|
|
221
|
+
if (c.kind === "covered") return c;
|
|
222
|
+
if (c.kind === "files") {
|
|
223
|
+
const mine = shardSlice(c.files, i, n);
|
|
224
|
+
return { ...c, files: mine, total: c.files.length,
|
|
225
|
+
argv: ["node", "scripts/test-run.mjs", "node", "--test", ...mine] };
|
|
226
|
+
}
|
|
227
|
+
if (c.kind === "workspace" && isShardable(root, c.name)) {
|
|
228
|
+
const all = workspaceTestFiles(root, c.name);
|
|
229
|
+
const mine = shardSlice(all, i, n);
|
|
230
|
+
return { ...c, kind: "files", files: mine, total: all.length,
|
|
231
|
+
argv: ["node", "scripts/test-run.mjs", "node", "--test", ...mine] };
|
|
232
|
+
}
|
|
233
|
+
// Not expressible as a file list: run it whole, once, on shard 1.
|
|
234
|
+
return { ...c, wholeOnShard: 1, skippedHere: i !== 1 };
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
169
238
|
function main() {
|
|
170
239
|
const only = (() => { const i = process.argv.indexOf("--only"); return i === -1 ? null : process.argv[i + 1]; })();
|
|
171
240
|
const listOnly = process.argv.includes("--list");
|
|
172
|
-
const
|
|
241
|
+
const shard = (() => {
|
|
242
|
+
const i = process.argv.indexOf("--shard");
|
|
243
|
+
if (i === -1) return null;
|
|
244
|
+
const m = String(process.argv[i + 1] ?? "").match(/^(\d+)\/(\d+)$/);
|
|
245
|
+
if (!m) { console.error("test-full: --shard wants i/n, e.g. --shard 2/4"); process.exit(2); }
|
|
246
|
+
const [a, b] = [Number(m[1]), Number(m[2])];
|
|
247
|
+
if (a < 1 || b < 1 || a > b) { console.error(`test-full: --shard ${a}/${b} is not a shard of anything`); process.exit(2); }
|
|
248
|
+
return { i: a, n: b };
|
|
249
|
+
})();
|
|
250
|
+
const { corpora: full, faults } = plan();
|
|
251
|
+
const corpora = shard ? applyShard(full, shard, ROOT) : full;
|
|
173
252
|
|
|
174
253
|
// FAULTS BEFORE ANYTHING RUNS. A missing corpus discovered after a green suite reads as an
|
|
175
254
|
// afterthought; discovered first, it is the answer.
|
|
@@ -195,9 +274,13 @@ function main() {
|
|
|
195
274
|
return;
|
|
196
275
|
}
|
|
197
276
|
|
|
277
|
+
if (shard) console.log(`\ntest-full: shard ${shard.i} of ${shard.n} — slices are taken by index from the sorted file list, so the shards are disjoint and their union is the whole list.`);
|
|
278
|
+
|
|
198
279
|
const ran = [];
|
|
199
280
|
for (const c of chosen) {
|
|
200
281
|
if (c.kind === "covered") { ran.push({ ...c, status: "covered" }); continue; }
|
|
282
|
+
if (c.skippedHere) { ran.push({ ...c, status: "elsewhere" }); continue; }
|
|
283
|
+
if (c.kind === "files" && c.files.length === 0) { ran.push({ ...c, status: "empty" }); continue; }
|
|
201
284
|
console.log(`\n──── ${c.name} ────`);
|
|
202
285
|
// STDIO INHERITED, NOT CAPTURED. CI reads the child stream for the corpus-guard markers, and a
|
|
203
286
|
// runner that buffered its children would take those markers out of the log the check greps.
|
|
@@ -213,13 +296,23 @@ function main() {
|
|
|
213
296
|
for (const c of ran) {
|
|
214
297
|
if (c.status === "covered") {
|
|
215
298
|
console.log(` covered ${c.name} — ${c.hasTests} file(s), run by the ${c.cover.corpus} corpus: ${c.cover.why}`);
|
|
299
|
+
} else if (c.status === "elsewhere") {
|
|
300
|
+
console.log(` shard ${c.wholeOnShard} ${c.name} — not expressible as a file list, so it runs WHOLE on shard ${c.wholeOnShard}, not here. It is not missing from this run; it is somewhere else in it.`);
|
|
301
|
+
} else if (c.status === "empty") {
|
|
302
|
+
console.log(` none ${c.name} — 0 of ${c.total} file(s) fell in this shard. Not an absence: the other shards hold them.`);
|
|
216
303
|
} else {
|
|
217
|
-
const n = c.kind === "files"
|
|
304
|
+
const n = c.kind === "files"
|
|
305
|
+
? `${c.files.length}${c.total != null ? ` of ${c.total}` : ""} file(s)`
|
|
306
|
+
: "workspace suite";
|
|
218
307
|
console.log(` ${c.status === "ok" ? "ran " : "FAILED "} ${c.name} — ${n}${c.code ? ` (exit ${c.code})` : ""}`);
|
|
219
308
|
}
|
|
220
309
|
}
|
|
221
310
|
const bad = ran.filter((c) => c.status === "FAILED");
|
|
222
|
-
console.log(bad.length
|
|
311
|
+
console.log(bad.length
|
|
312
|
+
? `\n${bad.length} corpus/corpora failed.`
|
|
313
|
+
: shard
|
|
314
|
+
? `\nEvery corpus above was run, accounted for, or named as belonging to another shard. This shard alone is not the suite: the gate is every shard green.`
|
|
315
|
+
: `\nEvery corpus above was run or accounted for.`);
|
|
223
316
|
if (bad.length) process.exit(1);
|
|
224
317
|
}
|
|
225
318
|
|
package/scripts/test-run.mjs
CHANGED
|
@@ -644,6 +644,16 @@ if (String(process.env[REAL_ENGINE_OVERRIDE] ?? "").trim()) {
|
|
|
644
644
|
catch { copyFileSync(ENGINE_STUB, p); chmodSync(p, 0o755); }
|
|
645
645
|
}
|
|
646
646
|
process.env.PATH = shimDir + delimiter + (process.env.PATH ?? "");
|
|
647
|
+
// AND THE COPY CLEAROTRON INSTALLED, which no PATH can close. The resolver's last step
|
|
648
|
+
// (driver.config.mjs resolveEngineProgram) reads the engines folder under the home directory, where
|
|
649
|
+
// setup installs `@anthropic-ai/claude-code` or `@openai/codex`, never PATH, and on a developer's machine
|
|
650
|
+
// that folder can hold a REAL program. The shim above answers every child that keeps this PATH, because
|
|
651
|
+
// PATH is asked first; a child that composes its own PATH would fall straight through to a real binary.
|
|
652
|
+
// So the folder is pointed at an EMPTY directory for the whole suite: the suite keeps the no-installed-
|
|
653
|
+
// copy world it was written for, and an arm about the installed copy plants its own folder and names it.
|
|
654
|
+
const noEngines = join(root, "no-installed-engines");
|
|
655
|
+
mkdirSync(noEngines, { recursive: true });
|
|
656
|
+
process.env.CLEAROTRON_ENGINES_DIR = noEngines;
|
|
647
657
|
// Names, never values — this line is read by whoever is wondering why a credential-reading test skipped.
|
|
648
658
|
const withheld = Object.keys(process.env)
|
|
649
659
|
.filter((n) => CREDENTIAL_RE.test(n) || CREDENTIAL_NAMES.includes(n))
|
package/shared/brand.mjs
CHANGED
|
@@ -174,6 +174,33 @@ export const ADMINISTRATOR_CONTACT = administratorContact(process.env.CLEAROTRON
|
|
|
174
174
|
// at all. Absent still means the default, never nothing.
|
|
175
175
|
export const CONF_DEFAULT = 'Privileged & Confidential';
|
|
176
176
|
|
|
177
|
+
/**
|
|
178
|
+
* THE APPROVED BOARDS' SECTION STRIP, filtered against the document it will sit in.
|
|
179
|
+
*
|
|
180
|
+
* Both report renderers draw one and each has its own board: the clearance kinds five entries, the
|
|
181
|
+
* knockout four, and the knockout's third points at the filings section while reading "Also considered".
|
|
182
|
+
* So the ENTRIES belong to each board and the RULE belongs here, because the rule is the part that is
|
|
183
|
+
* easy to get wrong the same way twice.
|
|
184
|
+
*
|
|
185
|
+
* The rule: a board is a specimen where every section exists, and a renderer draws several of them only
|
|
186
|
+
* when there is something to draw. A strip emitted whole would point a reader at anchors that resolve to
|
|
187
|
+
* nothing, which is worse than the missing strip it replaces. So it is composed from the FINISHED html by
|
|
188
|
+
* asking which anchors are in it — the document decides, and a section that stops being drawn takes its
|
|
189
|
+
* own entry with it without anyone remembering to.
|
|
190
|
+
*
|
|
191
|
+
* ONE ENTRY IS NOT A NAVIGATION. A strip pointing only at the top of the page is a control that does
|
|
192
|
+
* nothing, so below two entries it is not drawn at all.
|
|
193
|
+
*
|
|
194
|
+
* @param {string} html the assembled document
|
|
195
|
+
* @param {ReadonlyArray<readonly [string, string]>} entries [anchor id, label], in the board's order
|
|
196
|
+
*/
|
|
197
|
+
export function sectionStrip(html, entries) {
|
|
198
|
+
const live = (entries ?? []).filter(([id]) => String(html).includes(`id="${id}"`));
|
|
199
|
+
if (live.length < 2) return '';
|
|
200
|
+
return `<nav class="strip no-print">${live.map(([id, label], i) =>
|
|
201
|
+
`<a href="#${id}" data-sec="${id}"${i === 0 ? ' class="now"' : ''}><i></i>${label}</a>`).join('')}</nav>`;
|
|
202
|
+
}
|
|
203
|
+
|
|
177
204
|
export function confPosture(delivery) {
|
|
178
205
|
const p = delivery == null ? null : delivery.privileged;
|
|
179
206
|
return p === false ? '' : CONF_DEFAULT;
|