Skip to content

Tooling and the quality gate

One command decides whether a change is ready: pnpm verify. Two things on this page keep that command honest. Work that belongs to continuous integration stays off the local machine, and no rule is allowed to look enforced when only a reviewer will ever catch it.

pnpm verify runs its lanes in two rounds, parallel inside each, through concurrently. It reports every failure without aborting the siblings, so one run names every failure and not just the first one it met. ci.yml invokes the same command literally, so a green local run means something. When a green local verify turns into a red ci.yml, the bug is in verify and gets fixed as one. But the gate is one workflow of several, and the others below check what it does not.

Lane Command What it checks
format pnpm format:check Prettier formatting and import group order across the tree
lint pnpm lint oxlint over every project
typecheck pnpm typecheck TypeScript for every workspace project
tools pnpm typecheck:tools TypeScript for the repository scripts under tools/
test pnpm test Vitest for every project
packages pnpm lint:packages what a consumer would receive from each publishable package
generated pnpm generate:runtime --check drift between the generated runtime bundle and its sources
tables pnpm generate:idna --check the generated UTS #46 tables against the engine’s Unicode
docs pnpm validate:docs the documentation against the tree it describes
site pnpm validate:site the built documentation site, where every reference to a page of it has to resolve

Every lane but two starts in the first round. typecheck and test wait for the second, because both read what the packages lane writes: typecheck follows the project references into packages/*/dist, and the tests import each package through the import condition of its exports map. That lane empties those directories before it refills them, so a single round would land a delete in the middle of a read, and the lane that failed would be the one with nothing wrong with it.

Beyond the lanes, the gate scans the tree for files ending in .test.ts, reports them straight away, and fails the run once the lanes have settled. .spec.ts is the only test suffix here, and no lane reports the other one. Every Vitest config collects src/**/*.spec.ts alone, and the .oxlintrc.json override carrying the test rules matches that suffix too, so a file with the other suffix is never collected and never meets a test rule: its assertions never run and every lane stays green. What it costs beyond that depends on where it sits. Under packages/, tsconfig.lib.json excludes the suffix and each manifest drops it from the files array, so nothing typechecks it, it never compiles into dist and no consumer receives it. Under apps/ and examples/ nothing excludes it, so the build typechecks a test nobody runs and compiles it into that project’s dist.

pnpm format is the mutating counterpart of the format lane and the only command in the set that rewrites files.

Documentation is the part of a repository that rots without anything going red, so tools/docs/validate.ts gives it something that does. It reads the Markdown under docs/ and examples/, plus the pages GitHub surfaces at the repository root, and checks each of them against the tree they describe.

Every relative link has to resolve to a file that exists. But a relative link out of docs/ fails even when the file is there, because the site publishes that directory alone: the link resolves inside the repository and answers 404 on the site. Anything outside docs/ is named by its URL. apps/docs/src/sidebar.js holds the site’s reading order, and the lane compares that order against the tree in both directions. A page the sidebar never reaches is a failure, and so is an entry pointing at a page that does not exist.

Write every documentation link in README.md as a URL of the published site. Anyone following one on GitHub opens the rendered page and not the Markdown source. The lane checks such a URL the way it checks a relative link: it resolves the route to a page under docs/ and checks any heading the fragment names. So a renamed page or a renamed heading fails here and not in somebody’s browser. apps/docs/src/site.js declares the origin, and the Astro config reads it from there.

Pages that list something the code owns go stale in silence, so the lane compares each such list against its source. The primitives map has to point at code roots and documentation paths that exist, with a unique identifier per entry and a summary of one line inside the cap. The concept map beside it on the architecture page has to name paths that exist. In the operator guide, the settings table has to hold exactly the environment variables the daemon declares, in both directions.

A relative link that rots is a documentation bug of the same weight as a type error, so it fails the same gate.

Hooks are installed by husky through the root prepare script and stay affected-scoped, so a commit never pays for the whole tree.

Hook What runs
pre-commit lint-staged: formats staged files, then lints and typechecks affected projects
commit-msg commitlint with the conventional config
pre-push affected lint, typecheck and tests against origin/main

The full gate belongs to continuous integration. Do not widen a hook until a class of breakage proves the local cost worth paying, because a slow hook is a hook people learn to pass --no-verify to.

ci.yml runs pnpm verify on every pull request and every push to main, with no path filter. One lane of the gate builds the documentation site and reads what the build wrote. So a change to prose alone still has to pass every lane. .nvmrc pins the Node it runs on.

