From 20a3d3d245c5772c85865bcd2e91b20a727b5cff Mon Sep 17 00:00:00 2001 From: doxa-maps-88655c70 <88627970+doxa-maps-88655c70@users.noreply.github.com> Date: Wed, 23 Sep 2026 20:57:13 +0000 Subject: [PATCH] Run the verification chain in phases instead of one long queue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 29 gates in build:unlocked cost 84.5s chained with &&, but 21 of them finish in under 700ms — they were queueing behind check:mjs-parse (26.9s) and check:parse (18.7s) on an otherwise idle 8-core box. The 22 vite builds cost 129.0s and were never ordered at all: vite.config.js gives each bundle its own outDir with emptyOutDir:false and emits nothing outside it, so they only ever needed to not overwrite each other. internal/run-parallel.mjs runs a set of independent steps concurrently and grades the set by exit code. build:unlocked now reads: gates:pre -> library-index -> clean -> token -> build:bundles -> tree -> gates:post -> gallery Measured end-to-end, both chains green, two rounds each, clean+token excluded as common to both: 165.7s -> 65.3s mean (170.7/160.8 vs 60.2/70.5). No gate was removed, weakened, sampled, or made conditional on what changed. The step set is identical — 31 in, 31 out, asserted at edit time — and each step runs as the same command graded by the same exit code. Only start times moved. The && BETWEEN phases is load-bearing and stays: a source gate still blocks the build, and the build still blocks every gate reading its output. Within a phase the runner reports ALL failures rather than stopping at the first, so three broken gates cost one fix-and-rerun cycle instead of three. It imposes no timeout of its own, because a command killed by a short timeout is not a failure. Negative-tested three ways, each required to exit 1: a failing gate inside a phase, a red phase halting the chain, and a failing vite build inside build:bundles. The bundle case also confirmed the runner enumerates plugin-designs live (it picked up 23 dirs, not a cached 22). --- .../README-SECTION-3-cli-commands.md | 20 ++- internal/run-parallel.mjs | 166 ++++++++++++++++++ package.json | 7 +- 3 files changed, 190 insertions(+), 3 deletions(-) create mode 100644 internal/run-parallel.mjs diff --git a/documentation/README-SECTION-3-cli-commands.md b/documentation/README-SECTION-3-cli-commands.md index eed1d03e1..062a053ec 100644 --- a/documentation/README-SECTION-3-cli-commands.md +++ b/documentation/README-SECTION-3-cli-commands.md @@ -21,11 +21,29 @@ The scripts these commands are grounded in (from this branch's `package.json`): // START of every build, so two builds racing give opposite answers seconds apart). Use // BUILD_LOCK_WAIT=600 to QUEUE rather than collide. Note the lock serializes but does NOT // dedupe: N queued builds = N full rebuilds. +// ★ THE PIPELINE RUNS IN PHASES, NOT AS ONE `&&` CHAIN (2026-09-23). Measured on the build +// box: the 29 gates cost 84.5s chained, but 21 of them finish under 700ms — they were +// queueing behind check:mjs-parse (26.9s) and check:parse (18.7s), not working. The 22 vite +// builds cost 129.0s and were never ordered at all: vite.config.js gives each bundle its own +// outDir with emptyOutDir:false, so they only ever needed to not overwrite each other. +// Now: gates:pre ‖ → library-index → clean → token → build:bundles ‖ → tree → gates:post ‖. +// MEASURED end-to-end (both green, clean+token excluded as they are common to both): +// 165.7s → 65.3s mean of two rounds each. Concurrency: GATES_CONCURRENCY (default 6), +// BUILD_CONCURRENCY (default 4 — 6 buys only 3-9%, and ambient load on this shared box moves +// the same setting 40s→55s, so being a good neighbour costs nothing measurable). +// ★ NO GATE WAS REMOVED, WEAKENED, SAMPLED, OR MADE CONDITIONAL ON WHAT CHANGED. The step set +// is byte-identical (31 in, 31 out, asserted at edit time); only the start times moved. The +// `&&` BETWEEN phases is load-bearing and stays: a source gate still blocks the build, and +// the build still blocks every gate that reads its output. Within a phase the runner reports +// ALL failures instead of stopping at the first, so three broken gates cost one cycle, not three. // If you edit package.json, re-transcribe this block — a doc that QUOTES a config is // claiming to be ground truth, and this one had silently stopped being it. "dev": "bun x vite", "build": "bash internal/build-lock.sh", -"build:unlocked": "bun run check:template-literal && bun run check:mjs-parse && bun run check:sfc-parse && bun run check:tab-radius && bun run check:synced-css && bun run check:wp-embed && bun run check:doc-scripts && bun run library-index && bun run check:library-index && bun run clean && bun run token && for d in plugin-designs/*/; do BUNDLE=$(basename \"$d\") bun x vite build || exit 1; done && bun run tree && bun run check:drop && bun run check:parse && bun run verify && bun run check:sri && bun run check:public-hygiene && bun run check:label-joins && bun run check:facet-cascade && bun run check:facet-one-field && bun run check:heartbeat && bun run check:kpi-unit && bun run check:composite-reg && bun run check:descriptions && bun run check:no-abs-paths && bun run check:no-codename && bun run check:dep-names && bun run check:composite-srcs && bun run check:embed-targets && bun run check:nested-token && bun run gallery", +"build:unlocked": "bun run gates:pre && bun run library-index && bun run check:library-index && bun run clean && bun run token && bun run build:bundles && bun run tree && bun run gates:post && bun run gallery", +"gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:wp-embed check:doc-scripts", +"gates:post": "node internal/run-parallel.mjs --label=post -- check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token", +"build:bundles": "node internal/run-parallel.mjs --bundles", // the 22 vite builds, 4 at a time "preview": "bun x vite preview", "serve": "node internal/serve-strict.mjs", // 404s a directory, like production "cap:sync": "./node_modules/.bin/cap sync android", diff --git a/internal/run-parallel.mjs b/internal/run-parallel.mjs new file mode 100644 index 000000000..8af6f2c5f --- /dev/null +++ b/internal/run-parallel.mjs @@ -0,0 +1,166 @@ +#!/usr/bin/env node +/** + * run-parallel.mjs — runs a SET OF INDEPENDENT verification steps concurrently instead of + * one-at-a-time, and grades the set by exit code. + * + * WHY (measured 2026-09-23, on this box, `bun run` per step, load avg ~20): + * The 29 gates in build:unlocked cost 84.5s wall when chained with `&&`, but 21 of them + * finish in under 700ms — they are node-process-startup, not work. The chain's cost is + * the SUM of the steps while the machine sits on 7 idle cores. The two dominators + * (check:mjs-parse 26.9s, check:parse 18.7s) alone are 54% of the total, so every cheap + * gate behind them is pure queueing delay. Same shape for the 22 vite builds: 129.0s + * sequential, each writing to its OWN public/doxa-maps// (vite.config.js sets + * outDir per bundle, emptyOutDir:false, and emits nothing outside it) — so they were + * never actually ordered, only serialized. + * + * ★ COVERAGE IS NOT REDUCED, AND THAT IS THE POINT. Every step that ran before still runs, + * as the same command, graded by the same exit code. Nothing is skipped, sampled, + * cached, or made conditional on what changed. The only thing that changes is WHEN each + * step starts. A speed-up that drops a gate is not a speed-up, it is a coverage cut with + * a stopwatch attached. + * + * ★ WHY IT REPORTS ALL FAILURES INSTEAD OF STOPPING AT THE FIRST + * An `&&` chain hides every failure after the first, so a build with three broken gates + * costs three full fix-and-rerun cycles to discover. Within one phase the steps are + * independent, so there is no reason to stop: this runs the whole set, prints every + * failure's full output, and exits 1. Ordering that DOES matter is still enforced — + * build:unlocked keeps `&&` BETWEEN phases, so a source gate still blocks the build, and + * the build still blocks the gates that read its output. + * + * ★ WHY THERE IS NO TIMEOUT HERE + * AGENT-BRIEF.md: "A command killed by a short `timeout` is not a failure." A runner that + * imposed its own deadline would manufacture failures under exactly the load that makes + * parallelism worth having. Steps run to completion; the caller decides. + * + * ★ WHY PASSING OUTPUT IS SUPPRESSED (and --verbose exists) + * These gates print teaching prose AFTER their verdict. Interleaving 20 of those streams + * produces something no one can read, and the brief already warns that `| tail -1` + * inverts the signal. So: passing steps report one table line, failing steps report their + * ENTIRE captured output, unabridged. --verbose prints everything. + * + * USAGE + * node internal/run-parallel.mjs --label=pre -- check:a check:b ... # runs `bun run ` + * node internal/run-parallel.mjs --bundles # BUNDLE= bun x vite build, for every plugin-designs// + * env: GATES_CONCURRENCY (default min(cpus,6)) · BUILD_CONCURRENCY (default 4) + */ +import { spawn } from 'node:child_process' +import { readdirSync, existsSync } from 'node:fs' +import { cpus } from 'node:os' +import { resolve, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const argv = process.argv.slice(2) +const VERBOSE = argv.includes('--verbose') +const BUNDLES = argv.includes('--bundles') +const labelArg = argv.find((a) => a.startsWith('--label=')) +const LABEL = labelArg ? labelArg.slice('--label='.length) : BUNDLES ? 'bundles' : 'gates' + +// everything after `--` (or every bare non-flag arg) is a step name +const dashdash = argv.indexOf('--') +const names = (dashdash === -1 ? argv : argv.slice(dashdash + 1)).filter((a) => !a.startsWith('--')) + +/** + * WHY TWO DIFFERENT DEFAULTS: a gate is one short-lived node process (startup-bound, ~cheap + * RAM), while a vite build holds a whole rollup graph in memory. Measured on this box: + * 15GB total with ~6GB available while other agents run browsers, and a vite build peaks + * in the hundreds of MB — so 22-wide would swap the machine to death and report it as a + * slow build. 4-wide is ~2GB and leaves the box usable for everyone else on it. + */ +const CONCURRENCY = Math.max( + 1, + Number( + BUNDLES + ? process.env.BUILD_CONCURRENCY || 4 + : process.env.GATES_CONCURRENCY || Math.min(cpus().length, 6) + ) || 1 +) + +/** @returns {{name:string, cmd:string, args:string[], env:Record}[]} */ +function buildSteps() { + if (!BUNDLES) { + if (!names.length) { + console.error('run-parallel: no steps given. Pass script names after `--`.') + process.exit(2) + } + return names.map((name) => ({ name, cmd: 'bun', args: ['run', name], env: {} })) + } + // A bundle is a plugin-designs// directory — the same selector build:unlocked used + // (`for d in plugin-designs/*/`), so the SET of things built is identical. + const dir = resolve(ROOT, 'plugin-designs') + return readdirSync(dir, { withFileTypes: true }) + .filter((e) => e.isDirectory()) + .map((e) => e.name) + .sort() + .map((name) => ({ name, cmd: 'bun', args: ['x', 'vite', 'build'], env: { BUNDLE: name } })) +} + +function runStep(step) { + return new Promise((done) => { + const started = Date.now() + const child = spawn(step.cmd, step.args, { + cwd: ROOT, + env: { ...process.env, ...step.env }, + stdio: ['ignore', 'pipe', 'pipe'], + }) + let out = '' + const cap = (buf) => { + out += buf + } + child.stdout.on('data', cap) + child.stderr.on('data', cap) + child.on('error', (err) => { + // spawn itself failed (missing binary) — a real failure, not a gate verdict + done({ ...step, ms: Date.now() - started, code: 127, out: String(err && err.message) }) + }) + child.on('close', (code) => done({ ...step, ms: Date.now() - started, code: code ?? 1, out })) + }) +} + +async function main() { + const steps = buildSteps() + const t0 = Date.now() + console.log( + `▶ ${LABEL}: ${steps.length} step(s), ${CONCURRENCY} at a time ` + + `(${cpus().length} cpus; override with ${BUNDLES ? 'BUILD_CONCURRENCY' : 'GATES_CONCURRENCY'})` + ) + + const queue = steps.slice() + const results = [] + const worker = async () => { + for (;;) { + const step = queue.shift() + if (!step) return + const r = await runStep(step) + results.push(r) + console.log( + ` ${r.code === 0 ? '✓' : '✗'} ${r.name.padEnd(26)} ${String(r.ms).padStart(7)}ms` + + (r.code === 0 ? '' : ` exit ${r.code}`) + ) + } + } + await Promise.all(Array.from({ length: Math.min(CONCURRENCY, steps.length) }, worker)) + + const wall = Date.now() - t0 + const serial = results.reduce((a, r) => a + r.ms, 0) + const failed = results.filter((r) => r.code !== 0) + + if (VERBOSE) for (const r of results) if (r.out.trim()) console.log(`\n───── ${r.name} ─────\n${r.out}`) + // A failure must be readable in full: these gates explain themselves in their output, and + // a truncated explanation is how a real defect gets re-diagnosed as a flake. + else for (const r of failed) console.log(`\n───── FAILED: ${r.name} (exit ${r.code}) ─────\n${r.out}`) + + console.log( + `\n▶ ${LABEL}: ${results.length - failed.length}/${results.length} passed in ${(wall / 1000).toFixed(1)}s wall ` + + `(${(serial / 1000).toFixed(1)}s of work — ${(serial / Math.max(wall, 1)).toFixed(1)}x)` + ) + if (failed.length) { + console.error(`✗ ${LABEL}: ${failed.length} step(s) FAILED — ${failed.map((f) => f.name).join(', ')}`) + process.exit(1) + } +} + +main().catch((err) => { + console.error('run-parallel: crashed —', err) + process.exit(1) +}) diff --git a/package.json b/package.json index af4578fec..ba06dc2dd 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,7 @@ "check:facet-changes-map:self-test": "node internal/check-facet-changes-map.mjs --self-test # reverts the fix IN THE LIVE PAGE and requires the gate to FAIL. SELF_TEST_MODE=colour (default, isolates the colour assertion — the reported bug) | props | both | frozen (blocks the source update, isolating the set assertion). All four modes verified caught.", "check:heartbeat": "node internal/check-heartbeat-threshold.mjs # the HEARTBEAT ZONE threshold (the choropleth's top bin) must come from config AND the shipped config must still render today's ramp. Two sides: (B) a configured threshold must MOVE the edge — catches re-hard-coding; (A2) dashboard.config.js's own map.* must still resolve to 5/20/60/150/Infinity — catches the shipped default silently changing. A2 was MISSING at first and heartbeatThreshold:999 shipped past this gate printing 'default ramp unchanged'. A grep for '150' cannot do either job: it false-positives on the legitimate default and false-negatives on a re-hard-coding at 200. run check:heartbeat:self-test for the NEGATIVE tests (3 cases).", "check:heartbeat:self-test": "node internal/check-heartbeat-threshold.mjs --self-test # THREE regression cases, each required to fire ITS OWN assertion: [literal] resolver replaced by the old hard-coded array -> B; [shipped-default] dashboard.config.js heartbeatThreshold changed -> A2; [shipped-mode] heartbeatMode flipped to quantile -> A2. Poisons IN MEMORY, never the file. Testing that the FEATURE works is not testing that the REGRESSION is caught — this gate shipped with only the first and missed the second.", - "build:unlocked": "bun run check:template-literal && bun run check:mjs-parse && bun run check:sfc-parse && bun run check:tab-radius && bun run check:synced-css && bun run check:wp-embed && bun run check:doc-scripts && bun run library-index && bun run check:library-index && bun run clean && bun run token && for d in plugin-designs/*/; do BUNDLE=$(basename \"$d\") bun x vite build || exit 1; done && bun run tree && bun run check:drop && bun run check:parse && bun run verify && bun run check:sri && bun run check:public-hygiene && bun run check:label-joins && bun run check:facet-cascade && bun run check:facet-one-field && bun run check:heartbeat && bun run check:kpi-unit && bun run check:composite-reg && bun run check:descriptions && bun run check:no-abs-paths && bun run check:no-codename && bun run check:dep-names && bun run check:composite-srcs && bun run check:embed-targets && bun run check:nested-token && bun run gallery", + "build:unlocked": "bun run gates:pre && bun run library-index && bun run check:library-index && bun run clean && bun run token && bun run build:bundles && bun run tree && bun run gates:post && bun run gallery", "check:template-literal": "node internal/template-literal-check.mjs", "verify:map-paint": "node internal/verify-map-paint.mjs", "check:sri": "node internal/sri-consistency-check.mjs", @@ -125,7 +125,10 @@ "serve:permissive": "python3 -m http.server 4173 --bind 127.0.0.1 --directory public # LOOPBACK ONLY. This was --bind 0.0.0.0, which published the built tree on every interface of whatever machine ran a gate; measured on the build VPS it answered HTTP 200 on the box's PUBLIC IP while a browser gate was running. Every consumer (verify-browser-all, check:a11y-button-names, check:nav-reachable, smoke:share-all) already defaults to 127.0.0.1, so nothing needed the wider bind. Override with BASE_URL/BASE if you deliberately want to reach it from another host.", "check:doc-scripts": "node internal/check-doc-quotes-scripts.mjs", "check:doc-scripts:self-test": "node internal/check-doc-quotes-scripts.mjs --self-test", - "check:legend-source": "node internal/check-legend-matches-source.mjs # the numbers the maps SHOW must match the numbers the live SOURCE has. Every other gate checks INTERNAL consistency — a page can agree with itself perfectly while its legend has stopped describing reality. Offline SKIPs (exit 0); only a reachable endpoint that disagrees fails." + "check:legend-source": "node internal/check-legend-matches-source.mjs # the numbers the maps SHOW must match the numbers the live SOURCE has. Every other gate checks INTERNAL consistency — a page can agree with itself perfectly while its legend has stopped describing reality. Offline SKIPs (exit 0); only a reachable endpoint that disagrees fails.", + "gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:wp-embed check:doc-scripts", + "gates:post": "node internal/run-parallel.mjs --label=post -- check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token", + "build:bundles": "node internal/run-parallel.mjs --bundles" }, "devDependencies": { "@capacitor/android": "^7.6.9",