Skip to content

Commit 2eae6bd

Browse files
committed
test(runtime): route-ledger conformance gains a per-route arm over the dispatcher plugin's mounts
Every route dispatcher-plugin.ts registers on its host IHttpServer, recorded as the plugin starts in its widest composition (no REST, a serveable analytics service, environment scoping on), must have a ROUTE_LEDGER row. Deleting a mounted route's row now reds the runtime suite instead of only the hand-kept census. Rows with no mount of their own spelling stay census-guarded. Re-points the dead #17041 references in route-ledger.ts, the conformance header and check-route-ledger-census.mjs to #26513. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FEkQ5oFmiRCo3V9eo4wD3y
1 parent 16ca320 commit 2eae6bd

3 files changed

Lines changed: 152 additions & 27 deletions

File tree

‎packages/runtime/src/route-ledger.conformance.test.ts‎

Lines changed: 124 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -19,31 +19,41 @@
1919
* registry, so they are pinned in LEGACY_CHAIN_PREFIXES — when ADR-0076 D11
2020
* extracts another branch, that list and the ledger move together.
2121
*
22-
* These are domain-level checks, not route-level ones, and worth naming
23-
* precisely so nobody reads this suite's green as "every route has a row".
22+
* The registry checks are domain-level, not route-level, and worth naming
23+
* precisely so nobody reads their green as "every route has a row".
2424
* "At least one ledger entry" is satisfied the moment any row exists for a
2525
* domain — most domains here carry several routes, but a few carry exactly
2626
* one, where domain- and route-granularity coincide only by accident. For a
2727
* multi-route domain, any non-last row can be deleted, or never added,
28-
* without turning anything red. That is not a slip in these six assertions —
29-
* it is what the registry exposes: `DomainHandlerRegistry.list()` returns
28+
* without turning them red. That is not a slip in those assertions — it is
29+
* what the registry exposes: `DomainHandlerRegistry.list()` returns
3030
* `{prefix, match, methods, handler}` per domain, with no sub-route field to
3131
* walk, and a busy domain like `/automation` resolves its own routes through
3232
* one function's inline `parts[N] === 'literal'` if-chain rather than a table
3333
* anything here could enumerate. An ablation on #17038 measured the
3434
* consequence directly: deleting a whole `ROUTE_LEDGER` row left this suite
3535
* fully green, because the row's domain still held "at least one" through its
36-
* siblings. Whether the ledger should also be checked at route granularity —
37-
* and how, given the registry's shape above — is an open maintainer decision
38-
* tracked in #17041; this paragraph states today's coverage, not a plan for
39-
* tomorrow's. Deliberately no row/domain counts here: a hand-typed number in
40-
* this prose is exactly the unguarded-count defect this lane spent the same
41-
* day eliminating elsewhere (commits 2cd4c548e, edf59e359), and nothing in this file would
36+
* siblings.
37+
*
38+
* The route-granularity arm (#26513) reads a different source: the routes
39+
* `dispatcher-plugin.ts` registers on the host `IHttpServer`, recorded as the
40+
* plugin starts. That table is complete for what the plugin MOUNTS, so a
41+
* mounted route whose row is deleted now reds here. It cannot see a row with
42+
* no mount of its own spelling — a route only `dispatch()` answers behind a
43+
* catch-all host (`/meta`, `/data`, …), a `servedBy` specialization, a door a
44+
* plugin serves from the raw app — so those rows are still guarded per row by
45+
* `check:route-ledger-census` alone. Whether the ledger should be checked per
46+
* route beyond the mount is an open maintainer question, left open and tracked
47+
* at #26513 (the issue it was first filed under no longer resolves).
48+
* Deliberately no row/domain counts here: a hand-typed number in this prose is
49+
* exactly the unguarded-count defect this lane spent the same day eliminating
50+
* elsewhere (commits 2cd4c548e, edf59e359), and nothing in this file would
4251
* ever notice it going stale.
4352
*/
4453

45-
import { describe, it, expect } from 'vitest';
54+
import { describe, it, expect, beforeAll } from 'vitest';
4655
import { HttpDispatcher } from './http-dispatcher.js';
56+
import { createDispatcherPlugin } from './dispatcher-plugin.js';
4757
import { ROUTE_LEDGER, LEGACY_CHAIN_PREFIXES, NON_DISPATCH_MOUNT_PREFIXES } from './route-ledger.js';
4858

4959
/** Minimal kernel — enough for the constructor to register builtin domains. */
@@ -93,6 +103,109 @@ describe('route ledger ↔ dispatcher domain registry', () => {
93103
});
94104
});
95105

