From e80fc3e61b64bd06e22f38a4d35691fa50dc6489 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 01:53:09 +0800 Subject: [PATCH] fix(invariants): join package checks at startup --- ...kage-invariant-runtime-contracts.i18n.yaml | 4 +- ...-19-package-invariant-runtime-contracts.md | 14 +- ...-package-invariant-runtime-contracts.zh.md | 14 +- ...-package-owned-invariant-service.i18n.yaml | 4 +- ...6-07-19-package-owned-invariant-service.md | 6 +- ...7-19-package-owned-invariant-service.zh.md | 6 +- docs/testing.md | 2 +- .../code-runtime/tests/service.spec.ts | 10 +- .../examples/jsonrpc-demo/src/invariant.ts | 11 +- packages/hooks/hook-protocol/src/invariant.ts | 29 ++- .../sandbox/sandbox/tests/invariant.spec.ts | 43 ++++ packages/sdk/create-sdk/src/invariant.ts | 35 ++- packages/sdk/helper/src/invariant.ts | 25 +- packages/sdk/scripts/src/invariant.ts | 29 +-- packages/sdk/telemetry/src/invariant.ts | 23 +- .../subagent-inprocess/src/invariant.ts | 15 +- .../subagent-subprocess/src/invariant.ts | 29 ++- .../support/acp-snapshot/src/invariant.ts | 35 ++- .../agent-loop-testkit/src/invariant.ts | 17 +- packages/support/invariants/README.md | 6 +- packages/support/invariants/src/index.ts | 229 ++++++++++++++---- .../support/invariants/tests/service.spec.ts | 93 ++++++- .../support/loader-smoke/src/invariant.ts | 31 ++- packages/ui/app-boot/src/invariant.ts | 21 +- packages/util/brand/src/invariant.ts | 11 +- packages/util/home/src/invariant.ts | 19 +- packages/util/paths/src/invariant.ts | 19 +- packages/util/retention/src/invariant.ts | 33 ++- packages/util/timeout/src/invariant.ts | 23 +- scripts/test-invariants.spec.ts | 16 +- scripts/test-invariants.ts | 58 ++++- 31 files changed, 596 insertions(+), 314 deletions(-) create mode 100644 packages/sandbox/sandbox/tests/invariant.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.i18n.yaml index 06d1f9e07e..48407e2272 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-19-package-invariant-runtime-contracts.md: d3b327694b0d6779409582a7bb199e9cd48a1e4c -2026-07-19-package-invariant-runtime-contracts.zh.md: 07c15e80587a292fc94e3537980ee26cbae15115 +2026-07-19-package-invariant-runtime-contracts.md: 65986fc0b3aab695d8512d9e221052e1db83445f +2026-07-19-package-invariant-runtime-contracts.zh.md: 0ae0bd305692ee71359c5500e477dee22575a9ef diff --git a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md index d3b327694b..65986fc0b3 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md +++ b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md @@ -10,7 +10,7 @@ The package-owned invariant seam made registration and publication exhaustive, b Every package shape cannot use the same invariant. Cordis plugins own fibers, injections, effects, and services; service seams admit structural third-party implementations; stateful domains need event relations; pure libraries and bin packages expose algebra, parsing, normalization, or entrypoint constraints. The repository needs one enforceable obligation without moving those contracts back into a central product-aware package. -Vitest also mounts every companion globally. Companion modules therefore cannot eagerly import every product entrypoint before a test module establishes its hoisted mocks, and a name-based observer cannot mistake an anonymous child fiber that inherits its parent's display name for the package plugin itself. +Vitest mounts each package test's owning companion globally, and one exhaustive topology mounts every companion. Companion modules therefore cannot eagerly import every product entrypoint before a test module establishes its hoisted mocks, and a name-based observer cannot mistake an anonymous child fiber that inherits its parent's display name for the package plugin itself. ## Decision @@ -31,17 +31,17 @@ At implementation time this covers all 91 workspace packages: four stateful comp ### Product-independent observers -`observePluginInvariant` checks existing fibers immediately and future active fibers through global Cordis lifecycle events. A contract may supply an exact callback when that import is safe. Otherwise it matches `fiber.runtime.name`, the name declared by that fiber's own plugin runtime, rather than the inherited `fiber.name`; anonymous `ctx.inject()` children are therefore not misidentified as their parent package. The observer checks required injection keys, recursively collected effect labels, services provided by that exact fiber, and an optional owner validator. Config-dependent packages encode symmetric relations, such as automatic compaction owning both listeners or neither when disabled. +`observePluginInvariant` checks existing fibers immediately and future active fibers through a callback/name index behind one root-shared Cordis lifecycle listener pair. A contract may supply an exact callback when that import is safe. Otherwise it matches `fiber.runtime.name`, the name declared by that fiber's own plugin runtime, rather than the inherited `fiber.name`; anonymous `ctx.inject()` children are therefore not misidentified as their parent package. The observer checks required injection keys, recursively collected effect labels, services provided by that exact fiber, and an optional owner validator. Config-dependent packages encode symmetric relations, such as automatic compaction owning both listeners or neither when disabled. `observeServiceInvariant` checks the current service and every later binding. `serviceShapeViolation` validates callable members and non-empty string descriptors structurally instead of using `instanceof`, so conforming third-party backends and complete test doubles remain valid while incomplete stand-ins fail. -`assertInvariant` handles synchronous package algebra. Pure-package companions register an asynchronous child effect and dynamically import their owner inside that effect. This preserves atomic service-owned rollback while allowing the test module, Loader, or deployment to establish mocks and module resolution before the invariant samples the owner. +`assertInvariant` handles package algebra. Pure-package companions return an asynchronous installer promise and dynamically import their owner during child startup. The service joins that promise for atomic rollback while allowing the test module, Loader, or deployment to establish mocks and module resolution before the invariant samples the owner. ### Gate and test execution `verify-package-invariants` discovers every workspace package and retains the publication checks for the exact registration name, `./invariant` export, published files, invariant peer and development dependencies, TypeScript reference, and bundle entry. Its source check additionally parses the local `install` function, rejects a generated marker or empty body, requires a second failure-reporter parameter and its use, and rejects duplicate name-based plugin observers across packages. These AST checks are a minimum acceptance rule, not a claim that source shape proves semantic quality. -The Vitest setup host mounts `InvariantService` with `{ enabled: true }` and all 91 companions before an ordinary Cordis root's first plugin. The host joins companion startup to the test's root-level composition boundary, so asynchronous pure checks and plugin-observer setup fail the test rather than becoming background diagnostics. Focused selection, lifecycle, and owner suites build their own enabled topology to avoid duplicate registrations while still testing invariants. +The Vitest setup host mounts `InvariantService` with `{ enabled: true }` before an ordinary Cordis root's first plugin and adds the current test package's companion. The host joins companion startup to the test's root-level composition boundary, so asynchronous pure checks and plugin-observer setup fail the test rather than becoming background diagnostics. One exhaustive topology mounts all 91 companions once to prove runtime registration and coverage; focused selection, lifecycle, and owner suites build their own enabled topology to avoid duplicate registrations while still testing invariants. Helper tests reject invalid plugin names, missing injections, effects, services, custom relations, malformed service shapes, and failed assertions. Package suites then activate real plugins across their existing config and HMR paths. Test-only service stand-ins must implement the complete checked seam rather than bypass global invariants. @@ -50,7 +50,7 @@ Helper tests reject invalid plugin names, missing injections, effects, services, - **Keep generated ownership-only companions.** Rejected because registration without an executable assertion cannot reject a broken package and makes the exhaustive gate misleading. - **Generate one synthetic assertion into every package.** Rejected because a universal assertion would again optimize for satisfying the gate instead of protecting an owner-specific contract. - **Move the per-package contract matrix into `dsh-invariants`.** Rejected because product imports, vocabulary, and change ownership would return to the central service. -- **Import every owner entrypoint statically from its companion.** Rejected because the global test host would preload packages before hoisted mocks and shipped compositions would pay unrelated module initialization costs. +- **Import every owner entrypoint statically from its companion.** Rejected because owning and exhaustive test hosts would preload packages before hoisted mocks and shipped compositions would pay unrelated module initialization costs. - **Require first-party service-class identity.** Rejected because service seams are structural extension boundaries; `instanceof` would reject valid external implementations and test doubles. - **Register invariants implicitly from package root entrypoints.** Rejected for the composition-order and hidden-effect reasons in the package-owned service RFC. @@ -58,8 +58,8 @@ Helper tests reject invalid plugin names, missing injections, effects, services, - Every package contributes an executable check; adding a package without one fails the top-level gate. - The invariant service remains product-independent while providing reusable lifecycle and shape observers. -- Ordinary unit, snapshot, and e2e tests run with global invariant enablement and every companion registered. +- Ordinary unit, snapshot, and e2e roots run with global invariant enablement and the test package's companion; one exhaustive topology registers every companion. - Plugin names used for name-based observation must be unique within one Cordis root; packages may opt into exact callback identity when safe. - Pure-package checks sample stable startup contracts. Mutable behavior must use an event, service, or plugin-fiber observer. -- More companion work runs during tests and selected deployments, trading small startup cost for immediate package-attributed failures. +- Relevant companion work runs during package tests and selected deployments, trading bounded startup cost for immediate package-attributed failures. - The original regex selection, blocklist precedence, registration uniqueness, rollback, disposal, and HMR contracts remain unchanged. diff --git a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.zh.md b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.zh.md index 07c15e8058..0ae0bd3056 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.zh.md @@ -10,7 +10,7 @@ Status: implemented 不同包形态不能使用同一种不变式。Cordis 插件拥有 fiber、注入、effect 与服务;服务接缝允许结构兼容的第三方实现;有状态领域需要事件关系;纯库和 bin 包暴露代数、解析、规范化或入口约束。仓库需要一个可执行的统一义务,同时不能把这些契约重新移回了解产品语义的中央包。 -Vitest 还会全局挂载每个伴随插件。因此伴随模块不能在测试模块建立 hoisted mock 之前急切导入所有产品入口;按名称观察时,也不能把继承父级显示名的匿名子 fiber 误认为包插件本身。 +Vitest 会为每个包测试全局挂载其所有者伴随插件,并由一个完整拓扑挂载全部伴随插件。因此伴随模块不能在测试模块建立 hoisted mock 之前急切导入所有产品入口;按名称观察时,也不能把继承父级显示名的匿名子 fiber 误认为包插件本身。 ## 决策 @@ -31,17 +31,17 @@ Vitest 还会全局挂载每个伴随插件。因此伴随模块不能在测试 ### 与产品无关的观察器 -`observePluginInvariant` 会立即检查已有 fiber,并通过全局 Cordis 生命周期事件检查未来进入活跃状态的 fiber。安全导入时,契约可以提供准确 callback;否则匹配 `fiber.runtime.name`,即该 fiber 自身插件运行时声明的名称,而不是继承而来的 `fiber.name`,因此匿名 `ctx.inject()` 子级不会被误认成父包。观察器检查必要注入键、递归收集的 effect 标签、由该 fiber 准确提供的服务,以及可选的所有者验证器。依赖配置的包使用对称关系,例如自动压缩要么同时拥有两个监听器,要么在关闭时两个都没有。 +`observePluginInvariant` 会立即检查已有 fiber,并通过根上下文共享的一对 Cordis 生命周期监听器背后的 callback/名称索引检查未来进入活跃状态的 fiber。安全导入时,契约可以提供准确 callback;否则匹配 `fiber.runtime.name`,即该 fiber 自身插件运行时声明的名称,而不是继承而来的 `fiber.name`,因此匿名 `ctx.inject()` 子级不会被误认成父包。观察器检查必要注入键、递归收集的 effect 标签、由该 fiber 准确提供的服务,以及可选的所有者验证器。依赖配置的包使用对称关系,例如自动压缩要么同时拥有两个监听器,要么在关闭时两个都没有。 `observeServiceInvariant` 检查当前服务及之后的每次绑定。`serviceShapeViolation` 以结构方式验证可调用成员和非空字符串描述字段,而不使用 `instanceof`;因此符合契约的第三方后端和完整测试替身有效,不完整替身会失败。 -`assertInvariant` 处理同步包代数。纯包伴随插件注册异步子 effect,并在该 effect 内动态导入所有者。这样既保留服务拥有的原子回滚,又允许测试模块、Loader 或部署先建立 mock 和模块解析,再由不变式采样所有者。 +`assertInvariant` 处理包代数。纯包伴随插件返回异步 installer promise,并在子 fiber 启动期间动态导入所有者。服务会等待该 promise 以保证原子回滚,同时允许测试模块、Loader 或部署先建立 mock 和模块解析,再由不变式采样所有者。 ### 门禁与测试执行 `verify-package-invariants` 发现每个工作区包,并保留准确注册名、`./invariant` export、发布文件、不变式 peer 与开发依赖、TypeScript 引用和 bundle 入口的发布检查。源码检查还会解析本地 `install` 函数,拒绝生成标记或空函数体,要求第二个失败报告器参数及其使用,并拒绝跨包重复的按名称插件观察器。这些 AST 检查只是最低接收规则,并不宣称源码形状足以证明语义质量。 -Vitest setup host 使用 `{ enabled: true }` 挂载 `InvariantService` 和全部 91 个伴随插件,然后才启动普通 Cordis 根上下文的第一个插件。host 会把伴随插件启动加入测试的根级组合边界,因此异步纯检查和插件观察器安装会让测试失败,而不会变成后台诊断。选择、生命周期和所有者聚焦套件自行构建启用的不变式拓扑,在避免重复注册的同时继续测试不变式。 +Vitest setup host 会在普通 Cordis 根上下文启动第一个插件前,以 `{ enabled: true }` 挂载 `InvariantService`,并添加当前测试包的伴随插件。host 会把伴随插件启动加入测试的根级组合边界,因此异步纯检查和插件观察器安装会让测试失败,而不会变成后台诊断。一个完整拓扑会一次挂载全部 91 个伴随插件,以证明运行时注册与覆盖率;选择、生命周期和所有者聚焦套件自行构建启用的不变式拓扑,在避免重复注册的同时继续测试不变式。 辅助测试会拒绝错误插件名、缺失注入、effect、服务或自定义关系、错误服务形状和失败断言。随后,包套件在已有配置与 HMR 路径上激活真实插件。测试专用服务替身必须实现完整的已检查接缝,不能绕过全局不变式。 @@ -50,7 +50,7 @@ Vitest setup host 使用 `{ enabled: true }` 挂载 `InvariantService` 和全部 - **保留生成的仅声明所有权伴随插件。** 不予采纳,因为没有可执行断言的注册无法拒绝损坏的包,也会让完整门禁产生误导。 - **为每个包生成一个合成断言。** 不予采纳,因为通用断言仍是在优化如何通过门禁,而不是保护所有者专属契约。 - **把逐包契约矩阵移入 `dsh-invariants`。** 不予采纳,因为产品导入、词汇和变更所有权会重新回到中央服务。 -- **从伴随插件静态导入每个所有者入口。** 不予采纳,因为全局测试 host 会在 hoisted mock 之前预加载包,发布组合也会支付无关模块初始化成本。 +- **从伴随插件静态导入每个所有者入口。** 不予采纳,因为所有者测试 host 与完整测试 host 会在 hoisted mock 之前预加载包,发布组合也会支付无关模块初始化成本。 - **要求第一方服务类身份。** 不予采纳,因为服务接缝是结构化扩展边界;`instanceof` 会拒绝有效的外部实现和测试替身。 - **从包根入口隐式注册不变式。** 因包拥有服务 RFC 中的组合顺序与隐藏 effect 问题而不予采纳。 @@ -58,8 +58,8 @@ Vitest setup host 使用 `{ enabled: true }` 挂载 `InvariantService` 和全部 - 每个包都贡献可执行检查;新增包若没有检查,会在顶层门禁失败。 - 不变式服务保持与产品无关,同时提供可复用的生命周期与形状观察器。 -- 普通单元、snapshot 与 e2e 测试均全局启用不变式并注册每个伴随插件。 +- 普通单元、snapshot 与 e2e 根上下文均全局启用不变式并注册当前测试包的伴随插件;一个完整拓扑注册全部伴随插件。 - 用于按名称观察的插件名在一个 Cordis 根上下文内必须唯一;安全时包可以选择准确 callback 身份。 - 纯包检查对稳定启动契约采样;可变行为必须使用事件、服务或插件 fiber 观察器。 -- 测试和被选部署会执行更多伴随工作,以少量启动成本换取即时且带包归属的失败。 +- 包测试和被选部署会执行相关伴随工作,以有界启动成本换取即时且带包归属的失败。 - 原有正则选择、blocklist 优先级、注册唯一性、回滚、dispose 与 HMR 契约保持不变。 diff --git a/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.i18n.yaml index 8e83577949..9ab8642f4b 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-19-package-owned-invariant-service.md: 3ffba4ff849b6e4be731586dc7d1b37f4c1a76eb -2026-07-19-package-owned-invariant-service.zh.md: d8be195eccb1ab53439b6cdb90b725ae0889c8a6 +2026-07-19-package-owned-invariant-service.md: 552b088c1cafc2fa762487f57fa1d4ad64390f7e +2026-07-19-package-owned-invariant-service.zh.md: 84bdc6a6a3baefe95713f8ce5b8a4e2af49b64eb diff --git a/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.md b/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.md index 3ffba4ff84..552b088c1c 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.md +++ b/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.md @@ -49,7 +49,7 @@ Blocklist matches override allowlist matches. Each list entry is a case-sensitiv The public registration boundary is `ctx.invariants.register(packageName, installer)`. It reserves one active registration per full npm package name even when filters disable installation, and returns the effect disposer. Disposing the companion or service releases the reservation and all contribution state. -An enabled installer runs in a dedicated child Cordis fiber owned by the service. `InvariantInstaller.inject` declares the child fiber's service surface explicitly; the registry carries no product-specific dependency metadata. The installer receives a bound `fail(message)` reporter. Calling it throws an `Error` subclass named `InvariantError` with stable code `INVARIANT` and the registering `packageName`; it does not extend a product-package error base. +An enabled installer runs in a dedicated child Cordis fiber owned by the service. `InvariantInstaller.inject` declares the child fiber's service surface explicitly; the registry carries no product-specific dependency metadata. The service joins a returned installer promise before registration succeeds, so asynchronous startup checks remain transactional. The installer receives a bound `fail(message)` reporter. Calling it throws an `Error` subclass named `InvariantError` with stable code `INVARIANT` and the registering `packageName`; it does not extend a product-package error base. Registration setup is transactional. If an installer fails after registering listeners, the child fiber is disposed completely and the name reservation is released before the failure escapes. Filtered registrations create no child but retain their reservation until disposal. Reloading a companion therefore begins with one clean installer state; stateful contributions rebuild baselines from their owning services. @@ -84,7 +84,7 @@ Service tests cover defaults, global disablement, allow/block selection, blockli Composition tests cover standard-spine forwarding and generated SDK entries. Loader tests preserve each companion namespace, while built plain-Node smokes exercise the compiled subpath exports. The scoped-event freshness gate reruns its semantic Program analysis. -Every Vitest configuration loads a test host that mounts an explicitly enabled service and all package companions before an ordinary Cordis root's first plugin. Focused service and owner tests construct their own invariant topology so they can exercise disablement, filtering, rollback, and reload without duplicate ownership. Gate tests also execute every companion's `apply` function and verify that it calls `register` with its manifest name, rather than accepting source text alone. +Every Vitest configuration loads a test host that mounts an explicitly enabled service before an ordinary Cordis root's first plugin and adds the current test package's companion. One exhaustive topology mounts all package companions once; focused service and owner tests construct their own invariant topology so they can exercise disablement, filtering, rollback, and reload without duplicate ownership. Gate tests also execute every companion's `apply` function and verify that it calls `register` with its manifest name, rather than accepting source text alone. ## Alternatives considered @@ -101,5 +101,5 @@ Every Vitest configuration loads a test host that mounts an explicitly enabled s - Explicit companion entries make diagnostic cost and ownership visible in Cordis config and package exports. - One selected contribution adds one child fiber and its listener/state cost; filtered registrations retain only name ownership. - Regex sources are deployment configuration and remain fixed until the service reloads. -- Ordinary Vitest roots install every selected companion, trading extra child fibers during tests for repository-wide invariant coverage and immediate fixture failures. +- Ordinary Vitest roots install the owning test package's selected companion; one exhaustive topology pays the full child-fiber cost once for repository-wide registration coverage. - Session storage validation, snapshotting, freezing, provenance, and surface acceptance remain always on and are not affected by invariant selection. diff --git a/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.zh.md b/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.zh.md index d8be195ecc..84bdc6a6a3 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.zh.md @@ -49,7 +49,7 @@ blocklist 匹配优先于 allowlist 匹配。每个条目都是区分大小写 公开注册边界是 `ctx.invariants.register(packageName, installer)`。即使过滤器禁止安装,它也会为每个完整 npm 包名保留唯一的活跃注册,并返回 effect disposer。卸载伴随插件或服务都会释放注册名及全部贡献状态。 -启用的 installer 在服务拥有的独立 Cordis 子 fiber 中运行。`InvariantInstaller.inject` 显式声明该子 fiber 的服务表面;注册服务不携带产品专用依赖元数据。installer 接收绑定后的 `fail(message)` 报告器。调用它会抛出名为 `InvariantError` 的 `Error` 子类,保留稳定代码 `INVARIANT` 并记录注册方 `packageName`;该错误不继承产品包中的错误基类。 +启用的 installer 在服务拥有的独立 Cordis 子 fiber 中运行。`InvariantInstaller.inject` 显式声明该子 fiber 的服务表面;注册服务不携带产品专用依赖元数据。服务会在注册成功前等待 installer 返回的 promise,因此异步启动检查仍具有事务性。installer 接收绑定后的 `fail(message)` 报告器。调用它会抛出名为 `InvariantError` 的 `Error` 子类,保留稳定代码 `INVARIANT` 并记录注册方 `packageName`;该错误不继承产品包中的错误基类。 注册启动是事务性的。如果 installer 在注册监听器后失败,子 fiber 会完整释放,并在失败向外传播前解除包名占用。被过滤的注册不创建子 fiber,但会保留占用直到 dispose。伴随插件重载时总会从干净的 installer 状态开始;有状态贡献从其所属服务重建基线。 @@ -84,7 +84,7 @@ Workspace 约束识别独立的不变式 bundle;包 exports、项目引用、 组合测试覆盖标准 spine 转发和生成的 SDK 条目。Loader 测试固定每个伴随命名空间,构建后的纯 Node smoke 覆盖编译子路径 export。scoped event 新鲜度门禁会重新执行语义 Program 分析。 -每个 Vitest 配置都会加载测试宿主;在普通 Cordis 根上下文启动第一个插件之前,宿主会挂载显式启用的服务以及所有包的伴随插件。服务与所有者的聚焦测试自行构建不变式拓扑,从而在不发生重复所有权冲突的前提下覆盖关闭、过滤、回滚与重载。门禁测试还会执行每个伴随插件的 `apply` 函数,并验证它调用 `register` 时使用包清单中的包名,而不是只检查源码文本。 +每个 Vitest 配置都会加载测试宿主;在普通 Cordis 根上下文启动第一个插件之前,宿主会挂载显式启用的服务,并添加当前测试包的伴随插件。一个完整拓扑会一次挂载所有包的伴随插件;服务与所有者的聚焦测试自行构建不变式拓扑,从而在不发生重复所有权冲突的前提下覆盖关闭、过滤、回滚与重载。门禁测试还会执行每个伴随插件的 `apply` 函数,并验证它调用 `register` 时使用包清单中的包名,而不是只检查源码文本。 ## 考虑过的替代方案 @@ -101,5 +101,5 @@ Workspace 约束识别独立的不变式 bundle;包 exports、项目引用、 - 显式伴随条目让诊断成本和所有权在 Cordis 配置与包 export 中可见。 - 每个选中贡献增加一个子 fiber 及其监听器和状态成本;被过滤注册只保留包名占用。 - 正则表达式源属于部署配置,在服务重载前保持固定。 -- 普通 Vitest 根上下文会安装每个被选中的伴随插件,以增加测试期间的子 fiber 为代价,换取覆盖整个仓库的不变式检查和对 fixture(测试前置数据)错误的即时反馈。 +- 普通 Vitest 根上下文会安装当前测试包中被选中的伴随插件;一个完整拓扑只支付一次全部子 fiber 成本,用于覆盖整个仓库的注册。 - 会话存储验证、快照、冻结、provenance 与 surface 接受规则始终启用,不受不变式选择影响。 diff --git a/docs/testing.md b/docs/testing.md index 3bf37fe79e..1fcefb4bc3 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -9,7 +9,7 @@ How this repo tests, tier by tier, and the rules that keep a green suite meaning - **Real-API e2e** (`pnpm run test:e2e`): with-key tests against live provider APIs — the DeepSeek model plus provider-specific smokes that gate on their own keys (`EXA_API_KEY`, `PERPLEXITY_API_KEY`, …); each suite self-skips without its key so keyless CI stays green ([real-API e2e Agent Note](../.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.md)). - **Snapshot** (`pnpm run test:snapshot`): transport-specific keyless expected outputs cover external presentation. ACP suites boot the real example subprocess, replay a recorded session, and diff normalized JSON-RPC plus the re-persisted log ([ACP snapshot Agent Note](../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md)); the headless suite independently pins `stream-json` through its real one-shot subprocess. TUI completed journeys replay recorded primary/child JSONL through the real agent loop and tools before projecting ANSI into semantic terminal-state expected outputs; package-local snapshots retain transient renderer states, and a real PTY conversation covers the process boundary ([TUI snapshot Agent Note](../.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md)). Use `pnpm run test:snapshot:record` when a model transcript must change and `pnpm run test:snapshot:refresh` when committed replay input remains correct; review every JSONL and expected-output diff. System-prompt/tool-schema content is pinned by one ACP scenario (`text-turn`) and tokenized in every other fixture, so a prompt or schema edit churns one committed line ([pinned-header Agent Note](../.agents/notes/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)). -All Vitest configurations mount enabled `ctx.invariants` and every package companion before ordinary Cordis roots start. Focused invariant topology tests compose enabled or deliberately disabled services explicitly, without competing global registrations. +Every Vitest configuration mounts enabled `ctx.invariants` before ordinary roots start. Package tests add their owner's companion; one exhaustive topology mounts them all. Focused invariant topology tests compose enabled or deliberately disabled services without competing global registrations. ## The with-key policy: inference is cheap here diff --git a/packages/code-runtime/code-runtime/tests/service.spec.ts b/packages/code-runtime/code-runtime/tests/service.spec.ts index 01736ea0ab..68bc809de7 100644 --- a/packages/code-runtime/code-runtime/tests/service.spec.ts +++ b/packages/code-runtime/code-runtime/tests/service.spec.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import { CodeRuntime } from '@deepseek-ai/dsh-code-runtime' import type { CodeRunRequest, CodeRunResult } from '@deepseek-ai/dsh-code-runtime' +import { InvariantError } from '@deepseek-ai/dsh-invariants' /** * Minimal concrete runtime: records requests, "executes" by invoking every @@ -96,6 +97,13 @@ describe('CodeRuntime service seam', () => { child.provide('codeRuntime', value as unknown as CodeRuntime) }, } - await expect(ctx.plugin(invalidRuntime)).rejects.toThrow(message) + let caught: unknown + try { + await ctx.plugin(invalidRuntime) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(InvariantError) + expect((caught as Error).message).toMatch(message) }) }) diff --git a/packages/examples/jsonrpc-demo/src/invariant.ts b/packages/examples/jsonrpc-demo/src/invariant.ts index f65eee8165..a9352d02f8 100644 --- a/packages/examples/jsonrpc-demo/src/invariant.ts +++ b/packages/examples/jsonrpc-demo/src/invariant.ts @@ -12,13 +12,10 @@ export const name = 'jsonrpc-demo-invariant' export const inject = ['invariants'] /** Assert that Loader configuration, rather than a hidden root plugin, owns composition. */ -const install: InvariantInstaller = (ctx, fail) => { - ctx.effect(async () => { - const packageEntry = await import('./index.ts') - assertInvariant(fail, Object.keys(packageEntry).length === 0, - 'the JSON-RPC demo library entrypoint must remain empty because cordis.yml owns composition') - return () => {} - }, 'jsonrpc-demo: validate bin-only entrypoint') +const install: InvariantInstaller = async (_ctx, fail) => { + const packageEntry = await import('./index.ts') + assertInvariant(fail, Object.keys(packageEntry).length === 0, + 'the JSON-RPC demo library entrypoint must remain empty because cordis.yml owns composition') } /** diff --git a/packages/hooks/hook-protocol/src/invariant.ts b/packages/hooks/hook-protocol/src/invariant.ts index 6503791f1c..997dd75b30 100644 --- a/packages/hooks/hook-protocol/src/invariant.ts +++ b/packages/hooks/hook-protocol/src/invariant.ts @@ -12,22 +12,21 @@ export const name = 'hook-protocol-invariant' export const inject = ['invariants'] /** Assert blocking-exit decoding and restrictive merge precedence. */ -const install: InvariantInstaller = (ctx, fail) => { - ctx.effect(async () => { - const { parseHookOutput } = await import('./codec.ts') - const { mergeHookOutputs } = await import('./merge.ts') - const blocked = parseHookOutput(2, '', ' denied ') - assertInvariant(fail, blocked.decision === 'block' && blocked.reason === 'denied', - 'exit 2 must decode as a block whose reason is trimmed stderr') +const install: InvariantInstaller = async (_ctx, fail) => { + const [{ parseHookOutput }, { mergeHookOutputs }] = await Promise.all([ + import('./codec.ts'), + import('./merge.ts'), + ]) + const blocked = parseHookOutput(2, '', ' denied ') + assertInvariant(fail, blocked.decision === 'block' && blocked.reason === 'denied', + 'exit 2 must decode as a block whose reason is trimmed stderr') - const merged = mergeHookOutputs([ - { exitCode: 0, stderr: '', stdout: '', decision: 'allow', reason: 'permitted' }, - { exitCode: 0, stderr: '', stdout: '', decision: 'deny', reason: 'forbidden' }, - ]) - assertInvariant(fail, merged.decision === 'deny' && merged.reason === 'forbidden', - 'deny must override allow and retain only the winning decision reason') - return () => {} - }, 'hook-protocol: validate decode and merge algebra') + const merged = mergeHookOutputs([ + { exitCode: 0, stderr: '', stdout: '', decision: 'allow', reason: 'permitted' }, + { exitCode: 0, stderr: '', stdout: '', decision: 'deny', reason: 'forbidden' }, + ]) + assertInvariant(fail, merged.decision === 'deny' && merged.reason === 'forbidden', + 'deny must override allow and retain only the winning decision reason') } /** diff --git a/packages/sandbox/sandbox/tests/invariant.spec.ts b/packages/sandbox/sandbox/tests/invariant.spec.ts new file mode 100644 index 0000000000..24854b56ae --- /dev/null +++ b/packages/sandbox/sandbox/tests/invariant.spec.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { InvariantError } from '@deepseek-ai/dsh-invariants' +import { SandboxProvider } from '@deepseek-ai/dsh-sandbox' +import type { ConfinedArgv, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' + +class StubSandboxProvider extends SandboxProvider { + confine(argv: readonly string[], _policy: SandboxPolicy): ConfinedArgv { + return { + argv: [...argv], + enforcement: 'full', + denialSignatures: [], + runnerFailureSignatures: [], + } + } +} + +describe('sandbox package invariant', () => { + it('accepts a provider that exposes the confinement seam', async () => { + const ctx = new Context() + await ctx.plugin(StubSandboxProvider) + expect(ctx.sandbox).toBeInstanceOf(StubSandboxProvider) + }) + + it('rejects a service binding without confine()', async () => { + const ctx = new Context() + const invalidSandbox = { + name: 'invalid-sandbox', + apply(child: Context) { + child.provide('sandbox', {} as SandboxProvider) + }, + } + let caught: unknown + try { + await ctx.plugin(invalidSandbox) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(InvariantError) + expect(caught).toHaveProperty('packageName', '@deepseek-ai/dsh-sandbox') + expect((caught as Error).message).toMatch(/must expose method "confine"/) + }) +}) diff --git a/packages/sdk/create-sdk/src/invariant.ts b/packages/sdk/create-sdk/src/invariant.ts index 66a95e6a38..417d936128 100644 --- a/packages/sdk/create-sdk/src/invariant.ts +++ b/packages/sdk/create-sdk/src/invariant.ts @@ -12,24 +12,23 @@ export const name = 'create-sdk-invariant' export const inject = ['invariants'] /** Assert the bin-only entrypoint and its core argument mapping. */ -const install: InvariantInstaller = (ctx, fail) => { - ctx.effect(async () => { - const { parseCreateArgs } = await import('./args.ts') - const packageEntry = await import('./index.ts') - assertInvariant(fail, Object.keys(packageEntry).length === 0, - 'the create-sdk library entrypoint must remain empty because the package is bin-only') - const parsed = parseCreateArgs([ - 'workspace', '--provider=custom', '--base-url=https://example.test', '--interface=embed', '--no-install', - ]) - assertInvariant(fail, - parsed.directory === 'workspace' - && parsed.provider === 'custom' - && parsed.baseURL === 'https://example.test' - && parsed.runInterface === 'embed' - && parsed.install === false, - 'create-sdk arguments must preserve directory, provider, base URL, interface, and negative install flags') - return () => {} - }, 'create-sdk: validate bin and argument contracts') +const install: InvariantInstaller = async (_ctx, fail) => { + const [{ parseCreateArgs }, packageEntry] = await Promise.all([ + import('./args.ts'), + import('./index.ts'), + ]) + assertInvariant(fail, Object.keys(packageEntry).length === 0, + 'the create-sdk library entrypoint must remain empty because the package is bin-only') + const parsed = parseCreateArgs([ + 'workspace', '--provider=custom', '--base-url=https://example.test', '--interface=embed', '--no-install', + ]) + assertInvariant(fail, + parsed.directory === 'workspace' + && parsed.provider === 'custom' + && parsed.baseURL === 'https://example.test' + && parsed.runInterface === 'embed' + && parsed.install === false, + 'create-sdk arguments must preserve directory, provider, base URL, interface, and negative install flags') } /** diff --git a/packages/sdk/helper/src/invariant.ts b/packages/sdk/helper/src/invariant.ts index e91fb012d5..5faf1ea49c 100644 --- a/packages/sdk/helper/src/invariant.ts +++ b/packages/sdk/helper/src/invariant.ts @@ -12,20 +12,17 @@ export const name = 'helper-invariant' export const inject = ['invariants'] /** Assert FeatureId's zero-cost representation and boundary validation. */ -const install: InvariantInstaller = (ctx, fail) => { - ctx.effect(async () => { - const { featureId } = await import('./ids.ts') - assertInvariant(fail, featureId('local-plugin') === 'local-plugin', - 'a valid feature id must preserve its runtime string value') - let rejected = false - try { - featureId('Invalid Feature') - } catch (error) { - rejected = error instanceof Error - } - assertInvariant(fail, rejected, 'feature ids must reject values outside lowercase kebab-case') - return () => {} - }, 'dsh-helper: validate feature identities') +const install: InvariantInstaller = async (_ctx, fail) => { + const { featureId } = await import('./ids.ts') + assertInvariant(fail, featureId('local-plugin') === 'local-plugin', + 'a valid feature id must preserve its runtime string value') + let rejected = false + try { + featureId('Invalid Feature') + } catch (error) { + rejected = error instanceof Error + } + assertInvariant(fail, rejected, 'feature ids must reject values outside lowercase kebab-case') } /** diff --git a/packages/sdk/scripts/src/invariant.ts b/packages/sdk/scripts/src/invariant.ts index 316e7ebed1..522791fe3b 100644 --- a/packages/sdk/scripts/src/invariant.ts +++ b/packages/sdk/scripts/src/invariant.ts @@ -12,22 +12,19 @@ export const name = 'scripts-invariant' export const inject = ['invariants'] /** Assert the launcher's opaque post-separator forwarding boundary. */ -const install: InvariantInstaller = (ctx, fail) => { - ctx.effect(async () => { - const { splitForwardedArgs } = await import('./forwarding.ts') - const plain = splitForwardedArgs(['dev', 'src/index.ts']) - const separated = splitForwardedArgs(['dev', 'src/index.ts', '--', '--inspect', '9229']) - assertInvariant(fail, - plain.launcher.length === 2 - && plain.forwarded.length === 0 - && separated.launcher.length === 2 - && separated.launcher[1] === 'src/index.ts' - && separated.forwarded.length === 2 - && separated.forwarded[0] === '--inspect' - && separated.forwarded[1] === '9229', - 'dsh-sdk must split the first delimiter without interpreting forwarded runtime arguments') - return () => {} - }, 'dsh-sdk: validate command argument contracts') +const install: InvariantInstaller = async (_ctx, fail) => { + const { splitForwardedArgs } = await import('./forwarding.ts') + const plain = splitForwardedArgs(['dev', 'src/index.ts']) + const separated = splitForwardedArgs(['dev', 'src/index.ts', '--', '--inspect', '9229']) + assertInvariant(fail, + plain.launcher.length === 2 + && plain.forwarded.length === 0 + && separated.launcher.length === 2 + && separated.launcher[1] === 'src/index.ts' + && separated.forwarded.length === 2 + && separated.forwarded[0] === '--inspect' + && separated.forwarded[1] === '9229', + 'dsh-sdk must split the first delimiter without interpreting forwarded runtime arguments') } /** diff --git a/packages/sdk/telemetry/src/invariant.ts b/packages/sdk/telemetry/src/invariant.ts index 8333b1ff71..9ec704b672 100644 --- a/packages/sdk/telemetry/src/invariant.ts +++ b/packages/sdk/telemetry/src/invariant.ts @@ -12,19 +12,16 @@ export const name = 'telemetry-invariant' export const inject = ['invariants'] /** Assert the final telemetry redaction boundary removes secrets without corrupting ordinary package metadata. */ -const install: InvariantInstaller = (ctx, fail) => { - ctx.effect(async () => { - const [{ DEFAULT_REDACTION_PLACEHOLDER, SecretRedactor }, { telemetryRedactionViolation }] = await Promise.all([ - import('./secret-redactor.ts'), - import('./redaction-contract.ts'), - ]) - const redactor = new SecretRedactor() - const violation = telemetryRedactionViolation(redactor, DEFAULT_REDACTION_PLACEHOLDER, PACKAGE_NAME) - assertInvariant(fail, - violation === undefined, - violation ?? 'telemetry redaction contract failed without a diagnostic') - return () => {} - }, 'telemetry: validate secret-redaction boundary') +const install: InvariantInstaller = async (_ctx, fail) => { + const [{ telemetryRedactionViolation }, { DEFAULT_REDACTION_PLACEHOLDER, SecretRedactor }] = await Promise.all([ + import('./redaction-contract.ts'), + import('./secret-redactor.ts'), + ]) + const redactor = new SecretRedactor() + const violation = telemetryRedactionViolation(redactor, DEFAULT_REDACTION_PLACEHOLDER, PACKAGE_NAME) + assertInvariant(fail, + violation === undefined, + violation ?? 'telemetry redaction contract failed without a diagnostic') } /** diff --git a/packages/subagent/subagent-inprocess/src/invariant.ts b/packages/subagent/subagent-inprocess/src/invariant.ts index a007200d73..caaf598f55 100644 --- a/packages/subagent/subagent-inprocess/src/invariant.ts +++ b/packages/subagent/subagent-inprocess/src/invariant.ts @@ -12,15 +12,12 @@ export const name = 'subagent-inprocess-invariant' export const inject = ['invariants'] /** Assert that structured-output guidance names the tool it actually installs. */ -const install: InvariantInstaller = (ctx, fail) => { - ctx.effect(async () => { - const { STRUCTURED_OUTPUT_INSTRUCTION, STRUCTURED_OUTPUT_TOOL } = await import('./structured-protocol.ts') - assertInvariant(fail, /^[a-z][a-z0-9_]*$/.test(STRUCTURED_OUTPUT_TOOL), - 'the structured-output tool must retain a stable lowercase protocol name') - assertInvariant(fail, STRUCTURED_OUTPUT_INSTRUCTION.includes(STRUCTURED_OUTPUT_TOOL), - 'the structured-output instruction must name the exact installed tool') - return () => {} - }, 'subagent-inprocess: validate structured-output protocol') +const install: InvariantInstaller = async (_ctx, fail) => { + const { STRUCTURED_OUTPUT_INSTRUCTION, STRUCTURED_OUTPUT_TOOL } = await import('./structured-protocol.ts') + assertInvariant(fail, /^[a-z][a-z0-9_]*$/.test(STRUCTURED_OUTPUT_TOOL), + 'the structured-output tool must retain a stable lowercase protocol name') + assertInvariant(fail, STRUCTURED_OUTPUT_INSTRUCTION.includes(STRUCTURED_OUTPUT_TOOL), + 'the structured-output instruction must name the exact installed tool') } /** diff --git a/packages/subagent/subagent-subprocess/src/invariant.ts b/packages/subagent/subagent-subprocess/src/invariant.ts index ed29ecc855..9eed9ae9e3 100644 --- a/packages/subagent/subagent-subprocess/src/invariant.ts +++ b/packages/subagent/subagent-subprocess/src/invariant.ts @@ -13,19 +13,24 @@ export const name = 'subagent-subprocess-invariant' export const inject = ['invariants'] /** Assert ambient credential scrubbing and explicit credential precedence. */ -const install: InvariantInstaller = (ctx, fail) => { - ctx.effect(async () => { - const { buildChildEnv } = await import('./index.ts') - const scrubbed = buildChildEnv({}) - const ambientSensitiveNames = Object.keys(process.env).filter(key => SENSITIVE_ENV_PATTERN.test(key)) - assertInvariant(fail, ambientSensitiveNames.every(key => !Object.hasOwn(scrubbed, key)), - 'subprocess environments must omit every credential-shaped ambient variable') +const install: InvariantInstaller = async (_ctx, fail) => { + const { buildChildEnv } = await import('./index.ts') + const ambientProbe = `DSH_INVARIANT_AMBIENT_TOKEN_${process.pid}` + assertInvariant(fail, SENSITIVE_ENV_PATTERN.test(ambientProbe), + 'the invariant ambient probe must remain credential-shaped') + process.env[ambientProbe] = 'must-not-reach-child' + let scrubbed: NodeJS.ProcessEnv + try { + scrubbed = buildChildEnv({}) + } finally { + Reflect.deleteProperty(process.env, ambientProbe) + } + assertInvariant(fail, !Object.hasOwn(scrubbed, ambientProbe), + 'subprocess environments must omit every credential-shaped ambient variable') - const explicit = buildChildEnv({ DSH_INVARIANT_TOKEN: 'explicit-child-value' }) - assertInvariant(fail, explicit.DSH_INVARIANT_TOKEN === 'explicit-child-value', - 'explicit child credentials must be applied after ambient scrubbing') - return () => {} - }, 'subagent-subprocess: validate child environment isolation') + const explicit = buildChildEnv({ DSH_INVARIANT_TOKEN: 'explicit-child-value' }) + assertInvariant(fail, explicit.DSH_INVARIANT_TOKEN === 'explicit-child-value', + 'explicit child credentials must be applied after ambient scrubbing') } /** diff --git a/packages/support/acp-snapshot/src/invariant.ts b/packages/support/acp-snapshot/src/invariant.ts index fc568d7b37..ac29b83cff 100644 --- a/packages/support/acp-snapshot/src/invariant.ts +++ b/packages/support/acp-snapshot/src/invariant.ts @@ -12,25 +12,22 @@ export const name = 'acp-snapshot-invariant' export const inject = ['invariants'] /** Assert stable JSON-RPC correlation and volatile-value tokenization. */ -const install: InvariantInstaller = (ctx, fail) => { - ctx.effect(async () => { - const { normalizeStdout } = await import('./normalize.ts') - const sessionId = '12345678-1234-1234-1234-123456789abc' - const volatile = { sessionIds: [sessionId], cwd: '/tmp/dsh-acp-invariant' } - const raw = [ - JSON.stringify({ jsonrpc: '2.0', id: 'request-7', result: { cwd: volatile.cwd } }), - JSON.stringify({ jsonrpc: '2.0', id: 'request-7', result: { sessionId } }), - ].join('\n') - const normalized = normalizeStdout(raw, volatile) - assertInvariant(fail, - normalized.includes('"id":1') - && normalized.includes('"cwd":"{{cwd}}"') - && normalized.includes('"sessionId":"{{sessionId}}"'), - 'ACP normalization must preserve RPC correlation while tokenizing cwd and session ids') - assertInvariant(fail, normalizeStdout(normalized, volatile) === normalized, - 'ACP stdout normalization must be idempotent') - return () => {} - }, 'acp-snapshot: validate stable transcript normalization') +const install: InvariantInstaller = async (_ctx, fail) => { + const { normalizeStdout } = await import('./normalize.ts') + const sessionId = '12345678-1234-1234-1234-123456789abc' + const volatile = { sessionIds: [sessionId], cwd: '/tmp/dsh-acp-invariant' } + const raw = [ + JSON.stringify({ jsonrpc: '2.0', id: 'request-7', result: { cwd: volatile.cwd } }), + JSON.stringify({ jsonrpc: '2.0', id: 'request-7', result: { sessionId } }), + ].join('\n') + const normalized = normalizeStdout(raw, volatile) + assertInvariant(fail, + normalized.includes('"id":1') + && normalized.includes('"cwd":"{{cwd}}"') + && normalized.includes('"sessionId":"{{sessionId}}"'), + 'ACP normalization must preserve RPC correlation while tokenizing cwd and session ids') + assertInvariant(fail, normalizeStdout(normalized, volatile) === normalized, + 'ACP stdout normalization must be idempotent') } /** diff --git a/packages/support/agent-loop-testkit/src/invariant.ts b/packages/support/agent-loop-testkit/src/invariant.ts index fd72038d3f..69792b77ea 100644 --- a/packages/support/agent-loop-testkit/src/invariant.ts +++ b/packages/support/agent-loop-testkit/src/invariant.ts @@ -12,16 +12,13 @@ export const name = 'agent-loop-testkit-invariant' export const inject = ['invariants'] /** Assert the awaitable helper shape and optional-options call boundary. */ -const install: InvariantInstaller = (ctx, fail) => { - ctx.effect(async () => { - const { mountAgentLoopTestDependencies } = await import('./index.ts') - assertInvariant(fail, - mountAgentLoopTestDependencies.constructor.name === 'AsyncFunction', - 'the prerequisite mount helper must remain awaitable so tests cannot race service activation') - assertInvariant(fail, mountAgentLoopTestDependencies.length === 1, - 'the prerequisite mount helper must keep its options argument optional') - return () => {} - }, 'agent-loop-testkit: validate prerequisite mount boundary') +const install: InvariantInstaller = async (_ctx, fail) => { + const { mountAgentLoopTestDependencies } = await import('./index.ts') + assertInvariant(fail, + mountAgentLoopTestDependencies.constructor.name === 'AsyncFunction', + 'the prerequisite mount helper must remain awaitable so tests cannot race service activation') + assertInvariant(fail, mountAgentLoopTestDependencies.length === 1, + 'the prerequisite mount helper must keep its options argument optional') } /** diff --git a/packages/support/invariants/README.md b/packages/support/invariants/README.md index eb26f57e43..7b922bcda9 100644 --- a/packages/support/invariants/README.md +++ b/packages/support/invariants/README.md @@ -16,7 +16,7 @@ Defaults are `enabled: true`, `package_allowlist: []`, and `package_blocklist: [ Each entry is a case-sensitive JavaScript regular-expression source compiled with `new RegExp(pattern)`. Matching is unanchored unless the source supplies `^` and `$`; `/pattern/flags` syntax is not parsed. Blank, whitespace-padded, invalid, or duplicate entries within one list fail service startup. A valid pattern may match no currently loaded package so later loading and HMR remain deterministic. -`ctx.invariants.register(packageName, installer)` reserves one active registration for the full npm package name, including when filters keep its installer inactive, and returns its disposer. An enabled contribution runs in a dedicated child Cordis fiber. The installer can declare its required service surface through `installer.inject` and receives `fail(message)`, which throws an `InvariantError` bound to the registering package. Installer failure disposes the child and releases ownership atomically. +`ctx.invariants.register(packageName, installer)` reserves one active registration for the full npm package name, including when filters keep its installer inactive, and returns its disposer. An enabled contribution runs in a dedicated child Cordis fiber. The installer can declare its required service surface through `installer.inject` and receives `fail(message)`, which throws an `InvariantError` bound to the registering package. Synchronous or asynchronous installer completion is joined before registration succeeds; failure disposes the child and releases ownership atomically. The service owns every registration fiber, while the returned disposer also belongs to the companion fiber. Unloading either side removes the listeners and reservation completely. A companion can therefore reload and register the same package name without retaining trace state or duplicate listeners; packages that need an existing baseline rebuild it during installation. @@ -32,7 +32,7 @@ Packages select the narrowest runtime form that protects their public contract: |---|---| | Cordis plugin | `observePluginInvariant` validates the plugin's own declared name, required injections, owned effect group, provided services, and optional package-specific relation for existing, late, and HMR-activated fibers. | | Cordis service seam | `observeServiceInvariant` plus `serviceShapeViolation` validates current and future structural implementations, including conforming third-party backends and test doubles. | -| Pure library, bin, or support package | `assertInvariant` checks stable protocol algebra, parser mapping, path/timeout/retention rules, normalization, or entrypoint shape in a child effect. | +| Pure library, bin, or support package | `assertInvariant` checks stable protocol algebra, parser mapping, path/timeout/retention rules, normalization, or entrypoint shape during child startup. | Four companions additionally install stateful event and request checks: @@ -62,7 +62,7 @@ ctx.plugin(InvariantService, { ctx.plugin(SessionInvariant) ``` -The standard agent spine mounts the service and the four stateful companions. Custom compositions explicitly add the companions for the packages whose contracts they want checked and may disable or filter them without changing package entrypoints. Vitest mounts every package companion against an explicitly enabled service for ordinary Cordis roots, so all package checks execute across unit, snapshot, and e2e suites; focused invariant-service tests construct their own topology to exercise filtering and lifecycle behavior. +The standard agent spine mounts the service and the four stateful companions. Custom compositions explicitly add the companions for the packages whose contracts they want checked and may disable or filter them without changing package entrypoints. Plugin and service helpers multiplex package contracts through indexed lifecycle listeners shared by the Cordis root, while contribution disposal removes only that owner's contract. Vitest gives every ordinary root an explicitly enabled service and mounts the current test package's companion; one exhaustive topology test mounts all companions once, and focused invariant-service tests construct their own topology to exercise filtering and lifecycle behavior. ## Model Experience diff --git a/packages/support/invariants/src/index.ts b/packages/support/invariants/src/index.ts index 374012fdbd..265596b677 100644 --- a/packages/support/invariants/src/index.ts +++ b/packages/support/invariants/src/index.ts @@ -28,15 +28,15 @@ export interface Config { */ export type InvariantFailure = (message: string) => never -/** Install one package's listeners into the registration's child context. */ +/** Install one package's checks into the registration's child context. */ export interface InvariantInstaller { /** * Install the package contribution. * @param ctx - child context owned by this invariant registration. * @param fail - reporter bound to the registering package name. - * @returns nothing after synchronous listener installation completes. + * @returns nothing, or a promise settling after asynchronous checks finish. */ - (ctx: Context, fail: InvariantFailure): void + (ctx: Context, fail: InvariantFailure): void | Promise /** Services the child installer fiber may access. */ readonly inject?: Inject } @@ -70,11 +70,111 @@ function collectEffectLabels(fiber: Fiber): ReadonlySet { return labels } +/** One package check routed by a root-shared plugin lifecycle dispatcher. */ +interface PluginObservation { + readonly callback: globalThis.Function | undefined + readonly contract: PluginInvariantContract + readonly fail: InvariantFailure +} + +/** Indexed plugin checks and the two lifecycle listeners shared by one root. */ +interface PluginObservationHub { + readonly byCallback: Map> + readonly byName: Map> +} + +const pluginObservationHubs = new WeakMap() + +/** Check one already-matched active plugin fiber. */ +function inspectPluginObservation(observation: PluginObservation, fiber: Fiber): void { + if (fiber.state !== FiberState.ACTIVE || fiber.uid === null) return + const { callback, contract, fail } = observation + if (callback !== undefined && fiber.name !== contract.name) { + fail(`active plugin name must be ${JSON.stringify(contract.name)}, got ${JSON.stringify(fiber.name)}`) + } + const injections = new Set(Object.keys(fiber.inject)) + for (const service of contract.inject ?? []) { + if (!injections.has(service)) fail(`active plugin must inject ${JSON.stringify(service)}`) + } + + const effectLabels = collectEffectLabels(fiber) + for (const requirement of contract.effects ?? []) { + const alternatives = typeof requirement === 'string' ? [requirement] : requirement + if (!alternatives.some(label => effectLabels.has(label))) { + fail(`active plugin must own effect ${alternatives.map(label => JSON.stringify(label)).join(' or ')}`) + } + } + for (const service of contract.services ?? []) { + const provided = Reflect.ownKeys(fiber.ctx.reflect.store).some((key) => { + const implementation = fiber.ctx.reflect.store[key as symbol] + return implementation?.fiber === fiber && implementation.name === service + }) + if (!provided) fail(`active plugin must provide service ${JSON.stringify(service)}`) + } + const message = contract.validate?.(fiber, effectLabels) + if (message !== undefined) fail(message) +} + +/** Route one lifecycle notification only to checks that can match its runtime. */ +function inspectObservedPlugin(hub: PluginObservationHub, fiber: Fiber): void { + const callback = fiber.runtime?.callback + if (callback !== undefined) { + for (const observation of hub.byCallback.get(callback) ?? []) { + inspectPluginObservation(observation, fiber) + } + } + const runtimeName = fiber.runtime?.name + if (runtimeName !== undefined) { + for (const observation of hub.byName.get(runtimeName) ?? []) { + inspectPluginObservation(observation, fiber) + } + } +} + +/** Return the root's shared plugin dispatcher, creating its two listeners once. */ +function pluginObservationHub(ctx: Context): PluginObservationHub { + const root = ctx.root + const existing = pluginObservationHubs.get(root) + if (existing !== undefined) return existing + + const hub: PluginObservationHub = { + byCallback: new Map(), + byName: new Map(), + } + pluginObservationHubs.set(root, hub) + root.on('internal/plugin', (fiber) => { inspectObservedPlugin(hub, fiber) }, { global: true }) + root.on('internal/status', (fiber) => { inspectObservedPlugin(hub, fiber) }, { global: true }) + return hub +} + +/** Add one plugin observation to a typed exact-key index. */ +function addIndexedPluginObservation( + index: Map>, + key: Key, + observation: PluginObservation, +): () => void { + const observations = index.get(key) ?? new Set() + index.set(key, observations) + observations.add(observation) + return () => { + observations.delete(observation) + if (observations.size === 0) index.delete(key) + } +} + +/** Add one observation to its exact callback or runtime-name index. */ +function addPluginObservation(hub: PluginObservationHub, observation: PluginObservation): () => void { + if (observation.callback === undefined) { + return addIndexedPluginObservation(hub.byName, observation.contract.name, observation) + } + return addIndexedPluginObservation(hub.byCallback, observation.callback, observation) +} + /** * Observe one package plugin and fail whenever an active fiber violates its * declared name, dependency, effect, service, or package-specific contract. * Existing fibers are checked immediately; later starts and HMR activations - * are checked through Cordis lifecycle events. + * are checked through two indexed lifecycle listeners shared by the root. * @param ctx - invariant child context that owns the observers. * @param fail - reporter bound to the package that owns the plugin. * @param contract - expected runtime facts for the package plugin. @@ -90,50 +190,79 @@ export function observePluginInvariant( fail('invariant contract does not identify a Cordis plugin') } - const inspect = (fiber: Fiber): void => { - const matches = callback === undefined - ? fiber.runtime?.name === contract.name - : fiber.runtime?.callback === callback - if (!matches || fiber.state !== FiberState.ACTIVE) return - if (callback !== undefined && fiber.name !== contract.name) { - fail(`active plugin name must be ${JSON.stringify(contract.name)}, got ${JSON.stringify(fiber.name)}`) - } - const injections = new Set(Object.keys(fiber.inject)) - for (const service of contract.inject ?? []) { - if (!injections.has(service)) fail(`active plugin must inject ${JSON.stringify(service)}`) - } - - const effectLabels = collectEffectLabels(fiber) - for (const requirement of contract.effects ?? []) { - const alternatives = typeof requirement === 'string' ? [requirement] : requirement - if (!alternatives.some(label => effectLabels.has(label))) { - fail(`active plugin must own effect ${alternatives.map(label => JSON.stringify(label)).join(' or ')}`) - } - } - for (const service of contract.services ?? []) { - const provided = Reflect.ownKeys(fiber.ctx.reflect.store).some((key) => { - const implementation = fiber.ctx.reflect.store[key as symbol] - return implementation?.fiber === fiber && implementation.name === service - }) - if (!provided) fail(`active plugin must provide service ${JSON.stringify(service)}`) - } - const message = contract.validate?.(fiber, effectLabels) - if (message !== undefined) fail(message) - } + const observation: PluginObservation = { callback, contract, fail } if (contract.plugin === undefined) { for (const runtime of ctx.registry.values()) { - for (const fiber of runtime.fibers) inspect(fiber) + if (runtime.name !== contract.name) continue + for (const fiber of runtime.fibers) inspectPluginObservation(observation, fiber) } } else { - for (const fiber of ctx.registry.get(contract.plugin)?.fibers ?? []) inspect(fiber) + for (const fiber of ctx.registry.get(contract.plugin)?.fibers ?? []) { + inspectPluginObservation(observation, fiber) + } + } + const hub = pluginObservationHub(ctx) + ctx.effect( + () => addPluginObservation(hub, observation), + `invariants.observePlugin(${JSON.stringify(contract.name)})`, + ) +} + +/** One structural check routed by a root-shared service lifecycle dispatcher. */ +interface ServiceObservation { + readonly fail: InvariantFailure + readonly validate: (value: unknown) => string | undefined +} + +/** Service checks and the single service listener shared by one root. */ +interface ServiceObservationHub { + readonly byName: Map> +} + +const serviceObservationHubs = new WeakMap() + +/** Check one present service implementation. */ +function inspectServiceObservation(observation: ServiceObservation, value: unknown): void { + if (value === undefined) return + const message = observation.validate(value) + if (message !== undefined) observation.fail(message) +} + +/** Return the root's shared service dispatcher, creating its listener once. */ +function serviceObservationHub(ctx: Context): ServiceObservationHub { + const root = ctx.root + const existing = serviceObservationHubs.get(root) + if (existing !== undefined) return existing + + const hub: ServiceObservationHub = { byName: new Map() } + serviceObservationHubs.set(root, hub) + root.on('internal/service', (name, value: unknown) => { + for (const observation of hub.byName.get(name) ?? []) { + inspectServiceObservation(observation, value) + } + }, { global: true }) + return hub +} + +/** Add one service observation to its exact service-name index. */ +function addServiceObservation( + hub: ServiceObservationHub, + serviceName: string, + observation: ServiceObservation, +): () => void { + const observations = hub.byName.get(serviceName) ?? new Set() + hub.byName.set(serviceName, observations) + observations.add(observation) + return () => { + observations.delete(observation) + if (observations.size === 0) hub.byName.delete(serviceName) } - ctx.on('internal/plugin', inspect, { global: true }) - ctx.on('internal/status', inspect, { global: true }) } /** - * Validate every current and future implementation bound to one Cordis service. + * Validate every current and future implementation bound to one Cordis + * service through the root's indexed shared service listener. * @param ctx - invariant child context that owns the service observer. * @param fail - reporter bound to the package that owns the service seam. * @param serviceName - Cordis service name to observe. @@ -146,16 +275,14 @@ export function observeServiceInvariant( serviceName: string, validate: (value: unknown) => string | undefined, ): void { - const inspect = (value: unknown): void => { - if (value === undefined) return - const message = validate(value) - if (message !== undefined) fail(message) - } + const observation: ServiceObservation = { fail, validate } const current: unknown = ctx.get(serviceName) - inspect(current) - ctx.on('internal/service', (name, value: unknown) => { - if (name === serviceName) inspect(value) - }, { global: true }) + inspectServiceObservation(observation, current) + const hub = serviceObservationHub(ctx) + ctx.effect( + () => addServiceObservation(hub, serviceName, observation), + `invariants.observeService(${JSON.stringify(serviceName)})`, + ) } /** Structural runtime surface required from a Cordis service implementation. */ @@ -297,7 +424,7 @@ export class InvariantService extends Service { * even when filtering disables its checks. Enabled installers run in a child * fiber; failure disposes that fiber and releases the reservation. * @param packageName - full npm package name that owns the contribution. - * @param installer - synchronous listener installer for the child context. + * @param installer - listener or startup-check installer for the child context. * @returns an effect-scoped disposer for the registration. */ register(packageName: string, installer: InvariantInstaller): () => void { @@ -324,11 +451,11 @@ export class InvariantService extends Service { } } - const installInvariant = (childCtx: Context) => { + const installInvariant = (childCtx: Context) => ( installer(childCtx, (message): never => { throw new InvariantError(packageName, message) }) - } + ) const child = ctx.plugin(installer.inject === undefined ? installInvariant : Object.assign(installInvariant, { inject: installer.inject })) diff --git a/packages/support/invariants/tests/service.spec.ts b/packages/support/invariants/tests/service.spec.ts index 44f8d61139..76fda543ad 100644 --- a/packages/support/invariants/tests/service.spec.ts +++ b/packages/support/invariants/tests/service.spec.ts @@ -273,6 +273,25 @@ describe('InvariantService lifecycle', () => { expect(retry).toHaveBeenCalledOnce() }) + it('joins asynchronous checks and rolls back their effects on failure', async () => { + const { ctx } = await setup() + const leaked = vi.fn() + const failed = runtimeRegistration(ctx.invariants.register('@deepseek-ai/dsh-async-probe', async (child, fail) => { + child.on('invariants-test/ping', leaked, { global: true }) + await Promise.resolve() + fail('asynchronous check failed') + })) + await expect(Promise.resolve(failed)).rejects.toThrow(/asynchronous check failed/) + ctx.emit('invariants-test/ping') + expect(leaked).not.toHaveBeenCalled() + + const retry = runtimeRegistration(ctx.invariants.register('@deepseek-ai/dsh-async-probe', async () => { + await Promise.resolve() + })) + await retry + await retry() + }) + it('releases a synchronous reservation if the service fiber is already inactive', async () => { const { ctx, fiber } = await setup() const service = ctx.invariants @@ -282,11 +301,15 @@ describe('InvariantService lifecycle', () => { }) describe('package-owned invariant helpers', () => { + interface InvariantDisposer { + (): void | Promise + } + async function registerInstaller( ctx: Context, packageName: string, installer: InvariantInstaller, - ): Promise<() => void> { + ): Promise { const registration = runtimeRegistration(ctx.invariants.register(packageName, installer)) const dispose = await Promise.resolve(registration) return dispose @@ -376,6 +399,38 @@ describe('package-owned invariant helpers', () => { await ctx.plugin(plugin) }) + it('multiplexes same-runtime plugin checks through one root listener pair and disposes each owner', async () => { + const { ctx } = await setup() + const firstValidation = vi.fn(() => undefined) + const secondValidation = vi.fn(() => undefined) + const first = await registerInstaller(ctx, '@deepseek-ai/dsh-shared-plugin-first', (child, fail) => { + observePluginInvariant(child, fail, { name: 'shared-plugin-probe', validate: firstValidation }) + }) + const second = await registerInstaller(ctx, '@deepseek-ai/dsh-shared-plugin-second', (child, fail) => { + observePluginInvariant(child, fail, { name: 'shared-plugin-probe', validate: secondValidation }) + }) + const rootEffectLabels = ctx.fiber.getEffects().map(effect => effect.label) + expect(rootEffectLabels.filter(label => label === 'ctx.on("internal/plugin")')).toHaveLength(1) + expect(rootEffectLabels.filter(label => label === 'ctx.on("internal/status")')).toHaveLength(1) + + const plugin = effectPlugin({ name: 'shared-plugin-probe' }) + const firstFiber = await ctx.plugin(plugin) + expect(firstValidation).toHaveBeenCalledOnce() + expect(secondValidation).toHaveBeenCalledOnce() + + await first() + await firstFiber.dispose() + const secondFiber = await ctx.plugin(plugin) + expect(firstValidation).toHaveBeenCalledOnce() + expect(secondValidation).toHaveBeenCalledTimes(2) + + await second() + await secondFiber.dispose() + await ctx.plugin(plugin) + expect(firstValidation).toHaveBeenCalledOnce() + expect(secondValidation).toHaveBeenCalledTimes(2) + }) + it('rejects a contract that does not identify a plugin', async () => { const { ctx } = await setup() const registration = runtimeRegistration(ctx.invariants.register('@deepseek-ai/dsh-invalid-plugin', (child, fail) => { @@ -449,12 +504,46 @@ describe('package-owned invariant helpers', () => { .rejects.toThrow(/wrong watched service/) }) + it('multiplexes same-name service checks through one root listener and disposes each owner', async () => { + const { ctx } = await setup() + const firstValidation = vi.fn(() => undefined) + const secondValidation = vi.fn(() => undefined) + const first = await registerInstaller(ctx, '@deepseek-ai/dsh-shared-service-first', (child, fail) => { + observeServiceInvariant(child, fail, 'watchedInvariantProbe', firstValidation) + }) + const second = await registerInstaller(ctx, '@deepseek-ai/dsh-shared-service-second', (child, fail) => { + observeServiceInvariant(child, fail, 'watchedInvariantProbe', secondValidation) + }) + const rootEffectLabels = ctx.fiber.getEffects().map(effect => effect.label) + expect(rootEffectLabels.filter(label => label === 'ctx.on("internal/service")')).toHaveLength(1) + + const firstFiber = await ctx.plugin(WatchedInvariantProbeService) + expect(firstValidation).toHaveBeenCalledOnce() + expect(secondValidation).toHaveBeenCalledOnce() + + await first() + const firstCallsAfterDisposal = firstValidation.mock.calls.length + const secondCallsBeforeRemount = secondValidation.mock.calls.length + await firstFiber.dispose() + const secondFiber = await ctx.plugin(WatchedInvariantProbeService) + expect(firstValidation).toHaveBeenCalledTimes(firstCallsAfterDisposal) + expect(secondValidation.mock.calls.length).toBeGreaterThan(secondCallsBeforeRemount) + + await second() + const firstCallsAfterBothDisposals = firstValidation.mock.calls.length + const secondCallsAfterBothDisposals = secondValidation.mock.calls.length + await secondFiber.dispose() + await ctx.plugin(WatchedInvariantProbeService) + expect(firstValidation).toHaveBeenCalledTimes(firstCallsAfterBothDisposals) + expect(secondValidation).toHaveBeenCalledTimes(secondCallsAfterBothDisposals) + }) + it('reports synchronous package assertions through the bound failure reporter', async () => { const { ctx } = await setup() const valid = await registerInstaller(ctx, '@deepseek-ai/dsh-valid-assertion', (_child, fail) => { assertInvariant(fail, true, 'must stay true') }) - valid() + await valid() const invalid = runtimeRegistration(ctx.invariants.register('@deepseek-ai/dsh-invalid-assertion', (_child, fail) => { assertInvariant(fail, false, 'must stay true') diff --git a/packages/support/loader-smoke/src/invariant.ts b/packages/support/loader-smoke/src/invariant.ts index 826d33dd30..6fbb41a123 100644 --- a/packages/support/loader-smoke/src/invariant.ts +++ b/packages/support/loader-smoke/src/invariant.ts @@ -12,23 +12,20 @@ export const name = 'loader-smoke-invariant' export const inject = ['invariants'] /** Assert default source mode and plain-Node built-artifact launch resolution. */ -const install: InvariantInstaller = (ctx, fail) => { - ctx.effect(async () => { - const { resolveExampleLaunch, resolveExampleMode } = await import('./index.ts') - assertInvariant(fail, resolveExampleMode('') === 'src', - 'an empty example-mode selection must preserve source-mode development') - const launch = resolveExampleLaunch({ - srcBin: '/workspace/probe/src/bin.ts', - mode: 'lib', - }) - assertInvariant(fail, - launch.command === process.execPath - && launch.args.length === 1 - && launch.args[0] === '/workspace/probe/lib/bin.js' - && launch.env.TSX_TSCONFIG_PATH === undefined, - 'built example launches must use plain Node, the derived lib entry, and no tsx paths map') - return () => {} - }, 'loader-smoke: validate source and built launch resolution') +const install: InvariantInstaller = async (_ctx, fail) => { + const { resolveExampleLaunch, resolveExampleMode } = await import('./index.ts') + assertInvariant(fail, resolveExampleMode('') === 'src', + 'an empty example-mode selection must preserve source-mode development') + const launch = resolveExampleLaunch({ + srcBin: '/workspace/probe/src/bin.ts', + mode: 'lib', + }) + assertInvariant(fail, + launch.command === process.execPath + && launch.args.length === 1 + && launch.args[0] === '/workspace/probe/lib/bin.js' + && launch.env.TSX_TSCONFIG_PATH === undefined, + 'built example launches must use plain Node, the derived lib entry, and no tsx paths map') } /** diff --git a/packages/ui/app-boot/src/invariant.ts b/packages/ui/app-boot/src/invariant.ts index 30e2cfea69..8591c60dd1 100644 --- a/packages/ui/app-boot/src/invariant.ts +++ b/packages/ui/app-boot/src/invariant.ts @@ -13,18 +13,15 @@ export const name = 'app-boot-invariant' export const inject = ['invariants'] /** Assert ordinary and replay config-path selection. */ -const install: InvariantInstaller = (ctx, fail) => { - ctx.effect(async () => { - const { resolveConfigPath } = await import('./config-path.ts') - const cwd = '/tmp/dsh-app-boot-invariant' - const ordinary = resolveConfigPath('cordis.yml', undefined, cwd) - const replay = resolveConfigPath('cordis.yml', 'replay', cwd) - assertInvariant(fail, ordinary === resolve(cwd, 'cordis.yml'), - 'ordinary app boot must retain the requested config basename') - assertInvariant(fail, replay === resolve(cwd, 'cordis.snapshot.yml'), - 'snapshot replay must select cordis.snapshot.yml in the requested config directory') - return () => {} - }, 'app-boot: validate ordinary and replay config selection') +const install: InvariantInstaller = async (_ctx, fail) => { + const { resolveConfigPath } = await import('./config-path.ts') + const cwd = '/tmp/dsh-app-boot-invariant' + const ordinary = resolveConfigPath('cordis.yml', undefined, cwd) + const replay = resolveConfigPath('cordis.yml', 'replay', cwd) + assertInvariant(fail, ordinary === resolve(cwd, 'cordis.yml'), + 'ordinary app boot must retain the requested config basename') + assertInvariant(fail, replay === resolve(cwd, 'cordis.snapshot.yml'), + 'snapshot replay must select cordis.snapshot.yml in the requested config directory') } /** diff --git a/packages/util/brand/src/invariant.ts b/packages/util/brand/src/invariant.ts index 5ac98489cb..d632921d35 100644 --- a/packages/util/brand/src/invariant.ts +++ b/packages/util/brand/src/invariant.ts @@ -12,13 +12,10 @@ export const name = 'brand-invariant' export const inject = ['invariants'] /** Assert that the nominal-type primitive remains erased at runtime. */ -const install: InvariantInstaller = (ctx, fail) => { - ctx.effect(async () => { - const brandRuntime = await import('./index.ts') - assertInvariant(fail, Object.keys(brandRuntime).length === 0, - 'the branded-id primitive must remain type-only with no runtime exports') - return () => {} - }, 'brand: validate type-only runtime erasure') +const install: InvariantInstaller = async (_ctx, fail) => { + const brandRuntime = await import('./index.ts') + assertInvariant(fail, Object.keys(brandRuntime).length === 0, + 'the branded-id primitive must remain type-only with no runtime exports') } /** diff --git a/packages/util/home/src/invariant.ts b/packages/util/home/src/invariant.ts index 651d877e00..fc7c9c3129 100644 --- a/packages/util/home/src/invariant.ts +++ b/packages/util/home/src/invariant.ts @@ -13,17 +13,14 @@ export const name = 'home-invariant' export const inject = ['invariants'] /** Assert the canonical environment key and configured-path precedence. */ -const install: InvariantInstaller = (ctx, fail) => { - ctx.effect(async () => { - const { DSH_HOME_ENV, resolveDshHome } = await import('./index.ts') - const environmentKey: string = DSH_HOME_ENV - assertInvariant(fail, environmentKey === ['DSH', 'HOME'].join('_'), - 'the canonical Harness home environment key must remain DSH_HOME') - const configured = 'relative-invariant-home' - assertInvariant(fail, resolveDshHome(configured) === resolve(configured), - 'an explicitly configured Harness home must normalize to an absolute path') - return () => {} - }, 'home: validate canonical DSH home resolution') +const install: InvariantInstaller = async (_ctx, fail) => { + const { DSH_HOME_ENV, resolveDshHome } = await import('./index.ts') + const environmentKey: string = DSH_HOME_ENV + assertInvariant(fail, environmentKey === ['DSH', 'HOME'].join('_'), + 'the canonical Harness home environment key must remain DSH_HOME') + const configured = 'relative-invariant-home' + assertInvariant(fail, resolveDshHome(configured) === resolve(configured), + 'an explicitly configured Harness home must normalize to an absolute path') } /** diff --git a/packages/util/paths/src/invariant.ts b/packages/util/paths/src/invariant.ts index ce0ddb653c..20a3962ff4 100644 --- a/packages/util/paths/src/invariant.ts +++ b/packages/util/paths/src/invariant.ts @@ -14,17 +14,14 @@ export const name = 'paths-invariant' export const inject = ['invariants'] /** Assert tilde expansion and explicit-over-environment home precedence. */ -const install: InvariantInstaller = (ctx, fail) => { - ctx.effect(async () => { - const { DSH_HOME_ENV, expandHomePath, resolveDshHome } = await import('./index.ts') - assertInvariant(fail, expandHomePath('~/invariant-probe') === join(homedir(), 'invariant-probe'), - 'supported tilde prefixes must expand against the operating-system home') - const configured = 'relative-invariant-home' - const resolved = resolveDshHome(configured, { [DSH_HOME_ENV]: '/ignored-environment-home' }) - assertInvariant(fail, resolved === resolve(configured), - 'an explicit DSH home must override the environment and normalize to an absolute path') - return () => {} - }, 'paths: validate DSH home resolution') +const install: InvariantInstaller = async (_ctx, fail) => { + const { DSH_HOME_ENV, expandHomePath, resolveDshHome } = await import('./index.ts') + assertInvariant(fail, expandHomePath('~/invariant-probe') === join(homedir(), 'invariant-probe'), + 'supported tilde prefixes must expand against the operating-system home') + const configured = 'relative-invariant-home' + const resolved = resolveDshHome(configured, { [DSH_HOME_ENV]: '/ignored-environment-home' }) + assertInvariant(fail, resolved === resolve(configured), + 'an explicit DSH home must override the environment and normalize to an absolute path') } /** diff --git a/packages/util/retention/src/invariant.ts b/packages/util/retention/src/invariant.ts index 380e4e3d4b..fa8ab7d36a 100644 --- a/packages/util/retention/src/invariant.ts +++ b/packages/util/retention/src/invariant.ts @@ -12,24 +12,21 @@ export const name = 'retention-invariant' export const inject = ['invariants'] /** Assert exact head-retention accounting after the budget is exceeded. */ -const install: InvariantInstaller = (ctx, fail) => { - ctx.effect(async () => { - const { ItemRetainer } = await import('./index.ts') - const retainer = new ItemRetainer({ kind: 'head', maxItems: 2 }) - retainer.push('first') - retainer.push('second') - retainer.push('third') - const result = retainer.finish() - assertInvariant(fail, - result.items.join(',') === 'first,second' - && result.seen === 3 - && result.kept === 2 - && result.truncated - && result.omitted.kind === 'exact' - && result.omitted.count === 1, - 'head retention must keep the prefix and report exact seen, kept, and omitted counts') - return () => {} - }, 'retention: validate exact head accounting') +const install: InvariantInstaller = async (_ctx, fail) => { + const { ItemRetainer } = await import('./index.ts') + const retainer = new ItemRetainer({ kind: 'head', maxItems: 2 }) + retainer.push('first') + retainer.push('second') + retainer.push('third') + const result = retainer.finish() + assertInvariant(fail, + result.items.join(',') === 'first,second' + && result.seen === 3 + && result.kept === 2 + && result.truncated + && result.omitted.kind === 'exact' + && result.omitted.count === 1, + 'head retention must keep the prefix and report exact seen, kept, and omitted counts') } /** diff --git a/packages/util/timeout/src/invariant.ts b/packages/util/timeout/src/invariant.ts index 8302380eaa..2d9d22f5d6 100644 --- a/packages/util/timeout/src/invariant.ts +++ b/packages/util/timeout/src/invariant.ts @@ -12,19 +12,16 @@ export const name = 'timeout-invariant' export const inject = ['invariants'] /** Assert default-before-cap arithmetic and capability-code classification. */ -const install: InvariantInstaller = (ctx, fail) => { - ctx.effect(async () => { - const { clampTimeout, TimeoutReason, timeoutOf } = await import('./index.ts') - assertInvariant(fail, - clampTimeout(undefined, 50, 30) === 30 && clampTimeout(20, 50, 30) === 20, - 'timeout resolution must apply the default before capping and preserve smaller requests') - const reason = new TimeoutReason('INVARIANT_TIMEOUT', 25) - assertInvariant(fail, timeoutOf({ reason }, 'INVARIANT_TIMEOUT') === reason, - 'timeout classification must recover a matching capability-owned reason') - assertInvariant(fail, timeoutOf({ reason }, 'FOREIGN_TIMEOUT') === undefined, - 'timeout classification must reject a reason owned by another capability') - return () => {} - }, 'timeout: validate resolution and reason classification') +const install: InvariantInstaller = async (_ctx, fail) => { + const { clampTimeout, TimeoutReason, timeoutOf } = await import('./index.ts') + assertInvariant(fail, + clampTimeout(undefined, 50, 30) === 30 && clampTimeout(20, 50, 30) === 20, + 'timeout resolution must apply the default before capping and preserve smaller requests') + const reason = new TimeoutReason('INVARIANT_TIMEOUT', 25) + assertInvariant(fail, timeoutOf({ reason }, 'INVARIANT_TIMEOUT') === reason, + 'timeout classification must recover a matching capability-owned reason') + assertInvariant(fail, timeoutOf({ reason }, 'FOREIGN_TIMEOUT') === undefined, + 'timeout classification must reject a reason owned by another capability') } /** diff --git a/scripts/test-invariants.spec.ts b/scripts/test-invariants.spec.ts index 77150abb74..5c3b9120bc 100644 --- a/scripts/test-invariants.spec.ts +++ b/scripts/test-invariants.spec.ts @@ -2,7 +2,11 @@ import { describe, expect, it, vi } from 'vitest' import { Context, Service } from 'cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' import { packageInvariantOwners } from './package-invariants.ts' -import { MANUAL_INVARIANT_TESTS, testInvariantCompanions } from './test-invariants.ts' +import { + MANUAL_INVARIANT_TESTS, + testInvariantCompanionPaths, + testInvariantCompanions, +} from './test-invariants.ts' declare module 'cordis' { interface Context { @@ -17,7 +21,7 @@ class TestInvariantProbe extends Service { } describe('global test invariant host', () => { - it('loads every companion and reserves every package name with enabled checks', async () => { + it('uses one exhaustive topology to reserve every package name with enabled checks', async () => { const ctx = new Context() await ctx.plugin(TestInvariantProbe) @@ -39,6 +43,14 @@ describe('global test invariant host', () => { expect(unreserved).toEqual([]) }) + it('mounts the owning package companion while leaving non-package roots service-only', () => { + expect(testInvariantCompanionPaths('/repo/packages/core/tools/tests/tools.spec.ts')) + .toEqual(['../packages/core/tools/src/invariant.ts']) + expect(testInvariantCompanionPaths('/repo/examples/echo-agent/tests/echo.spec.ts')).toEqual([]) + expect(testInvariantCompanionPaths('/repo/scripts/test-invariants.spec.ts')) + .toEqual(Object.keys(testInvariantCompanions).sort()) + }) + it('executes each companion registration with its owning package name', async () => { const owners = new Map(packageInvariantOwners(process.cwd()).map(owner => [owner.sourcePath, owner.packageName])) const registrations = new Map() diff --git a/scripts/test-invariants.ts b/scripts/test-invariants.ts index 584db3d5d7..b91fdda462 100644 --- a/scripts/test-invariants.ts +++ b/scripts/test-invariants.ts @@ -1,7 +1,8 @@ /** * Vitest-wide invariant host. Ordinary Cordis roots receive the invariant - * service with global enablement and every package companion before their first - * plugin starts. Focused invariant tests own their service topology explicitly. + * service with global enablement plus the current test package's companion. + * One topology test mounts every companion; focused invariant tests own their + * service topology explicitly. */ import { expect } from 'vitest' @@ -40,6 +41,7 @@ export const MANUAL_INVARIANT_TESTS = [ interface InvariantHost { readonly fibers: readonly PluginFiber[] readonly byCallback: ReadonlyMap + readonly ready: Promise } type PluginFiber = ReturnType @@ -55,13 +57,15 @@ RegistryService.prototype.plugin = function(plugin: Plugin, config?: unknown, ge const host = hosts.get(root) ?? startInvariantHost(root) const callback = this.resolve(plugin) const existing = callback === undefined ? undefined : host.byCallback.get(callback) - if (existing !== undefined) return existing + if (existing !== undefined) { + return this.ctx === root ? joinInvariantStartup(existing, host.ready) : existing + } const fiber = originalPlugin.call(this, plugin, config, getOuterStack) // A root-level await is the test's composition boundary. Nested plugin // fibers must not await their own companion parent through the global host. if (this.ctx !== root) return fiber - return joinInvariantStartup(fiber, host.fibers) + return joinInvariantStartup(fiber, host.ready) } function usesManualInvariantTree(): boolean { @@ -69,6 +73,30 @@ function usesManualInvariantTree(): boolean { return MANUAL_INVARIANT_TESTS.some(path => testPath.endsWith(path)) } +const ALL_COMPANION_TESTS = ['/scripts/test-invariants.spec.ts'] as const + +/** + * Select the package companions that an ordinary test root must register. + * Package tests receive their owner's checks; the dedicated topology test + * receives every owner so coverage and exhaustive runtime registration remain + * independently enforced. + * @param testPath - absolute or repo-relative normalized Vitest file path. + * @returns sorted `import.meta.glob` keys for companions to mount. + */ +export function testInvariantCompanionPaths(testPath: string): string[] { + const normalized = testPath.replaceAll('\\', '/') + const allPaths = Object.keys(testInvariantCompanions).sort() + if (ALL_COMPANION_TESTS.some(path => normalized.endsWith(path))) return allPaths + + const owner = normalized.match(/\/packages\/([^/]+)\/([^/]+)\/tests\//) + if (owner === null) return [] + const companionPath = `../packages/${owner[1]}/${owner[2]}/src/invariant.ts` + if (testInvariantCompanions[companionPath] === undefined) { + throw new Error(`test invariants: package test has no companion at ${companionPath}`) + } + return [companionPath] +} + function startInvariantHost(root: Context): InvariantHost { const fibers: PluginFiber[] = [] const byCallback = new Map() @@ -81,21 +109,35 @@ function startInvariantHost(root: Context): InvariantHost { } mount(InvariantService, { enabled: true }) - for (const [path, companion] of Object.entries(testInvariantCompanions).sort(([left], [right]) => left.localeCompare(right))) { + const testPath = expect.getState().testPath ?? '' + const companionPaths = testInvariantCompanionPaths(testPath) + for (const path of companionPaths) { + const companion = testInvariantCompanions[path] + if (companion === undefined) { + throw new Error(`test invariants: selected companion vanished at ${path}`) + } if (!companion.inject.includes('invariants')) { throw new Error(`test invariants: ${path} must inject the invariant service`) } mount(companion) } - const host = { fibers, byCallback } + const [serviceFiber, ...companionFibers] = fibers + if (serviceFiber === undefined) throw new Error('test invariants: service fiber was not mounted') + // A companion is initially PENDING on the invariant service, and Cordis + // Fiber.await() only joins work already in flight. Wait for the service to + // activate its dependants before joining their startup and failures. + const ready = serviceFiber.await() + .then(() => Promise.all(companionFibers.map(fiber => fiber.await()))) + .then(() => undefined) + const host = { fibers, byCallback, ready } hosts.set(root, host) return host } -function joinInvariantStartup(fiber: PluginFiber, invariantFibers: readonly PluginFiber[]): PluginFiber { +function joinInvariantStartup(fiber: PluginFiber, invariantReady: Promise): PluginFiber { const readiness = fiber.await().then(async (loaded) => { - await Promise.all(invariantFibers.map(invariant => invariant.await())) + await invariantReady return loaded }) const joined = Object.create(fiber) as PluginFiber