clearotron 0.3.2-beta.7 → 0.3.2-beta.8
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 +24 -23
- package/INSTALL.md +142 -75
- package/README.md +3 -3
- package/bin/onboard.mjs +637 -216
- package/bin/start.mjs +133 -23
- package/bin/update.mjs +58 -11
- package/build-info.json +2 -2
- package/docs/architecture/04-configuration-reference.md +26 -11
- package/docs/architecture/05-config-governance.md +17 -7
- package/driver/CHANGELOG.md +76 -0
- package/driver/band-size.mjs +59 -0
- package/driver/config-inventory.mjs +112 -9
- package/driver/contract-arm2-baseline.json +1 -3
- package/driver/contract-e3-backlog.mjs +26 -26
- package/driver/contract-vocabulary.mjs +44 -10
- package/driver/door-gates.mjs +41 -7
- package/driver/driver.config.mjs +272 -59
- 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/recording-server.mjs +13 -0
- package/driver/engine/openai-agent.mjs +4 -2
- package/driver/engine/probe.mjs +110 -23
- package/driver/findings-model.mjs +1 -1
- package/driver/flag-snapshot.mjs +28 -5
- package/driver/gateway.mjs +24 -18
- package/driver/jx-lanes.mjs +21 -2
- package/driver/jx-units.mjs +6 -3
- package/driver/jx.mjs +4 -2
- package/driver/matter-frame-record.mjs +90 -1
- package/driver/named-band.mjs +34 -2
- package/driver/package.json +1 -1
- package/driver/pipeline.mjs +200 -23
- package/driver/portal-config-view.mjs +30 -1
- package/driver/portal-report.mjs +15 -1
- package/driver/portal-service.mjs +46 -6
- package/driver/predelivery-lint.mjs +12 -2
- package/driver/publish/index.mjs +46 -5
- package/driver/publish/knockout.mjs +10 -1
- package/driver/publish/render-knockout.mjs +69 -7
- package/driver/publish/render.mjs +170 -59
- package/driver/publish/report-data.mjs +4 -1
- package/driver/publish/report-topbar.mjs +58 -0
- package/driver/publish/templates/report.css +18 -1
- package/driver/publish/xlsx.mjs +13 -1
- package/driver/register-availability.mjs +2 -2
- package/driver/register-coverage.mjs +94 -1
- package/driver/register-digest-record.mjs +236 -11
- package/driver/register-plan.mjs +170 -0
- 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 +3 -3
- package/driver/stages.mjs +12 -8
- package/driver/suite-census.json +142 -64
- package/driver/systemd/README.md +7 -4
- package/driver/terminal-clamp.mjs +107 -1
- package/driver/tokens.mjs +169 -3
- package/driver/unit-environment.mjs +42 -15
- package/driver/unit-inventory.mjs +19 -2
- package/driver/verify.mjs +27 -0
- package/mcp-server/CHANGELOG.md +4 -0
- package/mcp-server/package.json +1 -1
- package/mcp-server/server.mjs +15 -1
- package/package.json +1 -1
- package/portal-ui/dist/assets/{index-5UyqAyNM.js → index-6jzO9HiX.js} +155 -79
- package/portal-ui/dist/index.html +1 -1
- package/portal-ui/package.json +1 -1
- package/providers/jx/README.md +2 -1
- package/providers/jx/src/turn-envelope.mjs +8 -3
- package/providers/oauth-mcp-bridge/CHANGELOG.md +4 -0
- package/providers/oauth-mcp-bridge/package.json +1 -1
- package/providers/uspto-local/README.md +1 -1
- package/scripts/authority-boundary-probe.mjs +4 -2
- package/scripts/env-audit.mjs +12 -6
- package/scripts/freeze-example-run.mjs +49 -16
- package/scripts/generated-files-are-current.mjs +69 -4
- package/scripts/settings-render-check.mjs +75 -2
- package/scripts/test-full.mjs +96 -3
- package/scripts/test-run.mjs +10 -0
- package/shared/deployment-box.mjs +7 -2
- package/shared/driver-dir.mjs +1 -1
- package/shared/names-in-force.mjs +1 -1
|
@@ -29,9 +29,11 @@
|
|
|
29
29
|
// _driver/run.jsonl the event log
|
|
30
30
|
// _driver/stage-inputs/ what each stage was handed
|
|
31
31
|
// _history/ pre-reopen snapshots
|
|
32
|
-
// Dropping the telemetry drops `meta.tokens` (driver/publish/index.mjs
|
|
33
|
-
// rollupTokens)
|
|
34
|
-
//
|
|
32
|
+
// Dropping the telemetry drops `meta.tokens` (driver/publish/index.mjs rollupTokens — the only consumer of
|
|
33
|
+
// rollupTokens), and with it the record of which models served the run (servedModels in
|
|
34
|
+
// driver/tokens.mjs): `servedModels` on meta.json and report-data.json, and the one line that closes the
|
|
35
|
+
// report's footer. Those are the differences step 5 is told to expect, and it says so out loud rather
|
|
36
|
+
// than normalising them away in silence.
|
|
35
37
|
//
|
|
36
38
|
// WHAT THIS SCRIPT DOES NOT DO
|
|
37
39
|
// It does not decide the sample is publishable. It greps for the shapes that must never leave the VM
|
|
@@ -59,21 +61,21 @@ const FROZEN_FILES = [
|
|
|
59
61
|
{ path: "audit.md", why: "publish/index.mjs:1022 auditMd, the audit workbook source" },
|
|
60
62
|
{ path: "findings.json", why: "publish/index.mjs:715 readStore, the per-finding machine contract" },
|
|
61
63
|
{ path: "status.json", why: "publish/index.mjs:913 machineLedgerNote + markName" },
|
|
62
|
-
{ path: "case-law-findings.md", why: "publish/index.mjs:
|
|
63
|
-
{ path: "common-law-grid.json", why: "publish/index.mjs:
|
|
64
|
+
{ path: "case-law-findings.md", why: "publish/index.mjs:875 clPath, the case-law section" },
|
|
65
|
+
{ path: "common-law-grid.json", why: "publish/index.mjs:1006 commonLawJoinedTerms, common-law coverage" },
|
|
64
66
|
// publish/index.mjs — the _driver sidecars it reads by name
|
|
65
67
|
{ path: "_driver/receipts.json", why: "publish/index.mjs:761 fetchReceipts" },
|
|
66
68
|
{ path: "_driver/senior-rights.json", why: "publish/index.mjs:787 seniorRights" },
|
|
67
69
|
{ path: "_driver/verdict.json", why: "publish/index.mjs:792 verdictInfo" },
|
|
68
70
|
{ path: "_driver/framework.json", why: "publish/index.mjs, the frozen band vocabulary the run was rated under" },
|
|
69
|
-
{ path: "_driver/register-plan.json", why: "publish/index.mjs:
|
|
70
|
-
{ path: "_driver/instructed-scope.json", why: "publish/index.mjs:
|
|
71
|
-
{ path: "_driver/enforcer-signals.json", why: "publish/index.mjs:
|
|
72
|
-
{ path: "_driver/predelivery-lint.json", why: "publish/index.mjs:
|
|
73
|
-
{ path: "_driver/escalation-state.json", why: "publish/index.mjs:
|
|
74
|
-
{ path: "_driver/reasoning-integrity.json", why: "publish/index.mjs:
|
|
75
|
-
{ path: "_driver/corrections-state.json", why: "publish/index.mjs:
|
|
76
|
-
{ path: "_driver/search-policy.json", why: "publish/index.mjs:
|
|
71
|
+
{ path: "_driver/register-plan.json", why: "publish/index.mjs:846 scopeBasis" },
|
|
72
|
+
{ path: "_driver/instructed-scope.json", why: "publish/index.mjs:847 searchedJurisdictions, the fallback for register-plan" },
|
|
73
|
+
{ path: "_driver/enforcer-signals.json", why: "publish/index.mjs:887 esPath" },
|
|
74
|
+
{ path: "_driver/predelivery-lint.json", why: "publish/index.mjs:172 lintSink" },
|
|
75
|
+
{ path: "_driver/escalation-state.json", why: "publish/index.mjs:173 escSink" },
|
|
76
|
+
{ path: "_driver/reasoning-integrity.json", why: "publish/index.mjs:924 integritySink" },
|
|
77
|
+
{ path: "_driver/corrections-state.json", why: "publish/index.mjs:174 correctionsSink" },
|
|
78
|
+
{ path: "_driver/search-policy.json", why: "publish/index.mjs:987 searchPolicy, level + stage label" },
|
|
77
79
|
{ path: "_driver/profile.json", why: "publish/index.mjs reads the frozen profile; report-registry.mjs:42 republishRun, customer key" },
|
|
78
80
|
];
|
|
79
81
|
|
|
@@ -106,7 +108,7 @@ const KNOCKOUT_FILES = [
|
|
|
106
108
|
// knockout report render empty (publish/knockout.mjs:140-155). Named by stages-knockout.mjs:32,41.
|
|
107
109
|
{ path: "_driver/register-counts.json", why: "publish/knockout.mjs:140-155 counted figures + the Register column" },
|
|
108
110
|
{ path: "_driver/register-records.json", why: "stages-knockout.mjs:41 the terms behind the close-variation axis" },
|
|
109
|
-
{ path: "_driver/instructed-scope.json", why: "publish/index.mjs:
|
|
111
|
+
{ path: "_driver/instructed-scope.json", why: "publish/index.mjs:847 searchedJurisdictions, the fallback for register-plan" },
|
|
110
112
|
];
|
|
111
113
|
|
|
112
114
|
/** The allowlist for a template. One place, so a new template cannot half-exist. */
|
|
@@ -162,7 +164,7 @@ const SCRUB = [
|
|
|
162
164
|
// hides the next real difference.
|
|
163
165
|
const VOLATILE = [
|
|
164
166
|
{ id: "issued", re: /\d{4}-\d{2}-\d{2} · \d{2}:\d{2} [A-Z]{2,5}/g, sub: "<issued>", why: "publish/index.mjs, the generation stamp in the firm locale" },
|
|
165
|
-
{ id: "iso-timestamp", re: /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z/g, sub: "<ts>", why: "publish/index.mjs:
|
|
167
|
+
{ id: "iso-timestamp", re: /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z/g, sub: "<ts>", why: "publish/index.mjs:668 asOf" },
|
|
166
168
|
];
|
|
167
169
|
|
|
168
170
|
// ── REWRITES — what is CHANGED on the way out, as opposed to what is refused ───────────────────────
|
|
@@ -226,8 +228,19 @@ const substituteVendorKey = (key) => {
|
|
|
226
228
|
// meta.json keys the freeze is EXPECTED to change, with the reason. Anything else differing is a finding.
|
|
227
229
|
const EXPECTED_META_DELTA = {
|
|
228
230
|
tokens: "telemetry pruned — _driver/*.jsonl is the only source (driver/tokens.mjs:82)",
|
|
231
|
+
servedModels: "telemetry pruned — the attempt rows in _driver/*.jsonl are the only source (servedModels in driver/tokens.mjs)",
|
|
229
232
|
};
|
|
230
233
|
|
|
234
|
+
// THE SAME CAUSE, ON THE TWO OTHER SURFACES THAT SHOW IT. report-data.json carries `servedModels` beside the
|
|
235
|
+
// report's content, and the page renders it as the scope section's closing line (render.mjs
|
|
236
|
+
// servedModelsLine, class "servedby"). Only that key and that one paragraph are set aside, on both sides
|
|
237
|
+
// and out loud; a difference anywhere else in either file is still a finding. The paragraph holds escaped
|
|
238
|
+
// text and no markup, so the pattern cannot run past its own closing tag. It takes the whitespace before
|
|
239
|
+
// the paragraph with it: the clearance page joins its scope parts with a line break and an indent, which
|
|
240
|
+
// exists only because the line does.
|
|
241
|
+
const EXPECTED_DATA_DELTA = ["servedModels"];
|
|
242
|
+
const SERVED_LINE_RE = /\s*<p class="servedby"[^>]*>[^<]*<\/p>/g;
|
|
243
|
+
|
|
231
244
|
// ── args ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
232
245
|
const argv = process.argv.slice(2);
|
|
233
246
|
const flag = (name) => { const i = argv.indexOf(name); return i >= 0 ? argv[i + 1] : null; };
|
|
@@ -594,7 +607,27 @@ if (proofOk) {
|
|
|
594
607
|
}
|
|
595
608
|
continue;
|
|
596
609
|
}
|
|
597
|
-
|
|
610
|
+
// The served-model record is set aside by name on both sides (EXPECTED_DATA_DELTA, SERVED_LINE_RE),
|
|
611
|
+
// and only when it is what differed does the note say so.
|
|
612
|
+
let sA = normalise(rawA), sB = normalise(rawB), aside = [];
|
|
613
|
+
if (/^report-data(?:-.+)?\.json$/.test(name)) {
|
|
614
|
+
let dA = null, dB = null;
|
|
615
|
+
try { dA = JSON.parse(rawA); dB = JSON.parse(rawB); } catch { dA = dB = null; }
|
|
616
|
+
if (dA && dB) {
|
|
617
|
+
aside = EXPECTED_DATA_DELTA.filter((k) => JSON.stringify(dA[k]) !== JSON.stringify(dB[k]));
|
|
618
|
+
for (const k of EXPECTED_DATA_DELTA) { delete dA[k]; delete dB[k]; }
|
|
619
|
+
sA = normalise(JSON.stringify(dA, null, 2)); sB = normalise(JSON.stringify(dB, null, 2));
|
|
620
|
+
}
|
|
621
|
+
} else if (name.endsWith(".html") && sA !== sB) {
|
|
622
|
+
const tA = sA.replace(SERVED_LINE_RE, ""), tB = sB.replace(SERVED_LINE_RE, "");
|
|
623
|
+
if (tA === tB) { aside = ["the footer's served-models line"]; sA = tA; sB = tB; }
|
|
624
|
+
}
|
|
625
|
+
if (sA === sB) {
|
|
626
|
+
note(aside.length
|
|
627
|
+
? `${name} identical apart from ${aside.join(", ")}, which differs as expected — ${EXPECTED_META_DELTA.servedModels}`
|
|
628
|
+
: `${name} identical (${rawB.length} bytes)`);
|
|
629
|
+
continue;
|
|
630
|
+
}
|
|
598
631
|
finding(`${name} differs between the source run and the frozen copy — the allowlist dropped an input the renderer reads`);
|
|
599
632
|
}
|
|
600
633
|
}
|
|
@@ -53,6 +53,29 @@ export function treeState(root = ROOT) {
|
|
|
53
53
|
* The alternative — dirtying a real generated file and restoring it — is a shared-file mutation, and
|
|
54
54
|
* the test runner runs files in parallel, so it would be a race that reddens somebody else's arm.
|
|
55
55
|
*/
|
|
56
|
+
/**
|
|
57
|
+
* Which paths differ between two `git status --porcelain` readings, named so a reader can see WHAT moved.
|
|
58
|
+
*
|
|
59
|
+
* A porcelain line is a two-character state, a space, and the path. Lines are compared as a multiset so
|
|
60
|
+
* a path whose STATE changed — staged to modified, say — is reported as having moved, and the paths are
|
|
61
|
+
* returned rather than a count, because two numbers agreeing is not the same as two sets agreeing.
|
|
62
|
+
*/
|
|
63
|
+
export function movedPaths(before, after) {
|
|
64
|
+
const bag = (s) => {
|
|
65
|
+
const m = new Map();
|
|
66
|
+
for (const line of String(s).split("\n")) {
|
|
67
|
+
if (!line.trim()) continue;
|
|
68
|
+
m.set(line, (m.get(line) ?? 0) + 1);
|
|
69
|
+
}
|
|
70
|
+
return m;
|
|
71
|
+
};
|
|
72
|
+
const [b, a] = [bag(before), bag(after)];
|
|
73
|
+
const out = new Set();
|
|
74
|
+
for (const [line, n] of a) if ((b.get(line) ?? 0) !== n) out.add(line.slice(3).trim() || line.trim());
|
|
75
|
+
for (const [line, n] of b) if ((a.get(line) ?? 0) !== n) out.add(line.slice(3).trim() || line.trim());
|
|
76
|
+
return [...out].sort();
|
|
77
|
+
}
|
|
78
|
+
|
|
56
79
|
export function checkAll({ dir = HERE, root = ROOT, log = console.log, readTree = () => treeState(root) } = {}) {
|
|
57
80
|
const found = minters(dir);
|
|
58
81
|
if (!found.length) return { found, stale: [], unreadable: [], wrote: [], empty: true };
|
|
@@ -60,6 +83,7 @@ export function checkAll({ dir = HERE, root = ROOT, log = console.log, readTree
|
|
|
60
83
|
const stale = [];
|
|
61
84
|
const unreadable = [];
|
|
62
85
|
const wrote = [];
|
|
86
|
+
const unattributable = [];
|
|
63
87
|
for (const m of found) {
|
|
64
88
|
// ── `--check` IS A CONTRACT, AND NOTHING WAS VERIFYING IT ──────────────────────────────────────
|
|
65
89
|
//
|
|
@@ -73,13 +97,41 @@ export function checkAll({ dir = HERE, root = ROOT, log = console.log, readTree
|
|
|
73
97
|
// one ran, it wrote. THE LIMIT, SAID RATHER THAN LEFT: this catches the harmful inert form, the
|
|
74
98
|
// one that silently repairs. A minter that ignores the flag and does nothing at all still reports
|
|
75
99
|
// `current`, and no probe from out here can tell that from a file that really is current.
|
|
100
|
+
// ── "DID THE TREE MOVE" IS NOT "DID THIS PROCESS WRITE" ────────────────────────────────────────
|
|
101
|
+
//
|
|
102
|
+
// Those are the same question only where nothing else can write, and this probe does not run
|
|
103
|
+
// there. Inside the suite it is a subprocess of one test file while every other file in its shard
|
|
104
|
+
// runs beside it, deliberately unserialised — dropping `--test-concurrency=1` is what made the
|
|
105
|
+
// suite 2.4 times faster. So a neighbour writing anywhere in the repository moved the snapshot and
|
|
106
|
+
// this reported it as the minter having written.
|
|
107
|
+
//
|
|
108
|
+
// Measured: a commit whose whole diff was one stylesheet's phone-width rules and a release note
|
|
109
|
+
// failed here naming TWO minters, and the same bytes passed on a rerun. Two is the tell — a minter
|
|
110
|
+
// that ignores `--check` and re-mints leaves its repair in the tree, so the NEXT minter's `before`
|
|
111
|
+
// already carries it and the next one is not flagged. Both being named cannot come from either.
|
|
112
|
+
//
|
|
113
|
+
// So the accusation is made only where it can be: from a tree that was CLEAN when this minter
|
|
114
|
+
// started, where a change appearing during its run has no other author available. A tree that was
|
|
115
|
+
// already dirty is one where somebody else is writing, and the honest answer is that this probe
|
|
116
|
+
// could not look — which is this file's own rule one level in, since it already refuses to read a
|
|
117
|
+
// minter that could not look as a pass.
|
|
118
|
+
//
|
|
119
|
+
// THE LIMIT, SAID RATHER THAN LEFT: a neighbour that begins writing after a clean reading and
|
|
120
|
+
// before the minter exits is still attributed here. Closing that needs the probe to own the tree,
|
|
121
|
+
// which is a change to where it runs rather than to what it asks.
|
|
76
122
|
const before = readTree();
|
|
77
123
|
const r = spawnSync(process.execPath, [join(dir, m), "--check"], { cwd: root, encoding: "utf8" });
|
|
78
124
|
const after = readTree();
|
|
79
125
|
const out = ((r.stdout || "") + (r.stderr || "")).trim();
|
|
80
126
|
if (before !== null && after !== null && before !== after) {
|
|
81
|
-
|
|
82
|
-
|
|
127
|
+
const moved = movedPaths(before, after);
|
|
128
|
+
if (before.trim() === "") {
|
|
129
|
+
wrote.push({ m, out, moved });
|
|
130
|
+
log(` WROTE ${m} (during --check): ${moved.join(", ") || "the tree moved"}`);
|
|
131
|
+
} else {
|
|
132
|
+
unattributable.push({ m, moved });
|
|
133
|
+
log(` ? ${m} — the tree moved and this probe cannot say who moved it: ${moved.join(", ") || "paths unknown"}`);
|
|
134
|
+
}
|
|
83
135
|
continue;
|
|
84
136
|
}
|
|
85
137
|
// 0 is current, 1 is stale, anything else is a minter that could not look — reported separately,
|
|
@@ -89,11 +141,11 @@ export function checkAll({ dir = HERE, root = ROOT, log = console.log, readTree
|
|
|
89
141
|
unreadable.push({ m, out, code: r.status });
|
|
90
142
|
log(` ? ${m} (exit ${r.status})`);
|
|
91
143
|
}
|
|
92
|
-
return { found, stale, unreadable, wrote, empty: false, contractChecked: readTree() !== null };
|
|
144
|
+
return { found, stale, unreadable, wrote, unattributable, empty: false, contractChecked: readTree() !== null };
|
|
93
145
|
}
|
|
94
146
|
|
|
95
147
|
function main() {
|
|
96
|
-
const { found, stale, unreadable, wrote, empty, contractChecked } = checkAll();
|
|
148
|
+
const { found, stale, unreadable, wrote, unattributable, empty, contractChecked } = checkAll();
|
|
97
149
|
if (empty) {
|
|
98
150
|
console.error("generated-files-are-current: no scripts/mint-*.mjs found. Either they moved or the "
|
|
99
151
|
+ "naming changed — and a pass over nothing is not a pass.");
|
|
@@ -111,6 +163,19 @@ function main() {
|
|
|
111
163
|
+ `commit without the repair, and reports current over a check that did not happen. Fix the minter.`);
|
|
112
164
|
process.exit(2);
|
|
113
165
|
}
|
|
166
|
+
// BEFORE the staleness verdict, and exit 2 rather than 1: a tree moving under the probe means the
|
|
167
|
+
// staleness answers were read off a tree that was changing while they were taken, so "out of date" is
|
|
168
|
+
// not a claim this run has the standing to make either. Could-not-look is the whole verdict.
|
|
169
|
+
if (unattributable.length) {
|
|
170
|
+
console.error(`\n${unattributable.length} minter(s) ran while the tree was ALREADY dirty and it moved `
|
|
171
|
+
+ `underneath them. This probe cannot say whether the minter wrote or something running beside it `
|
|
172
|
+
+ `did, so it names neither. That is a could-not-look, not a pass and not an accusation.\n`);
|
|
173
|
+
for (const { m, moved } of unattributable) {
|
|
174
|
+
console.error(` ${m} — moved: ${moved.join(", ") || "paths unknown"}`);
|
|
175
|
+
}
|
|
176
|
+
console.error(`\nRun it on a tree nobody else is writing to, and it will answer.`);
|
|
177
|
+
process.exit(2);
|
|
178
|
+
}
|
|
114
179
|
if (unreadable.length) {
|
|
115
180
|
console.error(`\n${unreadable.length} minter(s) could not look. That is not a pass; fix the minter first.`);
|
|
116
181
|
process.exit(2);
|
|
@@ -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))
|
|
@@ -16,10 +16,15 @@
|
|
|
16
16
|
// of the same allowlist agree on the day they are written and drift afterwards; that is the defect
|
|
17
17
|
// was filed about one field over, and the fix there was the same — import the function, never
|
|
18
18
|
// re-derive the answer.
|
|
19
|
-
|
|
19
|
+
// `preprod` JOINED 2026-09-17. It is a packaged install like production, on its own account, which takes
|
|
20
|
+
// every published version before production does. Until it could name itself it ran with the variable
|
|
21
|
+
// UNSET — and an unset box does not fail loudly: `live-surface-check` SKIPS its expected-units check
|
|
22
|
+
// when the box is null, so the one install that most wants that check was the one not getting it.
|
|
23
|
+
// Setting `prod` instead would have made the box lie about which box it is.
|
|
24
|
+
export const DEPLOYMENT_BOXES = ["prod", "preprod", "test"];
|
|
20
25
|
|
|
21
26
|
/**
|
|
22
|
-
* @returns {"prod"|"test"|null} the self-declared box, or null when unset or unrecognised.
|
|
27
|
+
* @returns {"prod"|"preprod"|"test"|null} the self-declared box, or null when unset or unrecognised.
|
|
23
28
|
*
|
|
24
29
|
* Read at CALL time, deliberately. `shared/brand.mjs` builds its value at module scope and that is
|
|
25
30
|
* exactly why a rename reached the report and not the portal — portal-service is outside
|
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 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 ────────────────────────────────────────────────────────────
|
|
@@ -23,7 +23,6 @@ export const NAMES_IN_FORCE = Object.freeze([
|
|
|
23
23
|
"CLEAROTRON_AI",
|
|
24
24
|
"CLEAROTRON_AI_BILLING",
|
|
25
25
|
"CLEAROTRON_AI_PATH",
|
|
26
|
-
"CLEAROTRON_AZURE_MODEL",
|
|
27
26
|
"CLEAROTRON_BAND_RESPONSE_CHARS",
|
|
28
27
|
"CLEAROTRON_BAND_RUN_DIR",
|
|
29
28
|
"CLEAROTRON_BAND_TRUTH_GATE",
|
|
@@ -54,6 +53,7 @@ export const NAMES_IN_FORCE = Object.freeze([
|
|
|
54
53
|
"CLEAROTRON_E2E_EVIDENCE_DIR",
|
|
55
54
|
"CLEAROTRON_E2E_EXPECT_DEMO_ROSTER",
|
|
56
55
|
"CLEAROTRON_EDGE_ORIGIN",
|
|
56
|
+
"CLEAROTRON_ENGINES_DIR",
|
|
57
57
|
"CLEAROTRON_ENGINE_CWD",
|
|
58
58
|
"CLEAROTRON_ENGINE_MAX_BUFFER",
|
|
59
59
|
"CLEAROTRON_ENUMERATE_CEILING",
|