Skip to content

Mechanical guards

Dougong makes one architectural claim that can be checked: a constraint that can be decided mechanically must be handed to a tool. A rule that lives only in prose decays into a suggestion within months. A rule in the gate does not.

This page lists every check pnpm check actually runs, and the invariant each one protects.

sh
pnpm check

Ten steps in order, aborting on the first failure:

#StepProtects
1typecheckFive tsconfig projects plus the test project, all --noEmit
2lintoxlint, --deny-warnings
3format:checkprettier
4testBehavioural semantics and the coverage floors
5knipUnused exports and dependencies
6check:circularDependency cycles
7check:layersImport direction, module layering, architecture invariants, vocabulary
8buildDist and declaration files for the four published packages plus examples
9check:apiBuilt-declaration type contracts, public surface, retired vocabulary, documentation coverage
10docs:checkDocumentation site build and dead links

Step 8 must precede step 9: dist/index.d.ts is the only place the complete type surface exists as an artifact. Source cannot show what an export * expands to.

check:layers

scripts/check-layers.mjs. Four families of check.

1 · Import direction

Package level: core and reactive never import each other; platform depends only on core; the facade may only re-export; examples is the outermost consumer and no published package may depend on it in reverse.

Module level: every module of @dougongjs/core and @dougongjs/platform declares a rank in a table, and may import only modules of strictly lower rank.

The rank table is exhaustive, and checked in both directions

A new module with no rank fails — somebody has to decide which layer it sits in. A rank with no corresponding source file also fails, so renaming a file and forgetting the table cannot pass silently.

2 · Source-text invariants

Constraints the type system cannot express but source text can decide. Two kinds.

Prohibitions — things that must not appear:

RuleReason
No node: built-in importsThe kernel stays independent of its runtime
No Date.now / performance.now / Math.randomHidden clocks and entropy make behaviour irreproducible
No direct console callsMust go through the Logger port
No deep imports into another package's internalsEntry points only
No explicit any in any TypeScript ASTSource, tests and tooling all preserve the checking boundary with a precise type, unknown or never
No @ts-ignore / @ts-nocheckSilent suppression makes later fixes unchecked too; repair the actual type error
@ts-expect-error appears only in public-api.types.tsAn expected error is a compile-time contract and does not belong in source or runtime tests
@dougongjs/reactive has zero external importsIt is an independent foundation
Resource implementations do not use [Symbol.dispose] / [Symbol.asyncDispose] directlyFoundation protocol modules must select stable keys instead of degrading a missing symbol into an "undefined" property
The facade contains re-exports onlyLogic there is a second execution path
HostImpl must not be exportedHost is an interface; createHost() is the only constructor
Only Runtime and Lifetime itself may construct a LifetimeAnywhere else produces a resource tree nobody disposes

Requirements (inverted rules) — things that must appear, because their absence means somebody started a second path:

RuleThe second path it prevents
Host command serialization must use Core SerialQueueA hand-written queue reintroduces "one failure poisons later commands"
Platform command serialization must use the same SerialQueueThe same state machine copied across packages
Platform diagnostics must compile to Core SnapshotPublisherA duplicated observation protocol
Contribution observation must compose the same SnapshotPublisherLikewise
Platform load cancellation must reuse Core isCancellationReasonTwo cancellation classifications
Platform declaration validation must reuse Core assertPlainRecordTwo prototype-chain validators
Host must delegate declarations and handle authority to InstallationRegistryHost becoming a god object again
Platform structural coordination must delegate activation to ActivatorA second dependency-activation path
Activator must trust CandidateGraph's cycle invariantA second — and unreachable — graph implementation
ChangeSet drafts must route empty commits through their authority portShort-circuiting lets a stale Group or Platform draft commit
Empty Group ChangeSets must cross the serialized boundaryLikewise
Empty Platform ChangeSets must cross the serialized command boundaryLikewise
Platform disposal must be a terminal SerialQueue commandA tail observer misses queued commands
Platform disposal must reuse Core asyncDisposeSymbolA third runtime Symbol resolver
Group ownership must use GroupNode identityEncoding ownership in a groupId prefix is an implicit relationship

Inverted rules are the least common and the most important kind here. An ordinary gate says "do not write X". An inverted rule says "you must write X". The first prevents decay; the second prevents forking.

3 · Retired vocabulary (source)

scripts/vocabulary.mjs is the single source of truth, listing every identifier the vocabulary rebuild retired.

The check walks the TypeScript AST and matches only identifiers and string literals, so prose and concept labels are never false positives:

text
extension-point          a concept label in prose     -> allowed
PluginHandle             a type identifier            -> fails
"PLUGIN_DEPENDENCY_..."  a retired error-code literal -> fails

4 · Fixed Contract ID uniqueness

The whole workspace is scanned for service("...") / extensionPoint("...") / event("..."). Declaring the same literal ID twice fails, and the message points at the first declaration.

It covers literals only

A dynamic Contract family such as service<T>(`workspaces/${id}/store`) is deliberately skipped — the uniqueness of a runtime ID cannot be decided statically. The gate claims only what it can prove.

Recognising a call requires tracing the factory to its import, so both import { service as svc } aliases and import * as dougong namespaces are handled.

check:circular

scripts/check-circular.mjs, madge, with an empty allowlist.

Every package ships as a library, and a value-level cycle between two modules of @dougongjs/core surfaces as a partially-initialised binding in a consumer's bundler, not as a failure in our tests. That is why this is stricter here than it would be in an application.

check:api

First, tsconfig.dist.json replays the public type contracts against the built package entries. Then scripts/check-api-surface.mjs reads all four dist/index.d.ts files and resolves exported symbols through the TypeScript checker — not text matching, so what a consumer finally sees after export * expansion is what gets checked.

Four independent assertions per package:

  1. The exported identifiers equal the allowlist exactly, values and types listed separately. A new export is a deliberate decision, never a side effect of an export *.

  2. No retired identifier returns to the public surface. The banlist holds whole tokens rather than patterns, so valid names are unaffected:

    text
    Plugin  PluginContext  InstanceMeta  definePlugin   -> legal
    PluginHandle  PluginDefinition  ExtensionView       -> retired
  3. Every public export appears in both the Chinese and English documentation for its package. Updating the allowlist cannot leave a supported API unexplained.

  4. Built declaration files contain no any. The source gate cannot see types inferred by declaration emit, so this step scans every published .d.ts and prevents type information from disappearing at the package boundary.

The facade's surface is computed rather than restated: it must equal exactly core plus platform plus the reactive names it forwards, and one name too many or too few fails.

The four published packages must also match the workspace runtime baseline exactly in engines and browserslist; packages cannot advertise contradictory support ranges.

Further checks span source and documentation:

  • Error codes are derived from source. The two reference tables must list exactly that set, and no other page may invent a code no source throws.
  • Documentation code fragments may not use retired identifiers. Only fenced blocks with a code language tag and inline `code` spans are extracted, so prose is unaffected.

Documentation navigation is derived from the file tree as well: every guide, reference and examples page in each language must appear in both its sidebar and homepage, and neither navigation may retain a deleted page. Adding a page can no longer update only one hand-maintained list.

Guards on the test side

Compile-time contracts

Each package's public-api.types.ts uses expectTypeOf and TypeScript expected-error directives to protect variance, generic inference and structural protocols, and deliberately sits outside Vitest's runtime *.test.ts pattern. The architecture gate prevents either kind of compile-time assertion from drifting back into runtime tests. pnpm typecheck first evaluates the source entries through workspace paths; after the build, check:api uses tsconfig.dist.json to redirect the same imports to the final dist/index.d.ts, catching consumer-visible changes caused by private-field folding or declaration generation. JavaScript emission erases these assertions; pnpm test alone cannot prove the contracts and does not present compile-time assertions as runtime cases.

Runtime shape

packages/core/test/api-surface.test.ts asserts exact Object.keys() results: which keys a Context exposes, whether handles are frozen, whether internal orchestration methods leak. check:api guards built-declaration type relationships, exported vocabulary and any, while runtime tests guard actual object shape. They are complementary, because after type erasure Object.keys is what a consumer can actually see.

Coverage floors

vitest.config.ts sets per-package thresholds pinned to the measured floors, with at most one point of slack:

Packagestatementsbranchesfunctionslines
core92839695
platform979010098
reactive968910099

A package cannot hide its own regression behind stronger coverage elsewhere in the workspace. Keeping the numbers tight is deliberate: slack is permission to quietly delete tests.

check:api derives this table from vitest.config.ts and verifies both language versions, so raising a floor without updating the documentation cannot pass.

How to add a guard

  1. Write the gate first, run it against current code, and watch it fail. A rule written to match a finished result is a snapshot, not a test.
  2. Change the code until it passes.
  3. Reverse-verify: remove the protected behaviour, confirm the gate turns red, then restore it.

Step 3 is mandatory for every important invariant in this repository — the working rules require that "for important regressions, verify that the test fails when the protected behavior is removed".

Released under the MIT License.