Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 124 additions & 11 deletions packages/runtime/src/route-ledger.conformance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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<string[]> {
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<string, unknown> = { 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));
Expand Down
22 changes: 14 additions & 8 deletions packages/runtime/src/route-ledger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 ────────────────────────────────────────────────────────────
Expand Down
22 changes: 14 additions & 8 deletions scripts/check-route-ledger-census.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand All @@ -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
*
Expand Down
Loading