diff --git a/.changeset/authenticated-contracts.md b/.changeset/authenticated-contracts.md index 5be3199..48be129 100644 --- a/.changeset/authenticated-contracts.md +++ b/.changeset/authenticated-contracts.md @@ -23,8 +23,8 @@ contract rather than detecting one that was forgotten. `@btravstack/http` resolves the principal through a new `Authenticator` port — `HttpAuthenticator

()([deps], { sync })`, an ordinary di provider, wired on `HttpModule`'s `authenticator` option. A contract that marks nothing needs no -authenticator; a marked router whose root provides none is di's existing -`UNSATISFIED DEPENDENCIES` gate, and an authenticator minted on a different +authenticator; a marked router whose root provides none carries the port as an +unmet need `start` refuses, and an authenticator minted on a different identity than the router is refused at `HttpModule`. A marked procedure whose authenticator declines is answered `UNAUTHORIZED` before dispatch, with the handler never running and no reason reaching the caller — `Unauthenticated` diff --git a/.changeset/gates-say-what-they-mean.md b/.changeset/gates-say-what-they-mean.md new file mode 100644 index 0000000..dad3cab --- /dev/null +++ b/.changeset/gates-say-what-they-mean.md @@ -0,0 +1,21 @@ +--- +"@btravstack/core": minor +"@btravstack/amqp": minor +"@btravstack/temporal": minor +"@btravstack/http": minor +"@btravstack/testing": minor +--- + +The compile-time gates name what is missing. `start`'s markers rode a phantom +rest tuple, whose failure is an arity error — and arity errors never print +types, so `NO RUNTIME` never reached a reader and TypeScript's related info +pointed at the wrong fix. They ride the module parameter now. + +`start`, `runMain` and `bootFixture` no longer take the trailing gate argument. +No production call site passed one; the documented hand-spelled bypass went +with it, so this is a signature change without a migration. + +The same widening reached the composers: `AmqpHandlers`'s/`TemporalActivities`'s +`UNCOVERED HANDLERS`/`UNCOVERED ACTIVITIES` marker and `HttpRouter`'s +`UNDECLARED KEY` marker now say the rule in English and name the missing key, +where each used to end on a bare `"UNCOVERED HANDLERS"` or `never`. diff --git a/CLAUDE.md b/CLAUDE.md index 40d5a6c..a71214a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -445,12 +445,18 @@ type checker already verifies. **`start`'s** gate (`order-api`, `order-temporal-worker`, `order-amqp-worker` — its `NO RUNTIME` arm, since no starter's runtime declares a `needs` any more; `order-api`'s also pins the `unit` halves) and - **di's** need on the starter's port (a composition importing `http()` / + the **unmet need** on the starter's port (a composition importing `http()` / `temporal({ contract, workflows })` / `amqp({ contract })` without providing - the router / activities / handlers carries the starter's port as an unmet - need `start` refuses); the fourth, - `order-application`'s, pins **di's** `UNSATISFIED DEPENDENCIES` gate on - `Module.scoped`. They are different gates and easy to conflate. `start`'s + the router / activities / handlers carries the starter's port in `Needs`, and + `start`'s `module` parameter takes only `Scope | Env`, so it fails to assign — + measured: a `TS2345` ending on + `Type '"HttpRouter"' is not assignable to type '"@di/Scope"'`, which names the + port); the fourth, `order-application`'s, pins **di's** + `UNSATISFIED DEPENDENCIES` gate on `Module.scoped`, which is a rest-tuple + **arity** error printing `Expected 5 arguments, but got 2` and nothing else. + **Three** different mechanisms, easy to conflate — and only the first prints + its name. Do not call the second "di's `UNSATISFIED DEPENDENCIES` gate": an + earlier revision of this file did, and it is wrong in both halves. `start`'s `UNSATISFIED RUNTIME NEEDS` arm is pinned only by `packages/core`'s own `start.test-d.ts`, since every shipped runtime declares `needs: []`. `examples/` is not the only place the gate is pinned by a **type test**: @@ -670,9 +676,13 @@ AuditSlice, observability()], … })`), the piece's own port id rather than on a record position — and `AmqpHandlers(contract)([...])` / `TemporalActivities(contract)([...])` compose them: every key the contract declares must be covered (an uncovered - one is refused at the call, against an `"UNCOVERED HANDLERS"` / - `"UNCOVERED ACTIVITIES"` marker that names the missing key too once the - array's length matches the marker tuple's own length of 2), and two slices + one is refused at the call, against an `"UNCOVERED HANDLERS — …"` / + `"UNCOVERED ACTIVITIES — …"` marker — at the **tail of the third line** of a + `TS2769`, past three hundred characters of the caller's own contract, which + is not shortenable from inside either package because the width is in the + type arguments rather than in a name; the missing key is named too once the + array's length matches the marker tuple's own length of 2, as a **separate** + diagnostic on the trailing element whose target is the bare key), and two slices both discharged for one key are di's duplicate-provider defect at build — the same exactness the keyed HTTP form gets from the shape of the record it composes, reached here through the @@ -735,9 +745,11 @@ CustomersSlice, observability()], exports: [Logger] })`** is the whole `port` back off `Serving.info`; binding, the drain and the trace-id policy are the package's. Two gates keep the composition honest at compile time: a root - that forgets `http()` fails on arity (`NO RUNTIME`), and one that imports - it without providing `orderRouter` fails di's own gate at `start`, since the - starter's runtime provider depends on its router port. + that forgets `http()` is refused against + `"NO RUNTIME — the module exports no port declared over RuntimePort"`, the + sentence intersected onto `start`'s `module` parameter, and one that imports + it without providing `orderRouter` leaves `HttpRouterPort` in `Needs`, which + the same parameter refuses by assignability — not di's arity gate. - **oRPC is pinned to an exact beta.** `@orpc/{client,contract,server}` sit at `2.0.0-beta.28` in the catalog because oRPC v2's `latest` dist-tag is still the **1.x** line, while `@unthrown/orpc` peers on `^2.0.0-beta`: an unpinned diff --git a/docs/examples/order-amqp-worker.md b/docs/examples/order-amqp-worker.md index 1c51c94..f59d842 100644 --- a/docs/examples/order-amqp-worker.md +++ b/docs/examples/order-amqp-worker.md @@ -301,9 +301,9 @@ an exchange, never a consumer. ## The gate -`needs-gate.test-d.ts` pins `NO RUNTIME`, and di's gate spelled with the -`amqp()` primitive — the sugar cannot leave the handlers out, which is what it -is for: +`needs-gate.test-d.ts` pins `NO RUNTIME — …`, and the unmet-need refusal +spelled with the `amqp()` primitive — the sugar cannot leave the handlers out, +which is what it is for: ```ts const HandlerlessAmqp = Module("HandlerlessAmqp")({ @@ -320,6 +320,17 @@ const HandlerlessAmqp = Module("HandlerlessAmqp")({ const _missingHandlers = start(HandlerlessAmqp, options); ``` +Two different diagnostics, worth telling apart. The first is `start`'s marker: +the module argument fails to match +`Module<…> & "NO RUNTIME — the module exports no port declared over RuntimePort"`, +and the sentence is the last line. The second is the `Needs` channel: the +handlers port is left outstanding and `start`'s `module` parameter takes only +`Scope | Env`, so what prints is +`Type 'HandlersInstanceOf<…>' is not assignable to type 'Env | Scope'` — wide, +because the contract expands, but ending on +`Type '"AmqpHandlers"' is not assignable to type '"@di/Scope"'`, which names the +port. Neither is di's `UNSATISFIED DEPENDENCIES` arity gate. + ## Where to go next - The other two deployments: [Order API (HTTP)](/examples/order-api), diff --git a/docs/examples/order-api.md b/docs/examples/order-api.md index 897ef8b..8f419c7 100644 --- a/docs/examples/order-api.md +++ b/docs/examples/order-api.md @@ -508,9 +508,11 @@ directions of `start`'s own gate and di's, side by side: const _missingRuntime = start(RuntimelessApi, options); ``` -`RuntimelessApi` is the same list of slices without `http(...)`: `start`'s phantom -rest tuple becomes a required argument naming the absence, and the call fails -on arity. It provides `bearerAuthenticator` even so, deliberately: the contract +`RuntimelessApi` is the same list of slices without `http(...)`: `start`'s +phantom marker becomes the sentence +`"NO RUNTIME — the module exports no port declared over RuntimePort"`, and the +module argument fails to match its parameter type — the sentence is the error's +last line. It provides `bearerAuthenticator` even so, deliberately: the contract marks `orders`, so a graph carrying the router without an authenticator has an unmet need too, and an arm that could fail either way pins neither gate. @@ -524,12 +526,17 @@ const RouterlessApi = Module("RouterlessApi")({ const _missingRouter = start(RouterlessApi, options); ``` -This one is **di's** gate, not the kernel's: `http()`'s runtime provider -depends on the starter's own router port through di, so a composition that -imports the starter without providing the router carries an unmet need, and -`start` — which accepts only `Scope | Env` outstanding — refuses the module. -There is no `UNSATISFIED RUNTIME NEEDS` arm here, because the shipped runtime -declares no needs. +This one is the **`Needs` channel**, not the kernel's marker: `http()`'s runtime +provider depends on the starter's own router port through di, so a composition +that imports the starter without providing the router carries an unmet need, and +`start` — whose `module` parameter accepts only `Scope | Env` outstanding — +refuses it. What prints is that assignability failure, and it names the port: +`Type 'HttpRouterPort' is not assignable to type 'Env | Scope'`, down to +`Type '"HttpRouter"' is not assignable to type '"@di/Scope"'`. It is **not** +di's `UNSATISFIED DEPENDENCIES` arity gate, which guards `Module.build` and +`Module.scoped`; conflating the two is easy and the distinction is the point of +having both pinned here. There is no `UNSATISFIED RUNTIME NEEDS` arm, because +the shipped runtime declares no needs. ```ts // @ts-expect-error — UNSATISFIED UNIT NEEDS: the module does not export Logger for RequestModule to read. diff --git a/docs/examples/order-application.md b/docs/examples/order-application.md index 020530c..307b3a6 100644 --- a/docs/examples/order-application.md +++ b/docs/examples/order-application.md @@ -284,14 +284,24 @@ activities or handlers that implement it. `start`'s, and easy to conflate with it: ```ts -// Negative: nothing provides `OrderRepository`, so the gate becomes a required -// two-element tuple and the call is an arity error naming the unmet need. +// Negative: nothing provides `OrderRepository`, so di's rest parameter is a +// required two-element tuple the call does not pass. // @ts-expect-error — UNSATISFIED DEPENDENCIES: no OrderRepository is provided. const _unwiredOrders = Module.scoped(OrderApplicationModule, (ctx) => ctx.get(PlaceOrder).execute("o-1", 1), ); ``` +What that prints is `error TS2554: Expected 5 arguments, but got 2.` and +nothing else — an arity error carries no type, so neither the +`UNSATISFIED DEPENDENCIES` label nor `OrderRepository` appears in it. Both are +in the rest parameter's type, and hand-spelling the phantom arguments is what +prints them: pass the label through as the fourth argument and the fifth reports +`Argument of type 'number' is not assignable to parameter of type +'Logger | OrderRepository'` — measured, this vertical's own two open needs. +`start`'s three arms are the deliberate contrast: they ride the `module` +parameter precisely so their sentence prints. + Each vertical's gate is pinned separately, which is the split showing up in the type tests: a graph that provides `OrderRepository` still cannot scope `CustomerApplicationModule`, and one that provides the customer repository diff --git a/docs/examples/order-temporal-worker.md b/docs/examples/order-temporal-worker.md index a5b4523..7d77d24 100644 --- a/docs/examples/order-temporal-worker.md +++ b/docs/examples/order-temporal-worker.md @@ -309,15 +309,22 @@ shutdown to escalate to. See ## The gate -`needs-gate.test-d.ts` pins `NO RUNTIME` (the graph without the starter fails -on arity) and di's gate spelled with the `temporal()` primitive, since the -sugar cannot leave the activities out at all: +`needs-gate.test-d.ts` pins `NO RUNTIME — …` (the graph without the starter +fails to match the sentence intersected onto `start`'s `module` parameter) and +the unmet-need refusal spelled with the `temporal()` primitive, since the sugar +cannot leave the activities out at all: ```ts // @ts-expect-error — UNMET NEED: the module's needs channel carries the activities port, which nothing provides. const _missingActivities = start(ActivitylessTemporal, options); ``` +That second one is the `Needs` channel, not di's `UNSATISFIED DEPENDENCIES` +arity gate: `start`'s `module` parameter accepts only `Scope | Env` +outstanding, so the activities port fails to assign and the diagnostic ends on +`Type '"TemporalActivities"' is not assignable to type '"@di/Scope"'` — the +port named, after several lines of the contract expanding. + Dropping one slice's import while still providing the composed activities is a different failure — the runtime `WiringDefect` the wiring rule above describes — and is not something a compile-time gate can catch, so it is diff --git a/docs/explanation/compile-time-wiring.md b/docs/explanation/compile-time-wiring.md index f338caf..a418cf3 100644 --- a/docs/explanation/compile-time-wiring.md +++ b/docs/explanation/compile-time-wiring.md @@ -1,6 +1,6 @@ --- title: Compile errors, not surprises -description: How the Needs channel and a conditional rest parameter turn missing dependencies, leaked internals, forgotten scopes and a missing runtime into errors at the call site — and where the compile-time line actually sits. +description: How the Needs channel, a conditional rest parameter and a phantom marker turn missing dependencies, leaked internals, forgotten scopes and a missing runtime into errors at the call site — what each one actually prints, and where the compile-time line sits. --- # Compile errors, not surprises @@ -72,11 +72,30 @@ build( When `Needs` is `never`, the tuple is empty and `Module.build(mod)` is an ordinary call. When it is not, the call is missing two required arguments — -arguments no value can supply — and the error names both the literal -`"UNSATISFIED DEPENDENCIES"` and, in `missing`, the actual ports. The gate -differs per entry point only in what it is entitled to exclude first: `scoped` -excludes `Scope` (it opens a real scope), `forkScope` excludes `Scope` and the -parent context's channel (the parent supplies those). +arguments no value can supply. The gate differs per entry point only in what it +is entitled to exclude first: `scoped` excludes `Scope` (it opens a real +scope), `forkScope` excludes `Scope` and the parent context's channel (the +parent supplies those). + +**What it prints, measured:** + +``` +src/scoped.test-d.ts(65,12): error TS2554: Expected 3 arguments, but got 1. +``` + +That is the whole message. An arity error never prints a type, so neither the +`"UNSATISFIED DEPENDENCIES"` label nor the ports in `missing` reach it: with +`--pretty`, TypeScript adds related information pointing at the rest parameter's +_declaration_ in `module.ts`, where a reader sees the labels but sees `N` +un-instantiated. The missing ports are in the parameter's type, and spelling the +phantom arguments out by hand is what gets them printed: a value the rest tuple +cannot accept turns the arity error into an assignability one naming each slot +in turn — the label first, then the port (measured, `Argument of type 'number' +is not assignable to parameter of type 'Scope'`). An editor's language service +reads that same type, so a hover would be expected to show them too. The label +is a signpost for whoever goes looking, not a sentence the compiler hands you. +`start`'s gate below is the same idea paying differently, and the difference is +exactly this. The same trick guards a related mistake at declaration time: an `exports` entry must be provided or imported, so a module cannot claim a surface it @@ -93,7 +112,26 @@ declaration and build can drop an entry is variance — the package's one rule: `Needs` and `E` sit in covariant (return) position. Assigning `Module` where `Module` is expected asks the compiler whether `Database` is assignable to `never` — it is not, and the -laundering fails. The opposite choice would make the same assignment reduce to +laundering fails: + +``` +error TS2322: Type 'Module' is not assignable to type 'Module'. + Type 'Database' is not assignable to type 'never'. +``` + +That is as good as this one gets, and it is worth knowing what it does **not** +promise. The two `Module<…>` types on the first line are the whole diagnostic +in the general case: the reader diffs them. On some fixtures TypeScript +elaborates a third line naming the offending member — +`Property 'url' is missing in type 'ConfigError' but required in type 'PoolError'` +names `ConfigError` — but that is structural elaboration into whichever +property happens to differ, so two error types differing only in a `_tag` would +elaborate onto `_tag` and name nothing actionable, and two structurally +identical ones would not elaborate at all. Attaching a named wrapper to the +phantom `_error` field was tried and the re-captured diagnostic came back +**byte-identical** — TypeScript elaborates straight to the leaf mismatch and +never prints the wrapper's key. The width here is in the _type arguments_, not +in a constructor name, so nothing di can spell moves it. The opposite choice would make the same assignment reduce to `never extends Database`, trivially true, and an annotation as innocent as a helper's return type could silently zero the ledger. The source pins this with type-level tests (`*.test-d.ts`), because the guarantee lives entirely in the @@ -105,12 +143,14 @@ it breaks. `start` accepts a `Module` — covariance is what lets a module needing nothing, one owing `Scope` and one reading `Env` all fit — and then asks three questions of `X` that di's gate has no reason to ask. They -arrive as the same shape, a phantom rest tuple named `StartGate` -that `start`, `runMain` and `@btravstack/testing`'s `Boot` all carry: +arrive as a phantom marker named `StartGate`, **intersected onto +the `module` parameter** — `unknown`, and invisible, when the gate is satisfied; +a sentence otherwise. `start`, `runMain` and `@btravstack/testing`'s `Boot` all +carry it: | Arm | Fires when | | --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `NO RUNTIME` | The module exports no port declared over `RuntimePort`. A process boots exactly one runtime, and it is a service of the module — a root that forgets `HttpModule`/`http(...)` fails on arity here. | +| `NO RUNTIME` | The module exports no port declared over `RuntimePort`. A process boots exactly one runtime, and it is a service of the module — a root that forgets `HttpModule`/`http(...)` is refused here. | | `UNSATISFIED RUNTIME NEEDS` | The runtime's declared `needs` are not among the module's exports — the **module's alone**, never the unit module's, because `RuntimeHost.ctx` is the application context and a unit-only port does not exist at startup. No shipped starter declares any today. | | `UNSATISFIED UNIT NEEDS` | With `StartOptions.unit`, the unit module's needs are not covered by the module's exports, `Scope` or `Env` — `forkScope`'s gate, stated at `start`'s call site, where the parent is actually known. | @@ -120,16 +160,33 @@ const Application = Module("Application")({ exports: [Greeter], }); -start(Application); // NO RUNTIME: the module exports no port declared over RuntimePort +start(Application); ``` -The gate is a trailing rest tuple rather than a conditional type on `module` -or `options` for a reason di shares: a conditional on an inference-bearing -parameter makes TypeScript defer that parameter's inference and can collapse -`X` or `E` to `unknown`. And like di's, it is **bypassable on purpose** — a -caller who spells the phantom arguments out by hand does typecheck, which the -kernel's own type tests assert rather than assume. It takes a deliberate act; -the gate exists to catch the accident, not to be unforgeable. +**What it prints, measured:** + +``` +error TS2345: Argument of type 'Module' is not assignable to parameter of type 'Module & "NO RUNTIME — the module exports no port declared over RuntimePort"'. + Type 'Module' is not assignable to type '"NO RUNTIME — the module exports no port declared over RuntimePort"'. +``` + +The sentence prints because the marker **rides the `module` parameter**: the +argument failed to match a parameter type, and a parameter type is something +TypeScript prints. That is the whole reason for the shape. This gate was a +trailing rest tuple until it was not, on the grounds that a conditional type in +an inference-bearing position can defer that parameter's inference and collapse +`X` or `E` to `unknown` — measured, and it does not here, because `X` still +infers from the `Module` half of the intersection. What the tuple cost was +the diagnostic: a missing rest argument is an arity error, `NO RUNTIME` never +reached a reader, and TypeScript's related information pointed at the wrong fix +("an argument for 'options' was not provided"). di's gate on `Module.scoped` is +**still** a rest tuple, so the two are no longer the same shape — do not read +them as parallel. + +One thing went with the tuple: the hand-spelled bypass. `start`'s gate is still +**bypassable on purpose**, but only by a cast (`start(App as never)`), which is +the ordinary TypeScript escape rather than anything this gate offers. It takes a +deliberate act; the gate exists to catch the accident, not to be unforgeable. ## Where the line actually is @@ -160,22 +217,66 @@ escape hatches (`as never`, `any`) that no library survives; the runtime checks exist precisely so that even those degrade into a loud pre-construction defect rather than silent misbehaviour. -## Why an arity error, of all things - -The gate could have been a constraint (`N extends never`) on the module -parameter. The rest-parameter form was chosen because of what the _error_ -looks like: the constraint form reports a failure on the whole argument, deep -in a generic instantiation; the arity form reports "expected 3 arguments, got -1" with a tuple whose labels spell `UNSATISFIED DEPENDENCIES` — or -`NO RUNTIME` — and whose type names the missing ports, at the call site, in -the order a reader debugs. When a guarantee's only user interface is a -compiler diagnostic, the diagnostic is the design. +## Why an arity error — and why the kernel stopped using one + +di's gate could have been a constraint (`N extends never`) on the module +parameter. The rest-parameter form was chosen for where it puts the blame: a +constraint reports a failure on the whole argument, deep in a generic +instantiation, while the arity form points at the call itself and leaves the +module type alone. That is a real property, and it is the one di keeps. + +What it is not is a message. `Expected 3 arguments, but got 1` is the entire +diagnostic, and the labels a reader is told to look for live in the rest +parameter's declaration rather than in the error. The kernel wanted the arm's +name in the message, so it moved its own gate onto the `module` parameter and +took the constraint-shaped diagnostic on purpose — the sentence is the last +thing printed, which is where the eye lands. **When a guarantee's only user +interface is a compiler diagnostic, the diagnostic is the design**, and this is +the same principle reaching two different answers because the two gates have +different things to say: di's `missing: N` is a set of ports a reader can read +off the signature, the kernel's is one of three fixed sentences. ## The cost, stated plainly -The types work hard, and it shows at the edges: a wiring mistake surfaces as -an arity error rather than a friendly sentence, and hovering a large module -shows real channel unions. The container is also deliberately small — one -construction family, one module algebra, three entry points, one name per -concept. Conditional registration DSLs, interceptors and property injection +The types work hard, and it shows at the edges: di's wiring mistakes surface as +an arity error rather than a friendly sentence, and the kernel's surface as a +long assignability error whose readable half is its last line. A large module's +channel unions are real types, and a diagnostic that has to print one prints it +at full width. The container is also deliberately small — one construction +family, one module algebra, three entry points, one name per concept. +Conditional registration DSLs, interceptors and property injection are not missing features; this is the wrong library for them on purpose. + +## The record + +Three gate mechanisms live in this repo that a composing application meets, +not two — and before this branch, thirteen places across the documentation and +the examples named one as another. A **fourth** lives in the test harness: +`@btravstack/testing`'s `tapped` keeps di's conditional rest tuple, so a port +the module does not export is an arity error there too, and +[the testing reference measures it](/reference/testing#the-tap-gate-an-arity-error). +This table is the index of the three. Where the full diagnostic is already +told above, the row points back rather than repeating it; where it is not, +the row carries the measured target — the type each diagnostic's last line +ends on, which is the payload of the whole message. + +| Mechanism | Case | Printed target, before | Printed target, after | +| ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| di's own gate (a conditional rest tuple) | `Module.scoped`/`build`/`forkScope` | `Expected 3 arguments, but got 1.` — [the whole message](#the-gate-an-arity-error), unchanged | same — no task on this branch touched it | +| An unmet need at `start` (plain assignability) | a starter's own port, e.g. `AmqpHandlers` | `'"AmqpHandlers"' is not assignable to type '"@di/Scope"'` | same — this was always the best diagnostic in the repo; the thirteen corrections were to the documentation calling it di's gate, not to the gate | +| `start`'s `StartGate` — `NO RUNTIME` | [the Greeter example above](#the-kernels-own-gate) | `Expected 4 arguments, but got 1.` | ends on `"NO RUNTIME — the module exports no port declared over RuntimePort"` — [full example above](#the-kernels-own-gate) | +| `start`'s `StartGate` — `UNSATISFIED RUNTIME NEEDS` | a runtime's `needs` uncovered by the module's exports | `Expected 4 arguments, but got 1.` | ends on `"UNSATISFIED RUNTIME NEEDS — the runtime needs a port the module does not export"` | +| `start`'s `StartGate` — `UNSATISFIED UNIT NEEDS` | a unit module's needs uncovered | `Expected 4 arguments, but got 2.` | ends on `"UNSATISFIED UNIT NEEDS — the unit module needs a port the module does not export"` | +| amqp's/temporal's composer — `UNCOVERED HANDLERS`/`UNCOVERED ACTIVITIES` | `AmqpHandlers(contract)([...])` / `TemporalActivities(contract)([...])` missing a key | ends on `'"UNCOVERED HANDLERS"'` / `'"UNCOVERED ACTIVITIES"'` | ends on `'"UNCOVERED HANDLERS — the contract declares a consumer this array does not cover"'` / the `ACTIVITIES` twin; the missing key prints too, as a separate diagnostic on the trailing element, once the array is as long as the marker tuple (measured: `'"orderAudit"'`, `'"fulfillOrder"'`) | +| http's keyed router — `UNDECLARED KEY` | `HttpRouter(contract)(controllers)` with a key the contract does not declare | ends on `'never'` | ends on `'"UNDECLARED KEY — the contract declares no fragment under billing"'` — the key is named too, straight from the mapped type's own `K` | + +No gate's behaviour moved: the same 82 `@ts-expect-error` directives fire +after this branch as before it — none added or removed, and none now guards a +different call. (Four in `packages/core/src/start.test-d.ts` shifted line — +56→57, 66→67, 92→95, 129→131 — because the hand-spelled bypass calls below them +became `expectTypeOf` assertions; each still sits above the call it always +guarded.) What changed is which of these target strings a reader sees. +The row that did **not** change and is still the best diagnostic in the +repo — the unmet need at `start` — is the one this branch's own documentation +most often mislabelled as di's gate; naming it correctly here is the closing +half of that fix. diff --git a/docs/explanation/design-decisions.md b/docs/explanation/design-decisions.md index 13c25d5..e9c799a 100644 --- a/docs/explanation/design-decisions.md +++ b/docs/explanation/design-decisions.md @@ -23,18 +23,28 @@ else and reads its collaborators the same way — which is what let every starter's `needs` go to `never`. It rules out `start(module, { runtime })`, and with it a runtime constructed outside the graph that reaches back into it. -## The gate is a phantom rest tuple, and it is bypassable on purpose - -`start`, `runMain` and `@btravstack/testing`'s `Boot` end in -`...gate: StartGate` — empty when the module exports a runtime -whose needs its exports cover, a named error tuple (`NO RUNTIME`, -`UNSATISFIED RUNTIME NEEDS`, `UNSATISFIED UNIT NEEDS`) otherwise. A conditional type on `module` or `options` would make -TypeScript defer that parameter's inference and can collapse `X` or `E` to -`unknown`; a trailing rest tuple leaves inference alone. It is the same shape -as di's `UNSATISFIED DEPENDENCIES` gate on `Module.scoped`. A caller who -hand-writes the phantom arguments does typecheck — proved in -`start.test-d.ts`, not assumed. The gate exists to catch the accident, not to -be unforgeable, and making it unforgeable would cost the inference it protects. +## The gate is a phantom marker on `module`, and it is bypassable on purpose + +`start`, `runMain` and `@btravstack/testing`'s `Boot` all intersect +`StartGate` onto their `module` parameter — `unknown`, and +invisible, when the module exports a runtime whose needs its exports cover; one +of three sentences (`NO RUNTIME — …`, `UNSATISFIED RUNTIME NEEDS — …`, +`UNSATISFIED UNIT NEEDS — …`) otherwise. It rides the parameter so that the +sentence **prints**: an argument that fails a parameter type makes TypeScript +name that type, where the trailing rest tuple this used to be failed as an +arity error and named nothing. + +The tuple was chosen originally because a conditional type in an +inference-bearing position can make TypeScript defer that parameter's +inference and collapse `X` or `E` to `unknown`. Measured, it does not here: `X` +still infers from the `Module` half of the intersection. di's +`UNSATISFIED DEPENDENCIES` gate on `Module.scoped` is still a rest tuple, so +the two are **no longer the same shape**. + +The gate is still bypassable, by a cast (`start(App as never)`) — the ordinary +TypeScript escape, not a hatch this gate offers, and nothing asserts it because +a cast defeats every gate. Hand-writing the phantom arguments went with the +tuple. The gate exists to catch the accident, not to be unforgeable. ## `RuntimeStartFailed` is the only error the kernel mints diff --git a/docs/explanation/one-process-one-runtime.md b/docs/explanation/one-process-one-runtime.md index 6152b75..aaa5f30 100644 --- a/docs/explanation/one-process-one-runtime.md +++ b/docs/explanation/one-process-one-runtime.md @@ -29,7 +29,8 @@ class HttpRuntime extends RuntimePort> {} — so at runtime every one of them has the id `"Runtime"`, while each carries its own `Needs` and `Info` in the type. `start` builds the graph, resolves that one port, and drives what it finds. A module that exports no port with that id -fails on arity at the call (`NO RUNTIME`); a module that provides two runtimes +is refused at the call, against the sentence +`"NO RUNTIME — the module exports no port declared over RuntimePort"`; a module that provides two runtimes is two providers for one port id, which di reports as a wiring defect before any factory runs. There is no `runtimes: [...]` option, and no surface in the kernel is meant to grow one. diff --git a/docs/explanation/scopes-and-resources.md b/docs/explanation/scopes-and-resources.md index adcb09a..3f5da98 100644 --- a/docs/explanation/scopes-and-resources.md +++ b/docs/explanation/scopes-and-resources.md @@ -32,7 +32,8 @@ and open a real scope, run construction and your callback inside it, and close it before their own result settles — so they exclude `Scope` from the gate. `Module.build` opens nothing, so it excludes nothing, and a resourceful graph -reaching it is an `UNSATISFIED DEPENDENCIES` error at the call site. The leak +reaching it is refused at the call site by the +[`UNSATISFIED DEPENDENCIES` gate](/reference/di/entry-points#the-gate). The leak is refused before it exists. Making `Scope` a port — rather than, say, a boolean flag on the module type — diff --git a/docs/explanation/starters.md b/docs/explanation/starters.md index a3b8960..616db38 100644 --- a/docs/explanation/starters.md +++ b/docs/explanation/starters.md @@ -162,9 +162,10 @@ application's router port is" — could not ship its port: `HttpRuntime` has to be one class in `@btravstack/http`, and its type cannot mention a port only the application knows. Making the router a dependency of the runtime's provider moves that knowledge to where it exists — the composition root that -provides the router — and di's own gate checks it there: a root that imports -`http()` without providing the router carries an unmet need `start` -refuses. The kernel keeps `Runtime.needs`, `RunUnit`'s typed `ctx` and the +provides the router — and the `Needs` channel checks it there: a root that +imports `http()` without providing the router carries an unmet need `start` +refuses, naming the port +(`Type 'HttpRouterPort' is not assignable to type 'Env | Scope'`). The kernel keeps `Runtime.needs`, `RunUnit`'s typed `ctx` and the `UNSATISFIED RUNTIME NEEDS` arm as the general contract for a hand-rolled runtime; the starters simply do not need them. diff --git a/docs/explanation/why-start.md b/docs/explanation/why-start.md index 5c4eb95..9283916 100644 --- a/docs/explanation/why-start.md +++ b/docs/explanation/why-start.md @@ -105,8 +105,8 @@ See [Nothing throws](/explanation/nothing-throws). The kernel is one piece of a stack with a stated goal: **you write business code, the framework owns the plumbing, and the type checker is what you -trust**. A composition root that forgets its runtime is an arity error, not a -boot-time crash. Configuration is a provider bound from the environment and +trust**. A composition root that forgets its runtime is a compile error naming +what is missing, not a boot-time crash. Configuration is a provider bound from the environment and validated once, not a string read at call time. A per-request scope is an option the kernel forks around every unit, not a `forkScope` call in every handler. diff --git a/docs/how-to/keep-a-port-private.md b/docs/how-to/keep-a-port-private.md index 762d108..5a0281e 100644 --- a/docs/how-to/keep-a-port-private.md +++ b/docs/how-to/keep-a-port-private.md @@ -58,8 +58,13 @@ const App = Module("App")({ The second provider does not wire: `Pool` is not among what `App` can see — its own provides plus its imports' exports — so the dependency stays unmet, -and surfaces as `UNSATISFIED DEPENDENCIES` at the entry point (or at -[`start`](/reference/core/start), which carries the same gate). +and surfaces as `UNSATISFIED DEPENDENCIES` at the entry point — the arity +error, `Expected 3 arguments, but got 1`. Under +[`start`](/reference/core/start) the same mistake is caught differently: the +kernel's `module` parameter is `Module`, so the leftover +need fails to assign and the diagnostic **names the port** — measured on the +starters' own gates, where the last line is +`Type '"HttpRouter"' is not assignable to type '"@di/Scope"'`. And on a built context: diff --git a/docs/how-to/manage-a-resource.md b/docs/how-to/manage-a-resource.md index d254de4..ea0e7d4 100644 --- a/docs/how-to/manage-a-resource.md +++ b/docs/how-to/manage-a-resource.md @@ -58,8 +58,11 @@ the scope **before its own result settles**. The close runs on every path: failure is released, in reverse order. `Module.build` — no scope, no teardown — refuses the graph at compile time: -the call fails on arity with `UNSATISFIED DEPENDENCIES` and `Scope` named as -the missing piece. +`Expected 3 arguments, but got 1`. That arity line is the whole message; the +`UNSATISFIED DEPENDENCIES` label and `Scope` as the missing piece live in the +rest parameter's type. To get them printed, spell the phantom arguments out by +hand — a value the tuple cannot accept names each slot in turn, ending on +`Argument of type 'number' is not assignable to parameter of type 'Scope'`. ## Under `start`, the process is the scope diff --git a/docs/how-to/open-a-per-request-scope.md b/docs/how-to/open-a-per-request-scope.md index 91aed71..de39f7d 100644 --- a/docs/how-to/open-a-per-request-scope.md +++ b/docs/how-to/open-a-per-request-scope.md @@ -119,10 +119,12 @@ whose `E` is `never`. ## The gate has an arm for it -`start`'s phantom rest tuple checks the fork's direction at the call site: the +`start`'s phantom marker checks the fork's direction at the call site: the unit module's needs must be covered by the module's **exports**, `Scope` or -`Env`. A root that has its runtime and router but does not export `Logger` -fails on arity with `UNSATISFIED UNIT NEEDS`: +`Env`. A root that has its runtime and router but does not export `Logger` is +refused against +`"UNSATISFIED UNIT NEEDS — the unit module needs a port the module does not export"`, +the last line of the error: ```ts const UnloggedApi = Module("UnloggedApi")({ diff --git a/docs/how-to/protect-a-procedure.md b/docs/how-to/protect-a-procedure.md index 4337453..a33ed70 100644 --- a/docs/how-to/protect-a-procedure.md +++ b/docs/how-to/protect-a-procedure.md @@ -239,10 +239,15 @@ export const OrderApi = HttpModule("OrderApi")({ Two things are checked here, and they are different gates: -- **Omitting the line** is di's own `UNSATISFIED DEPENDENCIES` at `start`. When +- **Omitting the line** leaves an unmet need, refused at `start`. When the contract marks anything, `HttpRouter` appends `AuthenticatorPort` to the router provider's dependencies, so the need is real and unmet — no new gate, - and nothing this package invents. + and nothing this package invents. What prints is the `Needs` channel failing + to assign: `Type 'AuthenticatorPort' is not assignable to type 'Env | Scope'`, + down to `Type '"HttpAuthenticator"' is not assignable to type '"@di/Scope"'`. + (Not di's `UNSATISFIED DEPENDENCIES` arity gate — that one guards + `Module.build`/`Module.scoped`; `start` types the need out on its `module` + parameter, which is why the port is named.) - **Supplying one minted on a different identity** is a compile error at the `HttpModule(...)` call itself. di cannot see it — `AuthenticatorPort`'s service type is erased to `unknown`, so any authenticator discharges the need diff --git a/docs/how-to/run-a-temporal-worker.md b/docs/how-to/run-a-temporal-worker.md index 5e8aac8..0bb73c7 100644 --- a/docs/how-to/run-a-temporal-worker.md +++ b/docs/how-to/run-a-temporal-worker.md @@ -157,8 +157,11 @@ line on stdout, every line carrying the activity attempt's own trace id. The starter's runtime provider depends on its activities port through di, so a root whose imports do not cover what the provider declared (`FulfillmentModule` and `BillingModule` here — `chargeOrder`'s `PaymentService` comes from the -latter) is refused at `start` — di's gate; a root with no starter fails on -arity (`NO RUNTIME`). `activities` is typed against the module's own +latter) is refused at `start` — the `Needs` channel failing to assign against +`Env | Scope`, which names the port, not di's `UNSATISFIED DEPENDENCIES` arity +gate; a root with no starter is refused against +`"NO RUNTIME — the module exports no port declared over RuntimePort"`. +`activities` is typed against the module's own `contract`: a provider built for another contract is refused at the call. `workflows` is a `WorkflowSource`: `{ workflowsPath }` for a process that lets diff --git a/docs/how-to/serve-orpc-over-http.md b/docs/how-to/serve-orpc-over-http.md index d7aaf2e..39ee550 100644 --- a/docs/how-to/serve-orpc-over-http.md +++ b/docs/how-to/serve-orpc-over-http.md @@ -194,9 +194,10 @@ Module("OrdersApi")({ The authenticator sits at the **root**, not beside the router: who a caller is is one answer per process. It is required here because the contract marks the fragment — a marked router carries `AuthenticatorPort` as a dependency, so -omitting the line is di's own `UNSATISFIED DEPENDENCIES` at `start`, and -supplying one minted on a different identity is a compile error at this very -call. +omitting the line leaves it in the module's `Needs` and `start` refuses the +module (`Type 'AuthenticatorPort' is not assignable to type 'Env | Scope'` — +the port is named), and supplying one minted on a different identity is a +compile error at this very call. [`observability()`](/reference/observability) is the other starter here: it brings the `Logger` the use cases and the request scope write to, bound from @@ -205,10 +206,12 @@ trace id of the unit `http()` opened around the request. It is exported because the per-request `RequestModule` reads it. Three gates hold at compile time, now that the contract is marked. A root that -forgets the starter exports no runtime port and `start` fails on arity -(`NO RUNTIME`). A root that imports `http()` without providing the router +forgets the starter exports no runtime port and `start` refuses it against +`"NO RUNTIME — the module exports no port declared over RuntimePort"`. A root +that imports `http()` without providing the router carries an unmet need — the starter's runtime provider depends on its router -port through di — and `start` refuses the module. And a root serving a **marked** +port through di — and `start` refuses the module, naming the port +(`Type 'HttpRouterPort' is not assignable to type 'Env | Scope'`). And a root serving a **marked** contract without an authenticator carries `AuthenticatorPort` as a second unmet need, refused the same way; drop the marker and that third gate goes with it. diff --git a/docs/how-to/split-a-router-into-controllers.md b/docs/how-to/split-a-router-into-controllers.md index 5b86c7a..db3f81a 100644 --- a/docs/how-to/split-a-router-into-controllers.md +++ b/docs/how-to/split-a-router-into-controllers.md @@ -205,8 +205,11 @@ and none of them owns it; `Logger` is exported because the per-request module reads it. The `authenticator` is here for the same kind of reason and a stronger one: who a caller is is one answer per process, not a slice's question. It is required because a marked fragment made it a dependency of the -router provider, so omitting it is di's own `UNSATISFIED DEPENDENCIES` at -`start`. Nothing else about what a slice needs is spelled at the root. +router provider, so omitting it leaves `AuthenticatorPort` in the root's +`Needs` and `start` refuses the module — not a gate of this package's, and not +di's arity gate either, but the plain assignability of the `Needs` channel +against `Env | Scope`, which names the port. Nothing else about what a slice +needs is spelled at the root. This form is **exact**: a key the record above is missing, a key the contract does not declare, and a controller wired under the wrong key are all diff --git a/docs/how-to/split-a-worker-into-slices.md b/docs/how-to/split-a-worker-into-slices.md index a278f9b..e438c9f 100644 --- a/docs/how-to/split-a-worker-into-slices.md +++ b/docs/how-to/split-a-worker-into-slices.md @@ -229,9 +229,9 @@ is refused right there — not at the root, and not at startup. The third is caught at the composing call. `AmqpHandlers(contract)([...])` and `TemporalActivities(contract)([...])` are exact against every top-level key the contract declares: an array missing one is refused, against an -`"UNCOVERED HANDLERS"` / `"UNCOVERED ACTIVITIES"` marker — readable straight -off the type error rather than a runtime stack trace, and never a silent -failure or an `undefined` merged into the record: +`"UNCOVERED HANDLERS — …"` / `"UNCOVERED ACTIVITIES — …"` marker rather than a +runtime stack trace, and never a silent failure or an `undefined` merged into +the record: ```ts // @ts-expect-error -- the "orderAudit" consumer is uncovered @@ -241,15 +241,27 @@ AmqpHandlers(orderContract)([orderNotifications]); TemporalActivities(orderContract)([chargeOrder]); ``` +**Where the marker actually is.** Both are a `TS2769` — +`No overload matches this call` — three lines long, and the sentence is at the +**tail of the third line**, past three hundred characters of type. TypeScript +names the source type first, and the source is the piece you wrote: di's +`Provider<…>` over your contract, which expands to the contract literal itself. +So this one is not readable at a glance; it is readable once you know the +sentence is the last thing on that line. Nothing either package can spell +shortens it — measured, the width is the caller's own contract in the type +arguments, not a name a package could alias — which is why the marker is a +whole sentence rather than a label: it is the only part of the line a reader +can act on, and it prints where the eye ends up. + Both arrays above are one element long, so both diagnostics report only the -marker (`"UNCOVERED HANDLERS"`, `"UNCOVERED ACTIVITIES"`) — the missing key -itself is not in either message. The key IS named — as -`readonly ["UNCOVERED HANDLERS", "orderAudit"]` or -`readonly ["UNCOVERED ACTIVITIES", "fulfillOrder"]` — but only once the array -under test is as long as the marker tuple itself (2), a two-piece array -missing one key being the common case. Below that length TypeScript can no -longer line the array up against the tuple positionally and falls back to -reporting the marker alone. +marker — the missing key itself is in neither message. The key **is** named +once the array under test is as long as the marker tuple itself (2), a +two-piece array missing one key being the common case: TypeScript then lines +the array up against the tuple positionally and reports one error per element, +the trailing one being — measured on this worker's own contract — +`is not assignable to type '"orderAudit"'`: the bare key, as its own +diagnostic, not folded into the marker's sentence. Below that length it can no +longer line them up and falls back to reporting the marker alone. This is why the composing arm is declared **last** in the intersection both packages build it from — di's builder first, the composer last — so diff --git a/docs/how-to/swap-an-adapter.md b/docs/how-to/swap-an-adapter.md index 74005d3..a0a20c3 100644 --- a/docs/how-to/swap-an-adapter.md +++ b/docs/how-to/swap-an-adapter.md @@ -120,12 +120,18 @@ const built = await Module.build(makeAppModule(InMemoryPersistenceModule)); The wrong pairing does not compile: ```ts -await Module.build(makeAppModule(makePersistenceModule())); // UNSATISFIED DEPENDENCIES +// error TS2554: Expected 3 arguments, but got 1. +await Module.build(makeAppModule(makePersistenceModule())); ``` `Scope` is still in `Needs`, so the call's arity gate rejects it before -anything runs. A test that quietly wires the production adapter into a -scope-less build breaks at compile time, not in CI at midnight. Passing the +anything runs. The message is the arity line and nothing more — the +`UNSATISFIED DEPENDENCIES` label and the missing port are in the rest +parameter's type, and hand-spelling the phantom arguments is what prints them +(`Argument of type 'number' is not assignable to parameter of type 'Scope'`, +once the label is passed through first). A test that quietly wires the +production adapter into a scope-less build breaks at compile time, not in CI at +midnight. Passing the in-memory module to `Module.scoped` is fine — `Scope` is simply absent from its `Needs`, and a scope that releases nothing is harmless. diff --git a/docs/how-to/test-an-application.md b/docs/how-to/test-an-application.md index 6eb17df..0f8133f 100644 --- a/docs/how-to/test-an-application.md +++ b/docs/how-to/test-an-application.md @@ -111,9 +111,10 @@ it("broadcasts every committed write, end to end", async ({ serve }) => { }); ``` -The gate refuses a port the module does not export (`NOT EXPORTED`, at the -call site), and `services()` throws if read before the graph is built — a -bug in the test, kept loud rather than answered with an `undefined`. +A port the module does not export is refused at the call site by the +[tap gate](/reference/testing#the-tap-gate-an-arity-error), and `services()` +throws if read before the graph is built — a bug in the test, kept loud +rather than answered with an `undefined`. ## Read a running graph's log lines with a sink diff --git a/docs/how-to/write-a-runtime.md b/docs/how-to/write-a-runtime.md index fec5eab..cd4f769 100644 --- a/docs/how-to/write-a-runtime.md +++ b/docs/how-to/write-a-runtime.md @@ -154,8 +154,12 @@ await runMain(TickerApp); The composition root is what differs between an `api`, a `worker` and a `consumer` process; the application module is the same in all three. Drop -`Ticker` from `exports` and `runMain` fails on arity with `NO RUNTIME`; drop -`Greeter` and it fails with `UNSATISFIED RUNTIME NEEDS`. +`Ticker` from `exports` and `runMain` refuses the module against +`"NO RUNTIME — the module exports no port declared over RuntimePort"`; drop +`Greeter` and it refuses it against +`"UNSATISFIED RUNTIME NEEDS — the runtime needs a port the module does not export"`. +Either way the sentence is the error's **last** line; the first names the two +`Module<…>` types. ## Honour the three contracts the kernel cannot check diff --git a/docs/index.md b/docs/index.md index 1d13a00..2f84ee4 100644 --- a/docs/index.md +++ b/docs/index.md @@ -22,7 +22,7 @@ features: - title: One process, one runtime details: An API, a Temporal worker and an AMQP consumer are three processes booting the same module under a different composition root. The runtime is a service of the module, and a graph holds exactly one. - title: Wiring proven at compile time - details: A module that forgets a provider, a runtime whose needs are not exported, a root with no runtime — each is an arity error at the call site, before anything runs. That is @btravstack/di, and start builds on it. + details: A module that forgets a provider, a runtime whose needs are not exported, a root with no runtime — each is a compile error at the call site, before anything runs. That is @btravstack/di, and start builds on it. - title: A drain that survives Kubernetes details: SIGTERM flips readiness, waits for endpoint removal to catch up, then stops accepting and gives in-flight work a deadline. Whatever is still open is reported abandoned, not lost silently. - title: Nothing throws diff --git a/docs/reference/amqp.md b/docs/reference/amqp.md index 60fea3a..26c1b8b 100644 --- a/docs/reference/amqp.md +++ b/docs/reference/amqp.md @@ -170,11 +170,17 @@ A third call composes several **pieces** instead of one record: piece first — they are the composed provider's own `deps`, declared under the very key each piece's port id carries, so the services record IS the handlers record. Every key the contract declares must be covered: an array -missing one is refused at the call, against an `"UNCOVERED HANDLERS"` marker -(`readonly ["UNCOVERED HANDLERS", ...]`) — the missing key itself is named -too once the array's length matches that marker tuple's own length of 2; a -single-element array's diagnostic names the marker alone; a piece built for -another contract +missing one is refused at the call, against an +`"UNCOVERED HANDLERS — the contract declares a consumer this array does not cover"` +marker. The diagnostic is a three-line `TS2769` and the sentence is at the +**tail of the third line**, past three hundred characters of the caller's own +contract type — measured, and not shortenable from inside this package. The +missing key itself is named too once the array's length matches that marker +tuple's own length of 2: TypeScript then matches the array against the tuple +positionally and reports the trailing element separately — measured against +this example's two-consumer contract, `is not assignable to type +'"orderAudit"'`: the bare key, not the marker tuple. A single-element array's +diagnostic names the marker alone; a piece built for another contract is refused too, structurally, since its port's service is that contract's handler for the key. `Uncovered` checks coverage, not injectivity, so two pieces claiming the same key still type-check together; di's duplicate-provider diff --git a/docs/reference/core/exit-codes.md b/docs/reference/core/exit-codes.md index 523c307..044feea 100644 --- a/docs/reference/core/exit-codes.md +++ b/docs/reference/core/exit-codes.md @@ -15,18 +15,18 @@ description: The signature of runMain, the exit-code table (0, 1, 2, 70, 78) wit ```ts const runMain: ( - module: Module, + module: Module & StartGate, options?: StartOptions, exit?: (code: number) => void, - ...gate: StartGate ) => Promise; ``` `runMain` is `start` composed with the wait for `exited`, then a fold of the -`Result` into a code. It carries the same phantom gate as `start` (see -[The gate](/reference/core/start#the-gate-startgate-x-unitneeds)), so `NO RUNTIME`, -`UNSATISFIED RUNTIME NEEDS` and `UNSATISFIED UNIT NEEDS` fail at this call site -too. +`Result` into a code. It carries the same phantom marker as `start`, intersected +onto `module` (see +[The gate](/reference/core/start#the-gate-startgate-x-unitneeds)), so +`NO RUNTIME — …`, `UNSATISFIED RUNTIME NEEDS — …` and +`UNSATISFIED UNIT NEEDS — …` are printed at this call site too. | Parameter | Default | Semantics | | --------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | diff --git a/docs/reference/core/runtime.md b/docs/reference/core/runtime.md index 8c2135f..899c867 100644 --- a/docs/reference/core/runtime.md +++ b/docs/reference/core/runtime.md @@ -249,4 +249,5 @@ const TickerModule = Module("Ticker")({ ``` A composition root that imports `TickerModule` must also export `Greeter`, or -`start` fails with `UNSATISFIED RUNTIME NEEDS`. +`start` refuses the module against +`"UNSATISFIED RUNTIME NEEDS — the runtime needs a port the module does not export"`. diff --git a/docs/reference/core/start.md b/docs/reference/core/start.md index eb7b117..688acf6 100644 --- a/docs/reference/core/start.md +++ b/docs/reference/core/start.md @@ -15,9 +15,8 @@ description: The signature of start, every StartOptions field with its default, ```ts const start: ( - module: Module, + module: Module & StartGate, options?: StartOptions, - ...gate: StartGate ) => RunningApp>; ``` @@ -78,29 +77,21 @@ already fired. ## The gate: `StartGate` -The trailing `...gate` rest parameter is a **phantom**: it never carries a -runtime argument. Its type is `[]` when the module is boot-able and a named -error tuple otherwise, so a bad composition fails on arity at the call site. +`StartGate` is a **phantom marker intersected onto the `module` parameter**: no +argument ever carries it. It is `unknown` — and therefore invisible — when the +module is boot-able, and one of three sentences otherwise, so a bad composition +fails to match the parameter type at the call site. ```ts type StartGate = [Extract] extends [ never, ] - ? [ - error: "NO RUNTIME", - hint: "the module exports no port declared over RuntimePort", - ] + ? "NO RUNTIME — the module exports no port declared over RuntimePort" : [InstanceType>] extends [X] ? [Exclude] extends [never] - ? [] - : [ - error: "UNSATISFIED UNIT NEEDS", - missing: Exclude, - ] - : [ - error: "UNSATISFIED RUNTIME NEEDS", - missing: Exclude>, X>, - ]; + ? unknown + : "UNSATISFIED UNIT NEEDS — the unit module needs a port the module does not export" + : "UNSATISFIED RUNTIME NEEDS — the runtime needs a port the module does not export"; ``` | Arm | Fires when | @@ -109,10 +100,27 @@ type StartGate = [Extract] extends [ | `UNSATISFIED RUNTIME NEEDS` | The runtime's declared `needs` are not all among the module's exports — the **module's alone**, never the unit module's, because `RuntimeHost.ctx` is the application context. | | `UNSATISFIED UNIT NEEDS` | The `unit` module's needs are not covered by the module's exports, `Scope` or `Env` — `Module.forkScope`'s gate, stated where the parent is actually known. | -`runMain`, and `@btravstack/testing`'s `Boot`, carry the same tuple. A rest tuple rather than a -conditional type on `module` is deliberate: a conditional on an -inference-bearing parameter makes TypeScript defer that parameter and can -collapse `X` or `E` to `unknown`. +`runMain`, and `@btravstack/testing`'s `Boot`, carry the same marker. + +**What a failing arm prints, measured** — a root exporting a `Greeter` and no +runtime port: + +``` +error TS2345: Argument of type 'Module' is not assignable to parameter of type 'Module & "NO RUNTIME — the module exports no port declared over RuntimePort"'. + Type 'Module' is not assignable to type '"NO RUNTIME — the module exports no port declared over RuntimePort"'. +``` + +The sentence prints because the marker rides the `module` parameter — an +argument that fails a parameter type makes TypeScript name that type. This was +a trailing `...gate` rest tuple until it was not: a rest tuple leaves inference +alone, but fails as an **arity** error, and an arity error never prints a type, +so the arm's name never reached a reader. `X` still infers from the +`Module` half of the intersection — measured, and the reason the swap was +free. Each arm's sentence is asserted by an `expectTypeOf>` in +`start.test-d.ts`, since `@ts-expect-error` accepts any error. + +The gate is bypassable by a cast (`start(App as never)`) — the ordinary +TypeScript escape. Spelling phantom arguments out by hand went with the tuple. ## Reading the runtime back: `RuntimePort` and `RuntimeInfoOf` @@ -151,8 +159,8 @@ const app = start(HttpishApp, { env: {}, probes: false }); const info = await app.runtimeInfo(); // Result ``` -Drop `Httpish` from `exports` and the call to `start` fails to compile with -`NO RUNTIME`. +Drop `Httpish` from `exports` and the call to `start` fails to compile against +`"NO RUNTIME — the module exports no port declared over RuntimePort"`. ## Lifecycle, in order diff --git a/docs/reference/di/entry-points.md b/docs/reference/di/entry-points.md index 741dd0b..6d8c129 100644 --- a/docs/reference/di/entry-points.md +++ b/docs/reference/di/entry-points.md @@ -25,8 +25,45 @@ parameter: when the module's remaining `Needs` (after the exclusions each entry point is entitled to) is `never`, the gate is the empty tuple and the call is ordinary; when it is not, two required parameters appear — `error: "UNSATISFIED DEPENDENCIES", missing: N` — and the call is an arity -error naming exactly what is missing. There is no value to supply for the -phantom arguments; the fix is always to satisfy the need. +error. There is no value to supply for the phantom arguments; the fix is always +to satisfy the need. + +**What it prints, measured:** + +``` +src/scoped.test-d.ts(65,12): error TS2554: Expected 3 arguments, but got 1. +``` + +That is the whole message, and it is worth knowing before you go looking for +more. An arity error never prints a type, so neither the +`"UNSATISFIED DEPENDENCIES"` label nor the ports in `missing` appear in it. +With `--pretty`, TypeScript adds related information pointing at the rest +parameter's declaration in `module.ts` — a reader sees the labels there, but +sees `N` un-instantiated. **To find out which port is missing, spell the +phantom arguments out by hand**: the rest parameter is +`[error: "UNSATISFIED DEPENDENCIES", missing: N]`, so a value neither slot +accepts turns the arity error into an assignability one, which does print a +type. The first slot answers first: + +``` +error TS2345: Argument of type 'number' is not assignable to parameter of type '"UNSATISFIED DEPENDENCIES"'. +``` + +Pass that label through as the first phantom argument and the second slot names +the port: + +``` +error TS2345: Argument of type 'number' is not assignable to parameter of type 'Scope'. +``` + +Both measured, on a scratch file since deleted; it is a diagnostic technique, +not an intended call form. `missing: N` is the same type an editor's language +service reads, so a hover would be expected to show the same ports — an +inference from the parameter's type, not something observed here. + +`@btravstack/core`'s [`start`](/reference/core/start) answers this differently: +its gate rides the `module` parameter so its sentence prints. The two are no +longer the same shape. | Entry point | Excludes from `Needs` before checking | | ------------------ | ------------------------------------- | @@ -102,7 +139,8 @@ parent's services; `use` receives a `Context` carrying both. Under the kernel you rarely call this yourself: `StartOptions.unit` names a module the kernel forks around **every unit**, and the same gate is checked at -`start`'s call site as `UNSATISFIED UNIT NEEDS`. See +`start`'s call site as +`"UNSATISFIED UNIT NEEDS — the unit module needs a port the module does not export"`. See [Open a per-request scope](/how-to/open-a-per-request-scope). ## `ScopedOptions` diff --git a/docs/reference/di/modules.md b/docs/reference/di/modules.md index 8ee997b..bc54667 100644 --- a/docs/reference/di/modules.md +++ b/docs/reference/di/modules.md @@ -56,11 +56,11 @@ flat map at runtime, unnameable through the built `Context`'s type. `Module`: -| Channel | Computed as | -| --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `Exports` | The union of exported ports' instance types, whole-module re-exports contributing their own `Exports`. This becomes the `Context` channel an entry point hands back. | -| `E` | Every way construction can fail: the union of all providers' error channels, here and in every import, transitively. | -| `Needs` | Everything still unmet: the union of all providers' needs and all imports' needs, **minus** what is available here. A dependency satisfied by a sibling provider or an import's export disappears from `Needs`; one nothing supplies propagates upward until some module satisfies it — or surfaces as `UNSATISFIED DEPENDENCIES` at the entry point. `Scope`, once introduced by a resourceful provider, propagates the same way and is discharged only by `Module.scoped`, `Module.forkScope` or `start`. | +| Channel | Computed as | +| --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `Exports` | The union of exported ports' instance types, whole-module re-exports contributing their own `Exports`. This becomes the `Context` channel an entry point hands back. | +| `E` | Every way construction can fail: the union of all providers' error channels, here and in every import, transitively. | +| `Needs` | Everything still unmet: the union of all providers' needs and all imports' needs, **minus** what is available here. A dependency satisfied by a sibling provider or an import's export disappears from `Needs`; one nothing supplies propagates upward until some module satisfies it — or is refused at the entry point by the [`UNSATISFIED DEPENDENCIES` gate](/reference/di/entry-points#the-gate). `Scope`, once introduced by a resourceful provider, propagates the same way and is discharged only by `Module.scoped`, `Module.forkScope` or `start`. | The variance rule, shared with [`Provider`](/reference/di/providers#the-channels): diff --git a/docs/reference/glossary.md b/docs/reference/glossary.md index 4e85bf8..bdcbe6d 100644 --- a/docs/reference/glossary.md +++ b/docs/reference/glossary.md @@ -55,10 +55,15 @@ context; a provided-but-unexported port is private to the module. See providers, then closed. `StartOptions.unit` is a fork the kernel opens around every unit. See [Open a per-request scope](/how-to/open-a-per-request-scope). -**gate** — A phantom rest tuple that is `[]` when a composition is sound and a named -error tuple otherwise, so a mistake fails on arity at the call site. `start`'s -is `StartGate` (`NO RUNTIME`, `UNSATISFIED RUNTIME NEEDS`, `UNSATISFIED UNIT -NEEDS`); di's `Module.scoped` has `UNSATISFIED DEPENDENCIES`. See +**gate** — A phantom type that is inert when a composition is sound and refuses the +call otherwise. The two shipped here are not the same shape. `start`'s is +`StartGate`, a marker **intersected onto `module`** — `unknown` when sound, one +of three sentences (`NO RUNTIME — …`, `UNSATISFIED RUNTIME NEEDS — …`, +`UNSATISFIED UNIT NEEDS — …`) otherwise, and the sentence prints in the error. +di's on `Module.scoped` is a conditional **rest tuple** labelled +`UNSATISFIED DEPENDENCIES`, so it fails on arity — `Expected 3 arguments, but +got 1`, which names nothing; the label and the missing ports are in the +parameter's type, not the message. See [start and StartOptions](/reference/core/start) and [Compile errors, not surprises](/explanation/compile-time-wiring). diff --git a/docs/reference/http.md b/docs/reference/http.md index 48a4831..2f27c47 100644 --- a/docs/reference/http.md +++ b/docs/reference/http.md @@ -181,11 +181,30 @@ Each value is what [`HttpController`](#httpcontrollername-fragment) returns. The call is **exact**: `M` is constrained to `{ readonly [K in Exclude]: ControllerFor>, Identity> }`, and the `controllers` -**parameter** itself is typed `M & { readonly [K in Exclude>]: never }` — the exactness intersection sits on -the parameter, not on `M`, so a key `C` does not declare is typed `never` there -without collapsing `M` (and with it the needs channel di orders the controllers -by) to `never` too. The `Exclude`/`Inherit` pair is the same one +**parameter** itself is typed: + +```ts +M & { + readonly [K in Exclude>]: + `UNDECLARED KEY — the contract declares no fragment under ${K & string}`; +}; +``` + +The exactness intersection sits on the parameter, not on `M`, so a key `C` does +not declare is refused there without collapsing `M` (and with it the needs +channel di orders the controllers by) to `never` too. Because the mapped type is +keyed by `K`, the sentence **names the offending key**, and it is the last line +of the error: + +``` +error TS2769: No overload matches this call. + The last overload gave the following error. + Type 'Minted<"GateOrders", { place: ContractBuilder; }, never, never>' is not assignable to type 'Provider; }>, never, never> & { ...; } & "UNDECLARED KEY — the contract declares no fragment under billing"'. + Type 'Minted<"GateOrders", { place: ContractBuilder; }, never, never>' is not assignable to type '"UNDECLARED KEY — the contract declares no fragment under billing"'. +``` + +Read the **last** line: the ones above it name the type you passed. The +`Exclude`/`Inherit` pair is the same one [`Implementation`](#authentication) carries: a contract marked at its **root** composes through this form too, and each fragment inherits that mark, so a controller under it types `context.principal`. @@ -375,9 +394,14 @@ When the contract marks anything, `HttpRouter` adds `AuthenticatorPort` to the router provider's deps record under a **namespaced** key (`"@btravstack/http/authenticator"`, so it cannot collide with one you wrote), strips it back out before your own `sync` sees the record, and adds it to the -provider's needs channel. Which makes a marked router with no authenticator behind it di's -existing `UNSATISFIED DEPENDENCIES` gate at `start`, not a gate this package -invented. +provider's needs channel. Which makes a marked router with no authenticator +behind it an ordinary unmet need at `start`, not a gate this package invented. +What prints is `start`'s `module` parameter refusing the leftover need — +`Type 'AuthenticatorPort' is not assignable to type 'Env | Scope'`, down to +`Type '"HttpAuthenticator"' is not assignable to type '"@di/Scope"'` — so the +port is named. (Not di's `UNSATISFIED DEPENDENCIES` arity gate: that one guards +`Module.build`/`Module.scoped`, and `start` types the need out on its parameter +instead.) What di cannot see is the **identity**: `AuthenticatorPort`'s service type is erased to `unknown`, so any authenticator discharges that need. So diff --git a/docs/reference/temporal.md b/docs/reference/temporal.md index 4c4164a..cf0f7ff 100644 --- a/docs/reference/temporal.md +++ b/docs/reference/temporal.md @@ -200,11 +200,18 @@ constructs every piece first — they are the composed provider's own `deps`, declared under the very key each piece's port id carries, so the services record IS the activities record. Every top-level key the contract's activities record declares must be covered: an array missing one is refused -at the call, against an `"UNCOVERED ACTIVITIES"` marker -(`readonly ["UNCOVERED ACTIVITIES", ...]`) — the missing key itself is named -too once the array's length matches that marker tuple's own length of 2; a -single-element array's diagnostic names the marker alone; a piece built for -another contract is refused too, structurally, since its port's service is +at the call, against an +`"UNCOVERED ACTIVITIES — the contract declares a workflow this array does not cover"` +marker. The diagnostic is a three-line `TS2769` and the sentence is at the +**tail of the third line**, past three hundred characters of the caller's own +contract type — measured, and not shortenable from inside this package. The +missing key itself is named too once the array's length matches that marker +tuple's own length of 2: TypeScript then matches the array against the tuple +positionally and reports the trailing element separately — measured against +this example's two-workflow contract, `is not assignable to type +'"fulfillOrder"'`: the bare key, not the marker tuple. A single-element array's +diagnostic names the marker alone; a piece built for another contract is refused +too, structurally, since its port's service is that contract's activities for the key. `Uncovered` checks coverage, not injectivity, so two pieces claiming the same key still type-check together; di's duplicate-provider defect at build catches it only once **both** end up diff --git a/docs/reference/testing.md b/docs/reference/testing.md index 47994a2..aec310f 100644 --- a/docs/reference/testing.md +++ b/docs/reference/testing.md @@ -37,16 +37,17 @@ const bootFixture: ( ) => (ctx: object, use: (boot: Boot) => Promise) => Promise; type Boot = ( - module: Module, + module: Module & StartGate, options?: Omit, "signals">, - ...gate: StartGate ) => RunningApp>; type BootDefaults = Omit; ``` A `test.extend` fixture that hands the test a `Boot` — `start`, with the -same signature and the same phantom gate, minus `signals` — and **stops every +same signature and the same +[phantom marker on `module`](/reference/core/start#the-gate-startgate-x-unitneeds), +minus `signals` — and **stops every application it started once the test is over**, on every exit path, a failing assertion included. Wire it once, in the fixture module every spec imports: @@ -109,11 +110,44 @@ the running graph writes through — not a fresh one — has nothing to `ctx.get it with. `tapped` composes one more provider around `module`, depending on `ports`, and remembers what it was built with. -| Member | Semantics | -| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `module` | A `Module` exporting **exactly what `module` exports** — the kernel still finds the runtime, the gate still sees the same `X`. Boot this one instead of `module`. | -| `services()` | The service instances behind `ports`, in order, as a tuple typed by `ServicesOf

` (`const [repository] = tap.services()`). **Throws** before the graph has been built: reading a tap nobody booted is a bug in the test, not a modeled outcome, so it is loud rather than an `undefined`. | -| `...gate` | Phantom, at the call site: `NOT EXPORTED` names any port `module` does not export. An application-scope service is the only thing there is to tap; a unit-scoped port exists only while a unit is open. | +| Member | Semantics | +| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `module` | A `Module` exporting **exactly what `module` exports** — the kernel still finds the runtime, the gate still sees the same `X`. Boot this one instead of `module`. | +| `services()` | The service instances behind `ports`, in order, as a tuple typed by `ServicesOf

` (`const [repository] = tap.services()`). **Throws** before the graph has been built: reading a tap nobody booted is a bug in the test, not a modeled outcome, so it is loud rather than an `undefined`. | +| `...gate` | A conditional rest tuple, phantom, refusing at the call site any port `module` does not export — as an **arity error**; see [what it prints](#the-tap-gate-an-arity-error) below. An application-scope service is the only thing there is to tap; a unit-scoped port exists only while a unit is open. | + +### The tap gate: an arity error + +`tapped` keeps the mechanism [di's own entry points +use](/reference/di/entry-points#the-gate) — a conditional rest parameter, empty +when every port is exported and two required parameters +(`error: "NOT EXPORTED", missing: …`) when one is not — rather than the marker +[`start` intersects onto its `module`](/reference/core/start#the-gate-startgate-x-unitneeds). +It is the fourth gate mechanism in this repo, and the only one a **test** +meets rather than a composing application. + +What it prints, measured on a one-port tap of a module that does not export +that port: + +``` +src/__scratch.ts(15,1): error TS2554: Expected 4 arguments, but got 2. +``` + +Reading that message, and finding the missing port by hand-spelling the +phantom arguments, is the same technique [di's own +gate](/reference/di/entry-points#the-gate) documents. The slots answer one at +a time, the first one first: + +``` +error TS2345: Argument of type '0' is not assignable to parameter of type '"NOT EXPORTED"'. +``` + +Pass that label through as the first phantom argument and the second slot names +the port: + +``` +error TS2345: Argument of type 'number' is not assignable to parameter of type 'Secret'. +``` The tap provider is not exported and nothing resolves it; di builds every provider in a graph, exported or not, which is what makes the capture work. diff --git a/examples/README.md b/examples/README.md index 99ada37..b8877ec 100644 --- a/examples/README.md +++ b/examples/README.md @@ -46,8 +46,9 @@ vocabulary — is declared by the caller that needs it, not by the database that happens to satisfy it. `OrderApplicationModule` therefore leaves that need **unmet**, which is not documentation but a type: `Module.scoped(OrderApplicationModule, …)` does not compile until an outer -module provides one. There is one such module per vertical, so the gate names -the repository that vertical actually uses. +module provides one. There is one such module per vertical, so each gate +carries the repository that vertical actually uses — carries, not prints: di's +gate is an arity error, and the port is in the parameter's type. ## The contract tier, which depends on nothing and is depended upon @@ -192,11 +193,16 @@ kernel's own type test now; what the examples pin is the other two gates. Pinned in `order-api/src/needs-gate.test-d.ts`, `order-temporal-worker/src/needs-gate.test-d.ts` and `order-amqp-worker/src/needs-gate.test-d.ts`: the wired call is an ordinary -one; a composition that forgets its starter fails on **arity** with -`NO RUNTIME`; and a composition that imports the starter without providing its -router / activities / handlers port fails at `start` — di's own -`UNSATISFIED DEPENDENCIES` gate, since the runtime provider depends on that -port. `order-api` also pins both halves of the `unit` gate. +one; a composition that forgets its starter is refused against +`"NO RUNTIME — the module exports no port declared over RuntimePort"`, the +sentence `start` intersects onto its `module` parameter; and a composition that +imports the starter without providing its router / activities / handlers port +fails at `start` too, but by a different mechanism — the port stays in the +module's `Needs`, which `start`'s parameter accepts only as `Scope | Env`, so +the diagnostic names the port. That second one is **not** di's +`UNSATISFIED DEPENDENCIES` arity gate, which guards `Module.build` and +`Module.scoped`; `order-application`'s file is where that one is pinned. +`order-api` also pins both halves of the `unit` gate. The two **worker** files pin one more, and it is the one worth reading if you are about to write a slice: a slice does **not** shield the ports its own diff --git a/examples/hexagonal-order-api/README.md b/examples/hexagonal-order-api/README.md index d9798a6..ae4e228 100644 --- a/examples/hexagonal-order-api/README.md +++ b/examples/hexagonal-order-api/README.md @@ -37,11 +37,15 @@ pnpm --filter @btravstack/example-hexagonal-order-api typecheck propagates through `makeAppModule` to anything built from it. Building that graph with `Module.build` is a compile error, not a runtime leak — the call's arity gate (the "UNSATISFIED DEPENDENCIES" rest parameter every unmet -requirement produces) rejects it before anything runs. `src/index.test-d.ts` -pins that with a `@ts-expect-error` of its own, right next to the privacy -one. `Module.scoped` is the one entry point that opens a scope and -discharges `Scope` — used in `src/index.spec.ts` against the production -adapter, closing the pool on every path out. +requirement produces) rejects it before anything runs. What it prints is the +arity line alone, `Expected 3 arguments, but got 1`; the label and `Scope` are +in the rest parameter's type, not in the message. Hand-spelling the phantom +arguments is what prints them — a value the tuple cannot accept names the label +first, then `Argument of type 'number' is not assignable to parameter of type +'Scope'`. `src/index.test-d.ts` pins that with a `@ts-expect-error` of its own, +right next to the privacy one. `Module.scoped` is the one entry point that +opens a scope and discharges `Scope` — used in `src/index.spec.ts` against +the production adapter, closing the pool on every path out. `InMemoryPersistenceModule` has nothing resourceful, so `makeAppModule` applied to it has `Needs = never` — `Module.build` accepts it directly, no diff --git a/examples/order-amqp-worker/src/needs-gate.test-d.ts b/examples/order-amqp-worker/src/needs-gate.test-d.ts index 37ed586..bd7d9a2 100644 --- a/examples/order-amqp-worker/src/needs-gate.test-d.ts +++ b/examples/order-amqp-worker/src/needs-gate.test-d.ts @@ -1,15 +1,18 @@ /** * The compile-time half of the broadcast deployment: `start` resolves its * runtime from the `AmqpRuntime` port `@btravstack/amqp`'s starter provides - * and the composition root exports, so a module that exports no runtime is a - * call-site arity error. Type-checked by this package's `test:types` script, + * and the composition root exports, so a module that exports no runtime fails + * to match the `NO RUNTIME — …` sentence `start` intersects onto its `module` + * parameter. Type-checked by this package's `test:types` script, * never executed. * * There is no UNSATISFIED RUNTIME NEEDS negative any more: the runtime has * none. What used to be its needs — the handlers, and what they read — is now * the starter's own handlers port, which the starter DEPENDS on, so a - * composition without a provider for it is di's own gate: the module's needs - * channel carries that port, and `start` accepts only `Scope | Env` there. + * composition without a provider for it is the needs channel, not the marker + * and not di's `UNSATISFIED DEPENDENCIES` arity gate: the module's needs + * channel carries that port, `start` accepts only `Scope | Env` there, and the + * assignability failure names the port. * * The third negative is about **slices**, and it is the mirror of * `order-temporal-worker`'s `FulfillmentlessSlice`. Composing pieces into one @@ -46,8 +49,8 @@ const RuntimelessAmqp = Module("RuntimelessAmqp")({ exports: [PlaceOrder, Logger], }); -// Negative: the gate becomes a required two-element tuple naming the missing -// runtime, and the call fails on arity. +// Negative: the marker becomes the `NO RUNTIME — …` sentence, which the module +// argument cannot satisfy, so the call fails to typecheck against it. // @ts-expect-error — NO RUNTIME: the module exports no port declared over RuntimePort. const _noRuntime = start(RuntimelessAmqp, options); @@ -65,7 +68,7 @@ const HandlerlessAmqp = Module("HandlerlessAmqp")({ exports: [AmqpRuntime, PlaceOrder, Logger], }); -// Negative, di's gate rather than the kernel's: `start` takes a +// Negative, the needs channel rather than the kernel's marker: `start` takes a // `Module`, and this one still needs the handlers port. // @ts-expect-error — the module's needs channel carries the handlers port, which nothing provides. const _missingHandlers = start(HandlerlessAmqp, options); diff --git a/examples/order-amqp-worker/src/test-fixtures.ts b/examples/order-amqp-worker/src/test-fixtures.ts index ac4969e..d547612 100644 --- a/examples/order-amqp-worker/src/test-fixtures.ts +++ b/examples/order-amqp-worker/src/test-fixtures.ts @@ -29,9 +29,9 @@ type ServeOptions = { readonly drainTimeoutMs: number }; /** * `X` is pinned to the ports the composition root exports rather than left - * generic: `start`'s gate is a phantom rest parameter proven at the call site, - * and no proof is available inside a helper generic in the module's own - * exports. `AmqpRuntime` is what `start` resolves; the rest is the writer's + * generic: `start`'s gate is a marker intersected onto `module`, proven at the + * call site, and no proof is available inside a helper generic in the module's + * own exports. `AmqpRuntime` is what `start` resolves; the rest is the writer's * surface, which the tap below reads. Spelled inline, like * `order-temporal-worker`'s: an alias for a port union reads like a domain * concept and is neither — the list IS the meaning. diff --git a/examples/order-api/src/module.ts b/examples/order-api/src/module.ts index ae76bda..c2bcad8 100644 --- a/examples/order-api/src/module.ts +++ b/examples/order-api/src/module.ts @@ -35,9 +35,11 @@ export const orderRouter = HttpRouter(contract)({ * dropping this line is an unmet dependency `start` refuses, and supplying one * that resolves a different principal is a compile error at this very call. * Importing the router - * and the starter is what closes di's arity gate (a composition without the - * router provider does not compile — the starter's provider depends on it), - * and `HttpRuntime`, which the sugar exports, is what closes the kernel's. + * and the starter is what empties the needs channel (a composition without the + * router provider does not compile — the starter's provider depends on it, so + * `HttpRouterPort` survives into `Needs` and `start`'s `module` parameter, + * which takes only `Scope | Env`, refuses it by name), and `HttpRuntime`, + * which the sugar exports, is what satisfies the kernel's own marker. * * A constant, not a function: configuration is read inside the graph, from the * `Env` port the kernel provides, so nothing has to be passed in from diff --git a/examples/order-api/src/needs-gate.test-d.ts b/examples/order-api/src/needs-gate.test-d.ts index 2563f0b..487abaa 100644 --- a/examples/order-api/src/needs-gate.test-d.ts +++ b/examples/order-api/src/needs-gate.test-d.ts @@ -3,10 +3,13 @@ import { start } from "@btravstack/core"; * The compile-time half of the transport layer: `start` resolves its runtime * from the `HttpRuntime` port the composition root exports, and * `http()`'s runtime provider depends on the router port through - * di. Two gates, both at compile time: `start`'s phantom rest-tuple gate turns - * a module that exports no runtime into a call-site arity error, and di's own + * di. Two gates, both at compile time and NOT the same mechanism: `start`'s + * phantom marker, intersected onto `module`, turns a module that exports no + * runtime into a `TS2345` whose last line is the arm's own sentence; and di's * `Module` typing turns a composition that imports the starter without - * providing its router into an unmet need `start` refuses. Type-checked by + * providing its router into an unmet need the same parameter refuses by + * assignability, naming the port. Neither is di's `UNSATISFIED DEPENDENCIES` + * arity gate. Type-checked by * this package's `test:types` script, never executed. */ import { Module } from "@btravstack/di"; @@ -22,31 +25,31 @@ import { OrdersSlice } from "./slices/orders/module.js"; const options = { signals: false, probes: false } as const; -// Positive: the composition root exports the runtime, so the gate collapses to -// an empty tuple and this is an ordinary two-argument call. +// Positive: the composition root exports the runtime, so the marker collapses +// to `unknown` and this is an ordinary two-argument call. const _wired = start(OrderApi, options); // The same graph without `http(...)`: nothing declared over `RuntimePort` is // exported, so there is no runtime for `start` to resolve. const RuntimelessApi = Module("RuntimelessApi")({ imports: [OrdersSlice, CustomersSlice, observability()], - // The authenticator is here so this arm fails on arity ALONE: the contract + // The authenticator is here so this arm fails on the marker ALONE: the contract // marks `orders`, so a graph carrying the router without one has an unmet // need too, and an arm that could fail either way pins neither gate. provides: [orderRouter, bearerAuthenticator], exports: [Logger], }); -// Negative: the gate becomes a required two-element tuple naming the missing -// runtime, and the call fails on arity. +// Negative: the marker becomes the `NO RUNTIME — …` sentence, which the module +// argument cannot satisfy, so the call fails to typecheck against it. // @ts-expect-error — NO RUNTIME: the module exports no port declared over RuntimePort. const _missingRuntime = start(RuntimelessApi, options); // The starter imported without its router provided: `http()`'s runtime // provider depends on the starter's own router port (the one // `HttpRouter(contract)({ name: Dep }, arm)` provides), so the composition carries it -// as an unmet need — di's gate, not the kernel's, and it rejects the module -// at `start` rather than at arity. +// as an unmet need — the `Needs` channel, not the kernel's marker, refused by +// `start`'s `Module` parameter, which names the port. const RouterlessApi = Module("RouterlessApi")({ imports: [OrdersSlice, CustomersSlice, observability(), http()], exports: [HttpRuntime, Logger], @@ -75,9 +78,10 @@ const _unitUnmet = start(UnloggedApi, { ...options, unit: RequestModule }); // The real root minus its authenticator. `contract.orders` is marked // `authenticated`, so `HttpRouter` gave the router provider a dependency on -// the starter's `AuthenticatorPort` and nothing here discharges it. Same gate -// as `_missingRouter` above — di's, at `start`, not at `HttpModule(...)`, -// which is why the module below builds without complaint. +// the starter's `AuthenticatorPort` and nothing here discharges it. Same +// mechanism as `_missingRouter` above — the `Needs` channel, refused at +// `start`, not at `HttpModule(...)`, which is why the module below builds +// without complaint. const UnauthenticatedApi = HttpModule("UnauthenticatedApi")({ router: orderRouter, imports: [OrdersSlice, CustomersSlice, observability()], @@ -89,7 +93,7 @@ const _missingAuthenticator = start(UnauthenticatedApi, options); // The OTHER authenticator gate, and a different one: whether the authenticator // resolves what the handlers read. `AuthenticatorPort`'s service type is -// erased to `unknown`, so di sees the need discharged and would let this +// erased to `unknown`, so the needs channel sees it discharged and would let this // through — `HttpModuleOptions` compares the ROUTER's identity against the // authenticator's itself, at the `HttpModule(...)` call, which is why this // directive sits on the option and not on a `start` below it. The contract diff --git a/examples/order-api/src/test-fixtures.ts b/examples/order-api/src/test-fixtures.ts index 4fc3bff..fff0dbd 100644 --- a/examples/order-api/src/test-fixtures.ts +++ b/examples/order-api/src/test-fixtures.ts @@ -210,8 +210,8 @@ export type ApiFixtures = { * through `boot`: its shutdown is the fixture's, on every exit path. * * The module's `X` is pinned to the two ports every composition here - * exports rather than left generic: `start`'s gate is a phantom rest - * parameter proven at the call site, and no proof is available inside a + * exports rather than left generic: `start`'s gate is a marker intersected + * onto `module`, proven at the call site, and no proof is available inside a * helper generic in the module's own exports. `HttpRuntime` is what `start` * resolves, and `Logger` is for the gate's OTHER half — `RequestModule`, * passed as `StartOptions.unit`, reads it out of the parent. diff --git a/examples/order-application/README.md b/examples/order-application/README.md index 6c61895..2e79478 100644 --- a/examples/order-application/README.md +++ b/examples/order-application/README.md @@ -63,7 +63,11 @@ the same reason from the other direction: it is `@btravstack/observability`'s port, not this layer's, so there is nothing here to provide and nothing to re-export. `Module.scoped(OrderApplicationModule, …)` is therefore a compile error — di's gate turns the module's remaining needs into a required argument -naming them (`src/needs-gate.test-d.ts` pins each vertical's gate separately). +that carries them (`src/needs-gate.test-d.ts` pins each vertical's gate +separately). The message itself is only `Expected 5 arguments, but got 2`: an +arity error prints no type, so the ports are in the rest parameter rather than +in the line — hand-spelling the phantom arguments is what prints them, ending on +`not assignable to parameter of type 'Logger | OrderRepository'`. The hole is not documentation; it is the type. An infrastructure module fills it, and only then does the graph build. diff --git a/examples/order-application/src/module.ts b/examples/order-application/src/module.ts index 08adc93..7e14869 100644 --- a/examples/order-application/src/module.ts +++ b/examples/order-application/src/module.ts @@ -16,8 +16,11 @@ import { findCustomerProvider, findOrderProvider, placeOrderProvider } from "./u * does not compile — an importing module must provide the repository and a * logger first. That arity error is the layering, enforced by the compiler * rather than by convention, and splitting the module sharpened it: each - * vertical's gate now names that vertical's own repository, so a graph cannot - * close the orders half with a customers adapter. + * vertical's gate now carries that vertical's own repository, so a graph + * cannot close the orders half with a customers adapter. Carries, not prints — + * the message is `Expected 5 arguments, but got 2` and nothing else; the + * repository is in the rest parameter's type, which hand-spelling the phantom + * arguments prints. * * The logger is `@btravstack/observability`'s port, not one this layer * declares: a composition root imports `observability()` and the lines this diff --git a/examples/order-application/src/needs-gate.test-d.ts b/examples/order-application/src/needs-gate.test-d.ts index d1d9555..1dbe4bc 100644 --- a/examples/order-application/src/needs-gate.test-d.ts +++ b/examples/order-application/src/needs-gate.test-d.ts @@ -42,8 +42,12 @@ const customerRepository = Provider(CustomerRepository)({ const logger = Provider(Logger)({ value: createLogger(() => {}) }); -// Negative: nothing provides `OrderRepository`, so the gate becomes a required -// two-element tuple and the call is an arity error naming the unmet need. +// Negative: nothing provides `OrderRepository`, so di's rest parameter is a +// required two-element tuple the call does not pass, and `Expected 5 arguments, +// but got 2` is the whole message. An arity error carries no type, so neither +// the label nor the ports are in it; hand-spelling the phantom arguments prints +// them, ending on `not assignable to parameter of type +// 'Logger | OrderRepository'`. // @ts-expect-error — UNSATISFIED DEPENDENCIES: no OrderRepository is provided. const _unwiredOrders = Module.scoped(OrderApplicationModule, (ctx) => ctx.get(PlaceOrder).execute("acme", "o-1", 1), diff --git a/examples/order-temporal-worker/src/needs-gate.test-d.ts b/examples/order-temporal-worker/src/needs-gate.test-d.ts index d91c987..8149ea0 100644 --- a/examples/order-temporal-worker/src/needs-gate.test-d.ts +++ b/examples/order-temporal-worker/src/needs-gate.test-d.ts @@ -5,7 +5,9 @@ import { start } from "@btravstack/core"; * runtime needs nothing from the application context — its activities reach * it as a port the starter depends on through di. So there is no * `UNSATISFIED RUNTIME NEEDS` arm to pin here, as there was when the runtime - * declared five `needs` of its own; what replaces it is di's gate. + * declared five `needs` of its own; what replaces it is the needs channel — + * refused by `start`'s `Module` parameter, which names the + * port, and NOT di's `UNSATISFIED DEPENDENCIES` arity gate. * * Two distinct negatives, at two distinct levels. `orderActivities`'s own * `deps` are the two pieces' PORTS (`fulfillOrder.port | chargeOrder.port`), @@ -30,6 +32,7 @@ import { Module } from "@btravstack/di"; import { OrderApplicationModule } from "@btravstack/example-order-application"; import { OrderPersistenceModule } from "@btravstack/example-order-infrastructure"; import { orderContract } from "@btravstack/example-order-temporal-contract"; +import { observability } from "@btravstack/observability"; import { TemporalModule, TemporalRuntime, temporal } from "@btravstack/temporal"; import { OrderTemporalWorker, orderActivities } from "./module.js"; @@ -45,15 +48,17 @@ const options = { signals: false, probes: false } as const; const _wired = start(OrderTemporalWorker, options); // The same graph without the starter: nothing declared over `RuntimePort` is -// exported, so there is nothing for `start` to boot. +// exported, so there is nothing for `start` to boot. `observability()` is here +// so this arm fails on the RUNTIME alone — the two slices owe `Logger` +// otherwise, and a module failing two gates at once elaborates the other one. const RuntimelessTemporal = Module("RuntimelessTemporal")({ - imports: [FulfillmentSlice, BillingSlice], + imports: [FulfillmentSlice, BillingSlice, observability()], provides: [orderActivities], exports: [orderActivities.port], }); -// Negative: the gate becomes a required two-element tuple naming the absence, -// and the call fails on arity. +// Negative: the marker intersected onto `module` becomes a sentence naming the +// absence, which is what the call fails to match. // @ts-expect-error — NO RUNTIME: the module exports no port declared over RuntimePort. const _noRuntime = start(RuntimelessTemporal, options); @@ -71,7 +76,7 @@ const ActivitylessTemporal = Module("ActivitylessTemporal")({ exports: [TemporalRuntime], }); -// Negative, di's gate rather than the kernel's: `start` takes a +// Negative, the needs channel rather than the kernel's marker: `start` takes a // `Module`, and this one still owes the activities port. // @ts-expect-error — UNMET NEED: the module's needs channel carries the activities port, which nothing provides. const _missingActivities = start(ActivitylessTemporal, options); diff --git a/examples/order-temporal-worker/src/test-fixtures.ts b/examples/order-temporal-worker/src/test-fixtures.ts index 4b19b91..cc61c5c 100644 --- a/examples/order-temporal-worker/src/test-fixtures.ts +++ b/examples/order-temporal-worker/src/test-fixtures.ts @@ -57,8 +57,8 @@ type Deployment = { /** * `X` is pinned to the four ports the activities provider depends on, plus - * `Logger` — rather than left generic: `start`'s gate is a phantom rest - * parameter proven at the call site, and no proof is available inside a + * `Logger` — rather than left generic: `start`'s gate is a marker intersected + * onto `module`, proven at the call site, and no proof is available inside a * helper generic in the module's own exports. `Logger` has to be in the union * because `serve` composes `BillingModule` beside `module` (see below), and * `BillingModule`'s own need for it is invisible past this type unless it is diff --git a/packages/amqp/CLAUDE.md b/packages/amqp/CLAUDE.md index deb2ce1..2ce5e9b 100644 --- a/packages/amqp/CLAUDE.md +++ b/packages/amqp/CLAUDE.md @@ -91,11 +91,16 @@ Provider>> & Compose` — di's builder first, the composer last. Reversed, TypeScript reports the FIRST arm's failure on a non-covering array, and the diagnostic degrades to `not assignable to 'Qualification'`, naming nothing; last, it reports the - composing arm's own conditional against `readonly ["UNCOVERED HANDLERS", -K]`, which always names the marker — the missing key `K` itself appears only - when the array's length matches that marker tuple's own length of 2; a - single-element array's diagnostic names the marker alone — measured, not - stylistic. The + composing arm's own conditional against `readonly ["UNCOVERED HANDLERS — …", +K]`, which always names the marker — printed as the **bare string**, the + tuple's element 0, not as the tuple. Measured, not stylistic, and measured + again for this: the marker sits at the **tail of the third line** of a + `TS2769`, ~360 characters in on a 444-character line, because TypeScript + names the source type first and the source is the caller's own piece. The + missing key `K` itself appears only when the array's length matches that + marker tuple's own length of 2, and then as a **separate** `TS2769` on the + trailing element whose target is the bare key (`is not assignable to type +'"c"'`); a single-element array's diagnostic names the marker alone. The composed provider's own `deps` are the **piece ports** (`InstanceType` in its return type), not what a piece closes over: di constructs each piece first, as its own provider, and the @@ -147,10 +152,13 @@ AmqpInfo>>` — the runtime has **no** needs) and the broker on **`AmqpConfig`** (`{ url }`, bound from `AMQP_URL`, default `amqp://127.0.0.1:5672`), and it **needs** its handlers port, typed for `contract`, which the application provides. The composition root imports - it, provides the handlers, exports `AmqpRuntime`; di's own gate checks the - need where the root is declared, and `start` refuses a module whose needs - channel still carries it - (`examples/order-amqp-worker/src/needs-gate.test-d.ts` pins that + it, provides the handlers, exports `AmqpRuntime`; di's `Needs` channel + carries the port, and `start` refuses a module whose needs channel still + carries it — by assignability against `Env | Scope` on the `module` + parameter, not by di's `UNSATISFIED DEPENDENCIES` arity gate, which is why + the diagnostic ends on + `Type '"AmqpHandlers"' is not assignable to type '"@di/Scope"'` and names the + port (`examples/order-amqp-worker/src/needs-gate.test-d.ts` pins that diagnostic, since `start`'s own gate has no `UNSATISFIED RUNTIME NEEDS` arm to fire any more). `AmqpOptions` — `contract: TContract` (`TContract` bounded by @@ -287,8 +295,10 @@ right])`, pinning that both slices run (_"serves a record composed from one composing form's compile-time gates on a contract of its own — a piece typed by its own key, an array covering every declared key, an uncovered array refused as `@ts-expect-error` (its own single-element case reports only the - `"UNCOVERED HANDLERS"` marker, not the missing key — see the composing-arm - entry above for when the key itself is named), and a piece built for another contract + `"UNCOVERED HANDLERS — …"` marker, not the missing key — the comment above + that gate says so, since the file's own array is one element long; see the + composing-arm entry above for when the key itself is named), and a piece + built for another contract refused structurally (that contract's own key needs its own message, not a reused one, or the two ports are the same type and there is nothing to refuse — di's port typing is structural on id and service, not nominal diff --git a/packages/amqp/src/amqp-runtime.ts b/packages/amqp/src/amqp-runtime.ts index 3669cf9..7a71052 100644 --- a/packages/amqp/src/amqp-runtime.ts +++ b/packages/amqp/src/amqp-runtime.ts @@ -150,13 +150,25 @@ type Uncovered[]> = Exc /** * The composing arm. Declared LAST in the intersection below on purpose: * TypeScript reports the last overload's failure, so a non-covering array is - * refused against the `"UNCOVERED HANDLERS"` marker rather than degrading to - * di's `Qualification`, which names nothing. The missing key itself is named - * in the diagnostic only when the array's length matches the marker tuple's - * own length (2) — measured. + * refused against the `"UNCOVERED HANDLERS — …"` marker rather than degrading + * to di's `Qualification`, which names nothing. The missing key itself is + * named in the diagnostic only when the array's length matches the marker + * tuple's own length (2) — measured. + * + * The marker is a **sentence**, not a bare label, because it is the only part + * of this diagnostic a reader can act on and it prints LAST: TypeScript names + * the source type first, and the source here is the piece the caller wrote — + * di's `Provider<…>` over the contract, several hundred characters wide and + * outside this package to name. Widening the literal costs nothing to print + * and is what carries the explanation to where the eye lands. */ type Compose = []>( - pieces: [Uncovered] extends [never] ? T : readonly ["UNCOVERED HANDLERS", Uncovered], + pieces: [Uncovered] extends [never] + ? T + : readonly [ + "UNCOVERED HANDLERS — the contract declares a consumer this array does not cover", + Uncovered, + ], ) => Provider, never, InstanceType> & { readonly port: HandlersPortOf; }; diff --git a/packages/amqp/src/handler.test-d.ts b/packages/amqp/src/handler.test-d.ts index d0c06c4..8c6078c 100644 --- a/packages/amqp/src/handler.test-d.ts +++ b/packages/amqp/src/handler.test-d.ts @@ -48,8 +48,9 @@ AmqpModule("Pin")({ contract: pinContract, handlers: composed }); // @ts-expect-error -- "middle" is not one of `pinContract`'s consumer/RPC names AmqpHandler(pinContract, "middle"); -// Negative: an array that misses a declared key is refused at the root, and -// the diagnostic names the key it is missing. +// Negative: an array that misses a declared key is refused at the root. This +// array is one element long, so the diagnostic reports the marker alone — the +// missing key is named only once the array is as long as the marker tuple (2). // @ts-expect-error -- the `right` consumer is uncovered AmqpHandlers(pinContract)([left]); diff --git a/packages/core/CLAUDE.md b/packages/core/CLAUDE.md index 27e29b8..d675020 100644 --- a/packages/core/CLAUDE.md +++ b/packages/core/CLAUDE.md @@ -31,13 +31,16 @@ never>`: `Needs` is covariant on `Module`, so this accepts a needs-free service of that module**, not an option: the module exports a port declared over `RuntimePort`, the kernel builds the graph, resolves that port and drives what it finds. The kernel is DI initialisation and lifecycle, nothing - else. Followed by the phantom `...gate` rest tuple, `StartGate`: `NO RUNTIME` when the module exports no runtime port, - `UNSATISFIED RUNTIME NEEDS` when the runtime's declared needs are not among - the module's exports (the module's alone — a unit-only port exists only + else. The `module` parameter is intersected with the phantom marker + `StartGate`: `NO RUNTIME` when the module exports no runtime + port, `UNSATISFIED RUNTIME NEEDS` when the runtime's declared needs are not + among the module's exports (the module's alone — a unit-only port exists only while a unit is open, and `RuntimeHost.ctx` is the application context), `UNSATISFIED UNIT NEEDS` for the fork's own direction — all three at the - call site, on arity. + call site, as an assignability failure that **prints the arm's sentence**. + `unknown` is the satisfied arm, and it has to be: intersecting `unknown` + leaves the module type untouched, so a good call infers exactly as it + would without the marker. - **`RuntimePort`** — `Port("Runtime")`, exported **generic** (no fixed service): a runtime package declares its own concrete port over it — `class HttpRuntime extends RuntimePort> {}` @@ -346,9 +349,12 @@ Type-level invariants live in `start.test-d.ts` and are checked by - **The module must export a runtime, and that runtime's declared `needs` are checked against the module's exports at the `start` call site** (the phantom - rest-tuple gate, `StartGate`). A composition with - no port declared over `RuntimePort` among its exports fails on arity with - `NO RUNTIME`; a missing need fails with `UNSATISFIED RUNTIME NEEDS`. + marker `StartGate`, intersected onto `module`). A composition + with no port declared over `RuntimePort` among its exports fails to match + `NO RUNTIME — …`; a missing need fails to match `UNSATISFIED RUNTIME NEEDS — …`. + Each arm's sentence is pinned by an `expectTypeOf>` in + `start.test-d.ts` — `@ts-expect-error` accepts any error, so the sentence a + reader is shown is asserted there or nowhere. `InstanceType` is `never`, so a needs-free runtime works against any module. `Needs` and `Info` are not type parameters of `start` any more: they are read off `X` (`RuntimeNeedsOf`, `RuntimeInfoOf` — `ServiceOf` of @@ -356,11 +362,14 @@ Type-level invariants live in `start.test-d.ts` and are checked by exported from the package, the rest are the gate's internals), which is what lets `RunningApp>` type `runtimeInfo()` from the module alone. -- **The gate is bypassable, deliberately.** A caller who spells the phantom - arguments out by hand (`start(M, o, "UNSATISFIED RUNTIME NEEDS", new Clock())`) - does typecheck — asserted, not assumed. This is the same escape hatch di's own - UNSATISFIED DEPENDENCIES gate leaves: it takes a deliberate act, and the gate - exists to catch the accident, not to be unforgeable. +- **The gate is bypassable, deliberately — by a cast.** `start(M as never)` + typechecks (verified), which is the ordinary TypeScript escape rather than + anything this gate offers: the gate exists to catch the accident, not to be + unforgeable. It used to be forgeable a second way — spelling the phantom rest + arguments out by hand — and that went with the rest tuple, so **parity with + di's UNSATISFIED DEPENDENCIES gate no longer holds**: di's is still a rest + tuple and still has the hand-spelled hatch. Nothing asserts the cast, because + a cast defeats every gate and asserting it would pin TypeScript, not this. `docs-examples.test-d.ts` compiles every code sample the two READMEs ship — the `@btravstack/testing` ones (`testRuntime`, `createFakeClock`, `bootFixture`) @@ -414,20 +423,24 @@ ConfigInvalid })` rather than widening `exited`'s error union for every wrapper) because a third-party Standard Schema may be async — and may throw, which the wrapper turns into the defect it is. -- **The needs check is a trailing phantom rest tuple, not a conditional on an - inference-bearing parameter.** - `...gate: [InstanceType>] extends [X] ? [] : [error: "UNSATISFIED RUNTIME NEEDS", missing: …]` +- **The needs check is a phantom marker intersected onto `module`, not a + trailing rest tuple.** + `module: Module & ([InstanceType>] extends [X] ? unknown : "UNSATISFIED RUNTIME NEEDS — …")` (preceded by the `NO RUNTIME` arm on `Extract`) — against the module's exports alone, never the `unit` module's: a unit-only port exists only while a unit is open, and `RuntimeHost.ctx` is the application context, so accepting it would type-check into a startup defect (`start.test-d.ts`'s `SpanApp` pins the rejection). - A conditional type on `module` or `options` would make TypeScript defer that - parameter's inference and can collapse `X` or `E` to `unknown` — the same - shape, and the same reasoning, as di's own gate on `Module.scoped`, and the - same rule unthrown records for `fromPromise`. It **is** bypassable by a caller - who hand-writes the phantom arguments (proved in `start.test-d.ts`); that is - accepted, exactly as di accepts it. + A rest tuple was the earlier spelling, on the grounds that a conditional type + in an inference-bearing position can defer that parameter's inference and + collapse `X` or `E` to `unknown`. It bought that safety at the cost of the + diagnostic: a missing rest argument is an **arity** error, and an arity error + never prints a type, so `NO RUNTIME` never reached a reader and tsc's related + info pointed at the wrong fix ("an argument for 'options' was not provided"). + Measured: `X` still infers from `Module` with the marker alongside, so + the intersection costs nothing the tuple was protecting. di's own gate on + `Module.scoped` is **still** a rest tuple, so the two are no longer the same + shape — do not describe them as parallel. - **The runtime is resolved from the built graph, through the one generic port.** `RuntimePort` is `Port("Runtime")` left generic (its construct @@ -451,7 +464,7 @@ ConfigInvalid })` rather than widening `exited`'s error union for every body where `X` and `Needs` are still unresolved type parameters. `@btravstack/testing`'s `bootFixture` has the same problem and solves it the same way — by forwarding through a signature with the - phantom tuple already discharged. + phantom marker already discharged. - **`finish` skips the drain for every reason but `"signal"` — and aborts the registry on exactly those paths.** @@ -591,7 +604,7 @@ ConfigInvalid })` rather than widening `exited`'s error union for every `forkScope` call goes through a discharged-signature cast — the same move `runMain` and `@btravstack/testing`'s `bootFixture` make on `start` — because the fork's gates are - proven by `start`'s rest tuple at the call site and invisible in a body + proven by `start`'s intersected marker at the call site and invisible in a body where `X`, `Needs` and `UnitX` are unresolved. The work's return union is normalised by an `async` wrapper exactly as `registry.run` does it. diff --git a/packages/core/src/run-main.ts b/packages/core/src/run-main.ts index 11dd161..5c44aee 100644 --- a/packages/core/src/run-main.ts +++ b/packages/core/src/run-main.ts @@ -110,20 +110,17 @@ export const awaitExit = async ( // Result world and become a process exit code. It is the boundary, and a // top-level `await runMain(...)` in an entry point is the intended shape. export const runMain = async ( - module: Module, + // The same phantom gate `start` carries, for the same reason: it makes the + // runtime's declared needs a compile-time check at *this* call site. + module: Module & StartGate, options: StartOptions = {}, exit: (code: number) => void = (code) => { process.exitCode = code; }, - // The same phantom gate `start` carries, for the same reason: it makes the - // runtime's declared needs a compile-time check at *this* call site. - ...gate: StartGate ): Promise => { - void gate; - // The gate above proves the needs at the call site, but that proof is not // visible inside a body where `X` is still an unresolved type parameter — - // the same reason `bootFixture` discharges the tuple the same way. + // the same discharged-signature cast `bootFixture` makes, for the same reason. const boot = start as ( module: Module, options: StartOptions, diff --git a/packages/core/src/start.test-d.ts b/packages/core/src/start.test-d.ts index 6ed37e9..11d9442 100644 --- a/packages/core/src/start.test-d.ts +++ b/packages/core/src/start.test-d.ts @@ -4,7 +4,7 @@ import { OkAsync } from "unthrown"; import { expectTypeOf } from "vitest"; import { RuntimePort, type Runtime, type Serving } from "./runtime.js"; -import { start, type RunningApp } from "./start.js"; +import { start, type RunningApp, type StartGate } from "./start.js"; class Greeting extends Port("Greeting")<{ readonly text: string }> {} class Clock extends Port("Clock")<{ readonly now: () => number }> {} @@ -46,8 +46,9 @@ const Satisfied = Module("Satisfied")({ }); expectTypeOf(start(Satisfied)).toEqualTypeOf>(); -// The gate bites: `Unsatisfied` does not export `Clock`, so the phantom rest -// tuple is non-empty and the one-argument call no longer typechecks. +// The gate bites: `Unsatisfied` does not export `Clock`, so the marker +// intersected onto `module` is a sentence the argument cannot satisfy — and +// the sentence is what tsc prints as the parameter type it did not match. const Unsatisfied = Module("Unsatisfied")({ imports: [AppModule], provides: [Provider(NeedsClock)({ value: needsClock })], @@ -56,16 +57,18 @@ const Unsatisfied = Module("Unsatisfied")({ // @ts-expect-error -- UNSATISFIED RUNTIME NEEDS: the runtime needs `Clock`, which the module does not export start(Unsatisfied); -// Documented, deliberate limit (verified, not assumed): a caller who spells the -// phantom arguments out by hand does typecheck. That is the same escape hatch -// di's own UNSATISFIED DEPENDENCIES gate leaves open — it takes a deliberate -// act, and the gate exists to catch the accident, not to be unforgeable. -start(Unsatisfied, {}, "UNSATISFIED RUNTIME NEEDS", new Clock()); +// WHICH arm rejected it, pinned: the directive above accepts ANY error, so the +// sentence a reader is actually shown is asserted here or nowhere. +expectTypeOf< + StartGate +>().toEqualTypeOf<"UNSATISFIED RUNTIME NEEDS — the runtime needs a port the module does not export">(); // The other way the gate bites: a module that exports no runtime port at all. // @ts-expect-error -- NO RUNTIME: `AppModule` exports no port declared over `RuntimePort` start(AppModule); -start(AppModule, {}, "NO RUNTIME", "the module exports no port declared over RuntimePort"); +expectTypeOf< + StartGate +>().toEqualTypeOf<"NO RUNTIME — the module exports no port declared over RuntimePort">(); // A needs-free runtime works against any module: `InstanceType` is // `never`, and `[never] extends [X]` holds for every `X`. `testRuntime` ships @@ -91,10 +94,9 @@ const ClockyUnit = Module("ClockyUnit")({ // @ts-expect-error -- UNSATISFIED UNIT NEEDS: the unit module reads `Clock`, which the module does not export start(Satisfied, { unit: ClockyUnit }); - -// The same escape hatch as the runtime half, naming the unit error literal — -// which is also what pins WHICH branch of the gate rejected the call above. -start(Satisfied, { unit: ClockyUnit }, "UNSATISFIED UNIT NEEDS", new Clock()); +expectTypeOf< + StartGate +>().toEqualTypeOf<"UNSATISFIED UNIT NEEDS — the unit module needs a port the module does not export">(); // A runtime may NOT draw a need from the unit module's exports: `Span` exists // only while a unit is open, and `RuntimeHost.ctx` is the application context diff --git a/packages/core/src/start.ts b/packages/core/src/start.ts index 268552e..5251cb4 100644 --- a/packages/core/src/start.ts +++ b/packages/core/src/start.ts @@ -123,15 +123,22 @@ export type RunningApp = { }; /** - * The phantom rest tuple `start`, `runMain` and `Boot` all carry: empty — - * and invisible — when the module exports a runtime and its exports cover - * that runtime's declared needs, a named error tuple otherwise, so a missing - * runtime or an unmet need fails to typecheck at the call site. A trailing - * rest tuple rather than a conditional type on `module` or `options` is - * deliberate: a conditional on an inference-bearing parameter makes - * TypeScript defer that parameter's inference and can collapse `X` or `E` to - * `unknown`. Same shape, and the same reasoning, as di's own UNSATISFIED - * DEPENDENCIES gate on `Module.scoped`. + * The phantom marker `start`, `runMain` and `Boot` all intersect onto their + * `module` parameter: `unknown` — and invisible — when the module exports a + * runtime and its exports cover that runtime's declared needs, a sentence + * otherwise, so a missing runtime or an unmet need fails to typecheck at the + * call site. + * + * It rides the `module` parameter rather than a trailing rest tuple because a + * rest tuple fails as an **arity** error, and an arity error never prints a + * type: `NO RUNTIME` never reached a reader, and tsc's related info pointed at + * the wrong fix ("an argument for 'options' was not provided"). Intersected, + * the sentence prints in full as the parameter type the argument did not + * match. `X` still infers from `Module` alongside the marker — measured, + * since a conditional type in an inference-bearing position can otherwise + * collapse `X` or `E` to `unknown`, which is what the rest tuple was avoiding + * and is why `unknown`, not `{}` or `never`, is the satisfied case: it leaves + * the module type untouched. * * With a `unit` module in play it also checks the fork's own direction: the * unit module's needs must be covered by the module's exports, `Scope` or @@ -143,12 +150,12 @@ export type RunningApp = { * into a startup defect. */ export type StartGate = [Extract] extends [never] - ? [error: "NO RUNTIME", hint: "the module exports no port declared over RuntimePort"] + ? "NO RUNTIME — the module exports no port declared over RuntimePort" : [InstanceType>] extends [X] ? [Exclude] extends [never] - ? [] - : [error: "UNSATISFIED UNIT NEEDS", missing: Exclude] - : [error: "UNSATISFIED RUNTIME NEEDS", missing: Exclude>, X>]; + ? unknown + : "UNSATISFIED UNIT NEEDS — the unit module needs a port the module does not export" + : "UNSATISFIED RUNTIME NEEDS — the runtime needs a port the module does not export"; // `Module`, not `Module`: `Needs` sits in // covariant position on `Module`, so this accepts a module with no needs at @@ -156,14 +163,12 @@ export type StartGate = [Extract] exte // the single need `Module.scoped` discharges by opening the scope itself — and // one whose configuration providers read `Env`, which the kernel provides. A // module with a genuine unmet dependency is rejected here, as di's own gate -// would reject it. The `gate` rest parameter is a phantom: it never carries a -// runtime argument. +// would reject it. The intersected `StartGate` is a phantom: it is `unknown` +// whenever the gate is satisfied, so no argument ever carries it. export const start = ( - module: Module, + module: Module & StartGate, options: StartOptions = {}, - ...gate: StartGate ): RunningApp> => { - void gate; type Info = RuntimeInfoOf; type Needs = RuntimeNeedsOf; const clock = options.clock ?? systemClock; @@ -394,7 +399,7 @@ export const start = ( // `Context` is contravariant, so an application context whose // exports cover the runtime's needs is assignable here. The assertion is - // needed only because the `gate` rest parameter proves + // needed only because the `StartGate` intersected onto `module` proves // `InstanceType extends X` at the *call site*, and that proof is // not visible to the checker inside this body, where `X` and `Needs` are // still unresolved type parameters. diff --git a/packages/di/CLAUDE.md b/packages/di/CLAUDE.md index 0db79f5..cae7935 100644 --- a/packages/di/CLAUDE.md +++ b/packages/di/CLAUDE.md @@ -60,7 +60,20 @@ prismaOrderRepository(db) }` — an adapter factory takes the client, not a close on every path), `Module.forkScope` (per-request scope seeded from a built parent `Context`). Unmet dependencies are compile errors via a conditional rest parameter — `[N] extends [never] ? [] : [error: "UNSATISFIED DEPENDENCIES", -missing: N]`. `exports` accepts an available **port class**, a **provider** for +missing: N]`. What that **prints** is the arity line alone — + `error TS2554: Expected 3 arguments, but got 1.` — because an arity error + never carries a type: neither the label nor the ports in `missing` reach the + message, and `--pretty`'s related information points at the declaration in + `module.ts`, where `N` is still un-instantiated. Hand-spelling the phantom + arguments is how a reader gets the port printed: a value the rest tuple cannot + accept names the label first, then the port itself (measured, + `Argument of type 'number' is not assignable to parameter of type 'Scope'`). + An editor's language service reads the same instantiated type, so a hover + would be expected to show it — inferred, never measured here. + `@btravstack/core`'s `start` answers this differently — its marker + rides the `module` parameter so its sentence prints — so **the two gates are + no longer the same shape**; do not describe them as parallel. + `exports` accepts an available **port class**, a **provider** for one (normalised to `provider.port` when the module is built, so the stored `exports` array stays `readonly (AnyPort | AnyModule)[]`, and yielding the identical `Exports` channel either way), or an imported module. The provider diff --git a/packages/di/README.md b/packages/di/README.md index de82311..2260710 100644 --- a/packages/di/README.md +++ b/packages/di/README.md @@ -77,7 +77,7 @@ The five provider arms — `value`, `sync`, `make` (may fail, with a modeled error), `class`, `acquire`/`release` (a resource, released when the scope closes) — the private-by-default modules, `Module.forkScope` for a per-request scope, and the compile-time gates that -name what is missing are on the [documentation site](https://btravstack.github.io/start/reference/di/ports). +carry what is missing are on the [documentation site](https://btravstack.github.io/start/reference/di/ports). Under [`@btravstack/core`](https://btravstack.github.io/start/reference/core/start), `start(module)` is the one `Module.scoped` call a process makes. diff --git a/packages/http/CLAUDE.md b/packages/http/CLAUDE.md index 16b04e3..0fe1739 100644 --- a/packages/http/CLAUDE.md +++ b/packages/http/CLAUDE.md @@ -33,7 +33,12 @@ the same commit, and with `README.md` — the package ships no takes. `Auth` is inferred from it and `Provides` spreads `[Auth] extends [undefined] ? [] : [NonNullable]`, so an **omitted** authenticator contributes no element and a marked router's need survives - to `start` — di's `UNSATISFIED DEPENDENCIES`, no gate of this package's. + to `start`, which refuses it — no gate of this package's, and **not** di's + `UNSATISFIED DEPENDENCIES` arity gate either (that one guards + `Module.build`/`Module.scoped`): `start`'s `module` parameter takes only + `Scope | Env` outstanding, so the leftover need fails to assign and the + diagnostic names the port, ending on + `Type '"HttpAuthenticator"' is not assignable to type '"@di/Scope"'`. What di cannot see is the **principal**: `AuthenticatorPort`'s service type is erased to `unknown`, so any authenticator discharges the need whatever it resolves. That half is checked here instead — `Principal` is inferred from @@ -89,19 +94,36 @@ PortInstance<…> }`) rather than the class's own type because a class `HttpController` per key, instead of `(deps, { sync })`. `M` is constrained `{ readonly [K in Exclude]: ControllerFor>, Identity> }`, and the `controllers` - **parameter** is typed `M & { readonly [K in Exclude>]: never }` — the same `Exclude` and the same `Inherit` the + **parameter** is typed ``M & { readonly [K in Exclude>]: `UNDECLARED KEY — the contract declares no fragment under +${K & string}` }`` — the same `Exclude` and the same `Inherit` the deps arm's `Implementation` carries, so a **root-marked** contract composes here at all (the phantom key is not a controller to supply) and each fragment inherits the root's mark (a controller under it types `context.principal`). Both were missing until `auth.test-d.ts`'s eleventh arm went in; the marked fixtures in `controller.test-d.ts` mark a **key**, which is why neither showed there. The exactness intersection is on the parameter, not on `M`: a key - `M` has that `C` does not declare types - as `never` there, so the call fails to compile rather than silently + `M` has that `C` does not declare types as a sentence **naming that key** — + `"UNDECLARED KEY — the contract declares no fragment under billing"` — so the + call fails to compile rather than silently dropping the key, without the intersection leaking into `M` and collapsing the needs channel di orders the controllers by (the failure mode `controller.test-d.ts`'s `_ComposedNeedsAreDeclared` check exists to catch). + It was a bare `never` until the diagnostics pass: `never` made the + intersection **reduce**, so the whole complaint printed as + `Type 'Minted<…>' is not assignable to type 'never'` — one short line that + named neither the key nor the rule. The sentence does not reduce, so the + reader pays one wide intersection line (the shape four other gates in + `controller.test-d.ts` already print) and the message **ends** on the rule in + English, with the offending key in it. `${K & string}` is what carries the + key: the mapped type is keyed by `K`, so the key is in scope at the value + position and costs a template literal to reach. The wide-line trade and the + named key were both **measured** — all five gates still fire, `tsc` clean, no + widening of `M`. A **symbol** key would intersect to `never` and collapse the + whole template to `never`, back to the old terse error: that one is + **reasoned from `K & string`, not measured**, and it is inert anyway, since a + contract's fragment keys are strings and no call of the keyed form can carry + a symbol. Do not upgrade it to a measured claim without measuring it. **`HttpRouter` is the one helper in the family with THREE forms and only two arguments' worth of arity**, so it is the one place arity alone cannot decide. `(deps, arm)` is settled by arity as everywhere else; the two @@ -294,8 +316,9 @@ InstanceType> & { readonly port: PortClassOf hands the caller's own `sync` the rest — and both `build` overloads add `HasMark extends true ? AuthenticatorPort : never` to the needs channel plus `readonly identity: Identity` to the result. - A marked router whose root provides no authenticator is therefore di's - existing `UNSATISFIED DEPENDENCIES` gate — no new gate. Whether the + A marked router whose root provides no authenticator is therefore an + ordinary unmet need refused at `start` — no new gate, and not di's arity + gate (see the `authenticator` bullet for what prints). Whether the authenticator resolves what the handlers read is the one thing that gate cannot see, and `HttpModule`'s `authenticator` option is where it is checked (see the first bullet). Note @@ -372,12 +395,16 @@ plugins })`: CORS, body limits, compression, CSRF are transport policy oRPC `HttpModuleOptions.securityHeaders` (`http-module.ts`) forwards it to `http()` on the same `...(x === undefined ? {} : { x })` spread every other option here uses. -- **Two gates, both compile-time.** `start`'s phantom rest tuple turns a - composition exporting no `HttpRuntime` into an arity error (`NO RUNTIME`); - and because the runtime provider depends on the router port **through di**, +- **Two gates, both compile-time, and they are different mechanisms.** + `start`'s phantom marker — intersected onto `module`, not a rest tuple — turns + a composition exporting no `HttpRuntime` into a `TS2345` whose last line is + `"NO RUNTIME — the module exports no port declared over RuntimePort"`; and + because the runtime provider depends on the router port **through di**, a composition that imports `http()` without providing the router - carries `HttpRouterPort` as an unmet need `start` refuses — di's gate, not - the kernel's. + carries `HttpRouterPort` as an unmet need `start` refuses on the same + parameter's `Module` half, ending on + `Type '"HttpRouter"' is not assignable to type '"@di/Scope"'`. Neither is + di's `UNSATISFIED DEPENDENCIES` arity gate. `examples/order-api/src/needs-gate.test-d.ts` pins both, plus the `StartOptions.unit` halves. There is no `UNSATISFIED RUNTIME NEEDS` case for this runtime any more: it declares none. diff --git a/packages/http/src/auth.test-d.ts b/packages/http/src/auth.test-d.ts index 7a9941b..83c52a7 100644 --- a/packages/http/src/auth.test-d.ts +++ b/packages/http/src/auth.test-d.ts @@ -88,9 +88,11 @@ void _none; // The composition half: a marked contract needs an authenticator, and the // composition root is where the router and the authenticator meet. The two // gates below are DIFFERENT gates, and fire at different calls. Whether an -// authenticator is there at all is di's own `UNSATISFIED DEPENDENCIES` at -// `start` (7) — the same arm `examples/order-api/src/needs-gate.test-d.ts` -// pins for the router. Whether it resolves what the handlers read is this +// authenticator is there at all is an unmet need `start` refuses (7) — its +// `module` parameter takes only `Scope | Env` outstanding, so the diagnostic +// names the port; NOT di's `UNSATISFIED DEPENDENCIES` arity gate, which guards +// `Module.build`/`Module.scoped`. Same mechanism as +// `examples/order-api/src/needs-gate.test-d.ts` pins for the router. Whether it resolves what the handlers read is this // package's own options check at the `HttpModule(...)` call (8), because // `AuthenticatorPort`'s service type is erased to `AuthenticatorService< // unknown>`: the need cannot carry the identity, so only the options type @@ -115,11 +117,11 @@ const options = { signals: false, probes: false } as const; // 7. A marked router with no authenticator supplied carries the port as an // unmet need — the module builds, `start` refuses it. const MissingApi = HttpModule("Missing")({ router: markedRouter }); -// @ts-expect-error — UNSATISFIED DEPENDENCIES: nothing provides the authenticator port the marked router needs. +// @ts-expect-error — UNMET NEED: nothing provides the authenticator port the marked router needs. const _missing = start(MissingApi, options); // 8. An authenticator minted on a DIFFERENT identity is refused. Unlike 7, -// this one is NOT di's gate and does not wait for `start`: the +// this one is not the needs channel and does not wait for `start`: the // authenticator port's service type is erased to `unknown`, so di sees the // need discharged. The two identities meet on `HttpModule`'s own options — // `RouterIdentity` is inferred from the router — which is where it is caught. diff --git a/packages/http/src/orpc.ts b/packages/http/src/orpc.ts index 9b24d00..2bbe9c3 100644 --- a/packages/http/src/orpc.ts +++ b/packages/http/src/orpc.ts @@ -169,7 +169,9 @@ export const routerFor = }, >( controllers: M & { - readonly [K in Exclude>]: never; + readonly [ + K in Exclude> + ]: `UNDECLARED KEY — the contract declares no fragment under ${K & string}`; }, ): Built< Identity, diff --git a/packages/temporal/CLAUDE.md b/packages/temporal/CLAUDE.md index 445ff5e..925054d 100644 --- a/packages/temporal/CLAUDE.md +++ b/packages/temporal/CLAUDE.md @@ -78,7 +78,7 @@ Provider>> & Compose` — di's builder first, the non-covering array, and the diagnostic degrades to `not assignable to 'Qualification'`, naming nothing; last, it reports the composing arm's own conditional against `readonly ["UNCOVERED -ACTIVITIES", K]`, which always names the marker — the missing key `K` itself +ACTIVITIES — …", K]`, which always names the marker — the missing key `K` itself appears only when the array's length matches that marker tuple's own length of 2; a single-element array's diagnostic names the marker alone — measured, not stylistic. The @@ -164,7 +164,11 @@ config: TemporalConfig, activities: TemporalActivitiesPort as ActivitiesPortOf }, { sync })` — the port rides di, which is why `ActivitiesInstanceOf` is in the module's `Needs` and a root that imports the starter without providing the - activities is rejected by `start` for still owing it (the + activities is rejected by `start` for still owing it — by assignability + against `Env | Scope` on the `module` parameter, not by di's + `UNSATISFIED DEPENDENCIES` arity gate, so the diagnostic names the port and + ends on `Type '"TemporalActivities"' is not assignable to type '"@di/Scope"'` + (the `examples/order-temporal-worker` `needs-gate.test-d.ts` pins that; there is no `UNSATISFIED RUNTIME NEEDS` arm any more, the runtime needs nothing). `TemporalActivitiesPort as ActivitiesPortOf` (here and in diff --git a/packages/temporal/src/temporal-module.ts b/packages/temporal/src/temporal-module.ts index f1cee91..38cd552 100644 --- a/packages/temporal/src/temporal-module.ts +++ b/packages/temporal/src/temporal-module.ts @@ -155,15 +155,25 @@ type Uncovered[]> = /** * The composing arm. Declared LAST in the intersection below on purpose: * TypeScript reports the last overload's failure, so a non-covering array is - * refused against the `"UNCOVERED ACTIVITIES"` marker rather than degrading - * to di's `Qualification`, which names nothing. The missing key itself is - * named in the diagnostic only when the array's length matches the marker - * tuple's own length (2) — measured. + * refused against the `"UNCOVERED ACTIVITIES — …"` marker rather than + * degrading to di's `Qualification`, which names nothing. The missing key + * itself is named in the diagnostic only when the array's length matches the + * marker tuple's own length (2) — measured. + * + * The marker is a **sentence**, not a bare label, because it is the only part + * of this diagnostic a reader can act on and it prints LAST: TypeScript names + * the source type first, and the source here is the piece the caller wrote — + * di's `Provider<…>` over the contract, several hundred characters wide and + * outside this package to name. Widening the literal costs nothing to print + * and is what carries the explanation to where the eye lands. */ type Compose = []>( pieces: [Uncovered] extends [never] ? T - : readonly ["UNCOVERED ACTIVITIES", Uncovered], + : readonly [ + "UNCOVERED ACTIVITIES — the contract declares a workflow this array does not cover", + Uncovered, + ], ) => Provider, never, InstanceType> & { readonly port: ActivitiesPortOf; }; diff --git a/packages/testing/src/boot-fixture.ts b/packages/testing/src/boot-fixture.ts index 8d82255..3df47df 100644 --- a/packages/testing/src/boot-fixture.ts +++ b/packages/testing/src/boot-fixture.ts @@ -14,9 +14,8 @@ import type { Module, Scope } from "@btravstack/di"; * when the test ends. */ export type Boot = ( - module: Module, + module: Module & StartGate, options?: Omit, "signals">, - ...gate: StartGate ) => RunningApp>; /** What every `boot` in the fixture starts with; a call's own options win. */