diff --git a/packages/runtime/src/route-ledger.conformance.test.ts b/packages/runtime/src/route-ledger.conformance.test.ts index 6acbc610deb..863b3baa61e 100644 --- a/packages/runtime/src/route-ledger.conformance.test.ts +++ b/packages/runtime/src/route-ledger.conformance.test.ts @@ -19,31 +19,41 @@ * registry, so they are pinned in LEGACY_CHAIN_PREFIXES — when ADR-0076 D11 * extracts another branch, that list and the ledger move together. * - * These are domain-level checks, not route-level ones, and worth naming - * precisely so nobody reads this suite's green as "every route has a row". + * The registry checks are domain-level, not route-level, and worth naming + * precisely so nobody reads their green as "every route has a row". * "At least one ledger entry" is satisfied the moment any row exists for a * domain — most domains here carry several routes, but a few carry exactly * one, where domain- and route-granularity coincide only by accident. For a * multi-route domain, any non-last row can be deleted, or never added, - * without turning anything red. That is not a slip in these six assertions — - * it is what the registry exposes: `DomainHandlerRegistry.list()` returns + * without turning them red. That is not a slip in those assertions — it is + * what the registry exposes: `DomainHandlerRegistry.list()` returns * `{prefix, match, methods, handler}` per domain, with no sub-route field to * walk, and a busy domain like `/automation` resolves its own routes through * one function's inline `parts[N] === 'literal'` if-chain rather than a table * anything here could enumerate. An ablation on #17038 measured the * consequence directly: deleting a whole `ROUTE_LEDGER` row left this suite * fully green, because the row's domain still held "at least one" through its - * siblings. Whether the ledger should also be checked at route granularity — - * and how, given the registry's shape above — is an open maintainer decision - * tracked in #17041; this paragraph states today's coverage, not a plan for - * tomorrow's. Deliberately no row/domain counts here: a hand-typed number in - * this prose is exactly the unguarded-count defect this lane spent the same - * day eliminating elsewhere (commits 2cd4c548e, edf59e359), and nothing in this file would + * siblings. + * + * The route-granularity arm (#26513) reads a different source: the routes + * `dispatcher-plugin.ts` registers on the host `IHttpServer`, recorded as the + * plugin starts. That table is complete for what the plugin MOUNTS, so a + * mounted route whose row is deleted now reds here. It cannot see a row with + * no mount of its own spelling — a route only `dispatch()` answers behind a + * catch-all host (`/meta`, `/data`, …), a `servedBy` specialization, a door a + * plugin serves from the raw app — so those rows are still guarded per row by + * `check:route-ledger-census` alone. Whether the ledger should be checked per + * route beyond the mount is an open maintainer question, left open and tracked + * at #26513 (the issue it was first filed under no longer resolves). + * Deliberately no row/domain counts here: a hand-typed number in this prose is + * exactly the unguarded-count defect this lane spent the same day eliminating + * elsewhere (commits 2cd4c548e, edf59e359), and nothing in this file would * ever notice it going stale. */ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, beforeAll } from 'vitest'; import { HttpDispatcher } from './http-dispatcher.js'; +import { createDispatcherPlugin } from './dispatcher-plugin.js'; import { ROUTE_LEDGER, LEGACY_CHAIN_PREFIXES, NON_DISPATCH_MOUNT_PREFIXES } from './route-ledger.js'; /** Minimal kernel — enough for the constructor to register builtin domains. */ @@ -93,6 +103,109 @@ describe('route ledger ↔ dispatcher domain registry', () => { }); }); +/** The wire prefix `route-ledger.ts` documents for its non-`absolute` rows. */ +const LEDGER_WIRE_PREFIX = '/api/v1'; + +/** The base the plugin mounts an environment-scoped twin under (`dispatcher-plugin.ts`). */ +const SCOPED_PREFIX = `${LEDGER_WIRE_PREFIX}/environments/:environmentId`; + +/** + * Every `VERB /path` the dispatcher plugin registers on its host, recorded as + * it starts. Mounts nothing; the plugin's own `start()` is the only author. + * + * The composition is the WIDEST the plugin has, so as few mounts as possible + * go unobserved: + * - no REST plugin on the kernel, so the plugin keeps `/discovery` (it cedes + * that one route when REST is present — a strict subset); + * - a serveable `analytics` service, so the capability-conditional + * `/analytics` routes mount; + * - environment scoping on, under the default `projectResolution: 'auto'`, so + * every scoped twin mounts beside its unscoped route (`'required'` drops + * the unscoped ones — again a strict subset). + * Not exercised: routes a service emits at runtime on the `ai:routes` hook. + * They are dynamic by construction and the `* /ai/**` family row speaks for + * them; the plugin's own `/ai/*` mounts ARE recorded here. + */ +async function dispatcherPluginMounts(): Promise { + const mounts: string[] = []; + const record = (verb: string) => (path: string) => { + mounts.push(`${verb} ${path}`); + }; + const server = { + get: record('GET'), + post: record('POST'), + put: record('PUT'), + delete: record('DELETE'), + patch: record('PATCH'), + use: () => {}, + }; + const services: Record = { analytics: {} }; + const kernel = { + getService: (name: string) => services[name], + getServiceAsync: async (name: string) => services[name], + }; + const plugin = createDispatcherPlugin({ + securityHeaders: false, + scoping: { enableProjectScoping: true }, + }); + await plugin.start?.({ + getKernel: () => kernel, + getService: (name: string) => (name === 'http.server' ? server : services[name]), + environmentId: undefined, + logger: { info() {}, warn() {}, error() {}, debug() {} }, + hook: () => {}, + on: () => {}, + } as any); + return mounts; +} + +/** + * Whether `mount` has a ledger row: its exact `VERB /pattern` under the wire + * prefix, or a `* /prefix/**` family row whose base it sits at or under — the + * same reading the live-mount parity gate gives a wildcard row. A scoped twin + * answers to its unscoped row: the plugin mounts it by calling the same + * registrar with the scoped base, and `dispatch()` strips the scope before + * routing, so the ledger's dispatcher-internal `cleanPath` is the same route. + */ +function hasLedgerRow(mount: string): boolean { + const sp = mount.indexOf(' '); + const verb = mount.slice(0, sp); + let path = mount.slice(sp + 1); + if (path.startsWith(`${SCOPED_PREFIX}/`)) path = LEDGER_WIRE_PREFIX + path.slice(SCOPED_PREFIX.length); + return ROUTE_LEDGER.some((row) => { + const rsp = row.route.indexOf(' '); + const rowVerb = row.route.slice(0, rsp); + const rowPath = (row.absolute ? '' : LEDGER_WIRE_PREFIX) + row.route.slice(rsp + 1); + if (rowPath.includes('*')) { + const base = rowPath.replace(/\/\*+$/, ''); + return (rowVerb === '*' || rowVerb === verb) && (path === base || path.startsWith(`${base}/`)); + } + return rowVerb === verb && rowPath === path; + }); +} + +describe('route ledger ← dispatcher plugin mounts, per route (#26513)', () => { + let mounts: string[]; + beforeAll(async () => { + mounts = await dispatcherPluginMounts(); + }); + + it('the composition reaches its conditional branches (a boot that skipped them would pass the arm below)', () => { + expect(mounts.some((m) => m.endsWith(`${LEDGER_WIRE_PREFIX}/discovery`)), 'no `/discovery` mount').toBe(true); + expect(mounts.some((m) => m.includes(`${LEDGER_WIRE_PREFIX}/analytics/`)), 'no `/analytics` mount').toBe(true); + expect(mounts.some((m) => m.includes(`${SCOPED_PREFIX}/`)), 'no environment-scoped mount').toBe(true); + }); + + it('every route the dispatcher plugin mounts has a ledger row', () => { + const unledgered = [...new Set(mounts)].filter((m) => !hasLedgerRow(m)); + expect( + unledgered, + `Dispatcher mounts with no route-ledger row: ${unledgered.join(', ')}. ` + + 'Add the row to route-ledger.ts with its reviewed disposition (#3563), or delete the mount.', + ).toEqual([]); + }); +}); + describe('non-dispatch mounts (#5090)', () => { it('every pinned non-dispatch mount has a ledger row', () => { const ledgerDomains = new Set(ROUTE_LEDGER.map((e) => e.domain)); diff --git a/packages/runtime/src/route-ledger.ts b/packages/runtime/src/route-ledger.ts index f7c0f1e2c22..55b2e147814 100644 --- a/packages/runtime/src/route-ledger.ts +++ b/packages/runtime/src/route-ledger.ts @@ -9,9 +9,10 @@ * That was one instance of a class: at audit time, six whole domains had zero * client expression. This ledger makes the class visible and ratchets it — * `route-ledger.conformance.test.ts` fails when a dispatcher domain appears - * with no ledger entry, and when an entry claims a client method that does not - * exist. A new route therefore lands with an explicit, reviewed disposition or - * not at all. + * with no ledger entry, and (#26513) when a route the dispatcher plugin mounts + * has no row; the client-side guards fail when an entry claims a client method + * that does not exist. A new route therefore lands with an explicit, reviewed + * disposition or not at all. * * SCOPE. Dispatcher (`http-dispatcher.ts`) routes, expressed as * dispatcher-internal `cleanPath` patterns (prepend `/api/v1` for the wire @@ -295,11 +296,16 @@ export const NON_DISPATCH_MOUNT_PREFIXES = [ * "correct" the digits to make a build green — regenerate them, and read what * they moved by. * - * It is a DELETION detector and deliberately NOT a route-granularity check - * against what the dispatcher registers: whether this ledger should be checked - * per route, given `DomainHandlerRegistry.list()` exposes domains and not - * routes, is an open maintainer decision in #17041. A census stays correct - * whichever way that is ruled. + * It is a DELETION detector, not a route-granularity check. That check lives + * in `route-ledger.conformance.test.ts` since #26513: every route + * `dispatcher-plugin.ts` mounts must have a row, so deleting a mounted route's + * row reds there. It sees only what the plugin mounts, so a row with no mount + * of its own spelling (a route only `dispatch()` answers behind a catch-all + * host, a `servedBy` specialization, a raw-app door) is still guarded per row + * by this census alone. Whether the ledger should be checked per route beyond + * the mount is an open maintainer question, left open and tracked at #26513 + * (the issue it was first filed under no longer resolves). A census stays + * correct whichever way that is ruled; whether it retires is #26431's call. */ export const ROUTE_LEDGER: readonly RouteLedgerEntry[] = [ // ── well-known ──────────────────────────────────────────────────────────── diff --git a/scripts/check-route-ledger-census.mjs b/scripts/check-route-ledger-census.mjs index 770d8bc83cb..6ecebc80619 100644 --- a/scripts/check-route-ledger-census.mjs +++ b/scripts/check-route-ledger-census.mjs @@ -32,7 +32,10 @@ * satisfied by a SHORTER list: for a multi-route domain, any non-last row can * be deleted with that suite fully green. That is not this gate's inference -- * the suite's own header states it, out of the #17038 ablation, and #17075 - * landed the paragraph saying so. + * landed the paragraph saying so. Since #26513 the suite also has a per-route + * arm, but over what `dispatcher-plugin.ts` MOUNTS only: a row with no mount of + * its own spelling (`/meta`, `/data`, `servedBy` rows, raw-app doors) is still + * deletable there with the suite green. * * (b) THE CROSS-LEDGER GATES READ A UNION, WHICH ABSORBS DELETIONS. * `packages/qa/dogfood/test/route-ledger-live-mount-parity.dogfood.test.ts` @@ -51,17 +54,20 @@ * and needs no boot: it is a set-difference against the union, and if the union * still holds the key, the mount is not reported unledgered. * - * ## Why a census and NOT a comparison against registered routes (#17041) + * ## Why a census and NOT a comparison against registered routes (#26513) * * The stronger instrument for this file would be a per-route cross-check * against what the dispatcher registers -- and whether the route ledger should * be checked at ROUTE granularity, given `DomainHandlerRegistry.list()` exposes - * domains and not routes, is an OPEN MAINTAINER DECISION tracked in #17041. - * Building that here would answer it silently. So this gate deliberately takes - * the census/deletion direction instead: it notices that the list got SHORTER, - * independent of what the routes are, and stays correct whichever way #17041 is - * ruled. ⛔ Do not grow this file into a router cross-check; that work belongs - * to #17041 and to whoever the maintainer rules should do it. + * domains and not routes, is an OPEN MAINTAINER DECISION, left open and + * tracked at #26513 (the issue it was first filed under no longer resolves). + * Building that here would answer it silently. So this gate deliberately + * takes the census/deletion direction instead: it notices that the list got + * SHORTER, independent of what the routes are, and stays correct whichever + * way that is ruled. ⛔ Do not grow this file into a router + * cross-check: the cross-check against what the plugin mounts lives in + * `route-ledger.conformance.test.ts` (#26513), and whether this census retires + * once that lands is #26431's call. * * ## Why the number is GENERATED and never hand-typed *