From 647720204d1bde3f921a0ed37719370c9fbdadd1 Mon Sep 17 00:00:00 2001 From: Antony Rizzitelli Date: Fri, 13 Nov 2026 09:52:26 +0000 Subject: [PATCH] refactor(controller): type ComputedParameter.provider without method 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. --- guardrails.suppressions.json | 4 +-- knip.config.ts | 6 +++- src/controller.ts | 13 ++++--- src/tests/route-lifecycle.test.ts | 19 ---------- src/tests/route-lifecycle.untyped.spec.js | 44 +++++++++++++++++++++++ tsconfig.json | 2 ++ 6 files changed, 59 insertions(+), 29 deletions(-) create mode 100644 src/tests/route-lifecycle.untyped.spec.js diff --git a/guardrails.suppressions.json b/guardrails.suppressions.json index 73f8dd7..1dacdff 100644 --- a/guardrails.suppressions.json +++ b/guardrails.suppressions.json @@ -2,9 +2,9 @@ "rules": { "anti-slop/no-unknown-parameters": 1 }, - "tsExpectError": 1, + "tsExpectError": 0, "tsIgnore": 0, - "safety": 0, + "safety": 1, "config": {}, "agentFiles": true } diff --git a/knip.config.ts b/knip.config.ts index 1293648..af9c4a7 100644 --- a/knip.config.ts +++ b/knip.config.ts @@ -1,5 +1,9 @@ import { antelopeKnipConfig } from "@antelopejs/tooling-configs/knip"; export default antelopeKnipConfig({ - entry: ["src/tests/**/*.test.ts", "src/antelope.test.ts"], + entry: [ + "src/tests/**/*.test.ts", + "src/tests/**/*.untyped.spec.js", + "src/antelope.test.ts", + ], }); diff --git a/src/controller.ts b/src/controller.ts index 03d330c..4dfbddf 100644 --- a/src/controller.ts +++ b/src/controller.ts @@ -116,13 +116,10 @@ export type ParameterModifier = ( */ export interface ComputedParameter { /** - * Source of the parameter. It reads the context its decorator declared, - * which the route that registers it supplies, so it holds a - * `ParameterProvider` of any `RequestContext` subtype. + * Source of the parameter. A provider that declared a `RequestContext` + * subtype is stored here after `ControllerMeta.setProvider` widens it. */ - provider?: { - provide(context: RequestContext): ReturnType; - }["provide"]; + provider?: ParameterProvider; modifiers: ParameterModifier[]; } @@ -227,7 +224,9 @@ export class ControllerMeta { param: number | undefined, provider: ParameterProvider, ) { - this.getComputedParameter(key, param).provider = provider; + // SAFETY: 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. + this.getComputedParameter(key, param).provider = + provider as ParameterProvider; } /** diff --git a/src/tests/route-lifecycle.test.ts b/src/tests/route-lifecycle.test.ts index a1e382b..96217ed 100644 --- a/src/tests/route-lifecycle.test.ts +++ b/src/tests/route-lifecycle.test.ts @@ -114,25 +114,6 @@ describe("Route lifecycle", () => { assert.equal(routesAt("/lifecycle/bystander"), 0); }); - it("refuses a handler that names no controller and registers nothing", () => { - const handler = handlerAt("/lifecycle/no-controller"); - // @ts-expect-error off-contract handler without its required controller; RegisterRoute must throw a TypeError at registration - delete handler.controller; - const before = getRegisteredRoutes().length; - - assert.throws( - () => RegisterRoute(handler), - (error) => - error instanceof TypeError && - error.message.includes("GET") && - error.message.includes("/lifecycle/no-controller") && - error.message.includes("controller"), - ); - - assert.equal(getRegisteredRoutes().length, before); - assert.deepEqual(proxyCalls, []); - }); - it("tolerates unregistering the same id twice", () => { const id = RegisterRoute(handlerAt("/lifecycle/twice")); UnregisterRoute(id); diff --git a/src/tests/route-lifecycle.untyped.spec.js b/src/tests/route-lifecycle.untyped.spec.js new file mode 100644 index 0000000..4aa8724 --- /dev/null +++ b/src/tests/route-lifecycle.untyped.spec.js @@ -0,0 +1,44 @@ +import assert from "node:assert"; + +import { getRegisteredRoutes, RegisterRoute, routesProxy } from "../index"; + +const proxyCalls = []; + +describe("Route lifecycle, off-contract handlers", () => { + before(() => { + routesProxy.onRegister((id) => { + proxyCalls.push({ kind: "register", id }); + }, true); + routesProxy.onUnregister((id) => { + proxyCalls.push({ kind: "unregister", id }); + }); + }); + + beforeEach(() => { + proxyCalls.length = 0; + }); + + it("refuses a handler that names no controller and registers nothing", () => { + const handler = { + location: "/lifecycle/no-controller", + method: "GET", + mode: "handler", + parameters: [], + properties: {}, + callback: async () => new Response(), + }; + const before = getRegisteredRoutes().length; + + assert.throws( + () => RegisterRoute(handler), + (error) => + error instanceof TypeError && + error.message.includes("GET") && + error.message.includes("/lifecycle/no-controller") && + error.message.includes("controller"), + ); + + assert.equal(getRegisteredRoutes().length, before); + assert.deepEqual(proxyCalls, []); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index a6cc7be..e24461a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,6 +3,8 @@ "target": "esnext", "module": "commonjs", "lib": ["ES2022"], + "allowJs": true, + "checkJs": false, "types": ["node", "mocha"], "esModuleInterop": true, "strict": true,