Skip to main content

Quality gates

Contents

  • Where a verdict comes from
  • The table — every gate and what it enforces
  • What pnpm verify runs, and how CI splits it
  • The gates that stay local, and why
  • pnpm publishable, and why the version stamp runs first
  • What the gates cannot catch

Where a verdict comes from

A repository invariant is a test, run by vitest. A general-purpose concern is a maintained tool, run as a pinned CLI. Nothing in between: a bespoke script that prints a report and chooses its own exit code is how a check comes to print a tick over files it never read, and how nine of them once reported success while asserting nothing.

The tests live in tests/, in a vitest project called repo, and pnpm test runs them beside the package suites. Each one pairs a fixture that proves the detector fires with an assertion over the whole repository, so a gate that stops detecting anything fails on the fixture rather than passing quietly on the corpus. vitest related is deliberately not used at pre-commit: these read files as data rather than importing them, so it would select none of them and run nothing.

Three scripts remain, and each is a mechanism a test cannot be. check-worktree.mjs and check-primary-tree.mjs are agent hooks — they answer a tool call. check-prose.mjs and check-a11y.mjs are still scripts because both are queued for a maintained tool (Vale and axe) and porting them into the suite first would be a rewrite thrown away twice.

GateEnforces
biomecomplexity ≤10, ≤50 lines/function, ≤200 lines/file, no any, no non-null assertion, no magic numbers, no barrels, filename === export
noUnknownCast.grit · booleanNaming.gritno as unknown as; booleans read as predicates
jscpd1% duplication, minTokens: 15
knipno unused files, exports, or dependencies
dependency-cruiserpackage boundaries — core stays portable, react stays free of node builtins and Next, store-fs stays free of frameworks
type-coverage≥99% typed
syncpackno range specifier anywhere — in a manifest or in the workspace catalog it resolves through, so an upgrade arrives only in a commit someone wrote
publint · attwevery publishable package resolves correctly — both iterate packages/*, and attw runs the esm-only profile because these packages are ESM-only by choice
tests/oneUnitPerFile.test.mjsone unit per file, counting module-private functions
tests/schemaDepth.test.mjsno nested object schemas — sub-schemas get their own file
tests/junkDrawerFilenames.test.mjsno junk-drawer filenames
tests/documentationStructure.test.mjslinks and anchors resolve, a label naming a path names the path it goes to, fences balance, table columns are named, every document beneath docs/ is in the index
tests/fileReferencesResolve.test.mjsa repository file named inside a code span exists, or is gitignored on purpose
tests/referenceRestatement.test.mjsno status: reference page declares a symbol a package exports — the generated reference is where a signature lives
tests/sidebarCoverage.test.mjsevery top-level group under docs/ is named browsable or deliberately unlisted, and no name outlives its directory
tests/gettingStarted.test.mjsthe first page installs every @nubbin/* package the demo consumes and no other, and shows every field the demo's simplest block declares
tests/proseDuplication.test.mjsone claim, one home — a run of 12 words written twice across docs/, AGENTS.md, the rules and the skills, measured after fences, comments and tables are stripped out
tests/planFiles.test.mjsno plan-shaped file under docs/ — a plans/ directory, a date-stamped filename, or a stem that is the word itself
tests/duplicationRatchet.test.mjsthe clone count jscpd reports and the number of files .jscpd.json excludes never exceed duplication.baseline.json, and the baseline never exceeds them — pnpm dupes:baseline records a drop
tests/docFrontmatter.test.mjsevery document beneath docs/ carries a title and a summary, and a keywords line, where present, is a comma-separated list — the fields docs/CATALOG.md quotes
tests/ruleFiles.test.mjsrule files carry paths, stay under 150 lines, end in a checklist, declare a gate, and glob at least one tracked file
tests/rulesCiteRealGates.test.mjsa rule naming a check-*.mjs or a skill names one that exists
tests/gateTableCoverage.test.mjsevery suite in tests/ has a row in this table, and every row names one that is there
tests/commitAttribution.test.mjsthe no-agent-attribution commitlint rule rejects trailers naming an agent, and the laundered form — a placeholder address or a one-letter co-author — that once slipped past it
tests/peerDependencies.test.mjsno package declares a peer dependency nothing in it imports
tests/packageMetadata.test.mjsevery publishable package has a README, a licence file and field, a description and a repository
tests/coreVersionStamp.test.mjsNUBBIN_VERSION, stamped into every artifact, matches the published version
tests/releaseConfiguration.test.mjsrelease-please releases every publishable package, at the version it is at, as one linked group
tests/skillsLock.test.mjsskills-lock.json is one a reinstall could use, and — where the skills are on disk — agrees with them by name and by a hash over every file in each skill directory
tests/codexParity.test.mjsevery tracked Claude skill and agent has a Codex adapter, Codex receives the same routed rules, and its edit and subagent hooks are present
tests/trackedFiles.test.mjsthe corpus every assertion above reads is what git would publish, and nothing else
tests/release/packagesInstallFromTarball.test.mjsevery package packs with no catalog:, workspace: or link: specifier surviving, installs from its own tarball into an empty project, and imports
examples/demo/guardrail/liveCompatibility.test.tsno block a page already published depends on has changed version or left the registry — the product guardrail, run against a committed artifact store
examples/demo/e2e/*.test.tsthe publish loop against a running server, asserted on served bytes — one file drives it through core directly, the other through the nubbin binary a consumer installs
scripts/check-prose.mjsclaims resting on a corpus no reader can open; references to what a thing used to be; promises of future work; filler
scripts/check-a11y.mjsan img with no alt; alt that is a filename or names the medium; a click handler on a plain element; positive tabIndex; an a with no href; a focus outline removed with nothing in its place
scripts/check-release-tag.mjsa prerelease version cannot be published to the latest dist-tag
scripts/check-worktree.mjsan edit aimed at the primary worktree, or at a linked worktree whose gates cannot run
scripts/check-primary-tree.mjsan uncommitted path in the primary worktree, whatever wrote it there

pnpm verify runs every gate above except the three named below, and needs a full install. It is a short chain of tools rather than a list of scripts, which is why nothing here reconciles it against this table: pnpm test reaches every row in tests/ by including the directory, so a new test is wired in by existing, and a deleted one is a deleted file rather than a silently dropped step.

CI splits the same set in two. One job runs the two remaining zero-dependency scripts against a bare checkout; the second installs the workspace and runs lint, typecheck, the suites, build, the compatibility guardrail, pinning, boundaries, duplication, dead code, type coverage and the publishable gates. That second job keeps turbo's cache directory between runs, which is safe for the reason turbo's cache is safe at all: a task's hash covers its inputs, so a restored entry replays only what running the task would have produced. On a pull request the typecheck and test gates carry --affected and skip the packages a change cannot reach; a push to main runs everything, because a release publishes on the conclusion of that run and a filtered one would vouch for code it never looked at.

The build step is never filtered. --affected selects packages by where a changed file sits, which cannot see a task whose inputs are somewhere else: the documentation site declares docs/ as an input, so a documentation-only change reaches no package and would skip the build that change is most likely to break. The cache is what makes running it anyway cheap.

Three gates stay out of verify. The two worktree gates stay out for the reason that makes them worth having: a CI checkout is clean, so a run there would report nothing and read as a pass. check-worktree.mjs fires at a Write, Edit or MultiEdit and refuses those three tool calls, which is a mechanism — a > redirect inside a shell command never meets it, and three untracked files reached the primary tree while it was active. check-primary-tree.mjs asks the outcome instead, at agent dispatch and at pre-push, and reports without blocking because it cannot tell whose file it found. check-release-tag.mjs runs only on the release path: every local version is a prerelease, so including it in verify would fail every run on every machine.

Half of tests/skillsLock.test.mjs cannot run in CI, because the third-party directories under .agents/skills/ are ignored the way node_modules is and a runner has none installed. That half is skipped, and vitest says so — which is the difference that matters. The gate it replaces printed a tick describing a content hash it had not computed, so "present in CI" read as "enforced in CI" for a comparison that never ran.

pnpm publishable is the release subset — the gates that read the artifact a consumer would install rather than the source. Run it before publishing anything; verify includes it. Its build is scoped to packages/* for the same reason the subset exists: publint, attw and the tarball install read nothing outside them, so it builds no application. The tarball install is its own vitest project, release, because its verdict depends on the registry: no cache key can see that, so it is invoked directly and is registered as no turbo task, and a green run of it can never be replayed.

Two rows above run against a server rather than against files, and neither is in verify. The e2e project starts the demo on its own port and reads what it serves, so its verdict depends on a port, a build and a store — none of which a task hash can see, which is why it is a turbo task nowhere and why a cache could only replay a pass about a server that never ran. pnpm e2e invokes it, building the demo's dependencies first because one of them is the executable it spawns. No CI workflow runs it, so what it covers — the loading of a consumer's TypeScript config, and the whole --origin path — is protected on a developer's machine and nowhere else.

One row above is not a repository invariant. The compatibility guardrail is a product feature — the claim that merging cannot break a page already live — held to the same standard as the gates around it. It is a vitest project in examples/demo, not a file in tests/, because what it reads is that site's published artifacts; pnpm guardrail invokes it, verify and the verify workflow both run it, and it is registered as no turbo task. A task hash cannot see inside an artifact store, so a cacheable form of this check could report green having read a store from another commit. What it cannot catch: a block whose component changed behaviour without its version moving, since the artifact records versions and nothing else.

pnpm core-version runs first, ahead of the build, because NUBBIN_VERSION is compiled into dist/ and stamped into every artifact as compiledWith. It is the one release gate that reads source, and it runs on both paths: the release workflow waits for verify to pass on the commit it is publishing, and then runs publishable again against the tree it checked out. Without it a version bump can publish artifacts that misreport what produced them.

The gates cannot catch everything. A logger that formats its own timestamp — the canonical violation — sits under every threshold in the table and is still wrong, because a threshold bounds how large a violation can grow and says nothing about whether one is there. That judgment lives in .claude/rules/single-concern.md, and a PostToolUse hook reviews for it. Rules auto-load by path; read the matching one before writing code.