fix(scope): close remaining ownership boundaries
This commit is contained in:
@@ -12,7 +12,7 @@ Scoped-context registration primitive. `createScope(ctx, key)` mints a Cordis co
|
||||
- `scopeTarget(base: T, key?: ScopeKey): Scoped<T>` Build the dispatch `thisArg` for a scope-filtered event: composes `base`'s own `Context.filter` with the scope predicate (untagged listener ⇒ admitted; tagged ⇒ admitted iff tag === key; `key === undefined` ⇒ untagged only). Listener `this` stays `base`-shaped. `{ global: true }` listeners bypass filtering (Cordis semantics).
|
||||
- `Scoped<T>` The compile-time carrier brand: scope-filtered events demand it as their `this` type, so dispatching with a bare subject is a compile error.
|
||||
- `isScopeCarrier(value)` / `carrierKeyOf(value)` Runtime carrier marks, used by the dev invariants to assert every scope-filtered dispatch carries a carrier keyed to the subject its arguments name.
|
||||
- `scopeHost(ctx, services)` Test/tooling host whose shared `dispose()` waits for both the host fiber and every minted scope, including a child already tearing down through `rawDispose`.
|
||||
- `scopeHost(ctx, services)` Test/tooling host that snapshots the requested service list before activation, fails loud with stable missing-service diagnostics, and whose shared `dispose()` waits for both the host fiber and every minted scope, including a child already tearing down through `rawDispose`.
|
||||
|
||||
## Design contract
|
||||
|
||||
|
||||
@@ -320,6 +320,8 @@ export interface ScopeHost {
|
||||
* can never be satisfied RESOLVES its fiber await without ever running the
|
||||
* callback — a silent no-op host. This helper fails LOUD instead: when the
|
||||
* callback did not run, it names the absent services and disposes the host.
|
||||
* The service list is copied before plugin activation so caller mutation
|
||||
* across the await cannot change dependency resolution or diagnostics.
|
||||
* @param ctx - the context to mount the host under.
|
||||
* @param services - the service names scopes minted through this host reach
|
||||
* (the host plugin's `inject` list).
|
||||
@@ -328,16 +330,20 @@ export interface ScopeHost {
|
||||
* Cordis dead end.
|
||||
*/
|
||||
export async function scopeHost(ctx: Context, services: string[]): Promise<ScopeHost> {
|
||||
// The inject list crosses an await before missing-service diagnostics run.
|
||||
// Detach it now so caller mutation cannot change either Cordis dependency
|
||||
// resolution or the names reported by this helper.
|
||||
const requiredServices = [...services]
|
||||
let hostCtx: Context | undefined
|
||||
// A named function statement (not Object.assign({name}) — Function.name is
|
||||
// read-only) so diagnostics read `scopeHost`.
|
||||
function scopeHostPlugin(inner: Context): void { hostCtx = inner }
|
||||
const fiber = ctx.plugin(Object.assign(scopeHostPlugin, { inject: services }))
|
||||
const fiber = ctx.plugin(Object.assign(scopeHostPlugin, { inject: requiredServices }))
|
||||
await fiber
|
||||
if (hostCtx === undefined) {
|
||||
// Dependency-pending: cordis resolves the await without running the
|
||||
// callback. Name the absentees and unwind the pending fiber.
|
||||
const missing = services.filter(name => ctx.get(name) === undefined)
|
||||
const missing = requiredServices.filter(name => ctx.get(name) === undefined)
|
||||
await fiber.dispose()
|
||||
/* v8 ignore next -- the '(unknown)' fallback is defensive: a pending
|
||||
* fiber with zero absent services cannot occur (an all-present inject
|
||||
|
||||
@@ -367,6 +367,16 @@ describe('scopeHost', () => {
|
||||
.rejects.toThrow('scopeHost: services "tools", "systemPrompt" not available')
|
||||
})
|
||||
|
||||
it('snapshots missing-service diagnostics across the host activation await', async () => {
|
||||
const ctx = new Context()
|
||||
const services = ['tools', 'systemPrompt']
|
||||
const pending = scopeHost(ctx, services)
|
||||
services.splice(0)
|
||||
|
||||
await expect(pending)
|
||||
.rejects.toThrow('scopeHost: services "tools", "systemPrompt" not available')
|
||||
})
|
||||
|
||||
it('names a single absent service in the singular', async () => {
|
||||
const ctx = new Context()
|
||||
await expect(scopeHost(ctx, ['tools'])).rejects.toThrow('scopeHost: service "tools" not available')
|
||||
|
||||
Reference in New Issue
Block a user