Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Quality gate

pnpm check = typechecklintqualitykniptest: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:

RuleLimitWhy
noExcessiveCognitiveComplexity25A function you cannot hold in your head cannot be reviewed or tested exhaustively.
noExcessiveNestedTestSuitesdefaultTests are specs; deep nesting hides which behaviour a case documents.
useConsistentArrayTypeT[]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, contracts and 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 helper store-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 type and 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.