Every other workflow that runs on a change is filtered by path. workflows.yml runs actionlint for syntax and expression types and zizmor for the security shapes a workflow can fall into, under a paths list naming .github. Neither linter is a lane of pnpm verify. So a change under .github is the one change a green local run says little about. The two linters do not read the same set. actionlint reads the repository’s workflow files, and those live under .github/workflows. zizmor audits action definitions as well, and an action.yml may sit anywhere in a tree. This tree has one, under .github/actions/setup, so the filter covers it. Move an action out of .github and the filter has to move with it.

pages.yml deploys the site from a push to main. No pull request builds there, because pnpm verify already builds the site and asserts that every reference resolves. That workflow carries the one allow-list paths: in the tree, naming docs/** and apps/docs/** beside the build inputs the site resolves through. A change that reaches the site by a path nobody listed leaves the live deployment where it is and reports no failure. Read the workflow for the list it names, and start it from the Actions tab for a change that falls outside. A filtered workflow also reports no status on a change it skips. Name one as a required check and those changes wait forever on a status that never arrives. So ci.yml is the only workflow a required check may name.

On a schedule, nightly.yml runs the fault and soak families, because a per-change gate has no minutes for them. It runs the containment corpus again, on a runner nobody warmed, because a boundary that holds only on a fast machine gives way there. One more job runs pnpm verify on the newest Node line. A failure there names something the next Node line has broken, ahead of the day this project moves onto it.

  • Pin every action by commit SHA, keep permissions minimal, and do not persist credentials. Dependabot keeps the pins fresh.
  • Keep workflow YAML thin, and name a job for what it does, not for what it is called elsewhere.
  • engineStrict in pnpm-workspace.yaml enforces the runtime floor at install. With it set, pnpm install refuses a Node outside the declared engines range, and refuses a dependency that declares an incompatibility too. pnpm’s settings page says a project’s own engines field fails the install with or without the setting. It does not. Without the setting the install prints a warning and exits 0. Runtime-neutral packages declare no range at all, by decision 0002, and the publishing rule below rests on that.
  • .nvmrc carries that floor as a bare major for the tools that read a file, the CI Node setup among them. packageManager declares the pnpm version, and pnpm’s pmOnFail default downloads and runs that version instead of whatever happens to be on the path.
  • Nx targets are inferred from the tool configuration; there are no project.json files. build comes from tsconfig.lib.json, typecheck from tsconfig.json, test from vitest.config.mts, lint from a package.json script.
  • TypeScript project references are generated. pnpm nx sync writes them from the project graph and nx sync:check proves they are current, so a hand-edited references array survives only until the next person runs either one.

These rules cover what a package contains. Releasing covers cutting a version and sending one out.

  • Published packages ship both dist and src, excluding specs and build metadata. Shipping sources gives consumers working sourcemaps and readable TypeScript stack traces.
  • The packages lane is the rehearsal for a release. It builds each publishable package from a clean dist, runs publint over the manifest, then packs the tarball and asks attw what a consumer’s own module resolution finds inside it. A manifest can be correct while the tarball is not. So both halves run.
  • Every publishable package builds with isolatedDeclarations. Runtime-neutral packages pin "types": [] so host ambient types can never leak into a package that has to run anywhere.
  • The example host depends on the protocol package and the remote executor, never on the Node core. Reaching for the core would stop it proving that a host can live entirely on the wire contract.
  • Keep package.json scripts to one line that hands off to a file under tools/. Real logic never sits inline in a script field, where nothing lints it and nothing types it.
  • Write repository scripts as plain TypeScript that Node runs directly, with no build step and no loader flag.
  • Treat tools/ as deliberately not a workspace project. The root tsconfig-tools.json types it and the tools lane checks it.
  • Prefer a short tool config inside package.json over one more file at the repository root. A separate config file earns its place only by carrying something a JSON block cannot, such as a comment next to a rule.
  • Write conventional commits. commitlint enforces them in the commit-msg hook.
  • Write commit bodies as flowing paragraphs, one line per paragraph. Never hard-wrap at a column; the commitlint body and footer length rules are switched off for exactly this reason.
  • Carry the contributor’s authorship only. Never add a co-author trailer or a tool-attribution trailer.
  • Squash-merge only. The branch history is a draft and the main history is the record.
  • Keep process artifacts out of the committed tree. Working notes, gate verdicts and trackers are not product.
  • Weigh every review finding, and never apply one wholesale. A finding can deserve a fix, a written refutation, or a deferral, and choosing between them is part of the review and not a way out of it.
  • Fix a defect inside the change that uncovered it.
  • Stop and report when a directive cannot be executed. Never substitute a workaround for an instruction that could not be carried out.

The lane table names a checker for every rule that has one. But every other rule on this page holds by agreement alone, so it holds exactly as long as reviewers keep asking for it.