106+
/** The wire prefix `route-ledger.ts` documents for its non-`absolute` rows. */
107+
const LEDGER_WIRE_PREFIX = '/api/v1';
108+
109+
/** The base the plugin mounts an environment-scoped twin under (`dispatcher-plugin.ts`). */
110+
const SCOPED_PREFIX = `${LEDGER_WIRE_PREFIX}/environments/:environmentId`;
111+
112+
/**
113+
* Every `VERB /path` the dispatcher plugin registers on its host, recorded as
114+
* it starts. Mounts nothing; the plugin's own `start()` is the only author.
115+
*
116+
* The composition is the WIDEST the plugin has, so as few mounts as possible
117+
* go unobserved:
118+
* - no REST plugin on the kernel, so the plugin keeps `/discovery` (it cedes
119+
* that one route when REST is present — a strict subset);
120+
* - a serveable `analytics` service, so the capability-conditional
121+
* `/analytics` routes mount;
122+
* - environment scoping on, under the default `projectResolution: 'auto'`, so
123+
* every scoped twin mounts beside its unscoped route (`'required'` drops
124+
* the unscoped ones — again a strict subset).
125+
* Not exercised: routes a service emits at runtime on the `ai:routes` hook.
126+
* They are dynamic by construction and the `* /ai/**` family row speaks for
127+
* them; the plugin's own `/ai/*` mounts ARE recorded here.
128+
*/
129+
async function dispatcherPluginMounts(): Promise<string[]> {
130+
const mounts: string[] = [];
131+
const record = (verb: string) => (path: string) => {
132+
mounts.push(`${verb} ${path}`);
133+
};
134+
const server = {
135+
get: record('GET'),
136+
post: record('POST'),
137+
put: record('PUT'),
138+
delete: record('DELETE'),
139+
patch: record('PATCH'),
140+
use: () => {},
141+
};
142+
const services: Record<string, unknown> = { analytics: {} };
143+
const kernel = {
144+
getService: (name: string) => services[name],
145+
getServiceAsync: async (name: string) => services[name],
146+
};
147+
const plugin = createDispatcherPlugin({
148+
securityHeaders: false,
149+
scoping: { enableProjectScoping: true },
150+
});
151+
await plugin.start?.({
152+
getKernel: () => kernel,
153+
getService: (name: string) => (name === 'http.server' ? server : services[name]),
154+
environmentId: undefined,
155+
logger: { info() {}, warn() {}, error() {}, debug() {} },
156+
hook: () => {},
157+
on: () => {},
158+
} as any);
159+
return mounts;
160+
}
161+
162+
/**
163+
* Whether `mount` has a ledger row: its exact `VERB /pattern` under the wire
164+
* prefix, or a `* /prefix/**` family row whose base it sits at or under — the
165+
* same reading the live-mount parity gate gives a wildcard row. A scoped twin
166+
* answers to its unscoped row: the plugin mounts it by calling the same
167+
* registrar with the scoped base, and `dispatch()` strips the scope before
168+
* routing, so the ledger's dispatcher-internal `cleanPath` is the same route.
169+
*/
170+
function hasLedgerRow(mount: string): boolean {
171+
const sp = mount.indexOf(' ');
172+
const verb = mount.slice(0, sp);
173+
let path = mount.slice(sp + 1);
174+
if (path.startsWith(`${SCOPED_PREFIX}/`)) path = LEDGER_WIRE_PREFIX + path.slice(SCOPED_PREFIX.length);
175+
return ROUTE_LEDGER.some((row) => {
176+
const rsp = row.route.indexOf(' ');
177+
const rowVerb = row.route.slice(0, rsp);
178+
const rowPath = (row.absolute ? '' : LEDGER_WIRE_PREFIX) + row.route.slice(rsp + 1);
179+
if (rowPath.includes('*')) {
180+
const base = rowPath.replace(/\/\*+$/, '');
181+
return (rowVerb === '*' || rowVerb === verb) && (path === base || path.startsWith(`${base}/`));
182+
}
183+
return rowVerb === verb && rowPath === path;
184+
});
185+
}
186+
187+
describe('route ledger ← dispatcher plugin mounts, per route (#26513)', () => {
188+
let mounts: string[];
189+
beforeAll(async () => {
190+
mounts = await dispatcherPluginMounts();
191+
});
192+
193+
it('the composition reaches its conditional branches (a boot that skipped them would pass the arm below)', () => {
194+
expect(mounts.some((m) => m.endsWith(`${LEDGER_WIRE_PREFIX}/discovery`)), 'no `/discovery` mount').toBe(true);
195+
expect(mounts.some((m) => m.includes(`${LEDGER_WIRE_PREFIX}/analytics/`)), 'no `/analytics` mount').toBe(true);
196+
expect(mounts.some((m) => m.includes(`${SCOPED_PREFIX}/`)), 'no environment-scoped mount').toBe(true);
197+
});
198+
199+
it('every route the dispatcher plugin mounts has a ledger row', () => {
200+
const unledgered = [...new Set(mounts)].filter((m) => !hasLedgerRow(m));
201+
expect(
202+
unledgered,
203+
`Dispatcher mounts with no route-ledger row: ${unledgered.join(', ')}. ` +
204+
'Add the row to route-ledger.ts with its reviewed disposition (#3563), or delete the mount.',
205+
).toEqual([]);
206+
});
207+
});
208+
96209
describe('non-dispatch mounts (#5090)', () => {
97210
it('every pinned non-dispatch mount has a ledger row', () => {
98211
const ledgerDomains = new Set(ROUTE_LEDGER.map((e) => e.domain));

‎packages/runtime/src/route-ledger.ts‎

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,10 @@
99
* That was one instance of a class: at audit time, six whole domains had zero
1010
* client expression. This ledger makes the class visible and ratchets it —
1111
* `route-ledger.conformance.test.ts` fails when a dispatcher domain appears
12-
* with no ledger entry, and when an entry claims a client method that does not
13-
* exist. A new route therefore lands with an explicit, reviewed disposition or
14-
* not at all.
12+
* with no ledger entry, and (#26513) when a route the dispatcher plugin mounts
13+
* has no row; the client-side guards fail when an entry claims a client method
14+
* that does not exist. A new route therefore lands with an explicit, reviewed
15+
* disposition or not at all.
1516
*
1617
* SCOPE. Dispatcher (`http-dispatcher.ts`) routes, expressed as
1718
* dispatcher-internal `cleanPath` patterns (prepend `/api/v1` for the wire
@@ -295,11 +296,16 @@ export const NON_DISPATCH_MOUNT_PREFIXES = [
295296
* "correct" the digits to make a build green — regenerate them, and read what
296297
* they moved by.
297298
*
298-
* It is a DELETION detector and deliberately NOT a route-granularity check
299-
* against what the dispatcher registers: whether this ledger should be checked
300-
* per route, given `DomainHandlerRegistry.list()` exposes domains and not
301-
* routes, is an open maintainer decision in #17041. A census stays correct
302-
* whichever way that is ruled.
299+
* It is a DELETION detector, not a route-granularity check. That check lives
300+
* in `route-ledger.conformance.test.ts` since #26513: every route
301+
* `dispatcher-plugin.ts` mounts must have a row, so deleting a mounted route's
302+
* row reds there. It sees only what the plugin mounts, so a row with no mount
303+
* of its own spelling (a route only `dispatch()` answers behind a catch-all
304+
* host, a `servedBy` specialization, a raw-app door) is still guarded per row
305+
* by this census alone. Whether the ledger should be checked per route beyond
306+
* the mount is an open maintainer question, left open and tracked at #26513
307+
* (the issue it was first filed under no longer resolves). A census stays
308+
* correct whichever way that is ruled; whether it retires is #26431's call.
303309
*/
304310
export const ROUTE_LEDGER: readonly RouteLedgerEntry[] = [
305311
// ── well-known ────────────────────────────────────────────────────────────

‎scripts/check-route-ledger-census.mjs‎

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,10 @@
3232
* satisfied by a SHORTER list: for a multi-route domain, any non-last row can
3333
* be deleted with that suite fully green. That is not this gate's inference --
3434
* the suite's own header states it, out of the #17038 ablation, and #17075
35-
* landed the paragraph saying so.
35+
* landed the paragraph saying so. Since #26513 the suite also has a per-route
36+
* arm, but over what `dispatcher-plugin.ts` MOUNTS only: a row with no mount of
37+
* its own spelling (`/meta`, `/data`, `servedBy` rows, raw-app doors) is still
38+
* deletable there with the suite green.
3639
*
3740
* (b) THE CROSS-LEDGER GATES READ A UNION, WHICH ABSORBS DELETIONS.
3841
* `packages/qa/dogfood/test/route-ledger-live-mount-parity.dogfood.test.ts`
@@ -51,17 +54,20 @@
5154
* and needs no boot: it is a set-difference against the union, and if the union
5255
* still holds the key, the mount is not reported unledgered.
5356
*
54-
* ## Why a census and NOT a comparison against registered routes (#17041)
57+
* ## Why a census and NOT a comparison against registered routes (#26513)
5558
*
5659
* The stronger instrument for this file would be a per-route cross-check
5760
* against what the dispatcher registers -- and whether the route ledger should
5861
* be checked at ROUTE granularity, given `DomainHandlerRegistry.list()` exposes
59-
* domains and not routes, is an OPEN MAINTAINER DECISION tracked in #17041.
60-
* Building that here would answer it silently. So this gate deliberately takes
61-
* the census/deletion direction instead: it notices that the list got SHORTER,
62-
* independent of what the routes are, and stays correct whichever way #17041 is
63-
* ruled. ⛔ Do not grow this file into a router cross-check; that work belongs
64-
* to #17041 and to whoever the maintainer rules should do it.
62+
* domains and not routes, is an OPEN MAINTAINER DECISION, left open and
63+
* tracked at #26513 (the issue it was first filed under no longer resolves).
64+
* Building that here would answer it silently. So this gate deliberately
65+
* takes the census/deletion direction instead: it notices that the list got
66+
* SHORTER, independent of what the routes are, and stays correct whichever
67+
* way that is ruled. ⛔ Do not grow this file into a router
68+
* cross-check: the cross-check against what the plugin mounts lives in
69+
* `route-ledger.conformance.test.ts` (#26513), and whether this census retires
70+
* once that lands is #26431's call.
6571
*
6672
* ## Why the number is GENERATED and never hand-typed
6773
*

0 commit comments

Comments
 (0)