Skip to content

refactor(controller): type ComputedParameter.provider without method bivariance - #110

Merged
Upd4ting merged 1 commit into
mainfrom
refactor/burn-down-suppressions
Nov 13, 2026
Merged

Upd4ting merged 1 commit into
mainfrom
refactor/burn-down-suppressions

Conversation

@Upd4ting

Copy link
Copy Markdown
Member

Why

ANT-1111 aims to bring this repo's suppression budget to zero by fixing the code each suppression hides, so the lint holds without escape hatches. Two of Antony's decisions apply here.

  1. ComputedParameter.provider was typed { provide(context: RequestContext): … }["provide"]. That is a method signature read through an indexed access. Method parameters are bivariant, so a provider that reads a RequestContext subtype was stored with no error and no assertion. The narrowing was still there. It was just hidden from strictFunctionTypes. Antony chose to make it visible: one explicit as in ControllerMeta.setProvider with a SAFETY: comment, which raises the safety ceiling from 0 to 1.
  2. The test that deletes RouteHandler.controller to check the runtime refusal needed @ts-expect-error. Antony chose option E. The case moves to a plain-JS *.untyped.spec.js that calls the built module, and no @ts-expect-error is left.

What changes

  • src/controller.ts. ComputedParameter.provider is now ParameterProvider. setProvider stores provider as ParameterProvider under a SAFETY: comment.
  • src/tests/route-lifecycle.untyped.spec.js is new. It holds the "refuses a handler that names no controller and registers nothing" case, with the same three observations as before: the TypeError message, an unchanged getRegisteredRoutes() count, and no call on a recording routesProxy provider. The case is removed from route-lifecycle.test.ts.
  • tsconfig.json sets allowJs: true and checkJs: false, so tsc copies the spec into dist/tests. The core runner matches /\.(test|spec)\.(js|ts)$/ there. This is the same setup AntelopeJS/mongodb uses for its untyped specs.
  • knip.config.ts lists src/tests/**/*.untyped.spec.js as an entry.
  • guardrails.suppressions.json changes tsExpectError from 1 to 0 and safety from 0 to 1. The safety raise is Antony's decision. The suppressions check reports it as a raise against the base until the ruleset bypass is applied.
flowchart LR
  D["SetParameterProvider<TenantContext>(…, provider)"] --> S["ControllerMeta.setProvider"]
  S -->|"before: bivariant method type, implicit"| C["ComputedParameter.provider"]
  S -->|"after: provider as ParameterProvider (SAFETY)"| C
  C --> X["computeParameter(context, …)"]
  X -->|"same request object a prefix handler or controller decorator filled"| P["provider(context)"]
Loading

Attention

Published declaration change. ComputedParameter.provider changes type in dist/index.d.ts (diff below). Code that assigns a provider reading a RequestContext subtype directly to ComputedParameter.provider no longer compiles. Such code goes through SetParameterProvider, which is unchanged, or asserts at its own boundary. Runtime exports are unchanged. A caller that only reads provider sees the same (context: RequestContext) => unknown.

Ceiling raise. safety goes from 0 to 1, as Antony approved. The suppressions step of the checks fails on the raise against base until he applies the ruleset bypass. Every other check passes.

SAFETY: comments added

  • src/controller.ts, in ControllerMeta.setProvider. The comment reads: "Context names the slots that the caller's controller decorator or prefix handler sets on the request before this provider runs, and computeParameter passes that same request object, so the provider receives the Context it declared." No type-level fix exists. Providers of different contexts sit side by side in Record<PropertyKey, ComputedParameter>. The context a route supplies is decided at run time, when a prefix handler or decorator writes a slot onto the request object. TypeScript has no existential type to keep each entry's Context and relate it to the request computeParameter receives. A generic ComputedParameter<Context> would still need the same widening where the record stores it, and it would change more of the published contract.

Known cases left in place

  • anti-slop/no-unknown-parameters at src/controller.ts:110 on previous: unknown in the published ParameterModifier. Each stacked decorator is a separate call, so no type carries what the step below produced. This waits on Antony's decision on unknown in published contract signatures.

Proof

The moved test still catches a wrong implementation

Head, pnpm exec ajs module test . -f dist/tests/route-lifecycle.test.js -f dist/tests/route-lifecycle.untyped.spec.js -f dist/tests/request-slot.test.js:

  Route lifecycle
    ✔ releases a route by the id RegisterRoute returned
    ✔ tolerates unregistering the same id twice
    ✔ ignores an id that was never registered

  getRegisteredRouteHandlers
    ✔ returns complete registered handlers without replacing the provider
    ✔ registers a decorated route with the class that declares it
    ✔ excludes explicitly unregistered routes
    ✔ excludes routes removed during module unload

  Route lifecycle, off-contract handlers
    ✔ refuses a handler that names no controller and registers nothing

  Request slot
    ✔ Provider reads the slot a prefix handler set: GET /request-slot/testTenant?tenant=acme (41ms)


  9 passing (50ms)

