Quality gate
pnpm check = typecheck → lint → quality → knip → test:coverage. CD runs it before every release; there is
no CI on main (private repo, limited Actions minutes), so run it locally before pushing. pnpm release adds
check:pkg (§6) after the build. Benchmarks (pnpm bench, benchmarking.md) are deliberately
not part of the gate. Each stage is described below with the reasoning, borrowed from projects that treat quality
checks as executable specs rather than advice.
1. Typecheck — tsc --noEmit, strict + noUncheckedIndexedAccess + erasableSyntaxOnly
Unchanged. Types are the first spec.
2. Lint — Biome (biome.json)
Formatter + recommended rules, plus complexity limits in the spirit of ESLint’s complexity/max-params
presets used by projects such as Node core and TypeScript-ESLint:
| Rule | Limit | Why |
|---|---|---|
noExcessiveCognitiveComplexity | 25 | A function you cannot hold in your head cannot be reviewed or tested exhaustively. |
noExcessiveNestedTestSuites | default | Tests are specs; deep nesting hides which behaviour a case documents. |
useConsistentArrayType | T[] | One spelling. |
No function is currently grandfathered. If one must be, mark it inline with
biome-ignore … grandfathered (score N, expires YYYY-MM-DD): the marker is the tech-debt list — split the function
when you next touch it, and delete the marker. The date is enforced (§3, Expiry).
3. Structural rules — pnpm quality (scripts/quality.ts)
A dependency-free checker (TypeScript’s own parser, no build) — the same idea as dependency-cruiser /
eslint-plugin-boundaries, but reading the layering straight out of CLAUDE.md so the rule and the doc cannot
drift. Rules live in scripts/quality/rules.ts as pure functions with their own tests in scripts/test/, so
changing a rule is itself TDD.
Rings (layering). Packages are assigned a ring from their path (scripts/quality/layers.ts is the model;
docs/dependency-graph.md, generated by pnpm graph, is the picture — rule graph-doc fails when it is stale):
ring 0 kernel imports nothing
ring 1 plugin-sdk | contracts two siblings over the kernel: what agent code sees | the waist (Database · Store · Host ·
ModelService · ObjectStore, kernel types only); neither imports the other
ring 2 drivers | plugins two columns on top of contracts; neither imports the other
ring 3 baseline composition: picks plugins, exposes createBaseline / createTenantPool
ring 4 examples/* composition roots that also pick drivers
A runtime import may only point at the same ring or below. Additionally:
kernel,plugin-sdk,contractsand every plugin import no platform code at all (node:*,fastify,@anthropic-ai/*, …) — a plugin must run wherever the kernel runs (Node, Workers, tests).- Drivers never import each other, except
host-fastify → host-node,host-cloudflare → store-do(composition-level, from CLAUDE.md) and the shared SQL helperstore-sql. - Plugins import each other only through
@sms/plugin-<x>/contract(below).LAYERING_EXCEPTIONS(dated, per package) exists for grandfathering and is currently empty — keep it that way. import typeand test files are ignored — types are free, and tests may compose anything.
Plugin contracts (contract-surface). Every plugin ships src/contract.ts (exported as ./contract): keys as
export const X_KEY = 'x', point names, service and item types, validators, pure helpers. Nothing reachable from it
through relative imports may be src/index.ts or declare a manifest: — a contract has no provider in it, so
depending on one never drags a plugin body along (Cordis: “consumers depend on service definitions, never
providers”; Linux: modules link against exported symbols, not each other’s source).
Manifest honesty (plugin-inject). The manifest is the dependency declaration the kernel reconciles on, and
there are two ways around it that the kernel cannot see: importing another plugin’s contract at runtime, and
ctx.kernel.getValue(key). The rule reads every inject/optional array in the plugin (string literals or
*_KEY identifiers), and fails when a runtime-imported plugin’s keys appear in neither, or when a getValue()
argument is undeclared. Manifests it cannot read statically (spreads, computed keys) are skipped.
Plugin DAG (plugin-cycle). The plugin-to-plugin import graph (runtime and type) must be acyclic.
Package metadata (deps, build-refs). package.json must tell the same story as the imports: every
@sms/* in dependencies/peerDependencies obeys the rings (at package level — a plugin may depend on a plugin
package; the /contract restriction is on import sites), is imported by src/, and everything src/ imports is
declared; a workspace dependency only tests use belongs in devDependencies. Each package’s
tsconfig.build.json references must equal its @sms/* dependencies, and the root tsconfig.build.json must
list every buildable package.
File length (file-length): ≤ 400 non-blank lines per source file. LIMITS.fileLength.allow is empty; a
grandfathered ceiling added there carries an expires date — lower the number as the file shrinks, never raise it.
Parameters (max-params): ≤ 4 per function/method/constructor. Beyond that, pass an options object
(the codebase already does this everywhere — createBaseline(opts), createJudgePlugin(opts)).
Tests present (tests-present): every package with src/ ships test/*.test.ts. Exemptions
(LIMITS.testsExempt) are type-only/helper packages (store-sql) and the sample apps under
examples/. Model drivers (drivers/model/*) ship the live contract test (SMS_LIVE_MODELS=1) and are only
excluded from coverage, since without a CLI login nothing in them executes.
Expiry (expired-exception): every grandfathered exception is a promise with a date, not configuration —
LIMITS.fileLength.allow entries and LAYERING_EXCEPTIONS carry expires: 'YYYY-MM-DD', and each
biome-ignore … grandfathered marker must say expires YYYY-MM-DD (a marker without a date fails). Once the
date passes the gate fails until the debt is paid or the date is extended, deliberately, in a reviewed commit.
Borrowed from Prisma’s expiring coverage exceptions.
Output is file:line [rule] message, exit 1 on any violation.
4. Dead code and dependencies — pnpm knip (knip.json)
knip walks the workspace graph from each package’s src/index.ts (and the app/example
entrypoints, scripts/, bench/) and reports unused files, exports, dependencies and unlisted imports. Its
first run here found four dead workspace dependencies. Exceptions are per-workspace in knip.json: the
driver test packages list @sms/plugin-collections/@sms/plugin-http only so the shared contract test
resolves; publint/attw are invoked via pnpm exec from scripts/check-pkg.ts. Add to ignore* only with a
comment-worthy reason; prefer deleting the thing.
5. Tests and coverage — pnpm test:coverage
Vitest with V8 coverage. Tests are the spec of the system: they describe behaviour, change rarely, and the judge’s unreachability tests are load-bearing for safety (see CLAUDE.md).
TDD is enforced structurally, not by ceremony: a package cannot exist without tests (rule above), and coverage cannot go down (below). The habit that satisfies both is to write the failing test first.
Coverage ratchet. SQLite reaches 100% branch coverage by treating every uncovered line as a defect and
never letting the number regress. We take the mechanism, not the number: vitest.config.ts holds
thresholds with autoUpdate: true. A run that beats the thresholds rewrites them upward in the config
(commit that change); a run below them fails. Never lower a threshold by hand — add a test. Current floor:
~93% lines, ~90% functions, ~89% statements, ~78% branches; the goal is to walk it toward 100.
The ratchet was re-baselined once, on 2026-08-24, when moving from Vitest 2 to 4. Vitest 2’s V8 remapping was non-deterministic — the same tree reported 1037 or 1038 total branches run to run, so a threshold sitting at an exact boundary failed spuriously — and Vitest 4’s AST-aware remapping counts statements and functions on a finer grain (arrow callbacks, per-statement). The numbers changed because the ruler changed; the measurement is now identical across runs, which is what makes a ratchet trustworthy.
Excluded from coverage, each for a stated reason (coverage.exclude): code running in a worker thread or
isolate that V8 cannot observe (executor-node/src/worker.ts, plugin-sdk/src/runtime.ts), process
entrypoints (main.ts, demo.ts), the credential-bound model adapters, and type-only modules.
The HTML report is written to coverage/ (git-ignored) — open coverage/index.html to find the uncovered
branches to test next.
6. Package shape — pnpm check:pkg (release only)
After pnpm build, scripts/check-pkg.ts runs publint --strict and
arethetypeswrong --pack on every publishable package: package.json
exports/types must match what is in dist/, and the packed tarball must resolve types under Node’s ESM
resolution the way a consumer sees it (our custom sms-source condition is exactly the kind of thing attw
catches when it leaks). It also asserts (scripts/release-exports.ts) that every subpath in the workspace exports
survives the release rewrite with import/types present in dist/ — v0.1.1 shipped without @sms/baseline/testkit
because release.ts replaced the map instead of rewriting it. pnpm release runs it right after the build; run pnpm build && pnpm check:pkg by
hand when changing a package’s exports. It is not in pnpm check because dev has no build step.
Adding a package
Use .claude/skills/new-plugin or .claude/skills/new-driver (they scaffold the shape that passes every rule on
the first run), or copy the package shape from CLAUDE.md, add test/<name>.test.ts before src/, run pnpm graph,
then pnpm check. The ring is read from the path; if a new kind of package does not fit, extend layerOf in
scripts/quality/layers.ts — with a test.