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.
pnpm checkEleven steps in order, aborting on the first failure:
| # | Step | Protects |
|---|---|---|
| 1 | check:generated | Generated callback and runtime protocols match their authoritative sources |
| 2 | typecheck | Five tsconfig projects plus the test project, all --noEmit |
| 3 | lint | oxlint, --deny-warnings |
| 4 | format:check | prettier |
| 5 | test | Behavioural semantics and the coverage floors |
| 6 | knip | Unused exports and dependencies |
| 7 | check:circular | Dependency cycles |
| 8 | check:layers | Import direction, module layering, architecture invariants, vocabulary |
| 9 | build | Dist and declaration files for the four published packages plus examples |
| 10 | check:api | Built-declaration type contracts, public surface, retired vocabulary, documentation coverage |
| 11 | docs:check | Documentation site build and dead links |
Step 9 must precede step 10: the published declaration tree is the final artifact of the complete type surface. Source cannot show what an export * expands to. dist/index.d.ts is the entry point of that tree.
check:generated
scripts/generate-internal.mjs --check verifies Core and reactive’s sync-result.ts and disposal-runtime.ts against their corresponding sources under scripts/internal/. Synchronous callbacks, Promise requirements and disposal Symbol resolution each have one editable source. Matching each other is insufficient: both projections must match their source. pnpm generate:internal regenerates the projections; package builds run the same generator before bundling.
check:layers
scripts/check-layers.mjs. Four families of check. It shares scripts/analyze-dependencies.mjs with the circular-dependency guard: scan scope, TypeScript resolution configuration and artifact exclusions have one configuration source, while the guards read different projections of the madge analysis. Analysis errors or any unresolved import stop verification; missing edges cannot be interpreted as an absence of violations.
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:
| Rule | Reason |
|---|---|
No node: built-in imports | The kernel stays independent of its runtime |
No Date.now / performance.now / Math.random | Hidden clocks and entropy make behaviour irreproducible |
No direct console calls | Must go through the Logger port |
| No deep imports into package internals | Entry points only |
No explicit any in any TypeScript AST | Source, tests and tooling all preserve the checking boundary with a precise type, unknown or never |
No @ts-ignore / @ts-nocheck | Silent suppression makes later fixes unchecked too; repair the actual type error |
@ts-expect-error appears only in public-api.types.ts | An expected error is a compile-time contract and does not belong in source or runtime tests |
*Port / *Control collaborator protocols use readonly function properties only | Bivariant method parameters let a narrow internal implementation cross the type boundary silently |
@dougongjs/reactive has zero external imports | It is an independent foundation |
Resource implementations do not use [Symbol.dispose] / [Symbol.asyncDispose] directly | Foundation protocol modules must select stable keys instead of degrading a missing symbol into an "undefined" property |
| The facade contains re-exports only | Logic there is a second execution path |
| Lifetime diagnostic module declares read-only schemas only | Read phase and membership from the real Lifetime without a second tree, state machine or counters |
| InstanceCoordinator cannot re-read external Contract identities | Lifetime captures inert identity before granting authority, so reflection cannot cross disposal |
| Installation diagnostics cannot reach through Lifetime's execution binding for a view | Instance retains the same read-only view for terminal reads during disposal |
| Event listener registrations cannot store a publication phase | EventHub membership is the sole visibility owner |
| Installation facades cannot keep lifecycle or authority state | InstallationRecord is the sole owner |
Host derives active/idle from Engine's committed plan without a separate state field | Engine owns steady execution availability; Host only tracks the in-flight command phase |
| RegistrationRecord cannot keep a mutable Manifest mirror | Manifest derives from the same Artifact; terminal data is immutable |
| Group facades cannot keep configuration or structural phases | Read GroupConfigurationSession and GroupNode |
| ContributionStore cannot store or assign a second contribution value | ContributionRecord owns the value; Store owns publication |
HostImpl must not be exported | Host is an interface; createHost() is the only constructor |
Only InstanceCoordinator and Lifetime itself may construct a Lifetime | Anywhere else produces a resource tree nobody disposes |
Requirements (inverted rules) — things that must appear, because their absence means somebody started a second path:
| Rule | The second path it prevents |
|---|---|
Host command serialization must use Core SerialQueue | A hand-written queue reintroduces "one failure poisons later commands" |
Platform command serialization must use the same SerialQueue | The same state machine copied across packages |
Platform diagnostics must compile to Core SnapshotPublisher | A duplicated observation protocol |
Contribution observation must compose the same SnapshotPublisher | Likewise |
Platform load cancellation must reuse Core isCancellationReason | Two cancellation classifications |
External Error recognition in Core and Platform must reuse Core isError | Repeated prototype checks can replace the original rejection |
Platform terminal failures must reuse Core RecordedFailure | Two terminal diagnostic recording protocols |
Declaration boundaries must use normalizePlainRecord without restoring the former assertion API | Validating an object but still letting its ordinary property reads select fields |
| Service lookup consumes a normalized requirement without rereading the original wrapper | Optionality and the actual Service identity diverging |
| Async result boundaries capture then once without a check followed by another read | Observing a different asynchronous branch and missing the original rejection |
| Observation cannot reread runner.result | A different completion result detaching an unfinished Task |
Platform declaration capture must reuse Core normalizePlainRecord | Two plain-data-record capture and validation semantics |
Host must delegate declarations and handle authority to InstallationRegistry | Host becoming a god object again |
Platform structural coordination must delegate activation to Activator | A second dependency-activation path |
Activator must trust CandidateGraph's cycle invariant | A second — and unreachable — graph implementation |
| ChangeSet drafts must route empty commits through their authority port | Short-circuiting lets a stale Group or Platform draft commit |
| Empty Group ChangeSets must cross the serialized boundary | Likewise |
| Empty Platform ChangeSets must cross the serialized command boundary | Likewise |
Platform disposal must be a terminal SerialQueue command | A tail observer misses queued commands |
Platform disposal must reuse Core asyncDisposeSymbol | A third runtime Symbol resolver |
Group ownership must use GroupNode identity | Encoding 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:
extension-point a concept label in prose -> allowed
PluginHandle a type identifier -> fails
"PLUGIN_DEPENDENCY_..." a retired error-code literal -> fails4 · 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.
It reads cycles through the shared analysis boundary, without recovering output from a failed CLI process. scripts/test/dependency-guards.test.mjs runs the real guards against temporary source copies, verifying that unresolved imports cannot pass while retaining coverage for valid graphs, actual cycles and upward dependencies.
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:
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 *.No retired identifier returns to the public surface. The banlist holds whole tokens rather than patterns, so valid names are unaffected:
textPlugin PluginContext InstanceMeta definePlugin -> legal PluginHandle PluginDefinition ExtensionView -> retiredEvery public export appears in both the Chinese and English documentation for its package. Updating the allowlist cannot leave a supported API unexplained.
Built declaration files contain no
any. The source gate cannot see types inferred by declaration emit, so this step scans every published.d.tsand 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.
scripts/runtime-baseline.mjs is the single source of truth for runtime support: the workspace root and all five workspace packages must share its engines, the root and four published packages must share its browserslist, and all five Vite builds must use its derived target. A package cannot advertise one runtime range while emitting syntax that needs another.
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
Runtime test source paths also belong to baseUrl/paths in tsconfig.base.json; Vitest derives its aliases directly. Each exact package name must have one exact target. Wildcards or multiple candidates abort configuration loading instead of silently selecting different resolution semantics. scripts/test/workspace-resolution.test.mjs changes only the TypeScript paths in a temporary directory and uses Vite's real configuration loader to verify changed targets, additional mappings and special filenames reach runtime resolution without manual synchronization.
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.
Release query and artifact boundaries
The release command parses input only through node:util's parseArgs, requires exactly one version argument and at most one occurrence of each option, and rejects unknown options and missing or blank OTP values. The workspace's existing semver library owns version syntax instead of a handwritten regular expression; input must equal the parsed version exactly. Invalid or ambiguous input is rejected before any external command.
Each package's package.json owns its publication identity and dependency declarations. The release script declares only directories and publication order, captures package names before preflight, and rejects missing or duplicate identities. Queries, downloads, upload confirmation and internal dependency membership use those captured names. Each tarball's name and version must match the release; its dependency set and external dependency ranges must match the original declarations, with internal dependencies projected only as this release's exact version. Missing, additional or changed dependencies are rejected; a second hardcoded package name or a version substring cannot prove artifact correctness.
Candidate and published archives use the same reader in scripts/release-archive.mjs. The tar parser reads member paths, types and bytes in strict mode, producing one snapshot for manifest validation, required files, entry-point checks and complete recovery comparisons. Paths must start with package; extra top-level entries, dot segments, empty components and backslashes are rejected. Only directories and regular files are accepted; symbolic links, hard links, repeated files and file/directory conflicts are rejected. The snapshot never passes through disk extraction, which could merge or hide link types, duplicate records and case-sensitive names. A directory cannot substitute for a required file. The reader also requires the parser's complete end-of-archive marker before returning the snapshot: input exhaustion alone cannot prove that the final header is complete. Damaged or truncated archives cannot produce partial success.
Preflight and upload confirmation in scripts/release.mjs share registryHasVersion. The npm registry owns publication facts; a failed query cannot create a missing-version fact. Successful output must return exactly the requested version, and only a structured E404 means absence. Network, permission, process-start and response-format failures stop immediately; upload polling retries only explicit E404 responses.
When resuming a release, npm also owns the contents of an already published version. A local candidate may skip upload only if the complete archived file set and the bytes of every file match. Comparing entry points alone cannot prove this: declarations referenced by those entries are also public contracts. Missing, additional or different files require a new version; archive metadata is excluded from the comparison. Even if every package has been uploaded, the release follows this same comparison and recording flow. It neither uploads again nor exits early merely because all packages are published.
Git's object graph owns release commits and tags; the process keeps no recovery marker. Preflight requires clean main equal to origin/main and explicitly checks every untracked file regardless of user settings. The sole recovery exception permits one extra commit with origin/main as its only parent, the exact release message, and only the four manifests' exact version transformation. Recovery cannot push other source, dependency or history changes. An existing tag must be annotated, point at current HEAD, and agree with all four manifest versions; recovery with a commit or tag also requires every package to be published. After complete verification, existing commits and tags are reused without empty commits or tag replacement. An atomic push explicitly disables follow-tags and updates only main and this release's tag; the user's push.followTags setting cannot expand publication scope, and a remote conflict cannot advance main separately.
Version changes are local staging owned by the release process. Before the first write, it captures the original bytes of all four package.json files and registers exit and signal handlers; changes derive from that snapshot. Until Git confirms a commit exists, failure, interruption, declined publication and dry runs restore those exact bytes. Recording failures also restore these manifests in the index. A failed restoration must not prevent attempts for the remaining files, and must name the failed path without claiming success. An established Git commit is never rolled back based on a command's exit code: tag or push failures preserve it, and the same version resumes from Git's facts.
scripts/test/release-archive.test.mjs constructs archive records directly to cover case-sensitive names, duplicate members, links, extended paths and damaged input that a filesystem cannot faithfully preserve. scripts/test/release.test.mjs runs the actual release CLI in a temporary workspace with an isolated command boundary, verifying strict version and option parsing, declaration-owned package identities, rejection of missing or duplicate identities, and preflight refusal of unknown state. scripts/test/release-artifacts.test.mjs uses real temporary archives to verify package identity, complete dependency declarations, exact internal versions and every file's bytes. Actual pnpm pack cases also cover workspace dependency conversion, required files excluded by a files allowlist, and directories occupying required-file paths. Publication cases extract the inspected tarball into the simulated registry and cover an empty registry, recovery after partial or complete publication, OTP forwarding, visibility before publishing dependents, and refusal to record success when uploads remain invisible or queries fail. Successful dry runs also assert that no upload or Git write occurred. Every non-dry-run case uses real temporary Git workspaces and bare remotes; add, commit, tag and push fault injection verifies recovery, established commits, tag conflicts and atomic pushes. scripts/test/release-manifests.test.mjs injects read, partial-write and restoration failures to check complete rollback and failure reporting; dry runs also verify exact byte restoration. These cases run under pnpm test without accessing the registry, publishing packages or changing the project's or a shared remote's Git state. Real packaging explicitly disables Corepack downloads.
All three release fixtures share asynchronous subprocess execution in scripts/test/release-fixture.mjs, using one execution deadline policy and result format. Timeouts, signals and launch failures still fail the tests, including a signal handler exiting with zero after a timeout. Each CLI execution loads ordinary fixture modules through --import; release-command-fixture.mjs adapts synchronous command results without starting a Node process for every simulated command. It rejects invalid working directories and missing or contradictory exit states, with real subprocess comparisons for output, signals and launch failures. Files own simulated registry contents: accepted uploads awaiting visibility reside in pending and move to published when visible. Queries match the complete package name and version against the stored manifest. Archive fixtures pack the complete stored directory, preserving extra top-level entries for release validation to reject instead of filtering them to the package subtree; packing disables macOS AppleDouble metadata generation so fixtures do not introduce host files absent from actual npm packages. The initial published-package list only seeds directories and does not participate in runtime decisions. Command logs only assert call order; they never decide publication state, and no separate publication flags or version copies exist.
Coverage floors
vitest.config.ts sets per-package thresholds pinned to the measured floors, with at most one point of slack:
| Package | statements | branches | functions | lines |
|---|---|---|---|---|
| core | 93 | 86 | 96 | 96 |
| platform | 97 | 92 | 100 | 99 |
| reactive | 96 | 89 | 100 | 99 |
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.
These are floors, not targets
Most of what remains uncovered is defence-in-depth behind an earlier check: a GroupNode.assertAttached() that the GroupCoordinator has already refused, a self-dependency SERVICE_CYCLE that definePlugin rejects first. Reaching those lines means bypassing the public API, so pushing the numbers higher would buy assertions about unreachable states rather than protection of behaviour.
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
- 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.
- Change the code until it passes.
- 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".
Related
- Architecture — the layering and design reasoning these constraints protect
- Core API specification — the public semantics being guarded
- Error codes — the table derived from source and cross-checked by the gate