From d6213a37ee341ef739ef6589c0e88ab3e48136cd Mon Sep 17 00:00:00 2001 From: Exoridus Date: Sun, 6 Sep 2026 06:41:56 +0200 Subject: [PATCH 1/4] build(bench): install the competitors under the release-age gate The competitor install ran with --ignore-workspace, which is what made the root workspace's minimumReleaseAge quarantine not apply to it - and the reason the bench package was kept out of CI. competitors/ is now its own workspace root carrying the same setting, and the install is frozen to its lockfile in every use, so a new version arrives only through a reviewed lockfile change. With that objection gone the bench lane runs the harness typecheck, the one bench check that needs the competitors (the adapters are typed against them, which is what catches an upstream change on a bump), and the pre-push hook's hand-rolled version of the same check goes. The harness's own unit tests need no competitor at all and join the ordinary test project list. --- .husky/pre-push | 44 --------- packages/exojs-bench/README.md | 97 +++++++------------ packages/exojs-bench/competitors/package.json | 2 +- .../competitors/pnpm-workspace.yaml | 6 ++ packages/exojs-bench/package.json | 2 +- scripts/ci/lanes.ts | 7 +- 6 files changed, 49 insertions(+), 109 deletions(-) create mode 100644 packages/exojs-bench/competitors/pnpm-workspace.yaml diff --git a/.husky/pre-push b/.husky/pre-push index 4ab22c8f2..ca8eeff6d 100644 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -24,10 +24,6 @@ # push that brought the commit to main; a tag that would be # rejected remotely is rejected here first. # -# Also, on branch pushes only: a path-gated `@codexo/exojs-bench` typecheck -# (see the block below verify:quick — it is intentionally NOT part of -# verify:quick/CI; see packages/exojs-bench/README.md for why). -# # Refs being pushed are read from stdin in the format: # is_tag_push=0 @@ -105,43 +101,3 @@ if [ "$is_branch_push" = "1" ]; then npm run lanes -- --run --tests-only --all || exit 1 fi fi - -# Path-gated bench typecheck. @codexo/exojs-bench is deliberately kept out of -# verify:quick / CI: its competitor devDependencies (pixi/phaser/excalibur/ -# matter/rapier) only resolve via `bench:setup`'s `pnpm install --dir -# competitors --ignore-workspace`, which bypasses pnpm-workspace.yaml's -# minimumReleaseAge supply-chain gate — installing that inside the shared-CI -# trust boundary is exactly what we don't want. So this hook is the only -# automated backstop, and it stays cheap for everyone else: -# - fires ONLY when the pushed commits touch packages/exojs-bench/** (zero -# cost otherwise — no diff, no pnpm call); -# - only runs the actual typecheck when the competitor deps are already -# linked locally (never forces the ~235MB bench:setup install on push — -# that would punish a push just because the optional deps aren't there -# yet, so it prints a warning and skips instead of failing). -# Residual gap (see packages/exojs-bench/README.md): an engine API change -# under src/ that breaks the bench adapters' types, without ALSO touching -# packages/exojs-bench/**, is not caught here. -if [ "$is_branch_push" = "1" ] && [ -n "$push_head_sha" ]; then - if [ -z "$push_base_sha" ]; then - # New branch / no remote tracking ref yet — fall back to the - # merge-base with origin/HEAD so the check still has a diff range. - push_base_sha=$(git merge-base "$push_head_sha" origin/HEAD 2>/dev/null || true) - fi - - bench_changed="" - if [ -n "$push_base_sha" ]; then - bench_changed=$(git diff --name-only "$push_base_sha" "$push_head_sha" -- packages/exojs-bench 2>/dev/null) - fi - - if [ -n "$bench_changed" ]; then - if [ -e packages/exojs-bench/node_modules/pixi.js ]; then - echo "[pre-push] packages/exojs-bench changed — running bench typecheck" - pnpm --filter @codexo/exojs-bench typecheck || exit 1 - else - echo "[pre-push] WARNING: packages/exojs-bench changed but its competitor deps aren't linked locally." - echo "[pre-push] Run 'pnpm --filter @codexo/exojs-bench bench:setup' (or 'pnpm typecheck:bench') to type-check it, then push again." - echo "[pre-push] Skipping bench typecheck for this push (not blocking)." - fi - fi -fi diff --git a/packages/exojs-bench/README.md b/packages/exojs-bench/README.md index 5b1084378..b4fb68b0d 100644 --- a/packages/exojs-bench/README.md +++ b/packages/exojs-bench/README.md @@ -681,65 +681,38 @@ caveats stated with the result, and a defined process for re-measuring when any those move. Until that exists, a cross-library number measured here is an engineering signal for the maintainers, not a claim. -## Why this package is out of required CI - -`bench:setup` runs `pnpm install --dir competitors --ignore-workspace`. That -`--ignore-workspace` install: - -- resolves a **separate lockfile** outside `pnpm-workspace.yaml`, so it - bypasses the root workspace's `minimumReleaseAge` supply-chain quarantine - (see `pnpm-workspace.yaml`) — a version bump here needs a manual release-age - sanity check instead of the automatic gate everything else gets; -- pulls in ~235MB of competitor libraries that a normal contributor should - never have to download just to typecheck their own PR. - -Running that inside the shared-CI trust boundary (a required, always-on gate) -would mean every contributor's PR — and the shared CI runners — install and -trust third-party libraries whose only purpose is being compared against, not -shipped. So `@codexo/exojs-bench` is deliberately excluded from -`typecheck:packages` / `verify:quick` / CI. A standalone `typecheck:bench` -root script exists for on-demand/manual runs: - -**The one exception is the structural gate**, and it is an exception precisely -because it needs none of that: it measures only the ExoJS arms on the software -rasterizer, so its CI job installs no competitor library and needs no GPU. -Nothing in that job runs `bench:setup`, so the competitor packages never enter -the CI trust boundary. It is path-gated on the rendering source, the harness and -the baseline itself — narrower than the `engine` area, since a change to audio or -input cannot move a draw-call count. - -```sh -pnpm typecheck:bench # bench:setup + typecheck, in one step -``` - -## Local backstop: the pre-push hook - -`.husky/pre-push` runs a **path-gated, local-only** check on branch pushes: - -- it fires **only** when the commits being pushed touch - `packages/exojs-bench/**` — zero cost for every other push; -- if the competitor deps are already linked locally (i.e. - `packages/exojs-bench/node_modules/pixi.js` exists from a prior - `bench:setup`), it runs `pnpm --filter @codexo/exojs-bench typecheck` and - **fails the push** on a type error; -- if they aren't linked, it prints a warning telling you to run `bench:setup` - and **skips without failing** — an optional, uninstalled dependency should - never block an unrelated push. - -## Known gap - -This is a local, path-gated backstop, not a CI gate — it only runs on the -machine that pushes a bench-touching commit, and only if that machine has -already run `bench:setup`. An engine API change under `src/` that breaks the -bench adapters' types, without a commit that also touches -`packages/exojs-bench/**`, is not caught by this hook. - -The structural-gate CI lane closes part of that gap, but only part: a change -under `src/rendering/` now runs the harness (and therefore compiles and executes -the ExoJS adapters) in CI, so a break there fails a PR. A change elsewhere under -`src/` still does not, and neither does anything that only affects a competitor -adapter. This is -an accepted trade-off to keep the bench package's ~235MB of competitor -dependencies out of the shared-CI trust boundary entirely. A future -self-hosted-GPU bench tier (see the engine's perf-tracking roadmap) is the -right place to run a full, unconditional `typecheck:bench` as a real backstop. +## What runs in CI, and what does not + +Three different things live in this package, and CI treats them differently. + +**The harness's own tests** (`test/`) exercise the profile contract, the slug, +the signature, the run pooling and the archetype definitions. They need none +of the competitor libraries - the adapters that import those are loaded by +`import()` inside the benchmark page, never by a test - so they run in the +ordinary `pnpm test` project list on every push, like any other package. + +**The typecheck** does need the competitors: the adapters are typed against +their APIs, which is exactly what catches an upstream change on a version +bump. `pnpm typecheck:bench` installs them first. It runs in the path-gated +`bench` CI lane, alongside the structural gate, whenever a change touches this +package, the rendering source or the baselines. + +**Measurements** never run in CI. A shared runner is neither idle nor a known +machine, and a number it produced would carry provenance nobody can reproduce. +Reference profiles are measured by hand on an idle machine and committed as +signed files - see `results/README.md`. + +## The competitor install and the supply-chain gate + +`bench:setup` runs `pnpm install --dir competitors --frozen-lockfile`. The +`competitors/` directory is its own workspace root (it carries a +`pnpm-workspace.yaml`), which does two things: a plain root `pnpm install` +never resolves or downloads anything in it, so a contributor who never +benchmarks pays nothing for ~235MB of libraries whose only purpose is being +compared against; and the install applies the same `minimumReleaseAge` +quarantine the repository workspace enforces, so a version bump here is held +back exactly as long as any other dependency. The lockfile is frozen in both +CI and local use: a new version enters through a reviewed lockfile change, not +through an install. + +`pnpm doctor` reports whether the competitors are linked. diff --git a/packages/exojs-bench/competitors/package.json b/packages/exojs-bench/competitors/package.json index eac77b2f7..64da46291 100644 --- a/packages/exojs-bench/competitors/package.json +++ b/packages/exojs-bench/competitors/package.json @@ -2,7 +2,7 @@ "name": "exojs-bench-competitors", "version": "0.0.0", "private": true, - "description": "Pinned exact-version competitor libraries for @codexo/exojs-bench (Pixi, Phaser, Excalibur, matter-js, planck, rapier2d-compat arms). Deliberately NOT a pnpm-workspace.yaml member: a plain root `pnpm install` never resolves or downloads anything here, so a normal contributor pays zero weight for competitor libraries. Only `pnpm --filter @codexo/exojs-bench bench:setup` installs this folder (via `pnpm install --ignore-workspace`, so a supply-chain review of a version bump should also sanity-check its release age by hand -- `--ignore-workspace` means the root pnpm-workspace.yaml's minimumReleaseAge gate does not apply here) and links the results into ../node_modules so the adapters' plain `import 'pixi.js'` (etc.) resolve unmodified.", + "description": "Pinned exact-version competitor libraries for @codexo/exojs-bench (Pixi, Phaser, Excalibur, matter-js, planck, rapier2d-compat arms). Deliberately NOT a member of the repository workspace: a plain root `pnpm install` never resolves or downloads anything here, so a normal contributor pays zero weight for competitor libraries. Only `pnpm --filter @codexo/exojs-bench bench:setup` installs this folder - as its own workspace root (see pnpm-workspace.yaml beside this file), which applies the same minimumReleaseAge supply-chain quarantine as the repository workspace - and links the results into ../node_modules so the adapters' plain `import 'pixi.js'` (etc.) resolve unmodified.", "dependencies": { "pixi.js": "8.19.0", "phaser": "4.2.1", diff --git a/packages/exojs-bench/competitors/pnpm-workspace.yaml b/packages/exojs-bench/competitors/pnpm-workspace.yaml new file mode 100644 index 000000000..45f10520c --- /dev/null +++ b/packages/exojs-bench/competitors/pnpm-workspace.yaml @@ -0,0 +1,6 @@ +# Standalone install root for the benchmark competitor libraries. Keeping a +# workspace file here makes pnpm stop its upward lookup at this directory, so +# the install never joins the repository workspace - and it applies the same +# supply-chain quarantine the root workspace enforces, which an +# --ignore-workspace install would silently skip. +minimumReleaseAge: 4320 diff --git a/packages/exojs-bench/package.json b/packages/exojs-bench/package.json index a6396d4fd..5f6e6760a 100644 --- a/packages/exojs-bench/package.json +++ b/packages/exojs-bench/package.json @@ -5,7 +5,7 @@ "private": true, "type": "module", "scripts": { - "bench:setup": "pnpm install --dir competitors --ignore-workspace && node competitors/link.ts", + "bench:setup": "pnpm install --dir competitors --frozen-lockfile && node competitors/link.ts", "bench": "node --conditions=@codexo/exojs-source --import ../../scripts/glsl-register.ts --import tsx/esm src/run.ts", "perf:baseline": "node --conditions=@codexo/exojs-source --import ../../scripts/glsl-register.ts --import tsx/esm src/run.ts", "gate:timing": "node --conditions=@codexo/exojs-source --import ../../scripts/glsl-register.ts --import tsx/esm src/runTimingGate.ts", diff --git a/scripts/ci/lanes.ts b/scripts/ci/lanes.ts index 05c4e0940..d4c9b223e 100644 --- a/scripts/ci/lanes.ts +++ b/scripts/ci/lanes.ts @@ -133,7 +133,12 @@ export const LANES: readonly Lane[] = [ id: 'bench', stage: 'test', when: 'benchStructural', - run: 'pnpm gate:bench:structural', + // The harness typecheck needs the competitor libraries (the adapters are + // typed against them, which is what catches an upstream API change on a + // version bump), so it lives here, path-gated, rather than in + // `typecheck:packages`. The harness's unit tests need none of them and run + // in the ordinary `test` project list. + run: 'pnpm typecheck:bench && pnpm gate:bench:structural', browser: 'chromium', local: 'browser', timeoutMinutes: 30, From 2f0eb9a542c555a7702194992bdd03e4f665bc44 Mon Sep 17 00:00:00 2001 From: Exoridus Date: Sun, 6 Sep 2026 06:42:01 +0200 Subject: [PATCH 2/4] ci(test): keep the sleeping step-time gate out of the parallel suite The physics sleeping test asserts a wall-clock ratio: a settled field must step at least twice as fast asleep as awake. Run alongside the rest of the suite, the light sleeping arm loses proportionally more to scheduling gaps than the heavy awake arm; the ratio collapsed from a measured 3.4x to 1.8x and the push gate failed with nothing regressed. The test now lives in its own project, run after the parallel suite, where the measurement has the machine to itself. The behavioural half of the claim stays in the main suite. A guard test now requires every vitest project to be named by a package script that a lane runs. It found the bench project, whose 287 tests no lane had ever executed. --- packages/exojs-physics/test/fields.ts | 52 +++++++++++ packages/exojs-physics/test/perf.test.ts | 91 +------------------ .../exojs-physics/test/sleeping-perf.test.ts | 75 +++++++++++++++ scripts/ci/lanes.ts | 6 +- test/ci/vitest-project-parity.test.ts | 52 +++++++++++ vitest.config.ts | 16 ++++ 6 files changed, 199 insertions(+), 93 deletions(-) create mode 100644 packages/exojs-physics/test/fields.ts create mode 100644 packages/exojs-physics/test/sleeping-perf.test.ts create mode 100644 test/ci/vitest-project-parity.test.ts diff --git a/packages/exojs-physics/test/fields.ts b/packages/exojs-physics/test/fields.ts new file mode 100644 index 000000000..a68a089dd --- /dev/null +++ b/packages/exojs-physics/test/fields.ts @@ -0,0 +1,52 @@ +/** + * Shared scene builder for the physics performance suites. + * + * The field is the scene both suites measure: independent box columns settled + * on a static floor, so the steady state is persistent contacts plus the + * broad-phase load of one AABB per body. + */ +import { BoxShape, PhysicsWorld } from '../src/index'; +import { PhysicsBody } from '../src/PhysicsBody'; + +export const FRAME = 1 / 60; + +/** A wide field of `columns` independent `rows`-high box stacks on a static floor. */ +export const buildField = (columns: number, rows: number, worldOptions: { enableSleeping?: boolean } = {}): { world: PhysicsWorld; bodies: PhysicsBody[] } => { + const world = new PhysicsWorld({ gravity: { x: 0, y: 1000 }, ...worldOptions }); + const size = 16; + const spacing = 20; + const floorTop = 1000; + const width = columns * spacing + 200; + + world.add(new PhysicsBody({ type: 'static', position: { x: width / 2, y: floorTop + 20 }, colliders: [{ shape: new BoxShape(width, 40), friction: 0.5 }] })); + + const bodies: PhysicsBody[] = []; + + for (let c = 0; c < columns; c++) { + const x = 100 + c * spacing; + + for (let r = 0; r < rows; r++) { + const body = world.add( + new PhysicsBody({ + type: 'dynamic', + position: { x, y: floorTop - size / 2 - 1 - r * size }, + colliders: [{ shape: new BoxShape(size, size), density: 1, friction: 0.5 }], + }), + ); + + bodies.push(body); + } + } + + return { world, bodies }; +}; + +export const stepTimes = (world: PhysicsWorld, steps: number): number => { + const start = performance.now(); + + for (let i = 0; i < steps; i++) { + world.step(FRAME); + } + + return (performance.now() - start) / steps; +}; diff --git a/packages/exojs-physics/test/perf.test.ts b/packages/exojs-physics/test/perf.test.ts index 8edbcaf7b..db9454e8a 100644 --- a/packages/exojs-physics/test/perf.test.ts +++ b/packages/exojs-physics/test/perf.test.ts @@ -1,8 +1,7 @@ import { describe, expect, it } from 'vitest'; -import { BoxShape, PhysicsWorld } from '../src/index'; -import { PhysicsBody } from '../src/PhysicsBody'; import { measureAllocationRate } from './allocationSampler'; +import { buildField, FRAME, stepTimes } from './fields'; /** * Performance gates: steady-state allocation and 1,000-body step time. The @@ -22,49 +21,6 @@ import { measureAllocationRate } from './allocationSampler'; * allocation-free), removable only by an invasive typed-array rewrite (post-1.0). */ -const FRAME = 1 / 60; - -/** A wide field of `columns` independent `rows`-high box stacks on a static floor. */ -const buildField = (columns: number, rows: number, worldOptions: { enableSleeping?: boolean } = {}): { world: PhysicsWorld; bodies: PhysicsBody[] } => { - const world = new PhysicsWorld({ gravity: { x: 0, y: 1000 }, ...worldOptions }); - const size = 16; - const spacing = 20; - const floorTop = 1000; - const width = columns * spacing + 200; - - world.add(new PhysicsBody({ type: 'static', position: { x: width / 2, y: floorTop + 20 }, colliders: [{ shape: new BoxShape(width, 40), friction: 0.5 }] })); - - const bodies: PhysicsBody[] = []; - - for (let c = 0; c < columns; c++) { - const x = 100 + c * spacing; - - for (let r = 0; r < rows; r++) { - const body = world.add( - new PhysicsBody({ - type: 'dynamic', - position: { x, y: floorTop - size / 2 - 1 - r * size }, - colliders: [{ shape: new BoxShape(size, size), density: 1, friction: 0.5 }], - }), - ); - - bodies.push(body); - } - } - - return { world, bodies }; -}; - -const stepTimes = (world: PhysicsWorld, steps: number): number => { - const start = performance.now(); - - for (let i = 0; i < steps; i++) { - world.step(FRAME); - } - - return (performance.now() - start) / steps; -}; - describe('physics dynamics performance', () => { it('1,000-body settled field: step time + steady-state allocation', async () => { const { world, bodies } = buildField(200, 5); @@ -135,49 +91,4 @@ describe('physics dynamics performance', () => { // (~484 KB/step) trips it. expect(bytesPerStep).toBeLessThan(250 * 1024); }); - - it('5,000-mostly-sleeping field: sleeping sharply cuts step time', () => { - // Baseline: the identical field with sleeping disabled stays fully active. - const awake = buildField(1000, 5, { enableSleeping: false }); - - // Skipped entirely under istanbul coverage: instrumentation inflates the - // per-step cost enough (see the identical `cov_` guard above) that the full - // 840-step awake+sleeping budget across two 5,000-body fields blows even the - // 60s timeout below. The sharp gate runs in the normal `pnpm test` run + - // `verify:ci`. - if (awake.world.step.toString().includes('cov_')) { - console.log('sleeping-vs-awake perf gate skipped under coverage (instrumentation slows the measurement past the timeout)'); - - return; - } - - for (let i = 0; i < 240; i++) { - awake.world.step(FRAME); - } - - const awakeMs = stepTimes(awake.world, 120); - - // Sleeping on (default): let the field settle and nap. - const sleeping = buildField(1000, 5, { enableSleeping: true }); - - for (let i = 0; i < 360; i++) { - sleeping.world.step(FRAME); - } - - const sleptCount = sleeping.bodies.filter(body => body.isSleeping).length; - const sleepingMs = stepTimes(sleeping.world, 120); - - expect(sleeping.bodies.length).toBe(5000); - console.log( - `awake ${awakeMs.toFixed(3)} ms/step vs sleeping ${sleepingMs.toFixed(3)} ms/step · ${sleptCount}/5000 asleep (${(awakeMs / sleepingMs).toFixed(1)}× faster)`, - ); - - // The vast majority of a settled field naps, and skipping their integration - // and constraint solve sharply cuts the per-step cost (measured ~3.4× faster - // on the reference machine - the remainder is detection, which still runs). - // The gate is a relative ratio (same machine, sleeping vs awake), so it is - // machine-independent; ≥2× leaves headroom for variance. - expect(sleptCount).toBeGreaterThan(4500); - expect(sleepingMs).toBeLessThan(awakeMs * 0.5); - }, 60_000); }); diff --git a/packages/exojs-physics/test/sleeping-perf.test.ts b/packages/exojs-physics/test/sleeping-perf.test.ts new file mode 100644 index 000000000..25353b44c --- /dev/null +++ b/packages/exojs-physics/test/sleeping-perf.test.ts @@ -0,0 +1,75 @@ +/** + * Sleeping gate: a settled field must skip the work its sleeping bodies would + * otherwise cost. + * + * Split out of the main performance suite because this is the one assertion + * here that reads WALL-CLOCK time. Wall-clock is not merely machine-dependent, + * it is load-dependent: run alongside the rest of the test suite, the light + * sleeping phase loses proportionally more to scheduling gaps than the heavy + * awake phase does, the ratio collapses (measured 3.4x alone against 1.8x under + * the parallel suite) and the gate fails without anything having regressed. + * + * So it runs in its own project, invoked separately after the parallel suite + * rather than inside it. Keep it that way: the assertion is only meaningful + * when this process has the machine to itself. + * + * The behavioural half of the claim - that a settled field falls asleep at all - + * carries no timing and is covered by `sleeping.test.ts` and + * `sleep-penetration.test.ts`, which stay in the main suite and catch a + * regression in the sleep bookkeeping regardless of load. What is left here is + * the part only a clock can show: that the solver skips the sleepers' work + * rather than merely flagging them. + */ +import { describe, expect, it } from 'vitest'; + +import { buildField, FRAME, stepTimes } from './fields'; + +describe('physics sleeping performance', () => { + it('5,000-mostly-sleeping field: sleeping sharply cuts step time', () => { + // Baseline: the identical field with sleeping disabled stays fully active. + const awake = buildField(1000, 5, { enableSleeping: false }); + + // Skipped entirely under istanbul coverage: instrumentation inflates the + // per-step cost enough (see the identical `cov_` guard above) that the full + // 840-step awake+sleeping budget across two 5,000-body fields blows even the + // 60s timeout below. The sharp gate runs in the normal `pnpm test` run + + // `verify:ci`. + if (awake.world.step.toString().includes('cov_')) { + console.log('sleeping-vs-awake perf gate skipped under coverage (instrumentation slows the measurement past the timeout)'); + + return; + } + + for (let i = 0; i < 240; i++) { + awake.world.step(FRAME); + } + + const awakeMs = stepTimes(awake.world, 120); + + // Sleeping on (default): let the field settle and nap. + const sleeping = buildField(1000, 5, { enableSleeping: true }); + + for (let i = 0; i < 360; i++) { + sleeping.world.step(FRAME); + } + + const sleptCount = sleeping.bodies.filter(body => body.isSleeping).length; + const sleepingMs = stepTimes(sleeping.world, 120); + + expect(sleeping.bodies.length).toBe(5000); + console.log( + `awake ${awakeMs.toFixed(3)} ms/step vs sleeping ${sleepingMs.toFixed(3)} ms/step · ${sleptCount}/5000 asleep (${(awakeMs / sleepingMs).toFixed(1)}× faster)`, + ); + + // The vast majority of a settled field naps, and skipping their integration + // and constraint solve sharply cuts the per-step cost (measured ~3.4× faster + // on the reference machine - the remainder is detection, which still runs). + // The ratio removes the machine, not the load: both arms run here, so a + // slower machine cancels out, but concurrent work does not - it costs the + // light sleeping arm proportionally more than the heavy awake one. Hence + // the dedicated project; the 2x gate against a measured ~3.4x leaves + // headroom for inlining variance across machines, not for a busy one. + expect(sleptCount).toBeGreaterThan(4500); + expect(sleepingMs).toBeLessThan(awakeMs * 0.5); + }, 60_000); +}); diff --git a/scripts/ci/lanes.ts b/scripts/ci/lanes.ts index d4c9b223e..93f83f51c 100644 --- a/scripts/ci/lanes.ts +++ b/scripts/ci/lanes.ts @@ -58,11 +58,11 @@ export const LANES: readonly Lane[] = [ id: 'unit', stage: 'test', when: 'unit', - run: 'pnpm test && pnpm test:alloc', + run: 'pnpm test && pnpm test:alloc && pnpm test:physics-perf', // The WGSL tests validate through Naga when it is on PATH and skip // otherwise; CI installs it and refuses the skip. - ciRun: `EXOJS_REQUIRE_NAGA=1 pnpm test ${junit('unit')} && pnpm test:alloc`, - coverageRun: `EXOJS_REQUIRE_NAGA=1 pnpm test:coverage ${junit('unit')} && pnpm test:alloc`, + ciRun: `EXOJS_REQUIRE_NAGA=1 pnpm test ${junit('unit')} && pnpm test:alloc && pnpm test:physics-perf`, + coverageRun: `EXOJS_REQUIRE_NAGA=1 pnpm test:coverage ${junit('unit')} && pnpm test:alloc && pnpm test:physics-perf`, naga: true, junit: true, }, diff --git a/test/ci/vitest-project-parity.test.ts b/test/ci/vitest-project-parity.test.ts new file mode 100644 index 000000000..c7a0affb9 --- /dev/null +++ b/test/ci/vitest-project-parity.test.ts @@ -0,0 +1,52 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { LANES } from '../../scripts/ci/lanes'; + +/** + * Every vitest project must be run by some package.json script, and that script + * by some lane. + * + * A project nobody invokes is worse than a missing test: the file still exists, + * still typechecks and still reads like a gate, so nothing signals that it + * stopped running. That is the failure mode this guards - the split that keeps + * a load-sensitive measurement out of the parallel suite (`physics-perf`, + * `rendering-alloc`) is exactly the kind of change that can drop a project on + * the floor. + * + * Browser projects are exempt: they run through their own lanes with their own + * commands rather than through a `--project` list. + */ + +const repoRoot = resolve(import.meta.dirname!, '../..'); +const config = readFileSync(resolve(repoRoot, 'vitest.config.ts'), 'utf8'); +const packageJson = JSON.parse(readFileSync(resolve(repoRoot, 'package.json'), 'utf8')) as { + scripts: Record; +}; + +const declaredProjects = [...config.matchAll(/name: '([\w-]+)'/g)].map(match => match[1]!).filter(name => !name.startsWith('browser-')); + +const scriptText = Object.values(packageJson.scripts).join(' '); +const laneText = LANES.map(lane => [lane.run, lane.ciRun ?? '', lane.coverageRun ?? ''].join(' ')).join(' '); + +describe('every vitest project is actually run', () => { + it('declares at least the projects this guard knows about', () => { + expect(declaredProjects).toContain('physics-perf'); + expect(declaredProjects).toContain('rendering-alloc'); + }); + + it.each(declaredProjects)('project `%s` is named by a package.json script', project => { + expect(scriptText).toContain(`--project=${project}`); + }); + + it.each(declaredProjects)('project `%s` reaches a lane through its script', project => { + const owning = Object.entries(packageJson.scripts) + .filter(([, command]) => command.includes(`--project=${project}`)) + .map(([name]) => name); + + expect(owning.length).toBeGreaterThan(0); + expect(owning.some(name => laneText.includes(`pnpm ${name}`))).toBe(true); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 9ef2b10b3..e1f7822a2 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -206,6 +206,22 @@ export default defineConfig({ name: 'exojs-physics', alias: aliasConfig, include: ['packages/exojs-physics/test/**/*.test.ts'], + // The sleeping gate is its own project - see `physics-perf`. + exclude: ['packages/exojs-physics/test/sleeping-perf.test.ts'], + }), + + // -- physics-perf - the sleeping-vs-awake step-time gate --------------- + // Separate project for exactly one reason: this is the only physics + // assertion that reads wall-clock time, and wall-clock is load-dependent. + // Run inside the parallel suite, the light sleeping arm loses more to + // scheduling gaps than the heavy awake arm and the ratio collapses (3.4x + // measured alone, 1.8x under the suite) - a failing push with nothing + // regressed. Kept out of `test` and run by `test:physics-perf` after it, + // so the measurement has the machine to itself. + createJsdomTestProject({ + name: 'physics-perf', + alias: aliasConfig, + include: ['packages/exojs-physics/test/sleeping-perf.test.ts'], }), createJsdomTestProject({ name: 'exojs-tilemap-physics', From 2981d34ca8a73fc40e363883e42fb618fe58ad41 Mon Sep 17 00:00:00 2001 From: Exoridus Date: Sun, 6 Sep 2026 06:42:09 +0200 Subject: [PATCH 3/4] build: one command sets up a clone, and doctor says what is missing Node was pinned nowhere: CI named 24.x in four places and drifted with it, and a contributor's shell ran whatever it had. .nvmrc is now the single declaration; devEngines refuses another major, the workflows read the file, and a parity test keeps the three equal. pnpm bootstrap stays what CI needs - dependencies and build tooling, install scripts off - which is why a clone set up that way had no git hooks: prepare never ran. bootstrap:dev adds the hooks, every build, the bench competitors and a Chromium, then runs doctor, which reports each prerequisite with the command that fixes it. The API docs generator wiped its output directory before converting the extension packages; when their dist was missing the conversion failed and the site lost every extension page. It now generates into a staging directory, moves it into place only on success, and refuses up front without a core dist. clean:artifacts removes what a test, benchmark or release run leaves behind, through git clean so a tracked placeholder survives; clean:all also drops every build output and cache. The root micro-benchmark script is renamed bench:micro so it no longer reads as the cross-library benchmark. --- .github/actions/setup/action.yml | 7 +- .github/workflows/ci.yml | 4 +- .github/workflows/release.yml | 4 +- .gitignore | 1 + .nvmrc | 1 + .prettierignore | 3 + CONTRIBUTING.md | 8 ++ README.md | 18 ++- package.json | 24 +++- scripts/artifacts.ts | 49 +++++++ scripts/check-dist-fresh.ts | 85 +++--------- scripts/clean-artifacts.ts | 41 ++++++ scripts/dist-freshness.ts | 89 ++++++++++++ scripts/doctor.ts | 202 ++++++++++++++++++++++++++++ site/scripts/build-api.ts | 41 +++++- test/ci/node-version-parity.test.ts | 39 ++++++ 16 files changed, 526 insertions(+), 90 deletions(-) create mode 100644 .nvmrc create mode 100644 scripts/artifacts.ts create mode 100644 scripts/clean-artifacts.ts create mode 100644 scripts/dist-freshness.ts create mode 100644 scripts/doctor.ts create mode 100644 test/ci/node-version-parity.test.ts diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index 55b997a63..0774f2320 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -2,9 +2,6 @@ name: Set up the workspace description: pnpm, Node, dependencies, and optionally a Playwright browser, the Naga validator and the built dist. inputs: - node-version: - description: Node version for setup-node - default: '24.x' browser: description: Playwright browser to install (chromium or firefox); empty installs none default: '' @@ -26,9 +23,11 @@ runs: with: run_install: false + # The Node version comes from `.nvmrc`, the same file `devEngines` in + # package.json enforces locally, so CI and a contributor's shell agree. - uses: actions/setup-node@v6 with: - node-version: ${{ inputs.node-version }} + node-version-file: .nvmrc check-latest: true cache: pnpm cache-dependency-path: pnpm-lock.yaml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d6a264746..09929d572 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -89,7 +89,7 @@ jobs: # No install: the planner is dependency-free TypeScript that node strips. - uses: actions/setup-node@v6 with: - node-version: '24.x' + node-version-file: .nvmrc - id: plan env: @@ -303,7 +303,7 @@ jobs: - uses: actions/checkout@v6 - uses: actions/setup-node@v6 with: - node-version: '24.x' + node-version-file: .nvmrc - env: NEEDS: ${{ toJSON(needs) }} PLAN: ${{ toJSON(needs.plan.outputs) }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5284a4f9e..967e80f03 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -52,7 +52,7 @@ jobs: # the developer's machine. - uses: actions/setup-node@v6 with: - node-version: '24.x' + node-version-file: .nvmrc - id: resolve env: @@ -144,7 +144,7 @@ jobs: - uses: ./.github/actions/setup - uses: actions/setup-node@v6 with: - node-version: '24.x' + node-version-file: .nvmrc registry-url: 'https://registry.npmjs.org' - uses: actions/download-artifact@v4 diff --git a/.gitignore b/.gitignore index 38cd110f7..0367fb21f 100644 --- a/.gitignore +++ b/.gitignore @@ -42,6 +42,7 @@ docs/ !.gitattributes !.gitignore !.gitkeep +!.nvmrc !.prettierignore !.prettierrc diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 000000000..a45fd52cc --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24 diff --git a/.prettierignore b/.prettierignore index 83a43362e..166c28289 100644 --- a/.prettierignore +++ b/.prettierignore @@ -27,3 +27,6 @@ packages/exojs-bench/results/*.json *.min.* pnpm-lock.yaml + +# API docs are generated here before being moved into src/content/api. +site/.api-staging diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fd1154720..7a530e55d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -32,9 +32,17 @@ the file. `pnpm release:changelog` previews what the cut would add. Run this once per clone: ```sh +pnpm bootstrap:dev git config pull.ff only ``` +`bootstrap:dev` installs the dependencies, the git hooks, every build output, the benchmark +competitor libraries and a Chromium for the browser lanes, then runs `pnpm doctor` - which +you can run again at any time to see what a clone is missing and how to fix it. The plain +`pnpm bootstrap` is the CI form: dependencies and build tooling only, with install scripts +disabled - which means it does not itself install the git hooks. `pnpm exec husky` adds them, +and `pnpm doctor` says whether they are there. + `main` and `next` only ever advance through a reviewed PR, so a local commit on either cannot reach the remote — it just makes the branch diverge from its remote counterpart. On a diverged branch, Git's default `pull` silently builds diff --git a/README.md b/README.md index ddc1850e6..c9ad9f3db 100644 --- a/README.md +++ b/README.md @@ -214,13 +214,27 @@ new Application({ backend: { type: 'auto' } }); // default ## Development +Prerequisites: Node 24 (`.nvmrc`; `devEngines` in `package.json` refuses any other major) and +pnpm (`packageManager` pins the version; with Corepack enabled, or any installed pnpm 10+, it +switches itself). + +```bash +pnpm bootstrap:dev # dependencies, git hooks, every build, the bench competitors, a Chromium +pnpm doctor # what is missing, and the command that fixes it +``` + +`pnpm bootstrap` alone is what CI runs: dependencies and the build tooling, nothing else. It +installs with scripts disabled, so whether a clone ends up with git hooks depends on whether pnpm +ran an install of its own first - and it builds nothing. `pnpm doctor` reports the actual state +either way. + ```bash -pnpm bootstrap pnpm typecheck pnpm lint pnpm test -pnpm build +pnpm build:all # core plus every extension package pnpm verify:package +pnpm clean:artifacts # what a local test, benchmark or release run left behind ``` Package-internal imports use Node `package.json#imports` subpath imports: `./X` for the same directory, `#dir/X` for any other path in the same package, and the public bare specifier (`@codexo/exojs`) across packages. See [CONTRIBUTING.md](./CONTRIBUTING.md) for the full import policy, per-package commands, and the shared `@codexo/exojs-config` tooling. Building the library requires TypeScript 6. diff --git a/package.json b/package.json index aeeddf007..1571177b6 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,13 @@ "version": "0.17.0", "type": "module", "packageManager": "pnpm@11.4.0", + "devEngines": { + "runtime": { + "name": "node", + "version": "^24", + "onFail": "error" + } + }, "files": [ "dist/esm/", "dist/exo.esm.js", @@ -73,10 +80,16 @@ "{src,test,examples,scripts,packages}/**/*.{ts,tsx,mts,cts}": "eslint --fix --no-warn-ignored --max-warnings=0" }, "scripts": { - "bootstrap": "pnpm install --frozen-lockfile --ignore-scripts && pnpm build:tooling && pnpm bench:setup", + "bootstrap": "pnpm install --frozen-lockfile --ignore-scripts && pnpm build:tooling", + "bootstrap:dev": "pnpm bootstrap && pnpm exec husky && pnpm build:all && pnpm bench:setup && pnpm exec playwright install chromium && pnpm doctor", + "doctor": "tsx ./scripts/doctor.ts", "build:tooling": "pnpm --filter @codexo/exojs-build build", "clean": "rimraf dist", + "clean:artifacts": "tsx ./scripts/clean-artifacts.ts", + "clean:all": "tsx ./scripts/clean-artifacts.ts --all", "build": "pnpm build:tooling && pnpm clean && tsx scripts/build.ts", + "build:packages": "pnpm -r --filter \"@codexo/exojs-*\" --filter \"!@codexo/exojs-examples\" --filter \"!@codexo/exojs-build\" --filter \"!@codexo/exojs-config\" --filter \"!@codexo/exojs-bench\" build", + "build:all": "pnpm build && pnpm build:packages", "build:dev": "pnpm build:tooling && pnpm clean && tsx scripts/build.ts --dev", "build:watch": "tsx scripts/build.ts --dev --watch", "verify:exports": "tsx ./scripts/verify-exports.ts", @@ -105,7 +118,7 @@ "verify:external-consumers": "tsx ./scripts/release/external-consumers.ts", "release:notes": "tsx ./scripts/generate-release-notes.ts", "release:changelog": "tsx ./scripts/release/generate-changelog.ts", - "docs:api:generate": "pnpm site:build:api", + "docs:api:generate": "tsx scripts/check-dist-fresh.ts && pnpm site:build:api", "docs:api:check": "tsx scripts/check-api-docs-sync.ts", "examples:sync:check": "tsx scripts/check-examples-sync.ts", "full-bundle:exports:check": "tsx scripts/check-dist-fresh.ts && tsx scripts/check-full-bundle-exports.ts", @@ -143,10 +156,11 @@ "lint:shaders": "tsx scripts/check-shader-sources.ts", "format": "prettier --write .", "format:check": "prettier --check .", - "test": "vitest run --project=exojs --project=exojs-build --project=exojs-particles --project=exojs-tilemap --project=exojs-tiled --project=exojs-physics --project=exojs-tilemap-physics --project=exojs-lighting --project=exojs-pathfinding --project=exojs-audio-fx --project=exojs-aseprite --project=exojs-ldtk --project=exojs-react --project=rendering-perf --project=rendering-alloc", + "test": "vitest run --project=exojs --project=exojs-build --project=exojs-particles --project=exojs-tilemap --project=exojs-tiled --project=exojs-physics --project=exojs-tilemap-physics --project=exojs-lighting --project=exojs-pathfinding --project=exojs-audio-fx --project=exojs-aseprite --project=exojs-ldtk --project=exojs-react --project=exojs-bench --project=rendering-perf --project=rendering-alloc", "test:core": "vitest run --project=exojs", "test:coverage": "vitest run --coverage --project=exojs --project=exojs-build --project=exojs-particles --project=exojs-tilemap --project=exojs-tiled --project=exojs-physics --project=exojs-tilemap-physics --project=exojs-lighting --project=exojs-pathfinding --project=exojs-audio-fx --project=exojs-aseprite --project=exojs-ldtk --project=exojs-react --project=rendering-perf", "test:alloc": "vitest run --project=rendering-alloc", + "test:physics-perf": "vitest run --project=physics-perf", "test:watch": "vitest --project=exojs", "test:production-stripping": "vitest run --project=exojs test/build-defines/production-stripping.test.ts", "test:skips": "pnpm run test --reporter=default --reporter=junit --outputFile.junit=./test-results/unit.junit.xml && tsx scripts/check-skipped-tests.ts", @@ -168,9 +182,7 @@ "test:parity:webkit": "tsx ./scripts/run-parity.ts --project=browser-parity-webkit", "test:parity:safari": "tsx ./scripts/run-parity.ts --project=browser-parity-safari", "webgpu:probe": "tsx scripts/webgpu-probe.ts", - "bench": "vitest bench", - "bench:run": "vitest bench --run", - "site:install": "pnpm bootstrap", + "bench:micro": "vitest bench --run", "site:build": "tsx scripts/check-dist-fresh.ts && pnpm --filter @codexo/exojs-examples build", "site:build:api": "pnpm --filter @codexo/exojs-examples build:api", "test:examples:smoke": "pnpm --filter @codexo/exojs-examples examples:smoke", diff --git a/scripts/artifacts.ts b/scripts/artifacts.ts new file mode 100644 index 000000000..1f263cca9 --- /dev/null +++ b/scripts/artifacts.ts @@ -0,0 +1,49 @@ +/** + * Directories a local run leaves behind, and which of them are safe to delete. + * + * Everything listed here is gitignored and regenerated by the step that wrote + * it. Private scratch directories are deliberately absent: by repository + * convention they may hold results someone means to keep. + */ +import { existsSync, readdirSync, statSync } from 'node:fs'; +import { join } from 'node:path'; + +/** Outputs of a test, benchmark or release run. Deleting one loses nothing a rerun cannot produce. */ +export const RUN_ARTIFACTS: readonly string[] = [ + 'test-results', + '.vitest-attachments', + 'coverage', + '.release', + 'test/perf/results', + 'test/rendering/browser/__screenshots__', + 'test/rendering/parity/__screenshots__', + 'site/.api-staging', + 'site/dist', +]; + +/** Caches and synced inputs a build recreates; deleting one only costs the next build its warm start. */ +export const BUILD_CACHES: readonly string[] = ['.cache', 'site/.astro', 'site/public/vendor', 'site/public/assets', 'site/public/examples']; + +const directorySize = (dir: string): number => { + let total = 0; + + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + + if (entry.isDirectory()) total += directorySize(full); + else if (entry.isFile()) total += statSync(full).size; + } + + return total; +}; + +export interface ArtifactPresence { + readonly path: string; + readonly bytes: number; +} + +/** The listed paths that currently exist under `root`, with their size on disk. */ +export const presentArtifacts = (root: string, paths: readonly string[]): ArtifactPresence[] => + paths.filter(path => existsSync(join(root, path))).map(path => ({ path, bytes: directorySize(join(root, path)) })); + +export const formatBytes = (bytes: number): string => (bytes >= 1024 * 1024 ? `${(bytes / (1024 * 1024)).toFixed(0)} MB` : `${(bytes / 1024).toFixed(0)} KB`); diff --git a/scripts/check-dist-fresh.ts b/scripts/check-dist-fresh.ts index eb2f59faa..97543bfca 100644 --- a/scripts/check-dist-fresh.ts +++ b/scripts/check-dist-fresh.ts @@ -1,87 +1,36 @@ /** * Refuse to run a dist-consuming step against a stale build. * - * The site build and the full-bundle export check read `dist/` (Core) and - * `packages/exojs-*\/dist/` (extensions) rather than the sources. After a - * pull or a local edit those artifacts silently lag behind: the site bundles - * an engine without the new export and the example smoke then reports a - * black canvas with no error; the export check names a symbol the package - * "does not export". Each of those wasted a diagnosis before this check - * existed. The smoke itself is not gated: it consumes the site build, which - * is checked here, and in CI it runs from a downloaded site artifact with no - * engine dist beside it. + * The site build, the API docs generator and the full-bundle export check read + * `dist/` (Core) and `packages/exojs-*\/dist/` (extensions) rather than the + * sources. After a pull or a local edit those artifacts silently lag behind: + * the site bundles an engine without the new export and the example smoke then + * reports a black canvas with no error; the export check names a symbol the + * package "does not export"; the docs generator drops every extension page. + * Each of those wasted a diagnosis before this check existed. * - * Every build records a content hash of its source tree in its dist (see - * `source-hash.ts`); a unit is stale when the hash of the sources on disk no - * longer matches. A Core dist without a stamp is stale; a package that was - * never built is left to the consuming step's own error. Set - * `EXOJS_SKIP_DIST_CHECK=1` to bypass. + * A package that was never built is left to the consuming step's own error, + * except where that step would destroy something first - the docs generator + * guards that case itself. Set `EXOJS_SKIP_DIST_CHECK=1` to bypass. */ - -import { existsSync, readdirSync } from 'node:fs'; -import { join, relative, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -import { hashSourceTree, readSourceStamp } from './source-hash.ts'; - -const root = resolve(fileURLToPath(new URL('..', import.meta.url))); - -interface BuildUnit { - readonly name: string; - readonly sourceDir: string; - readonly distDir: string; -} - -const TOOLING_PACKAGES = new Set(['exojs-build', 'exojs-config', 'exojs-bench', 'exojs-examples']); - -const units: BuildUnit[] = [{ name: '@codexo/exojs', sourceDir: join(root, 'src'), distDir: join(root, 'dist') }]; - -for (const entry of readdirSync(join(root, 'packages'), { withFileTypes: true })) { - if (!entry.isDirectory() || !entry.name.startsWith('exojs-')) continue; - - const dir = join(root, 'packages', entry.name); - const sourceDir = join(dir, 'src'); - const distDir = join(dir, 'dist'); - - // Only runtime extension packages build through the shared library pipeline; - // tooling, config, the bench harness and the site own their outputs and are - // never bundled by the checks. - if ( - TOOLING_PACKAGES.has(entry.name) || - !existsSync(sourceDir) || - !existsSync(join(dir, 'tsconfig.build.json')) || - !existsSync(join(distDir, 'esm', 'index.js')) - ) - continue; - - units.push({ name: `@codexo/${entry.name}`, sourceDir, distDir }); -} +import { checkFreshness, REBUILD_COMMAND } from './dist-freshness.ts'; if (process.env['EXOJS_SKIP_DIST_CHECK'] === '1') { console.log('check-dist-fresh: skipped (EXOJS_SKIP_DIST_CHECK=1).'); process.exit(0); } -const stale: string[] = []; - -for (const unit of units) { - const recorded = readSourceStamp(unit.distDir); - - if (recorded === null) { - stale.push(`${unit.name}: ${relative(root, unit.distDir)} carries no source stamp (built before stamps existed, or not at all)`); - } else if (recorded !== hashSourceTree(unit.sourceDir)) { - stale.push(`${unit.name}: ${relative(root, unit.sourceDir)} changed since ${relative(root, unit.distDir)} was built`); - } -} +const report = checkFreshness(); +const checked = report.units.length - report.unbuilt.length; -if (stale.length === 0) { - console.log(`check-dist-fresh: ${units.length} build unit(s) up to date.`); +if (report.stale.length === 0) { + console.log(`check-dist-fresh: ${checked} build unit(s) up to date.`); process.exit(0); } console.error('check-dist-fresh: dist is older than its sources; the step you are about to run would use a stale build.\n'); -for (const line of stale) console.error(` - ${line}`); +for (const line of report.stale) console.error(` - ${line}`); -console.error('\nRebuild with: pnpm build && pnpm -r --filter "@codexo/exojs-*" --filter "!@codexo/exojs-examples" build'); +console.error(`\nRebuild with: ${REBUILD_COMMAND}`); process.exit(1); diff --git a/scripts/clean-artifacts.ts b/scripts/clean-artifacts.ts new file mode 100644 index 000000000..a284a9e18 --- /dev/null +++ b/scripts/clean-artifacts.ts @@ -0,0 +1,41 @@ +/** + * Delete what a local test, benchmark or release run left behind. + * + * By default only run artifacts go; build outputs and caches stay, since the + * next build needs them. `--all` removes those too - every dist, the site's + * cache and its synced inputs - which is the state of a fresh clone before + * `bootstrap:dev`. Prints what it removed so a surprising deletion is at + * least a visible one. + * + * Deletion goes through `git clean -X`, which removes only files the + * repository ignores: a tracked placeholder inside an artifact directory + * (`test/perf/results/.gitkeep`) survives, and so does anything a person put + * there that git does not know to be disposable. + */ +import { execFileSync } from 'node:child_process'; +import { join, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { BUILD_CACHES, formatBytes, presentArtifacts, RUN_ARTIFACTS } from './artifacts.ts'; +import { collectBuildUnits } from './dist-freshness.ts'; + +const root = resolve(fileURLToPath(new URL('..', import.meta.url))); +const all = process.argv.includes('--all'); +const buildOutputs = all ? collectBuildUnits().map(unit => relative(root, unit.distDir).replace(/\\/g, '/')) : []; +const present = presentArtifacts(root, all ? [...RUN_ARTIFACTS, ...BUILD_CACHES, ...buildOutputs] : RUN_ARTIFACTS); +const label = all ? 'clean:all' : 'clean:artifacts'; + +if (present.length === 0) { + console.log(`${label}: nothing to remove.`); + process.exit(0); +} + +let freed = 0; + +for (const artifact of present) { + execFileSync('git', ['clean', '-fdXq', '--', join(root, artifact.path)], { cwd: root, stdio: 'inherit' }); + freed += artifact.bytes; + console.log(` removed ${artifact.path} (${formatBytes(artifact.bytes)})`); +} + +console.log(`${label}: ${present.length} director${present.length === 1 ? 'y' : 'ies'} removed, ${formatBytes(freed)} freed.`); diff --git a/scripts/dist-freshness.ts b/scripts/dist-freshness.ts new file mode 100644 index 000000000..f53b2e10a --- /dev/null +++ b/scripts/dist-freshness.ts @@ -0,0 +1,89 @@ +/** + * Which build units the dist-consuming steps depend on, and whether each is + * current. + * + * Shared by the freshness gate (which refuses to run a step against a stale + * build) and the doctor (which reports it as one line among the other + * prerequisites). A unit is stale when the content hash of its sources no + * longer matches the stamp its dist recorded at build time; a unit that was + * never built is reported separately, because the consuming step's own error + * for that case is usually a wall of unresolved imports rather than the cause. + */ +import { existsSync, readdirSync } from 'node:fs'; +import { join, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { hashSourceTree, readSourceStamp } from './source-hash.ts'; + +export const repoRoot = resolve(fileURLToPath(new URL('..', import.meta.url))); + +export interface BuildUnit { + readonly name: string; + readonly sourceDir: string; + readonly distDir: string; + /** Whether the dist directory holds an emitted entry point. */ + readonly built: boolean; +} + +/** Packages that own their outputs and are never bundled by a dist-consuming step. */ +const TOOLING_PACKAGES = new Set(['exojs-build', 'exojs-config', 'exojs-bench', 'exojs-examples']); + +/** Core plus every runtime extension package, whether or not it has been built. */ +export const collectBuildUnits = (): BuildUnit[] => { + const units: BuildUnit[] = [ + { + name: '@codexo/exojs', + sourceDir: join(repoRoot, 'src'), + distDir: join(repoRoot, 'dist'), + built: existsSync(join(repoRoot, 'dist', 'esm', 'index.js')), + }, + ]; + + for (const entry of readdirSync(join(repoRoot, 'packages'), { withFileTypes: true })) { + if (!entry.isDirectory() || !entry.name.startsWith('exojs-') || TOOLING_PACKAGES.has(entry.name)) continue; + + const dir = join(repoRoot, 'packages', entry.name); + const sourceDir = join(dir, 'src'); + const distDir = join(dir, 'dist'); + + // Only packages built through the shared library pipeline carry a stamp. + if (!existsSync(sourceDir) || !existsSync(join(dir, 'tsconfig.build.json'))) continue; + + units.push({ name: `@codexo/${entry.name}`, sourceDir, distDir, built: existsSync(join(distDir, 'esm', 'index.js')) }); + } + + return units; +}; + +export interface FreshnessReport { + readonly units: readonly BuildUnit[]; + /** One reason per unit whose dist lags its sources. */ + readonly stale: readonly string[]; + /** Units with no emitted dist at all. */ + readonly unbuilt: readonly BuildUnit[]; +} + +export const checkFreshness = (units: readonly BuildUnit[] = collectBuildUnits()): FreshnessReport => { + const stale: string[] = []; + const unbuilt: BuildUnit[] = []; + + for (const unit of units) { + if (!unit.built) { + unbuilt.push(unit); + continue; + } + + const recorded = readSourceStamp(unit.distDir); + + if (recorded === null) { + stale.push(`${unit.name}: ${relative(repoRoot, unit.distDir)} carries no source stamp (built before stamps existed, or not at all)`); + } else if (recorded !== hashSourceTree(unit.sourceDir)) { + stale.push(`${unit.name}: ${relative(repoRoot, unit.sourceDir)} changed since ${relative(repoRoot, unit.distDir)} was built`); + } + } + + return { units, stale, unbuilt }; +}; + +/** The one command that brings every unit up to date. */ +export const REBUILD_COMMAND = 'pnpm build:all'; diff --git a/scripts/doctor.ts b/scripts/doctor.ts new file mode 100644 index 000000000..77fa5fb01 --- /dev/null +++ b/scripts/doctor.ts @@ -0,0 +1,202 @@ +/** + * One screen that says whether this clone can run what the repository asks of + * it, and names the command for whatever cannot. + * + * Every check here corresponds to a failure that has cost a diagnosis before: + * a Node version the tooling silently tolerated, git hooks that never fired + * because the install skipped `prepare`, a stale dist that made the site + * render a black canvas, an API docs generator that deleted the extension + * pages because the packages were not built, a benchmark that could not find + * its competitor libraries. Run it after `pnpm bootstrap:dev`, and whenever + * something fails in a way that reads like a broken change but smells like a + * missing prerequisite. + */ +import { execFileSync } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { join } from 'node:path'; + +import { BUILD_CACHES, formatBytes, presentArtifacts, RUN_ARTIFACTS } from './artifacts.ts'; +import { checkFreshness, REBUILD_COMMAND, repoRoot } from './dist-freshness.ts'; + +interface Outcome { + readonly ok: boolean; + readonly detail: string; + /** The command that turns a failed check green. */ + readonly fix?: string; +} + +interface Check { + readonly name: string; + /** A failed required check exits non-zero; an optional one is reported and moves on. */ + readonly required: boolean; + run(): Outcome; +} + +const read = (relativePath: string): string => readFileSync(join(repoRoot, relativePath), 'utf8'); + +const command = (file: string, args: readonly string[]): string | null => { + try { + return execFileSync(file, args, { + cwd: repoRoot, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + shell: process.platform === 'win32', + }).trim(); + } catch { + return null; + } +}; + +const packageJson = JSON.parse(read('package.json')) as { packageManager?: string }; + +const checks: Check[] = [ + { + name: 'node', + required: true, + run: () => { + // `.nvmrc` is the single source of the required major; `devEngines` in + // package.json mirrors it for pnpm and a parity test keeps them equal. + const wanted = read('.nvmrc').trim(); + const actual = process.versions.node; + const ok = actual.split('.')[0] === wanted; + + return { + ok, + detail: `v${actual} (required: ${wanted}.x)`, + fix: ok ? undefined : `install Node ${wanted} - nvm use, fnm use or volta pin node@${wanted}`, + }; + }, + }, + { + name: 'pnpm', + required: true, + run: () => { + const pinned = packageJson.packageManager?.split('@')[1] ?? '?'; + const actual = command('pnpm', ['--version']); + + if (actual === null) { + return { ok: false, detail: 'not found on PATH', fix: 'corepack enable, or install pnpm - it switches itself to the pinned version' }; + } + + return { ok: actual === pinned, detail: `${actual} (pinned: ${pinned})`, fix: actual === pinned ? undefined : 'corepack enable' }; + }, + }, + { + name: 'git hooks', + required: true, + run: () => { + // Whether a clone has hooks depends on how it was set up: `bootstrap` + // installs with --ignore-scripts, which skips the `prepare` that + // installs them, so they appear only when pnpm's own install ran first. + // `bootstrap:dev` installs them outright; this reports what is there. + const hooksPath = command('git', ['config', 'core.hooksPath']) ?? ''; + const installed = hooksPath.replace(/\\/g, '/').endsWith('.husky/_') && existsSync(join(repoRoot, '.husky', '_', 'h')); + + return { + ok: installed, + detail: installed ? 'installed' : 'not installed - the pre-commit and pre-push guards are not running', + fix: installed ? undefined : 'pnpm exec husky', + }; + }, + }, + { + name: 'git pull.ff', + required: false, + run: () => { + const value = command('git', ['config', 'pull.ff']); + const ok = value === 'only'; + + return { + ok, + detail: ok ? 'only' : `${value ?? 'unset'} - a pull on a diverged main/next would build a merge commit`, + fix: ok ? undefined : 'git config pull.ff only', + }; + }, + }, + { + name: 'dist', + required: true, + run: () => { + const report = checkFreshness(); + const problems = [...report.unbuilt.map(unit => `${unit.name} not built`), ...report.stale]; + + if (problems.length === 0) return { ok: true, detail: `${report.units.length} build unit(s) current` }; + + return { ok: false, detail: problems.join('; '), fix: REBUILD_COMMAND }; + }, + }, + { + name: 'playwright chromium', + required: false, + run: () => { + // The browser lanes, the example smoke and the benchmarks all launch it. + // Resolved through the repository's own playwright dependency, so the + // answer is about the build the tests will use, not a global install. + const { chromium } = createRequire(import.meta.url)('playwright') as { chromium: { executablePath(): string } }; + const ok = existsSync(chromium.executablePath()); + + return { + ok, + detail: ok ? 'installed' : 'not installed - browser tests, the example smoke and the benchmarks cannot run', + fix: ok ? undefined : 'pnpm exec playwright install chromium', + }; + }, + }, + { + name: 'bench competitors', + required: false, + run: () => { + const linked = existsSync(join(repoRoot, 'packages', 'exojs-bench', 'node_modules', 'pixi.js', 'package.json')); + + return { + ok: linked, + detail: linked ? 'linked' : 'not linked - the benchmark competitor arms and the bench typecheck cannot run', + fix: linked ? undefined : 'pnpm bench:setup', + }; + }, + }, + { + name: 'naga', + required: false, + run: () => { + const version = command('naga', ['--version']); + + return { ok: version !== null, detail: version ?? 'not on PATH - WGSL validation tests skip locally (CI installs it)' }; + }, + }, +]; + +let failed = false; + +console.log('doctor: prerequisites for this clone\n'); + +for (const check of checks) { + const outcome = check.run(); + const mark = outcome.ok ? 'ok ' : check.required ? 'FAIL' : 'warn'; + + console.log(` ${mark} ${check.name.padEnd(20)} ${outcome.detail}`); + + if (!outcome.ok && outcome.fix !== undefined) console.log(` fix: ${outcome.fix}`); + if (!outcome.ok && check.required) failed = true; +} + +const artifacts = presentArtifacts(repoRoot, RUN_ARTIFACTS); +const caches = presentArtifacts(repoRoot, BUILD_CACHES); +const sum = (items: readonly { bytes: number }[]): number => items.reduce((total, item) => total + item.bytes, 0); + +if (artifacts.length > 0 || caches.length > 0) { + console.log(''); + + if (artifacts.length > 0) { + console.log(` info run artifacts ${formatBytes(sum(artifacts))} in ${artifacts.map(a => a.path).join(', ')} - pnpm clean:artifacts removes them`); + } + + if (caches.length > 0) { + console.log(` info build caches ${formatBytes(sum(caches))} in ${caches.map(c => c.path).join(', ')} - pnpm clean:all removes these too`); + } +} + +console.log(''); +console.log(failed ? 'doctor: a required prerequisite is missing; see the fix lines above.' : 'doctor: this clone is ready.'); +process.exit(failed ? 1 : 0); diff --git a/site/scripts/build-api.ts b/site/scripts/build-api.ts index 3bc22fb34..380b377ab 100644 --- a/site/scripts/build-api.ts +++ b/site/scripts/build-api.ts @@ -13,7 +13,11 @@ const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const siteRoot = path.resolve(__dirname, '..'); const repoRoot = path.resolve(siteRoot, '..'); -const outputDir = path.resolve(siteRoot, 'src', 'content', 'api'); +const finalOutputDir = path.resolve(siteRoot, 'src', 'content', 'api'); +// Pages are generated here and moved into place only once every package has +// produced its own. Writing straight into the content directory meant a failed +// extension conversion left the site with the core pages and nothing else. +const outputDir = path.resolve(siteRoot, '.api-staging'); const toPosix = (value: string): string => value.replaceAll('\\', '/'); // The subsystem set is owned by the site, not by the generator: the pages order @@ -551,14 +555,36 @@ const entryPointTitle = (reflection: DeclarationReflection): string => { return '@codexo/exojs'; }; +// maxRetries covers the transient Windows EPERM/EBUSY that hits recursive +// rmSync when a file indexer or a parallel build step (examples:sync) has +// the directory momentarily open. Node retries these error codes. +const RM_OPTIONS = { recursive: true, force: true, maxRetries: 5, retryDelay: 100 } as const; + const ensureCleanOutput = (): void => { - // maxRetries covers the transient Windows EPERM/EBUSY that hits recursive - // rmSync when a file indexer or a parallel build step (examples:sync) has - // the output directory momentarily open. Node retries these error codes. - fs.rmSync(outputDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + fs.rmSync(outputDir, RM_OPTIONS); fs.mkdirSync(outputDir, { recursive: true }); }; +/** + * The extension packages import `@codexo/exojs` through its published entry + * points, which exist only after a core build. Without them TypeDoc resolves + * nothing for those packages, so refuse before anything is generated rather + * than discover it after the core pages are already written. + */ +const ensureCoreDist = (): void => { + const entry = path.resolve(repoRoot, 'dist', 'esm', 'index.d.ts'); + if (!fs.existsSync(entry)) { + throw new Error( + `[build:api] core dist is missing (${path.relative(repoRoot, entry)}): run 'pnpm build:all' first - the extension packages resolve @codexo/exojs through its published entry points.`, + ); + } +}; + +const publishOutput = (): void => { + fs.rmSync(finalOutputDir, RM_OPTIONS); + fs.renameSync(outputDir, finalOutputDir); +}; + const MODIFIER_TAGS: `@${string}`[] = [ '@stable', '@advanced', @@ -831,6 +857,7 @@ const convertEntryPoints = async (entryPoints: readonly string[], tsconfig: stri }; const build = async (): Promise => { + ensureCoreDist(); ensureCleanOutput(); const usedSlugs = new Set(); @@ -901,8 +928,10 @@ const build = async (): Promise => { packageCounts.push({ importPath: pkg.importPath, count }); } + publishOutput(); + const summary = [`${coreCount} core`, ...packageCounts.map(p => `${p.count} ${p.importPath}`)].join(', '); - console.log(`[build:api] Generated ${usedSlugs.size} API page(s) (${summary}) in ${outputDir}`); + console.log(`[build:api] Generated ${usedSlugs.size} API page(s) (${summary}) in ${finalOutputDir}`); }; void build(); diff --git a/test/ci/node-version-parity.test.ts b/test/ci/node-version-parity.test.ts new file mode 100644 index 000000000..71e8d439f --- /dev/null +++ b/test/ci/node-version-parity.test.ts @@ -0,0 +1,39 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +/** + * One Node version, declared once. `.nvmrc` is the source; `devEngines` in + * package.json enforces it for pnpm, and every workflow reads the file rather + * than naming a version of its own. A second declaration anywhere is a second + * place for the two to drift apart. + */ + +const repoRoot = resolve(import.meta.dirname!, '../..'); +const nvmrc = readFileSync(resolve(repoRoot, '.nvmrc'), 'utf8').trim(); +const packageJson = JSON.parse(readFileSync(resolve(repoRoot, 'package.json'), 'utf8')) as { + devEngines?: { runtime?: { name?: string; version?: string; onFail?: string } }; +}; +const workflowFiles = ['.github/workflows/ci.yml', '.github/workflows/release.yml', '.github/actions/setup/action.yml']; + +describe('the Node version is declared once', () => { + it('.nvmrc names a bare major', () => { + expect(nvmrc).toMatch(/^\d+$/); + }); + + it('devEngines enforces the same major and fails hard', () => { + const runtime = packageJson.devEngines?.runtime; + + expect(runtime?.name).toBe('node'); + expect(runtime?.onFail).toBe('error'); + expect(runtime?.version).toBe(`^${nvmrc}`); + }); + + it.each(workflowFiles)('%s reads .nvmrc instead of naming a version', file => { + const text = readFileSync(resolve(repoRoot, file), 'utf8'); + + expect(text).not.toMatch(/node-version:\s*['"]?\d/); + expect(text).toContain('node-version-file: .nvmrc'); + }); +}); From f9ca09b924941fcba3f3830071c104dbaf00bbfb Mon Sep 17 00:00:00 2001 From: Exoridus Date: Sun, 6 Sep 2026 07:46:09 +0200 Subject: [PATCH 4/4] fix(ci): drop the core-dist guard the docs generator never needed The staging-and-swap change gave the API docs generator a guard that refused to run without a core dist, on the belief that TypeDoc resolved the extension packages through the published entry points. It does not: it converts from the sources through the workspace aliases, and with every dist removed it still produces all 1085 pages byte for byte. The guard only broke the sync gate, whose CI job has never had a dist. The bench unit tests now run in CI for the first time, and two of them self-skip where no real GPU is present. Their budget is recorded. --- package.json | 2 +- scripts/check-dist-fresh.ts | 19 +++++++++---------- scripts/doctor.ts | 5 ++--- scripts/skipped-tests-baseline.json | 2 ++ site/scripts/build-api.ts | 16 ---------------- 5 files changed, 14 insertions(+), 30 deletions(-) diff --git a/package.json b/package.json index 1571177b6..4d1619433 100644 --- a/package.json +++ b/package.json @@ -118,7 +118,7 @@ "verify:external-consumers": "tsx ./scripts/release/external-consumers.ts", "release:notes": "tsx ./scripts/generate-release-notes.ts", "release:changelog": "tsx ./scripts/release/generate-changelog.ts", - "docs:api:generate": "tsx scripts/check-dist-fresh.ts && pnpm site:build:api", + "docs:api:generate": "pnpm site:build:api", "docs:api:check": "tsx scripts/check-api-docs-sync.ts", "examples:sync:check": "tsx scripts/check-examples-sync.ts", "full-bundle:exports:check": "tsx scripts/check-dist-fresh.ts && tsx scripts/check-full-bundle-exports.ts", diff --git a/scripts/check-dist-fresh.ts b/scripts/check-dist-fresh.ts index 97543bfca..2a162d6d0 100644 --- a/scripts/check-dist-fresh.ts +++ b/scripts/check-dist-fresh.ts @@ -1,17 +1,16 @@ /** * Refuse to run a dist-consuming step against a stale build. * - * The site build, the API docs generator and the full-bundle export check read - * `dist/` (Core) and `packages/exojs-*\/dist/` (extensions) rather than the - * sources. After a pull or a local edit those artifacts silently lag behind: - * the site bundles an engine without the new export and the example smoke then - * reports a black canvas with no error; the export check names a symbol the - * package "does not export"; the docs generator drops every extension page. - * Each of those wasted a diagnosis before this check existed. + * The site build and the full-bundle export check read `dist/` (Core) and + * `packages/exojs-*\/dist/` (extensions) rather than the sources. After a + * pull or a local edit those artifacts silently lag behind: the site bundles + * an engine without the new export and the example smoke then reports a black + * canvas with no error; the export check names a symbol the package "does not + * export". Each of those wasted a diagnosis before this check existed. The + * API docs generator is not gated: it converts from the sources. * - * A package that was never built is left to the consuming step's own error, - * except where that step would destroy something first - the docs generator - * guards that case itself. Set `EXOJS_SKIP_DIST_CHECK=1` to bypass. + * A package that was never built is left to the consuming step's own error. + * Set `EXOJS_SKIP_DIST_CHECK=1` to bypass. */ import { checkFreshness, REBUILD_COMMAND } from './dist-freshness.ts'; diff --git a/scripts/doctor.ts b/scripts/doctor.ts index 77fa5fb01..558028b9c 100644 --- a/scripts/doctor.ts +++ b/scripts/doctor.ts @@ -5,9 +5,8 @@ * Every check here corresponds to a failure that has cost a diagnosis before: * a Node version the tooling silently tolerated, git hooks that never fired * because the install skipped `prepare`, a stale dist that made the site - * render a black canvas, an API docs generator that deleted the extension - * pages because the packages were not built, a benchmark that could not find - * its competitor libraries. Run it after `pnpm bootstrap:dev`, and whenever + * render a black canvas, a benchmark that could not find its competitor + * libraries. Run it after `pnpm bootstrap:dev`, and whenever * something fails in a way that reads like a broken change but smells like a * missing prerequisite. */ diff --git a/scripts/skipped-tests-baseline.json b/scripts/skipped-tests-baseline.json index 286d813a3..f02dc5329 100644 --- a/scripts/skipped-tests-baseline.json +++ b/scripts/skipped-tests-baseline.json @@ -1,6 +1,8 @@ { "note": "Per-file budget of skipped tests, measured as the SUM across every CI test lane (unit + browser-webgl + browser-webgpu + browser-webgl-firefox + browser-audio + browser-tilemap-worker), aggregated from each lane's own JUnit report — one global budget, not one per lane. Skipping more than the budget fails `pnpm test:skips`, and so does any skip in a file with no budget — a conditional test (including a runtime `ctx.skip(...)`) must be a reviewed entry, not a quiet default. Skipping fewer only prints a note, because the count depends on which lanes actually ran (a local partial run, or a local `dist/` that unskips the production-stripping checks). Run `pnpm test:skips:update-baseline` against a full set of lane reports to record a change.", "files": { + "packages/exojs-bench/test/smoke.test.ts": 1, + "packages/exojs-bench/test/split-screen.test.ts": 2, "packages/exojs-pathfinding/test/allocation.test.ts": 3, "test/build-defines/production-stripping.test.ts": 12, "test/perf/rendering/sweep.test.ts": 1 diff --git a/site/scripts/build-api.ts b/site/scripts/build-api.ts index 380b377ab..e1dd37d1f 100644 --- a/site/scripts/build-api.ts +++ b/site/scripts/build-api.ts @@ -565,21 +565,6 @@ const ensureCleanOutput = (): void => { fs.mkdirSync(outputDir, { recursive: true }); }; -/** - * The extension packages import `@codexo/exojs` through its published entry - * points, which exist only after a core build. Without them TypeDoc resolves - * nothing for those packages, so refuse before anything is generated rather - * than discover it after the core pages are already written. - */ -const ensureCoreDist = (): void => { - const entry = path.resolve(repoRoot, 'dist', 'esm', 'index.d.ts'); - if (!fs.existsSync(entry)) { - throw new Error( - `[build:api] core dist is missing (${path.relative(repoRoot, entry)}): run 'pnpm build:all' first - the extension packages resolve @codexo/exojs through its published entry points.`, - ); - } -}; - const publishOutput = (): void => { fs.rmSync(finalOutputDir, RM_OPTIONS); fs.renameSync(outputDir, finalOutputDir); @@ -857,7 +842,6 @@ const convertEntryPoints = async (entryPoints: readonly string[], tsconfig: stri }; const build = async (): Promise => { - ensureCoreDist(); ensureCleanOutput(); const usedSlugs = new Set();