feat(module): the bundle skeleton (phase 3, slice 0)
Some checks failed
PR Checks / server-tests (pull_request) Failing after 10s
PR Checks / client-build (pull_request) Successful in 8m45s

The first real module. It registers nothing, deliberately: what slice 0 proves
is the delivery path itself, end to end, before a single UO file moves into it.

Server half: module.json, an entry point that takes (ctx, api) and registers
nothing, a test suite built on a fake ctx, and scripts/checkImports.js -- the
MODULE_API.md §5.1 boundary check. Client half: the Vite library build, four
shims re-exporting react / react-dom/client / react-router-dom / jsx-runtime
from window.__rg, an entry that verifies each is identity-equal to core's copy,
and scripts/checkExternals.js. 29 server tests, 9 client tests, both new.

Verified against a real core: the module loads, mounts its zero routes, runs to
`started`, and is published by /api/v1/public/modules. Its chunk serves from
the entry's directory with `Cache-Control: no-cache` while the module's server
source, module.json and package.json all 404. In Chrome, under the enforced
`script-src 'self'`, the chunk evaluates and reports all four shared
dependencies OK, with zero CSP reports and no console errors.

Three findings, each of which had produced a green build that was wrong.

MODULE_API.md §3.6 shows `external` alongside the aliases and they do not
compose. Rollup asks `external` BEFORE Vite's alias resolver runs, so a
specifier in both is marked external and never aliased -- the chunk then ships
bare `import "react"`, which no browser can resolve without an import map, and
CSP forbids one. Built cleanly and emitted exactly that; checkExternals caught
it. So: alias only, `external` empty, and vite.config.js grows a resolution-time
guard that fails the build if a shared dependency resolves into node_modules.

That guard was wrong twice before it worked. Written against Rollup's `load`
hook it never ran -- `load` is first-wins and an earlier plugin had already
claimed the module -- so a deliberately-broken alias produced a 24 kB chunk with
react-router welded in, and a green build. And its forbidden-package list was
derived from the alias list "so the two cannot disagree", which meant deleting
an alias also deleted the guard against what that alias prevented. It states the
contract now, and a test asserts the aliases stay inside it.

checkImports failed on its own documentation the first time it ran: the comment
naming require("../../etc/passwd") as an example of what to catch, and index.js
explaining why the module must never require("express"). A boundary check that
cannot survive being described is one people stop writing comments around. It
strips comments and template literals with a character walk rather than a
regexp, because a URL in a string contains a comment opener and a comment
contains quotes -- and it has its own test suite, since a check never shown to
fail is a check nobody knows the state of.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-11 01:31:37 -05:00
committed by Claude
parent 4a0d8f0873
commit 5d7668d5ea
21 changed files with 3982 additions and 71 deletions

View File