Built dist/routes.js with the !handler.controller guard replaced by false:

  0 passing (4ms)
  1 failing
     AssertionError [ERR_ASSERTION]: Missing expected exception.

Built dist/routes.js calling routesProxy.register('leak', handler) before it throws:

  0 passing (5ms)
  1 failing
      AssertionError [ERR_ASSERTION]: Expected values to be loosely deep-equal:
    id: 'leak',

The narrowing is now visible to the type checker

The probe assigns a ParameterProvider<string, TenantContext> straight to ComputedParameter.provider, compiled against each side's src/ (only probe.ts lines shown).

Before (origin/main): no probe.ts error. The narrowing is accepted silently.

After:

probe.ts(4,47): error TS2322: Type 'ParameterProvider<string, TenantContext>' is not assignable to type 'ParameterProvider<unknown, RequestContext>'.
  Property 'tenant' is missing in type 'RequestContext' but required in type 'TenantContext'.

src/tests/request-slot.test.ts still passes (above): a TenantContext provider registered through SetParameterProvider reads the slot a prefix handler set.

API diff

API: differences against origin/main (committed HEAD).
diff --git a/base/_root/exports/root.d.ts b/head/_root/exports/root.d.ts
index 3d641ab..fe23387 100644
--- a/base/_root/exports/root.d.ts
+++ b/head/_root/exports/root.d.ts
@@ -3,9 +3,7 @@ export type AllowedOrigin = Array<RegExp | string> | RegExp | string;
 export declare const BodyStream: () => PropertyDecorator$1 & ParameterDecorator$1;
 export interface ComputedParameter {
     modifiers: ParameterModifier[];
-    provider?: {
-        provide(context: RequestContext): ReturnType<ParameterProvider>;
-    }["provide"];
+    provider?: ParameterProvider;
 }
 export declare function computeParameter(context: RequestContext, param: ComputedParameter | null, obj: InstanceType<ControllerClass>): Promise<unknown>;
 export declare const Connection: () => PropertyDecorator$1 & ParameterDecorator$1;
diff --git a/base/_root/main.d.ts b/head/_root/main.d.ts
index 3d641ab..fe23387 100644
--- a/base/_root/main.d.ts
+++ b/head/_root/main.d.ts
@@ -3,9 +3,7 @@ export type AllowedOrigin = Array<RegExp | string> | RegExp | string;
 export declare const BodyStream: () => PropertyDecorator$1 & ParameterDecorator$1;
 export interface ComputedParameter {
     modifiers: ParameterModifier[];
-    provider?: {
-        provide(context: RequestContext): ReturnType<ParameterProvider>;
-    }["provide"];
+    provider?: ParameterProvider;
 }
 export declare function computeParameter(context: RequestContext, param: ComputedParameter | null, obj: InstanceType<ControllerClass>): Promise<unknown>;
 export declare const Connection: () => PropertyDecorator$1 & ParameterDecorator$1;
diff --git a/base/_root/types.d.ts b/head/_root/types.d.ts
index 3d641ab..fe23387 100644
--- a/base/_root/types.d.ts
+++ b/head/_root/types.d.ts
@@ -3,9 +3,7 @@ export type AllowedOrigin = Array<RegExp | string> | RegExp | string;
 export declare const BodyStream: () => PropertyDecorator$1 & ParameterDecorator$1;
 export interface ComputedParameter {
     modifiers: ParameterModifier[];
-    provider?: {
-        provide(context: RequestContext): ReturnType<ParameterProvider>;
-    }["provide"];
+    provider?: ParameterProvider;
 }
 export declare function computeParameter(context: RequestContext, param: ComputedParameter | null, obj: InstanceType<ControllerClass>): Promise<unknown>;
 export declare const Connection: () => PropertyDecorator$1 & ParameterDecorator$1;
API: skipped .agents/skills/verify-interface, private.
Runtime API: 1 packages, 2 entries unchanged against origin/main (committed HEAD).
No behavioural probe ran.

Suppressions in authored code files (origin/main and committed HEAD). A lint directive counts once per rule it names; `*` is a directive naming none.
| Suppressions | base | HEAD |
| --- | ---: | ---: |
| Lint suppressions, all rules | 1 | 1 |
| Lint suppressions, `anti-slop/no-unknown-parameters` | 1 | 1 |
| `@ts-expect-error` | 1 | 0 |
| `@ts-ignore` | 0 | 0 |
| `SAFETY:` comments | 0 | 1 |
| Lint config rule-offs, all rules | 0 | 0 |

…bivariance

Store a provider that declares a RequestContext subtype through one
explicit assertion in ControllerMeta.setProvider, with a SAFETY comment,
instead of an indexed method type that hid the narrowing from
strictFunctionTypes. Move the runtime refusal of a controller-less
handler into a plain-JS spec so no @ts-expect-error is needed.
@Upd4ting
Upd4ting merged commit 4d47ad3 into main Nov 13, 2026
6 of 8 checks passed
@Upd4ting
Upd4ting deleted the refactor/burn-down-suppressions branch November 13, 2026 09:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant