Quality gates
Contents
- Where a verdict comes from
- The table — every gate and what it enforces
- What
pnpm verifyruns, 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.
| Gate | Enforces |
|---|---|
biome | complexity ≤10, ≤50 lines/function, ≤200 lines/file, no any, no non-null assertion, no magic numbers, no barrels, filename === export |
noUnknownCast.grit · booleanNaming.grit | no as unknown as; booleans read as predicates |
jscpd | 1% duplication, minTokens: 15 |
knip | no unused files, exports, or dependencies |
dependency-cruiser | package boundaries — core stays portable, react stays free of node builtins and Next, store-fs stays free of frameworks |
type-coverage | ≥99% typed |
syncpack | no 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 · attw | every 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.mjs | one unit per file, counting module-private functions |
tests/schemaDepth.test.mjs | no nested object schemas — sub-schemas get their own file |
tests/junkDrawerFilenames.test.mjs | no junk-drawer filenames |
tests/documentationStructure.test.mjs | links 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.mjs | a repository file named inside a code span exists, or is gitignored on purpose |
tests/referenceRestatement.test.mjs | no status: reference page declares a symbol a package exports — the generated reference is where a signature lives |
tests/sidebarCoverage.test.mjs | every top-level group under docs/ is named browsable or deliberately unlisted, and no name outlives its directory |
tests/gettingStarted.test.mjs | the 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.mjs | one 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.mjs | no plan-shaped file under docs/ — a plans/ directory, a date-stamped filename, or a stem that is the word itself |
tests/duplicationRatchet.test.mjs | the 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.mjs | every 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.mjs | rule files carry paths, stay under 150 lines, end in a checklist, declare a gate, and glob at least one tracked file |
tests/rulesCiteRealGates.test.mjs | a rule naming a check-*.mjs or a skill names one that exists |
tests/gateTableCoverage.test.mjs | every suite in tests/ has a row in this table, and every row names one that is there |
tests/commitAttribution.test.mjs | the 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.mjs | no package declares a peer dependency nothing in it imports |
tests/packageMetadata.test.mjs | every publishable package has a README, a licence file and field, a description and a repository |
tests/coreVersionStamp.test.mjs | NUBBIN_VERSION, stamped into every artifact, matches the published version |
tests/releaseConfiguration.test.mjs | release-please releases every publishable package, at the version it is at, as one linked group |
tests/skillsLock.test.mjs | skills-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.mjs | every 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.mjs | the corpus every assertion above reads is what git would publish, and nothing else |
tests/release/packagesInstallFromTarball.test.mjs | every 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.ts | no 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.ts | the 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.mjs | claims resting on a corpus no reader can open; references to what a thing used to be; promises of future work; filler |
scripts/check-a11y.mjs | an 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.mjs | a prerelease version cannot be published to the latest dist-tag |
scripts/check-worktree.mjs | an edit aimed at the primary worktree, or at a linked worktree whose gates cannot run |
scripts/check-primary-tree.mjs | an 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.