@@ -5,25 +5,33 @@
# shaped like that repo's `server/` and `client/`, and it is loaded into that
# repo's process, so it is checked the same way with the same Node version.
#
# ── Package guard ────────────────────────────────────────────────────────────
# This repo is in the planning phase and has no module code yet: the API contract
# is settled in Phase 1 and Phase 3 is what extracts the UO half of `website/`
# into this repo (docs/website/MODULE_SYSTEM.md §2.7). Rather than leave the repo
# ungated until then — or land a workflow that red-Xes every governance/docs PR —
# each half's gates are conditional on its package.json existing. Before the code
# lands, the job reports green with a notice saying so. The moment a package.json
# appears the gates arm themselves; nothing here has to change.
# ── What each job is really asking ───────────────────────────────────────────
#
# The same trick guards the client build, which is the higher-risk half: it must
# build with Vite in library mode with react/react-dom/react-router-dom EXTERNAL,
# because there is exactly one React instance in the page and core owns it. A
# module that bundles its own React loads and then breaks hooks at runtime, which
# is precisely the kind of failure worth catching before merge.
# The tests are the ordinary half. The two `check:*` scripts are the interesting
# one, because they are the acceptance criteria of the module contract itself
# (docs/website/MODULE_API.md Part 5) rather than of this module's behaviour:
#
# • `server: check:imports` — no relative path escapes the module root, and no
# shipped file resolves a bare specifier. A module that reaches into core's
# tree works right up until core moves a file, and the whole boundary is
# worth exactly as much as this check is (§5.1).
#
# • `client: check:externals` — the BUILT chunk has no bare imports left. That
# failure is invisible in source: `import { useState } from 'react'` is
# correct in every file, and whether it becomes core's React or a bare
# specifier no browser can resolve is decided by vite.config.js. It has to
# be asked of the artifact, so it runs after the build. (The other half —
# a shared dependency being BUNDLED — fails the build itself, from a
# resolution-time guard inside vite.config.js.)
#
# Building the chunk in CI is not only a check: it is how the chunk that ships is
# produced, since an operator never builds (MODULE_SYSTEM.md §1.14).
#
# Not here yet, deliberately, because there is nothing for them to act on until
# Phase 3: a release workflow (the `module-uo-<version>.tar.gz` artifact and its
# sha256 manifest), the zero-internal-imports check, and the module's own frozen
# route manifest. Each lands with the code it checks.
# the extraction is further along: the release workflow (the
# `module-uo-<version>.tar.gz` artifact and its sha256 manifest) and the module's
# own frozen route manifest, which needs core checked out at a pinned ref
# (MODULE_API.md §5.3). Each lands with the slice it checks.
#
# Enforcement (one-time, in the Gitea UI):
# Repository Settings → Branches → Branch Protection (rule for `main`)
@@ -54,22 +62,7 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Detect whether the server half exists yet
id: detect
run: |
set -euo pipefail
if [ -f server/package.json ]; then
echo "pkg=true" >> "$GITHUB_OUTPUT"
echo "==> server/package.json found - running the server gates."
else
echo "pkg=false" >> "$GITHUB_OUTPUT"
echo "==> No server/package.json yet (planning phase)."
echo " Skipping install/test. These gates arm themselves as soon"
echo " as Phase 3 lands the server half - see MODULE_SYSTEM.md."
fi
- uses: actions/setup-node@v4
if: ${{ steps.detect.outputs.pkg == 'true' }}
with:
node-version: 20
cache: npm
@@ -78,50 +71,34 @@ jobs:
# `npm ci` rather than `npm install`: it also proves the lockfile is in
# sync with package.json instead of silently updating it.
- name: Install server deps
if: ${{ steps.detect.outputs.pkg == 'true' }}
run: npm ci --prefix server
- name: Run server tests
if: ${{ steps.detect.outputs.pkg == 'true' }}
run: npm test --prefix server
- name: Check the module boundary (MODULE_API.md §5.1)
run: npm run check:imports --prefix server
client-build:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- name: Detect whether the client half exists yet
id: detect
run: |
set -euo pipefail
if [ -f client/package.json ]; then
echo "pkg=true" >> "$GITHUB_OUTPUT"
echo "==> client/package.json found - running the client gates."
else
echo "pkg=false" >> "$GITHUB_OUTPUT"
echo "==> No client/package.json yet (planning phase)."
echo " Skipping install/test/build. These gates arm themselves as"
echo " soon as Phase 3 lands the client half."
fi
- uses: actions/setup-node@v4
if: ${{ steps.detect.outputs.pkg == 'true' }}
with:
node-version: 20
cache: npm
cache-dependency-path: client/package-lock.json
- name: Install client deps
if: ${{ steps.detect.outputs.pkg == 'true' }}
run: npm ci --prefix client
- name: Run client tests
if: ${{ steps.detect.outputs.pkg == 'true' }}
run: npm test --prefix client
# Building the ESM chunk in CI is not just a check: it is how the chunk
# that ships in the release is produced, since an operator never builds.
- name: Build the client chunk
if: ${{ steps.detect.outputs.pkg == 'true' }}
run: npm run build --prefix client
- name: Check the built chunk's externals (MODULE_API.md §3.6)
run: npm run check:externals --prefix client