diff --git a/scripts/docs-audit/README.md b/scripts/docs-audit/README.md index 7e8be71c79..e716a69b01 100644 --- a/scripts/docs-audit/README.md +++ b/scripts/docs-audit/README.md @@ -49,12 +49,12 @@ Three anchor kinds, each exact: | anchor | what it is | how it is derived | |:--|:--|:--| | `symbol` | a documentable declaration the diff touched | the top-level declaration, or a member of a top-level **container** (class / interface / type / enum / schema object), enclosing each changed line — on **both** sides of the diff, so a removed export still anchors the pages naming it. A member that is a **data property** is additionally qualified by its declaring container against the authorable surface (see below) | -| `route` | a wire path the change touched | a path literal on a changed line, plus every route whose **registrar handler** references a changed symbol | +| `route` | a wire path the change touched | a path literal on a changed line, plus every route whose **route-source handler** references a changed symbol | | `sdk` | the client method bound to an anchor route | the declared `route` ⟷ `client` rows in the repo's route ledgers | The `route` and `sdk` hops are what carry the derivation across the surface boundary the package graph cannot cross: `auditMetaItem` (changed) → `GET /api/v1/meta/:type/:name/audit` -(`rest-server.ts` registrar) → `meta.getAudit` (`rest-route-ledger.ts`) → the token +(`rest-server.ts`, a registration call site) → `meta.getAudit` (`rest-route-ledger.ts`) → the token `api/client-sdk.mdx` actually contains. **A local variable is not documentable surface.** That one rule is what drops the measured @@ -87,7 +87,7 @@ Each anchor kind names its own origin, from the same field the JSON publishes as | kind | the clause | |:--|:--| | `symbol` | `a field of interface MetaOverlayCacheKey` · `a method of class RestServer` · `a top-level function` | -| `route` | `a path literal in RestServer` · `bridged from symbol enforceEnvironmentOwnership — its registrar handler names it` | +| `route` | `a path literal in RestServer` · `bridged from symbol enforceEnvironmentOwnership — its route-source handler names it` | | `sdk` | `the route ledger binds it to GET /api/v1/ui/view/:object/:type` | | `literal` | `a string literal in cacheKeyOf` | | `command` | `read off packages/cli/src/commands/environments/bind.ts` | @@ -181,7 +181,7 @@ whole anchor set, in every kind, after it is derived.) The first build of this derivation was, on some PRs, *noisier* than the proxy it replaced (134 rows where the old tool gave 26). Two guards fixed that, and both run **before** the route bridge — a name left in the set does not merely add a noisy row, it mints noisy route -and SDK anchors from every registrar handler that mentions it: +and SDK anchors from every route-source handler that mentions it: 1. **Shape** — an anchor must be code-shaped (camelCase / PascalCase / snake_case / dotted). `label`, `object`, `start`, `locale` and `sections` all arrived as real @@ -200,7 +200,7 @@ entirely — a data table is consulted by handlers, it is not their implementati ### The `sdk` bridge reaches part of its own population, and says which part (#9572) -The `sdk` hop needs a registrar `path:` tail to select a route-ledger row. Measured on +The `sdk` hop needs a route source's `path:` tail to select a route-ledger row. Measured on `9ff11921a`: **45 of the 221 client-bound ledger rows are reachable, 176 are not.** An unreachable row is not "unlisted this time" — no symbol change bridges to it, ever. @@ -210,7 +210,7 @@ drift — each move was measured row by row: | `--bridge-coverage` | `a6eca9223` | `8f10a79f7a` | why | | --- | ---: | ---: | --- | -| registrar files | 12 | 12 | two ADR-0049 ledger entries were admitted in between and are excluded again here; they produced **0 tails and 0 reachable rows**, so they never moved the figures below | +| route sources | 12 | 12 | two ADR-0049 ledger entries were admitted in between and are excluded again here; they produced **0 tails and 0 reachable rows**, so they never moved the figures below | | route tails | 43 | 44 | `rest-server.ts` unrolled `for (const publishedPath of […])` into a literal `path:` — a variable path yields no tail, a literal one does | | client-bound rows | 222 | 219 | three `:type/:section/:name` rows deleted from `rest-route-ledger.ts`, all three already unreachable | | **reachable** | **45** | **47** | the one new tail `/:type/:name/published` selects `meta.getPublished` on the rest ledger *and* on the runtime ledger | @@ -218,7 +218,7 @@ drift — each move was measured row by row: ⭐ 45 → 47 is the bridge reaching **more** of its population, not losing track of it, so the figure stands at 47. ⛔ Do not "restore" 45: the only recognizer spelling that reproduces it drops ten of the fourteen matched files, including a tail-producing -registrar — the control appears to recover exactly when the recognizer stops working. +call site — the control appears to recover exactly when the recognizer stops working. That number now travels with the answer. `bridgeCoverage` is emitted on every run whose change carried a bridgeable symbol (`{ measured: false, reason }` when it did not — never a @@ -246,34 +246,96 @@ among them), so the silence is not an empty region. `56 of 56` and `46 of 87` used to print in the same words, and they are not the same finding. Every unreachable row is now attributed against a **ceiling** — every `path:` any -`packages/**` file declares, with `REGISTRAR_FILE_RE` ignored entirely, built by -`maximalTailsFrom` from the same `parseRegistrarSource` over the same walk. Measured on +`packages/**` file declares, with `CALL_SITE_FILE_RE` ignored entirely, built by +`maximalTailsFrom` from the same `parseRouteSource` over the same walk. Measured on `589758d22`, the 177 unreachable rows partition as: | cause | rows | what it means | | --- | --- | --- | | `discovery-gap` | 14 | an in-repo file declares this exact path; the filename convention did not scan that file. The JSON **names the witness**. | -| `no-in-repo-registrar` | 56 | on a ledger where **not one** row is declared in-repo — declared upstream and catch-all-mounted. No discovery change reaches it. | -| `undecided` | 107 | no in-repo declaration for the row, on a ledger that *has* in-repo registrars. Absence and an unreadable spelling are not distinguishable here, so neither is claimed. | +| `no-in-repo-declaration` | 56 | on a ledger where **not one** row is declared in-repo — declared upstream and catch-all-mounted. No discovery change reaches it. | +| `undecided` | 107 | no in-repo declaration for the row, on a ledger that *has* in-repo route sources. Absence and an unreadable spelling are not distinguishable here, so neither is claimed. | -Exactly **one** of the seven ledgers is `no-in-repo-registrar` today: `auth-route-ledger.ts`, +Exactly **one** of the seven ledgers is `no-in-repo-declaration` today: `auth-route-ledger.ts`, whose own header has said so since #3656 — better-auth declares those routes inside `node_modules` and the plugin mounts them with a single ``rawApp.all(`${basePath}/*`)``, which `routeTailOf` cannot and should not turn into a tail. That is why widening -`REGISTRAR_FILE_RE` to admit `auth-plugin.ts` was measured to move `registrar files +`CALL_SITE_FILE_RE` to admit `auth-plugin.ts` was measured to move `route sources scanned` 12 → 13 and **nothing else**. -⛔ **This changes no discovery and moves no reach.** `REGISTRAR_FILE_RE` is byte-identical, -the bridge still rides on `registrarByTail` alone, and `reachable` is 45 before and after — +⛔ **This changes no discovery and moves no reach.** `CALL_SITE_FILE_RE` is byte-identical, +the bridge still rides on `routeSourceByTail` alone, and `reachable` is 45 before and after — pinned in `--self-test`. The ceiling only explains the number; it never participates in it, and because it is a superset by construction a ceiling that misses a *reachable* row is a `brokenScan` verdict rather than a quieter result. The classification is **derived, never listed**. Control on `589758d22`: adding one in-repo file that declares one auth route — under a filename the convention does not match -— moves the auth ledger out of `no-in-repo-registrar` on its own (structural 56 → 0, +— moves the auth ledger out of `no-in-repo-declaration` on its own (structural 56 → 0, `reachable` still 45), and removing it restores 56. +### A route SOURCE is two kinds, and one of them is admitted by evidence (#11857) + +Maintainer ruling A, 2026-09-04 decision batch #31. Until this card the recognizer's name, +docblock and `--self-test` all meant *the file that registers the route* — while the +measured widening that motivated the card admits five `packages/spec` **Zod contract +declarations**, which register nothing. The ruling made the **rename** a condition of +admitting them: nothing here is called a "registrar" any more, because the word would +otherwise denote two constructs. + +A **route source** is a file whose source declares a route. Two kinds: + +| kind | admitted by | today | +| --- | --- | ---: | +| registration **call site** | the `CALL_SITE_FILE_RE` filename convention (unchanged) | 12 | +| spec contract **declaration** | **evidence**: a non-test `packages/**/*.ts` whose masked source declares a route beside the HTTP method it answers | 5 | + +Why a declaration counts: the drift check exists to say *"the contract changed, re-verify +the manual"*, and a Zod API declaration in `packages/spec` **is** the contract. No widening +of the filename convention would ever reach it — `storage.zod.ts` is not going to be +renamed `storage-routes.ts`. + +**The guard is the HTTP method, not a file list.** A route declaration names the verb it +serves; a data payload carrying a `path:` key does not. Measured on `460134af8`: of the 61 +literal `path:` sites in `packages/spec/src/conversions/registry.ts` — whose +`/api/v1/health` is a connector-action **input** inside an automation fixture — **zero** +carry an HTTP method, while the five contract declarations carry one at 61 of their 62 +sites. (The 62nd has its `method:` five *lines* but zero *properties* up, behind a JSDoc +that `maskComments` blanks; the lookaround skips blank lines for exactly that reason.) The +separation is total at every lookaround from 1 to 6, so the constant is a margin, not a +threshold. + +⛔ **The fixture and the benchmark are not re-excluded here.** The card also named +`test/fixtures/*.ts` and `*.bench.ts`; `isTestFile` grew those arms in #12965, so the walk +never offers them to either kind. `--self-test` **pins** that over the two real repo paths +rather than restating the exclusion — the day either arm is loosened, they come back as +contract declarations and the self-test says so. + +Measured on `460134af8`, before → after: + +| `--bridge-coverage` | before | after | +| --- | ---: | ---: | +| route sources scanned | 12 | **17** (12 call sites + 5 contract declarations) | +| route tails produced | 44 | **78** | +| client-bound rows reachable | 47 | **61** | +| `discovery-gap` rows | 14 | **0** | +| `storage` ledger unreachable | 7 of 7 | **0 of 7** | +| `i18n` ledger unreachable | 3 of 3 | **1 of 3** | +| `rest` ledger unreachable | 42 of 84 | **40 of 84** | +| `runtime` ledger unreachable | 64 of 69 | **61 of 69** | +| `plugin-auth` unreachable | 56 of 56 | 56 of 56 (unchanged — declared upstream, see above) | + +No reach regression is possible **by construction**: `selectsFrom` is a `some()` over the +tail list, so added tails can only add selections. `discovery-gap` reaching **0** is the +strong reading — every row the ceiling said a widening *could* reach is now actually +reached, and what remains unreachable is unreachable for a reason no discovery change +touches. + +⚠️ **The published `45 → 59` figure from the card is not what landed.** The card measured +on `a6eca9223`; both endpoints have since moved with the tree (see the #9572 table above), +and the guard admits five files rather than the card's eight. The figure this PR +establishes is **47 → 61 on `460134af8`**, re-measured rather than copied. + ### A PARTIAL ledger read is a verdict too (#9896) The row recognizer reads **single-quoted** values only, and the `rowsParsed === 0` guard @@ -429,7 +491,7 @@ How often it renders, re-derived over the 40 first-parent commits ending at `e43 is a rare notice rather than a per-PR banner, which is what keeps it readable. **Cost** (the card's open question): the anchor derivation reads the same 178-page corpus -the old one did, plus the 18 route-registrar/ledger sources (~875 KB) and one `git show` +the old one did, plus the 18 route-source/ledger files (~875 KB) and one `git show` per changed file per side. Measured end-to-end on the ten PRs above, `node affected-docs.mjs` went from 85-195 ms to 114-582 ms. The heaviest case is the widest diff; every case stays well under a second, against a job that already spends seconds checking out the repo and diff --git a/scripts/docs-audit/affected-docs.mjs b/scripts/docs-audit/affected-docs.mjs index 965c95f05f..a41faf9c27 100644 --- a/scripts/docs-audit/affected-docs.mjs +++ b/scripts/docs-audit/affected-docs.mjs @@ -248,11 +248,12 @@ const SELF_TEST_BATTERIES = Object.freeze({ 'the sdk bridge\'s REACH over the declared surface (#9572)': 11, '#11178: WHY a row is unreachable, and the two causes that printed as one': 28, '`causes` GETS THE SAME TREATMENT, AT BOTH ENDS (#11867)': 16, + 'the ROUTE SOURCE concept: two kinds, and the runtime-registration guard (#11857)': 23, }); // DELETING an entry silences that battery's floor exactly as effectively as // zeroing it, so the roster's own size is pinned too. -const SELF_TEST_BATTERY_FLOOR = 28; +const SELF_TEST_BATTERY_FLOOR = 29; // The key an assertion is filed under when no battery is open. It is not a // declared battery, so it reds by the same set difference rather than silently @@ -588,19 +589,51 @@ function qualifyDataProperty(inner, outer, surface) { } /** - * Where route REGISTRARS live. Deliberately a filename convention rather than a hand-kept - * file list — the same choice the package-root derivation made for the same reason (#4162: - * a hardcoded list fails again on container number eight). A registrar this misses costs - * recall on the `sdk` anchor kind only. + * A ROUTE SOURCE — a file whose source DECLARES a route, so a change to it may nominate + * docs. There are TWO KINDS, and keeping them named apart is the point (#11857, maintainer + * ruling 2026-09-04 batch #31): + * + * (a) a registration CALL SITE — the filename convention in `CALL_SITE_FILE_RE` below. + * The file that MOUNTS the route on a server at runtime. + * (b) a spec contract DECLARATION — a non-test `packages/**` file whose masked source + * declares a route beside the HTTP method it answers (`ROUTE_METHOD_RE`). The file + * that DECLARES the contract a registration serves. + * + * ⛔ "REGISTRAR" MUST NOT MEAN BOTH, which is why nothing here is called one any more. + * Until this card the word meant kind (a) alone — the recognizer, its docblock and its + * `--self-test` all said "the file that registers the route" — and the measured widening + * that motivated this change admits five `packages/spec` Zod contract declarations, which + * register nothing with anything. Stretching the old word over them would have left one + * term denoting two constructs in a tool whose whole job is telling declared surfaces + * apart; the ruling made the rename a CONDITION of admitting them, not a tidy-up after. + * + * WHY KIND (b) IS A SOURCE AND NOT A WIDENING OF KIND (a). The drift check exists to say + * "the contract changed, re-verify the manual". A Zod API declaration in `packages/spec` + * IS the contract — arguably the better source of truth than the mount that serves it — + * so admitting it narrows the class of rows this tool STRUCTURALLY cannot see. It does not + * make those files registration call sites, and no convention widening would reach them: + * `storage.zod.ts` is not going to be renamed `storage-routes.ts`. + * + * ⛔ THE TWO KINDS ARE NOT INTERCHANGEABLE AT THE ADMISSION TEST. Kind (a) is admitted by + * its NAME and contributes every tail it declares; kind (b) is admitted by EVIDENCE and + * contributes only the tails carrying a runtime-registration signal — see + * `ROUTE_METHOD_RE` for what that buys and what it keeps out. + */ + +/** + * Kind (a): where route registration CALL SITES live. Deliberately a filename convention + * rather than a hand-kept file list — the same choice the package-root derivation made for + * the same reason (#4162: a hardcoded list fails again on container number eight). A call + * site this misses costs recall on the `sdk` anchor kind only. * * ⛔ THE SECOND HALF OF THAT SENTENCE USED TO READ "and `anchorlessChanges` reports the * silence". It does not, and #9572 measured why: `anchorlessChanges` fires per changed - * FILE that yielded ZERO anchors, while a handler change in a missed registrar yields its + * FILE that yielded ZERO anchors, while a handler change in a missed call site yields its * own symbol anchors, so the run is never anchorless and prints nothing. The silence was * reported by no field at all until `bridgeCoverage` existed. Keep that distinction in * mind before leaning on any other field to "report" a gap: the populations differ. */ -const REGISTRAR_FILE_RE = /(?:^|\/)(?:[\w.-]*route[\w.-]*|[\w.-]*-server)\.ts$/; +const CALL_SITE_FILE_RE = /(?:^|\/)(?:[\w.-]*route[\w.-]*|[\w.-]*-server)\.ts$/; /** Route LEDGERS — the declared `route` ⟷ `client` tables the `sdk` anchor rides on. */ const LEDGER_FILE_RE = /(?:^|\/)[\w.-]*route-ledger\.ts$/; @@ -622,8 +655,46 @@ const selectsFrom = (tails) => { return (route) => tailList.some((t) => route.replace(/^[A-Z*]+\s+/, '').endsWith(t)); }; -/** How far past a `path:` line a registrar's handler body is scanned for identifiers. */ -const REGISTRAR_HANDLER_WINDOW = 150; +/** How far past a `path:` line a route source's handler body is scanned for identifiers. */ +const ROUTE_SOURCE_HANDLER_WINDOW = 150; + +/** + * THE RUNTIME-REGISTRATION SIGNAL that admits a kind (b) route source: the declaration + * names the HTTP METHOD it answers, beside the path it answers on. + * + * A route declaration says WHAT VERB it serves; a data payload that happens to carry a + * `path:` key does not. That is the whole separation, and it is EVIDENCE READ OFF THE + * DECLARATION rather than a hand-kept exclusion list — the same choice, for the same + * reason, as `CALL_SITE_FILE_RE` and `packageRootOf` (#4162: a list fails again on the + * eighth one). + * + * ⛔ THE EXCLUSION THIS REPLACES WOULD HAVE BEEN A FILE NAME. The one non-route the + * evidence route admits on today's tree is `packages/spec/src/conversions/registry.ts`, + * whose `/api/v1/health` is a connector-action INPUT inside an automation fixture — + * `config: { input: { path: '/api/v1/health' } }`. Naming that file would have kept it out + * and taught the recognizer nothing; the signal keeps out its whole CLASS. Measured on + * `460134af8`: of that file's 61 literal `path:` sites, ZERO carry an HTTP method, while + * the five spec contract declarations carry one at 61 of their 62 sites — and the 62nd is + * a site whose `method:` sits five lines up behind a JSDoc block, which the blank-skipping + * below reaches. The separation is total at every lookaround from 1 to 6, so the number is + * not load-bearing; it is a margin, not a threshold. + * + * ⚠️ THIS IS AN ADMISSION TEST, NOT A REACH TEST. It runs per SITE and only for kind (b). + * Kind (a) is untouched — a call site admitted by name still contributes every tail it + * declares, exactly as before this card, so no reach this tool had can be lost here. + */ +const ROUTE_METHOD_RE = /(?:^|[\s{,(])method\s*:\s*(['"`])(?:GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\1/i; + +/** + * How many NON-BLANK lines either side of a `path:` site are read for the signal above. + * + * Non-blank is what makes this a property-list test rather than a line-distance one: + * `maskComments` blanks a comment to whitespace WITHOUT moving any line, so a JSDoc + * between `method:` and `path:` reads as blank here and the two properties stay adjacent + * — which is exactly the shape of the one site in `plugin-rest-api.zod.ts` that a naive + * line-distance window misses. + */ +const ROUTE_METHOD_LOOKAROUND = 4; /** * Above this many routes, a changed symbol is a CROSS-CUTTING helper rather than one @@ -689,7 +760,7 @@ const OCLIF_COMMANDS_DIR = 'src/commands'; * inside the prose it describes and is deleted with it. That is the whole difference from * the hand-kept registry of canonical-rule sites #9282 considered and rejected — a second * source of truth beside the rule drifts from it silently (the same reason `packageRootOf`, - * `REGISTRAR_FILE_RE` and `commandIdFor` are all derived rather than listed). + * `CALL_SITE_FILE_RE` and `commandIdFor` are all derived rather than listed). * * The opt-in itself is deliberate and is the only authored bit. Deriving from EVERY doc * comment in `packages/**` would hand the corpus-share guard a flood to filter rather than @@ -852,14 +923,14 @@ if (args.includes('--self-test')) { // a complete read) is broken rather than clean, and no such verdict can fire on a tree // where the scan works at all. if (args.includes('--bridge-coverage')) { - const { registrarFiles, sourceFiles, ledgers, registrarByTail } = scanRouteSurface(); + const { routeSources, sourceFiles, ledgers, routeSourceByTail } = scanRouteSurface(); // THE CEILING (#11178) — read lazily off the walk this scan already did, so the census // holds one file's source at a time rather than the tree's. Since #11867 the PHASE 2 // advisory run pays for it too, through this same `ceilingTailsFrom` — one derivation, // so the two paths cannot report the same three buckets from two populations. const ceiling = ceilingTailsFrom(sourceFiles); - const coverage = bridgeCoverageFrom(ledgers, registrarByTail.keys(), ceiling.keys()); - const selects = selectsFrom(registrarByTail.keys()); + const coverage = bridgeCoverageFrom(ledgers, routeSourceByTail.keys(), ceiling.keys()); + const selects = selectsFrom(routeSourceByTail.keys()); const causeOf = new Map(coverage.ledgers.map((l) => [l.file, l.cause])); // Through the one definition, per tail, so naming a witness cannot drift from the rule // that decided the row was remediable in the first place (⛔ never restate the suffix test). @@ -867,7 +938,7 @@ if (args.includes('--bridge-coverage')) { if (asJson) { process.stdout.write(JSON.stringify({ ...coverage, - registrarFiles: registrarFiles.filter((f) => !LEDGER_FILE_RE.test(f)), + routeSources, unreachableRows: ledgers.flatMap(({ file, rows }) => rows.filter((r) => r.client && !selects(r.route)).map((r) => { const witnesses = witnessesFor(r.route); @@ -880,7 +951,14 @@ if (args.includes('--bridge-coverage')) { }, null, 2) + '\n'); } else { console.log(`sdk route bridge — reach over the declared client-bound surface`); - console.log(` registrar files scanned .... ${registrarFiles.filter((f) => !LEDGER_FILE_RE.test(f)).length}`); + // BOTH KINDS, NAMED, NEVER ONE OPAQUE TOTAL (#11857). The two are admitted by + // different tests — a filename for one, evidence for the other — so a count that + // merged them would hide exactly the movement this card exists to make legible: a + // contract declaration appearing or disappearing reads as "the convention changed". + const byKind = (k) => routeSources.filter((r) => r.kind === k).length; + console.log(` route sources scanned ...... ${routeSources.length}`); + console.log(` registration call sites .${String(byKind('call-site')).padStart(4)} admitted by the filename convention`); + console.log(` contract declarations ...${String(byKind('contract')).padStart(4)} admitted by evidence: a route declared beside its HTTP method`); console.log(` route tails produced ....... ${coverage.tails}`); console.log(` ledger files ............... ${coverage.ledgers.length}`); // BOTH HALVES OF THE FRACTION, ALWAYS. A bare "221" cannot be told apart from a 221 @@ -905,7 +983,7 @@ if (args.includes('--bridge-coverage')) { // moves it by zero rows. That misreading is the reason this split exists — it aimed a // whole card at widening a recognizer that was never the constraint. const CAUSE_NOTE = { - 'no-in-repo-registrar': () => 'NO in-repo registrar for ANY row — discovery cannot reach this surface', + 'no-in-repo-declaration': () => 'NO in-repo declaration for ANY row — discovery cannot reach this surface', 'discovery-gap': (l) => `all ${l.remediable} remediable by discovery`, 'undecided': (l) => `${l.remediable} remediable by discovery, ${l.unwitnessed} undecided`, 'no-client-surface': () => 'no client-bound rows to reach', @@ -921,11 +999,11 @@ if (args.includes('--bridge-coverage')) { // fraction is: remediable + structural + undecided === UNREACHABLE, so a reader can see // it stay whole, and a bucket that starts absorbing another cannot do it quietly. if (coverage.causes.measured) { - console.log(` why — against every \`path:\` any packages/** file declares (${coverage.causes.ceilingTails}-tail ceiling vs the ${coverage.tails} the filename convention yields):`); + console.log(` why — against every \`path:\` any packages/** file declares (${coverage.causes.ceilingTails}-tail ceiling vs the ${coverage.tails} the two route-source kinds yield):`); const n = (v) => String(v).padStart(4); - console.log(` remediable by discovery ..${n(coverage.causes.remediable)} an in-repo file declares the row's path; the convention did not scan that file`); - console.log(` NO in-repo registrar .....${n(coverage.causes.structural)} on a ledger where not ONE row is declared in-repo — declared upstream and catch-all-mounted, so no discovery change reaches it`); - console.log(` undecided ................${n(coverage.causes.undecided)} no in-repo declaration, on a ledger that HAS in-repo registrars — absence and an unreadable spelling are not distinguishable here`); + console.log(` remediable by discovery ..${n(coverage.causes.remediable)} an in-repo file declares the row's path; NEITHER route-source kind admitted that file`); + console.log(` NO in-repo declaration ...${n(coverage.causes.structural)} on a ledger where not ONE row is declared in-repo — declared upstream and catch-all-mounted, so no discovery change reaches it`); + console.log(` undecided ................${n(coverage.causes.undecided)} no in-repo declaration, on a ledger that HAS in-repo route sources — absence and an unreadable spelling are not distinguishable here`); } else { console.log(` why ........................ ${coverage.causes.reason}`); } @@ -1203,7 +1281,7 @@ const liveManifestIo = { * from paths — topic = directory, command = filename — so the machine-readable registry * this anchor kind needs ALREADY EXISTS as the convention, and a hand-kept ledger beside * it would be a second source of truth that drifts silently (the same reasoning that made - * `packageRootOf` and `REGISTRAR_FILE_RE` filesystem-derived rather than listed). + * `packageRootOf` and `CALL_SITE_FILE_RE` filesystem-derived rather than listed). * * Shapes it handles, all mechanically: * - `build.ts` → `build` (a top-level command) @@ -1596,6 +1674,28 @@ function symbolAnchorsFromSource(text, changed, surface = liveContainerSurface() return { names, bridgeable, from }; } +/** + * Does the `path:` site at `i` carry the runtime-registration signal — an HTTP `method:` + * inside its own property list? See `ROUTE_METHOD_RE` for why the verb is the evidence. + * + * Reads OUTWARD over NON-BLANK lines in both directions, plus the site's own line (a + * one-line `{ method: 'GET', path: '/api/x' }` is one property list, not two). `lines` + * must already be MASKED — the blank-skipping is what steps over a JSDoc sitting between + * the two properties, and on unmasked source those lines are not blank. + */ +function hasRouteMethodSignal(lines, i) { + if (ROUTE_METHOD_RE.test(lines[i])) return true; + for (const step of [-1, 1]) { + let seen = 0; + for (let j = i + step; j >= 0 && j < lines.length && seen < ROUTE_METHOD_LOOKAROUND; j += step) { + if (!lines[j].trim()) continue; + seen++; + if (ROUTE_METHOD_RE.test(lines[j])) return true; + } + } + return false; +} + /** * `path:` literals in a route registrar, each mapped to the identifiers its handler body * mentions. This is the mechanical half of the SDK bridge: a changed protocol method @@ -1633,7 +1733,7 @@ function symbolAnchorsFromSource(text, changed, surface = liveContainerSurface() * anchor there are DOC-COMMENT-only, so `publishItem`'s behaviour never moved, and the page * names none of the three symbols that did. */ -function parseRegistrarSource(text) { +function parseRouteSource(text, { requireMethodSignal = false } = {}) { const lines = maskComments(text).split('\n'); const sites = []; for (let i = 0; i < lines.length; i++) { @@ -1646,14 +1746,18 @@ function parseRegistrarSource(text) { // NON-LITERAL `path:` line behave the same way, which is the whole change. if (!/(?:^|[\s{,(])path\s*:/.test(lines[i])) continue; const m = lines[i].match(/(?:^|[\s{,(])path\s*:\s*([`'"])(.*?)\1/); - sites.push({ line: i, tail: m ? routeTailOf(m[2]) : null }); + sites.push({ line: i, tail: m ? routeTailOf(m[2]) : null, signalled: hasRouteMethodSignal(lines, i) }); } const byTail = new Map(); for (let k = 0; k < sites.length; k++) { - const { line, tail } = sites[k]; - if (!tail) continue; + const { line, tail, signalled } = sites[k]; + // ⚠️ A SITE DROPPED FOR THE TAIL STILL BOUNDS THE PREVIOUS WINDOW. `next` is read off + // the SITES array, never off the tails, so requiring the signal narrows what a file + // CONTRIBUTES without moving where any other site's handler window ends (#9503 — + // the same reason a `path:` with no literal was always kept in this array). + if (!tail || (requireMethodSignal && !signalled)) continue; const next = k + 1 < sites.length ? sites[k + 1].line : lines.length; - const end = Math.min(next, line + REGISTRAR_HANDLER_WINDOW, lines.length); + const end = Math.min(next, line + ROUTE_SOURCE_HANDLER_WINDOW, lines.length); let ids = byTail.get(tail); if (!ids) byTail.set(tail, (ids = new Set())); for (let j = line; j < end; j++) { @@ -1668,7 +1772,7 @@ function parseRegistrarSource(text) { * ignored entirely — the CEILING on what a widened discovery could ever reach (#11178). * * ⛔ THIS IS NOT A SECOND DISCOVERY ROUTE, and nothing downstream of it selects a row. - * `REGISTRAR_FILE_RE` is untouched, the bridge still rides on `registrarByTail` alone, and + * `CALL_SITE_FILE_RE` is untouched, the bridge still rides on `routeSourceByTail` alone, and * the published `reachable` figure is computed exactly where it was. This function exists * only so the REPORT can name WHY a row is unreachable, which the report could not do * while it had one number for two causes: @@ -1684,11 +1788,11 @@ function parseRegistrarSource(text) { * discovery, whose own remedy was then measured to move the auth ledger by ZERO rows. * * SUPERSET BY CONSTRUCTION, which is what makes the comparison legitimate: the same - * `parseRegistrarSource`, over the same walk's files, minus the convention test. So every + * `parseRouteSource`, over the same walk's files, minus the convention test. So every * tail discovery yields appears here too, and `reachable` can never exceed this ceiling — * an invariant `bridgeCoverageFrom` turns into a broken-scan verdict rather than trusting. * - * The `includes('path')` prefilter is a SOUND superset, not a heuristic: `parseRegistrarSource` + * The `includes('path')` prefilter is a SOUND superset, not a heuristic: `parseRouteSource` * yields a tail only from a line matching `path\s*:` in the MASKED source, and masking * replaces bytes with spaces rather than inserting any, so those four bytes must be present * in the raw text for any tail to exist. Positive control on `589758d22`: masking all 1930 @@ -1723,7 +1827,7 @@ function maximalTailsFrom(sources) { const byTail = new Map(); for (const { file, text } of sources) { if (!text.includes('path')) continue; - for (const [tail] of parseRegistrarSource(text)) { + for (const [tail] of parseRouteSource(text)) { let owners = byTail.get(tail); if (!owners) byTail.set(tail, (owners = [])); owners.push(file); @@ -1739,7 +1843,7 @@ function maximalTailsFrom(sources) { * WHY THIS IS REPORTED AND NOT INFERRED. The bridge's one hop from a changed symbol to * `api/client-sdk.mdx` is `registrar tail` ⟶ `ledger row`, and a ledger row no registrar * tail can select is STRUCTURALLY unreachable: no symbol change bridges to it, ever. The - * file's own note on `REGISTRAR_FILE_RE` says a missed registrar "costs recall on the + * file's own note on `CALL_SITE_FILE_RE` says a missed registrar "costs recall on the * `sdk` anchor kind only, and `anchorlessChanges` reports the silence" — measured on * `9ff11921a`, neither half of that holds. `anchorlessChanges` fires per changed FILE * with ZERO anchors, and a handler change yields its own symbol anchors, so the run is @@ -1830,14 +1934,14 @@ function bridgeCoverageFrom(ledgers, tails, maximalTails) { // and the plugin mounts them with a single catch-all — and it is derived here rather // than asserted, so a ledger that grows an in-repo registrar leaves this bucket by // itself. Measured on `589758d22`: exactly one ledger of the seven. - else if (hit.length === 0 && witnessed.length === 0) cause = 'no-in-repo-registrar'; + else if (hit.length === 0 && witnessed.length === 0) cause = 'no-in-repo-declaration'; else if (unwitnessed.length === 0) cause = 'discovery-gap'; // ⛔ NOT defaulted into either bucket. This ledger HAS in-repo registrars, so its // unwitnessed rows are not the auth shape — but nothing here can tell "no registration // site" apart from "a registration site whose path this recognizer cannot read", and // rendering that ignorance as either verdict is exactly the #9747 false green. else cause = 'undecided'; - if (cause === 'no-in-repo-registrar') structuralRows += unwitnessed.length; + if (cause === 'no-in-repo-declaration') structuralRows += unwitnessed.length; else undecidedRows += unwitnessed.length; remediableRows += witnessed.length; byLedger.push({ file, clientRows: bound.length, reachable: hit.length, unreachable: bound.length - hit.length, rowsParsed: rows.length, routesDeclared, clientsDeclared, declined, outsideCode, cause, remediable: censusMeasured ? witnessed.length : null, unwitnessed: censusMeasured ? unwitnessed.length : null }); @@ -1854,7 +1958,7 @@ function bridgeCoverageFrom(ledgers, tails, maximalTails) { // answer, and pinning it would be a false red on an accurate ledger. const brokenScan = []; if (!ledgers.length) brokenScan.push('no route-ledger file was found at all — the ledger walk selected nothing, so every `sdk` anchor is silently unavailable'); - if (!tailList.length) brokenScan.push('the registrar scan produced no route tail at all — the symbol → route → sdk bridge cannot fire for any change'); + if (!tailList.length) brokenScan.push('the route-source scan produced no route tail at all — the symbol → route → sdk bridge cannot fire for any change'); if (censusBroken) brokenScan.push(censusBroken); for (const l of byLedger) { if (l.rowsParsed === 0) brokenScan.push(`${l.file} matched the ledger convention but parsed 0 rows — the row recognizer no longer reads this file's shape`); @@ -1922,7 +2026,7 @@ function bridgeCoverageFrom(ledgers, tails, maximalTails) { * * ⭐ AND THE EXCLUSION IS RIGHT FOR THE CEILING TOO, which is the question this walk's * two consumers make live. `sourceFiles` is the #11178 ceiling population and - * `registrarFiles` is the bridge's own discovery — one walk, both. It is tempting to + * `conventionFiles` is the bridge's own discovery — one walk, both. It is tempting to * argue the ceiling wants the WIDEST possible population and so should keep test * directories; it does not, because of what the ceiling's verdict MEANS. A row counted * `remediable by discovery` is a claim that widening the FILENAME CONVENTION would reach @@ -1938,10 +2042,14 @@ function bridgeCoverageFrom(ledgers, tails, maximalTails) { * @param {string} root the tree to walk (`packages/**` under it); paths come back * relative to it, which is what `isTestFile` and both file regexes are defined over. * @param {(dir: string, opts: {withFileTypes: true}) => Array<{name: string, isFile(): boolean, isDirectory(): boolean}>} [readDir] - * @returns {{registrarFiles: string[], sourceFiles: string[]}} + * @returns {{conventionFiles: string[], sourceFiles: string[]}} */ function walkSourceFiles(root, readDir = readdirSync) { - const registrarFiles = []; + // The files the FILENAME CONVENTION picks out — route ledgers and kind (a) route + // sources, in one list because one walk fills both (#4851). ⛔ NOT the route-source + // population: kind (b) is admitted by EVIDENCE in `scanRouteSurface`, off `sourceFiles`, + // and no filename convention can see it. + const conventionFiles = []; // EVERY candidate the convention CHOSE FROM, collected in the same walk (#11178). This // is not a second discovery route and nothing downstream of the bridge reads it: it is // the population `maximalTailsFrom` measures the convention against, so that "the @@ -1961,11 +2069,11 @@ function walkSourceFiles(root, readDir = readdirSync) { const rel = relative(root, p); if (isTestFile(rel) || isMigrationLedgerEntry(rel)) continue; sourceFiles.push(rel); - if (LEDGER_FILE_RE.test(rel) || REGISTRAR_FILE_RE.test(rel)) registrarFiles.push(rel); + if (LEDGER_FILE_RE.test(rel) || CALL_SITE_FILE_RE.test(rel)) conventionFiles.push(rel); } }; walkSrc(join(root, 'packages')); - return { registrarFiles, sourceFiles }; + return { conventionFiles, sourceFiles }; } /** @@ -1974,12 +2082,24 @@ function walkSourceFiles(root, readDir = readdirSync) { * same walk — a second walk here is exactly the drift #4851 billed us for. */ function scanRouteSurface() { - const { registrarFiles, sourceFiles } = walkSourceFiles(repoRoot); + const { conventionFiles, sourceFiles } = walkSourceFiles(repoRoot); const ledgers = []; const ledgerRows = []; - const registrarByTail = new Map(); - for (const rel of registrarFiles) { + const routeSourceByTail = new Map(); + // Every admitted route source WITH THE KIND THAT ADMITTED IT, so the report can say + // which of the two definitions reached a row rather than printing one opaque count. + const routeSources = []; + const absorb = (byTail) => { + for (const [tail, ids] of byTail) { + let acc = routeSourceByTail.get(tail); + if (!acc) routeSourceByTail.set(tail, (acc = new Set())); + for (const id of ids) acc.add(id); + } + }; + + // KIND (a) — the filename convention, unchanged. Ledgers ride the same list. + for (const rel of conventionFiles) { let text; try { text = readFileSync(join(repoRoot, rel), 'utf8'); } catch { continue; } if (LEDGER_FILE_RE.test(rel)) { @@ -1987,15 +2107,42 @@ function scanRouteSurface() { ledgers.push({ file: rel, rows, declined, routesDeclared, clientsDeclared, outsideCode }); ledgerRows.push(...rows); } - if (REGISTRAR_FILE_RE.test(rel)) { - for (const [tail, ids] of parseRegistrarSource(text)) { - let acc = registrarByTail.get(tail); - if (!acc) registrarByTail.set(tail, (acc = new Set())); - for (const id of ids) acc.add(id); - } + // ⛔ A ROUTE LEDGER IS NOT A ROUTE SOURCE, even though most ledger names match the + // call-site convention (`route-ledger.ts` carries the word `route`). A ledger is the + // declared TABLE tails are matched INTO; a route source is a file that declares a + // route. Before this card the combined convention list meant every ledger was also + // parsed as a registrar — harmless, because ledgers declare `route:` rows and not + // `path:` sites, but it is the sort of overlap that makes one word mean two things. + // + // MEASURED, NOT ASSUMED: on `460134af8` all 11 live route ledgers yield ZERO route + // tails, so narrowing this predicate moves no tail at all. `--self-test` pins that + // against the LIVE tree rather than trusting the sentence, so the day a ledger starts + // declaring a `path:` route it reds here instead of losing the tail in silence. + if (CALL_SITE_FILE_RE.test(rel) && !LEDGER_FILE_RE.test(rel)) { + routeSources.push({ file: rel, kind: 'call-site' }); + absorb(parseRouteSource(text)); } } - return { registrarFiles, sourceFiles, ledgers, ledgerRows, registrarByTail }; + + // KIND (b) — the CONTRACT DECLARATION, admitted by evidence (#11857). Same walk, same + // parser, same `includes('path')` prefilter that makes `maximalTailsFrom`'s superset + // sound: masking replaces bytes with spaces and inserts none, so those four bytes must + // be present in the raw text for any tail to exist. + // + // ⛔ A LEDGER IS NOT A ROUTE SOURCE and a call site is not counted twice — both are + // skipped here, so `routeSources` partitions cleanly by kind. + for (const rel of sourceFiles) { + if (LEDGER_FILE_RE.test(rel) || CALL_SITE_FILE_RE.test(rel)) continue; + let text; + try { text = readFileSync(join(repoRoot, rel), 'utf8'); } catch { continue; } + if (!text.includes('path')) continue; + const byTail = parseRouteSource(text, { requireMethodSignal: true }); + if (!byTail.size) continue; + routeSources.push({ file: rel, kind: 'contract' }); + absorb(byTail); + } + + return { conventionFiles, routeSources, sourceFiles, ledgers, ledgerRows, routeSourceByTail }; } /** @@ -2745,13 +2892,13 @@ function selfTest() { // // ⛔ NON-VACUITY IS THE ENTIRE POINT OF THESE FIXTURES, and it is why none of them is a // real repo path. The live population is ZERO — measured on `d63b01436`, the three files - // under a test directory that the old walk admitted match neither `REGISTRAR_FILE_RE` + // under a test directory that the old walk admitted match neither `CALL_SITE_FILE_RE` // nor `LEDGER_FILE_RE` and declare no `path:`, so `--bridge-coverage` is byte-identical // across this fix. A pin built from real paths would therefore have passed just as // green with the bug in place and pinned NOTHING. Every fixture below is instead a file // the BASENAME test admits and the PATH test excludes: `x-route.ts` carries no `.test.` // / `.spec.` infix, so under the old call site it was walked, matched - // `REGISTRAR_FILE_RE`, and a test double contributed production route tails — the exact + // `CALL_SITE_FILE_RE`, and a test double contributed production route tails — the exact // failure this is here to keep out. Verified RED against the pre-fix line: 4 registrar // files and 6 source files, against the 1 and 2 asserted here. battery('the WALK\'s admission decision, against a fake tree (#11866)'); @@ -2770,7 +2917,7 @@ function selfTest() { 'packages/foo/README.md', // not a .ts file // #12966. NON-VACUOUS BY CONSTRUCTION, like every fixture above it: neither entry // carries a test infix or lives under a test directory, so BOTH are walked under the - // previous line and BOTH matched `REGISTRAR_FILE_RE` — an ADR-0049 tombstone + // previous line and BOTH matched `CALL_SITE_FILE_RE` — an ADR-0049 tombstone // recording that `contributes.routes` was DELETED was being counted as a file that // registers routes. Reverting the exclusion turns the two equality checks below red. 'packages/foo/src/migrations/entries/retired-keys/18.kernel__Manifest__contributes.routes.ts', @@ -2803,40 +2950,40 @@ function selfTest() { const walked = walkSourceFiles(fakeRoot, fakeReadDir); const sorted = (a) => [...a].sort().join(' | '); check('walkSourceFiles', 'a registrar-NAMED file under __tests__/ is NOT a registrar — the walk tests the path', - 'registrarFiles', 'packages/foo/src/migrations/runner-route.ts | packages/foo/src/real-route.ts', sorted(walked.registrarFiles)); + 'conventionFiles', 'packages/foo/src/migrations/runner-route.ts | packages/foo/src/real-route.ts', sorted(walked.conventionFiles)); check('walkSourceFiles', 'and the three test directories contribute nothing to the CEILING population either', 'sourceFiles', 'packages/foo/src/engine.ts | packages/foo/src/migrations/runner-route.ts | packages/foo/src/real-route.ts', sorted(walked.sourceFiles)); // Per-fixture, so a regression NAMES the arm that came back rather than only the totals. const excludedFixtures = [ - ['packages/foo/src/__tests__/x-route.ts', 'a __tests__/ file matching REGISTRAR_FILE_RE'], + ['packages/foo/src/__tests__/x-route.ts', 'a __tests__/ file matching CALL_SITE_FILE_RE'], ['packages/foo/src/__mocks__/fake-server.ts', 'a __mocks__/ file matching the `-server` alternative'], ['packages/foo/src/__fixtures__/stub-route-ledger.ts', 'a __fixtures__/ file matching LEDGER_FILE_RE'], ['packages/foo/src/__tests__/helper.ts', 'a __tests__/ helper with no registrar name'], // #11857. NON-VACUOUS BY CONSTRUCTION, exactly as the four above are: neither // carries a .test. / .spec. infix, so under the PREVIOUS predicate both were - // walked - `stub-route.ts` then matched `REGISTRAR_FILE_RE` and a test fixture + // walked - `stub-route.ts` then matched `CALL_SITE_FILE_RE` and a test fixture // contributed production route tails, and `engine.bench.ts` entered the ceiling // population. Re-admitting either arm breaks the two equality checks above, and // these two rows name WHICH arm came back. - ['packages/foo/test/fixtures/stub-route.ts', 'a non-underscore test/fixtures/ file matching REGISTRAR_FILE_RE'], + ['packages/foo/test/fixtures/stub-route.ts', 'a non-underscore test/fixtures/ file matching CALL_SITE_FILE_RE'], ['packages/foo/src/engine.bench.ts', 'a .bench.ts benchmark'], // #12966, one row per entry so a regression NAMES which one came back. ['packages/foo/src/migrations/entries/retired-keys/18.kernel__Manifest__contributes.routes.ts', - 'an ADR-0049 retired-keys tombstone matching REGISTRAR_FILE_RE'], + 'an ADR-0049 retired-keys tombstone matching CALL_SITE_FILE_RE'], ['packages/foo/src/migrations/entries/semantic/18.plugin-manifest-contributes-routes-retired.ts', - 'an ADR-0049 semantic entry matching REGISTRAR_FILE_RE'], + 'an ADR-0049 semantic entry matching CALL_SITE_FILE_RE'], ['packages/foo/src/migrations/entries/semantic/18.plain-rename.ts', 'a ledger entry with no registrar name — excluded from the CEILING too'], ]; for (const [rel, label] of excludedFixtures) { check('walkSourceFiles', `${label} is walked at all`, rel, false, walked.sourceFiles.includes(rel)); - check('walkSourceFiles', `${label} reaches the registrar list`, rel, false, walked.registrarFiles.includes(rel)); + check('walkSourceFiles', `${label} reaches the registrar list`, rel, false, walked.conventionFiles.includes(rel)); } check('walkSourceFiles', 'a genuine registrar still survives the walk', 'packages/foo/src/real-route.ts', - true, walked.registrarFiles.includes('packages/foo/src/real-route.ts')); + true, walked.conventionFiles.includes('packages/foo/src/real-route.ts')); check('walkSourceFiles', 'a migrations/ registrar OUTSIDE entries/ survives — the exclusion is the DIRECTORY class, not the word', 'packages/foo/src/migrations/runner-route.ts', true, - walked.registrarFiles.includes('packages/foo/src/migrations/runner-route.ts')); + walked.conventionFiles.includes('packages/foo/src/migrations/runner-route.ts')); check('walkSourceFiles', 'a .test.ts file is still excluded by the FILE arm', 'packages/foo/src/engine.test.ts', false, walked.sourceFiles.includes('packages/foo/src/engine.test.ts')); check('walkSourceFiles', 'node_modules/ and dist/ are still pruned', 'packages/foo/{node_modules,dist}/route.ts', @@ -3353,7 +3500,7 @@ function selfTest() { check('changedLineNumbers', 'old-side lines (a REMOVED export still anchors)', 'hunks', JSON.stringify([6376, 13396, 13397]), JSON.stringify(parsed.oldLines)); // The two declared tables the SDK bridge rides on. - const registrarSource = [ + const routeSourceFixture = [ 'this.routeManager.register({', " method: 'GET',", ' path: `${metaPath}/:type/:name/audit`,', @@ -3370,11 +3517,11 @@ function selfTest() { ' },', '});', ].join('\n'); - const registrar = parseRegistrarSource(registrarSource); - check('parseRegistrarSource', 'the audit route is indexed by its tail', 'tail', true, registrar.has('/:type/:name/audit')); - check('parseRegistrarSource', 'its handler symbols are captured', 'auditMetaItem', true, !!registrar.get('/:type/:name/audit')?.has('auditMetaItem')); - check('parseRegistrarSource', 'a handler does NOT absorb the NEXT route\'s symbols', 'historyMetaItem', false, !!registrar.get('/:type/:name/audit')?.has('historyMetaItem')); - check('parseRegistrarSource', 'the second route is indexed too', 'historyMetaItem', true, !!registrar.get('/:type/:name/history')?.has('historyMetaItem')); + const registrar = parseRouteSource(routeSourceFixture); + check('parseRouteSource', 'the audit route is indexed by its tail', 'tail', true, registrar.has('/:type/:name/audit')); + check('parseRouteSource', 'its handler symbols are captured', 'auditMetaItem', true, !!registrar.get('/:type/:name/audit')?.has('auditMetaItem')); + check('parseRouteSource', 'a handler does NOT absorb the NEXT route\'s symbols', 'historyMetaItem', false, !!registrar.get('/:type/:name/audit')?.has('historyMetaItem')); + check('parseRouteSource', 'the second route is indexed too', 'historyMetaItem', true, !!registrar.get('/:type/:name/history')?.has('historyMetaItem')); const ledgerSource = [ 'export const REST_ROUTE_LEDGER = [', @@ -4372,7 +4519,7 @@ function selfTest() { // THE CHAIN, each link real and each one but the last correct: a doc-comment line has no // declaration of its own and its indent walk climbs past every sibling member to the // CLASS, so `RestServer` enters the anchor set (a correct row — three release pages name - // that class) → the route bridge accepts it as a bridge symbol → `parseRegistrarSource` + // that class) → the route bridge accepts it as a bridge symbol → `parseRouteSource` // scans handler windows for the BARE IDENTIFIER and two unrelated handlers call // `RestServer.` statics → two route anchors → the ledger maps one to `meta.getBookTree`. // Two routes is UNDER `MAX_ROUTES_PER_SYMBOL`, so the cross-cutting cap never saw it. @@ -4405,10 +4552,10 @@ function selfTest() { ' }', '}', ].join('\n'); - const bookRegistrar = parseRegistrarSource(serverSource); - const bookIds = bookRegistrar.get('/book/:name/tree'); - check('parseRegistrarSource', 'the mechanism is real: a static-call QUALIFIER lands in the handler window', 'RestServer', true, !!bookIds?.has('RestServer')); - check('parseRegistrarSource', 'and so does the handler\'s own implementation symbol', 'anyPermissionSetAudience', true, !!bookIds?.has('anyPermissionSetAudience')); + const bookRouteSource = parseRouteSource(serverSource); + const bookIds = bookRouteSource.get('/book/:name/tree'); + check('parseRouteSource', 'the mechanism is real: a static-call QUALIFIER lands in the handler window', 'RestServer', true, !!bookIds?.has('RestServer')); + check('parseRouteSource', 'and so does the handler\'s own implementation symbol', 'anyPermissionSetAudience', true, !!bookIds?.has('anyPermissionSetAudience')); const docCommentLine = 3; // `* [#9120] Resolve the environment …` — inside the JSDoc const methodBodyLine = 6; // `return this.resolveRequestEnvironmentId(req);` @@ -4426,8 +4573,8 @@ function selfTest() { for (const [tail, ids] of registrarMap) if ([...symbols].some((sym) => ids.has(sym))) tails.push(tail); return tails; }; - check('bridge', 'a doc-comment-only edit inside a class selects no route at all', 'tails', JSON.stringify([]), JSON.stringify(tailsSelectedBy(bridgeableAt(serverSource, docCommentLine), bookRegistrar))); - check('bridge', 'the pre-fix behaviour, held as the counterfactual: the raw anchor set DID select the book route', 'tails', JSON.stringify(['/book/:name/tree']), JSON.stringify(tailsSelectedBy(anchorsAt(serverSource, docCommentLine), bookRegistrar))); + check('bridge', 'a doc-comment-only edit inside a class selects no route at all', 'tails', JSON.stringify([]), JSON.stringify(tailsSelectedBy(bridgeableAt(serverSource, docCommentLine), bookRouteSource))); + check('bridge', 'the pre-fix behaviour, held as the counterfactual: the raw anchor set DID select the book route', 'tails', JSON.stringify(['/book/:name/tree']), JSON.stringify(tailsSelectedBy(anchorsAt(serverSource, docCommentLine), bookRouteSource))); check('bridge', 'a changed protocol METHOD still selects its own route', 'tails', JSON.stringify(['/:type/:name/audit']), JSON.stringify(tailsSelectedBy(bridgeableAt(protocolSource, 9), registrar))); // The container/leaf split on the two fixtures the derivation is already pinned against, @@ -4467,7 +4614,7 @@ function selfTest() { // name — that last one is the counterfactual, and it is what stops this block going green // because the fixture drifted into carrying no comment at all. battery('the handler-window scan reads CODE, never PROSE (#9432)'); - const commentaryRegistrar = [ + const commentaryRouteSource = [ 'export class RestServer {', ' private registerPublishRoutes() {', ' this.routeManager.register({', @@ -4494,12 +4641,12 @@ function selfTest() { ' private describeRegistration() {}', '}', ].join('\n'); - const commentary = parseRegistrarSource(commentaryRegistrar); + const commentary = parseRouteSource(commentaryRouteSource); const publishIds = commentary.get('/:type/:name/publish'); - check('parseRegistrarSource', 'a leaf the handler actually CALLS is still seen — the bridge is untouched where its premise holds', 'publishMetaItem', true, !!publishIds?.has('publishMetaItem')); - check('parseRegistrarSource', 'a leaf named only in an English comment is NOT this route\'s implementation', 'promoteDraftForPublish', false, !!publishIds?.has('promoteDraftForPublish')); - check('parseRegistrarSource', 'a `//` inside a STRING opens no comment — this scan rides the shared scanner, not a private regex', 'base + publishMetaItem', true, !!publishIds?.has('base') && !!publishIds?.has('publishMetaItem')); - check('parseRegistrarSource', 'a `path:` inside a JSDoc @example is an illustration, not a registration', 'tails', JSON.stringify(['/:type/:name/publish']), JSON.stringify([...commentary.keys()])); + check('parseRouteSource', 'a leaf the handler actually CALLS is still seen — the bridge is untouched where its premise holds', 'publishMetaItem', true, !!publishIds?.has('publishMetaItem')); + check('parseRouteSource', 'a leaf named only in an English comment is NOT this route\'s implementation', 'promoteDraftForPublish', false, !!publishIds?.has('promoteDraftForPublish')); + check('parseRouteSource', 'a `//` inside a STRING opens no comment — this scan rides the shared scanner, not a private regex', 'base + publishMetaItem', true, !!publishIds?.has('base') && !!publishIds?.has('publishMetaItem')); + check('parseRouteSource', 'a `path:` inside a JSDoc @example is an illustration, not a registration', 'tails', JSON.stringify(['/:type/:name/publish']), JSON.stringify([...commentary.keys()])); // The counterfactual, twice: the PRE-FIX scan, verbatim, over the same fixture. Both // halves must still be there to be excluded, or this block proves nothing. @@ -4507,13 +4654,13 @@ function selfTest() { const ls = src.split('\n'); const start = ls.findIndex((l) => l.includes(siteFragment)); const ids = new Set(); - for (let j = start; j >= 0 && j < Math.min(ls.length, start + REGISTRAR_HANDLER_WINDOW); j++) { + for (let j = start; j >= 0 && j < Math.min(ls.length, start + ROUTE_SOURCE_HANDLER_WINDOW); j++) { for (const m of ls[j].matchAll(/[A-Za-z_$][\w$]*/g)) ids.add(m[0]); } return ids; }; - check('parseRegistrarSource', 'counterfactual: the bare-token scan DID read the prose name out of that window', 'promoteDraftForPublish', true, rawWindowIds(commentaryRegistrar, ':type/:name/publish').has('promoteDraftForPublish')); - check('parseRegistrarSource', 'counterfactual: that @example line DID match the registration-site regex', '/api/users/:id', '/api/users/:id', routeTailOf(commentaryRegistrar.split('\n').find((l) => l.includes('/api/users/:id')).match(/(?:^|[\s{,(])path\s*:\s*([`'"])(.*?)\1/)?.[2] ?? '')); + check('parseRouteSource', 'counterfactual: the bare-token scan DID read the prose name out of that window', 'promoteDraftForPublish', true, rawWindowIds(commentaryRouteSource, ':type/:name/publish').has('promoteDraftForPublish')); + check('parseRouteSource', 'counterfactual: that @example line DID match the registration-site regex', '/api/users/:id', '/api/users/:id', routeTailOf(commentaryRouteSource.split('\n').find((l) => l.includes('/api/users/:id')).match(/(?:^|[\s{,(])path\s*:\s*([`'"])(.*?)\1/)?.[2] ?? '')); // End to end through the same selection step the bridge runs: a diff confined to the // commented-about symbol selects no route, and one on the called symbol still selects its @@ -4573,7 +4720,7 @@ function selfTest() { // which is still the shape the scan cannot see wherever it survives. Kept verbatim // for that reason — ⛔ do not "refresh" a hermetic fixture to match today's tree. battery('a registration BOUNDS the previous window even when its path is a variable (#9503)'); - const variablePathRegistrar = [ + const variablePathRouteSource = [ 'export class RestServer {', ' private registerStateRoutes() {', " for (const objectsSegment of ['objects', 'object']) {", @@ -4602,14 +4749,14 @@ function selfTest() { ' }', '}', ].join('\n'); - const variablePath = parseRegistrarSource(variablePathRegistrar); + const variablePath = parseRouteSource(variablePathRouteSource); const stateIds = variablePath.get('/:name/state/:field'); - check('parseRegistrarSource', 'the site\'s OWN implementation symbol still lands in its window', 'legalNextStates', true, !!stateIds?.has('legalNextStates')); - check('parseRegistrarSource', 'a `path:` written in a COMMENT bounds nothing — the boundary rides the same mask the tail does', 'legalNextStates after a commented `path:`', true, !!stateIds?.has('legalNextStates')); - check('parseRegistrarSource', 'the NEXT route\'s handler symbol is not this route\'s implementation', 'getMetaItemLayered', false, !!stateIds?.has('getMetaItemLayered')); - check('parseRegistrarSource', 'a variable `path:` bounds a window without claiming a tail — the recall half is untouched, not silently faked', 'tails', JSON.stringify(['/:name/state/:field']), JSON.stringify([...variablePath.keys()])); + check('parseRouteSource', 'the site\'s OWN implementation symbol still lands in its window', 'legalNextStates', true, !!stateIds?.has('legalNextStates')); + check('parseRouteSource', 'a `path:` written in a COMMENT bounds nothing — the boundary rides the same mask the tail does', 'legalNextStates after a commented `path:`', true, !!stateIds?.has('legalNextStates')); + check('parseRouteSource', 'the NEXT route\'s handler symbol is not this route\'s implementation', 'getMetaItemLayered', false, !!stateIds?.has('getMetaItemLayered')); + check('parseRouteSource', 'a variable `path:` bounds a window without claiming a tail — the recall half is untouched, not silently faked', 'tails', JSON.stringify(['/:name/state/:field']), JSON.stringify([...variablePath.keys()])); - // The counterfactual: `parseRegistrarSource` verbatim as it stood before this hop — + // The counterfactual: `parseRouteSource` verbatim as it stood before this hop — // literal `path:` lines are the only sites. Both halves have to be real for the block // above to prove anything: the defect must reach the foreign symbol, and it must do so // through the boundary and not through the 150-line cap. @@ -4625,16 +4772,16 @@ function selfTest() { const { line, tail } = sites[k]; if (!tail) continue; const next = k + 1 < sites.length ? sites[k + 1].line : ls.length; - const end = Math.min(next, line + REGISTRAR_HANDLER_WINDOW, ls.length); + const end = Math.min(next, line + ROUTE_SOURCE_HANDLER_WINDOW, ls.length); let ids = byTail.get(tail); if (!ids) byTail.set(tail, (ids = new Set())); for (let j = line; j < end; j++) for (const id of ls[j].matchAll(/[A-Za-z_$][\w$]*/g)) ids.add(id[0]); } return byTail; }; - const preFix = literalOnlySites(variablePathRegistrar); - check('parseRegistrarSource', 'counterfactual: the literal-only site scan DID swallow the next route\'s handler whole', 'getMetaItemLayered', true, !!preFix.get('/:name/state/:field')?.has('getMetaItemLayered')); - check('parseRegistrarSource', 'counterfactual: and the fixture is short enough that the 150-line cap is not what stops it', 'lines under the window', true, variablePathRegistrar.split('\n').length < REGISTRAR_HANDLER_WINDOW); + const preFix = literalOnlySites(variablePathRouteSource); + check('parseRouteSource', 'counterfactual: the literal-only site scan DID swallow the next route\'s handler whole', 'getMetaItemLayered', true, !!preFix.get('/:name/state/:field')?.has('getMetaItemLayered')); + check('parseRouteSource', 'counterfactual: and the fixture is short enough that the 150-line cap is not what stops it', 'lines under the window', true, variablePathRouteSource.split('\n').length < ROUTE_SOURCE_HANDLER_WINDOW); // End to end through the same selection step the bridge runs. check('bridge', 'a diff touching only the FOREIGN handler\'s symbol selects no route', 'tails', JSON.stringify([]), JSON.stringify(tailsSelectedBy(new Set(['getMetaItemLayered']), variablePath))); @@ -5024,7 +5171,7 @@ function selfTest() { const causeOf = (f) => causeCov.ledgers.find((l) => l.file === f).cause; const causeCases = [ // THE ONE THIS CARD IS ABOUT: not one row of the surface is declared anywhere in-repo. - ['a ledger no in-repo file declares ANY row of is structural', 'no-in-repo-registrar', causeOf('structural-route-ledger.ts')], + ['a ledger no in-repo file declares ANY row of is structural', 'no-in-repo-declaration', causeOf('structural-route-ledger.ts')], ['a ledger whose every unreachable row has an in-repo witness is a discovery gap', 'discovery-gap', causeOf('gap-route-ledger.ts')], // ⛔ NOT defaulted into either bucket — the #9747 rule this whole split is an // application of: a recognizer narrower than the repo reports "unrecognised". @@ -5113,7 +5260,7 @@ function selfTest() { // render branch with no ceiling prints `unmeasured` in a nicer shape. battery('`causes` GETS THE SAME TREATMENT, AT BOTH ENDS (#11867)'); check('emit', 'the ADVISORY path measures causes — it passes a ceiling, not just tails', 'affected-docs.mjs', - true, /bridgeCoverageFrom\(ledgers, registrarByTail\.keys\(\), ceilingTailsFrom\(sourceFiles\)\.keys\(\)\)/.test(ownSource)); + true, /bridgeCoverageFrom\(ledgers, routeSourceByTail\.keys\(\), ceilingTailsFrom\(sourceFiles\)\.keys\(\)\)/.test(ownSource)); // ⚠️ READ THE CODE, NOT THE COMMENT THAT FORBIDS IT. These three pins are about what // the renderer DOES, and the block it lives in names `bridge.ledgers` in prose precisely // to forbid deriving from it — so a raw-text negative pin fails on its own rationale @@ -5148,7 +5295,7 @@ function selfTest() { // that count is by deriving selection a second time. Pinned at the source, because the // two agreeing today is what a re-statement costs nothing to break tomorrow. check('emit', 'the --json row list selects through `selectsFrom`, not a second copy of the rule', 'affected-docs.mjs', - true, /const selects = selectsFrom\(registrarByTail\.keys\(\)\);/.test(ownSource)); + true, /const selects = selectsFrom\(routeSourceByTail\.keys\(\)\);/.test(ownSource)); check('emit', 'and nothing else restates the suffix test inline', 'affected-docs.mjs', 1, (ownSource.match(/\.some\(\(t\) => route\.replace\(/g) || []).length); @@ -5247,6 +5394,193 @@ function selfTest() { } + // ── The ROUTE SOURCE concept: two kinds, and the guard between them (#11857) ── + // + // Ruling A, 2026-09-04 batch #31. Everything here defends ONE sentence: a route source + // is a file that DECLARES a route, of two kinds — a registration CALL SITE admitted by + // its name, and a spec contract DECLARATION admitted by EVIDENCE — and the evidence is + // the HTTP method the declaration answers on. + // + // ⛔ THE GUARD IS THE POINT, NOT THE ADMISSION. Admitting contract declarations without + // it also admits every data payload carrying a `path:` key, and the card that measured + // this route named the instance: a connector-action INPUT inside an automation fixture. + // So the cases below pin the guard from BOTH sides — that it keeps the payload out, and + // that it does not cost the real declarations a single tail. + battery('the ROUTE SOURCE concept: two kinds, and the runtime-registration guard (#11857)'); + + // The shape kind (b) exists to admit: a contract declaring its verb beside its path. + const contractDecl = [ + 'export const StorageApiContracts = {', + ' getPresignedUrl: {', + " method: 'POST' as const,", + " path: '/api/v1/storage/upload/presigned',", + ' input: GetPresignedUrlRequestSchema,', + ' },', + '};', + ].join('\n'); + check('parseRouteSource', 'a contract declaring its VERB beside its path is a route source', + 'tails under the guard', JSON.stringify(['/api/v1/storage/upload/presigned']), + JSON.stringify([...parseRouteSource(contractDecl, { requireMethodSignal: true }).keys()])); + + // The shape the guard exists to keep out — `packages/spec/src/conversions/registry.ts`'s + // own, reduced to the two lines that matter. A path inside a connector-action `input:` + // is an ARGUMENT to an action, not a route anything serves. + const connectorInput = [ + ' {', + " id: 'n5',", + " type: 'connector_action',", + " connectorConfig: { connectorId: 'rest', actionId: 'get' },", + " config: { input: { path: '/api/v1/health' } },", + ' },', + ].join('\n'); + check('parseRouteSource', 'a connector-action INPUT is not a route source — the guard declines it', + 'tails under the guard', 0, parseRouteSource(connectorInput, { requireMethodSignal: true }).size); + // ⭐ THE COUNTERFACTUAL, and the reason the case above proves anything: the parser DOES + // read a tail out of that payload. What declines it is the guard, not a parse failure — + // without this pin the case above would keep passing if `path:` scanning broke entirely. + check('parseRouteSource', 'counterfactual: unguarded, that same payload DOES yield a tail', + 'tails without the guard', JSON.stringify(['/api/v1/health']), + JSON.stringify([...parseRouteSource(connectorInput).keys()])); + + // `actionId: 'get'` is one masked line from the path and reads like a verb to a human. + // It is not an HTTP `method:`, and the signal must not accept it — otherwise the guard + // admits the very file the ruling named. + check('hasRouteMethodSignal', 'an actionId is not an HTTP method — the signal keys on the KEY too', + "actionId: 'get'", false, hasRouteMethodSignal([" actionId: 'get',", " path: '/api/v1/health',"], 1)); + check('hasRouteMethodSignal', 'and a non-HTTP `method:` value is not the signal either', + "method: 'cron'", false, hasRouteMethodSignal([" method: 'cron',", " path: '/api/v1/health',"], 1)); + check('hasRouteMethodSignal', 'a one-line property list carries its own signal', + "{ method: 'GET', path: '/api/x' }", true, hasRouteMethodSignal([" { method: 'GET', path: '/api/x' },"], 0)); + check('hasRouteMethodSignal', 'and the verb is read case-insensitively — a lowercase `get` still registers', + "method: 'get'", true, hasRouteMethodSignal([" method: 'get',", " path: '/api/x',"], 1)); + + // THE MASKED-COMMENT GAP. `plugin-rest-api.zod.ts` has one site whose `method:` sits five + // LINES up but zero PROPERTIES up — a JSDoc block sits between them, which `maskComments` + // blanks to whitespace without moving a line. Blank-skipping is what makes the lookaround + // a property-list test rather than a line-distance one; a naive window misses this site. + const jsdocGap = [" method: 'POST',", ' ', ' ', ' ', ' ', " path: '/trigger/:name',"]; + check('hasRouteMethodSignal', 'a JSDoc between the two properties is blank after masking — the site still signals', + 'method five blank lines up', true, hasRouteMethodSignal(jsdocGap, 5)); + // …and the lookaround is still BOUNDED: five non-blank properties away is a different + // object, and reads as one. The blank-skipping widens what counts as adjacent, never + // how many PROPERTIES away the verb may sit. + const farMethod = [" method: 'POST',", ' a: 1,', ' b: 2,', ' c: 3,', ' d: 4,', " path: '/trigger/:name',"]; + check('hasRouteMethodSignal', 'but five NON-BLANK properties away is another object — not a signal', + 'method past the lookaround', false, hasRouteMethodSignal(farMethod, 5)); + + // A DROPPED SITE STILL BOUNDS THE PREVIOUS WINDOW (#9503, one card on). The guard changes + // what a file CONTRIBUTES, never where another site's handler window ends — so a payload + // sitting between two real declarations must not let the first one swallow the second's + // handler symbols. + const guardedBoundary = [ + " { method: 'GET', path: '/api/v1/a',", + ' handler: firstImpl },', + ' filler1: 1,', + ' filler2: 2,', + ' filler3: 3,', + ' filler4: 4,', + " { config: { input: { path: '/api/v1/payload' } } },", + ' filler5: 5,', + ' filler6: 6,', + ' filler7: 7,', + ' filler8: 8,', + " { method: 'GET', path: '/api/v1/b',", + ' handler: secondImpl },', + ].join('\n'); + const guarded = parseRouteSource(guardedBoundary, { requireMethodSignal: true }); + check('parseRouteSource', 'the payload between them contributes no tail of its own', + 'tails', JSON.stringify(['/api/v1/a', '/api/v1/b']), JSON.stringify([...guarded.keys()])); + check('parseRouteSource', 'and the first route does NOT absorb the second route\'s handler symbol', + 'secondImpl', false, !!guarded.get('/api/v1/a')?.has('secondImpl')); + check('parseRouteSource', 'while its own handler symbol still lands in its window', + 'firstImpl', true, !!guarded.get('/api/v1/a')?.has('firstImpl')); + + // KIND (a) IS UNTOUCHED, which is the no-reach-regression half. A call site admitted by + // NAME contributes every tail it declares, guard or no guard — this is the default. + check('parseRouteSource', 'kind (a) is unguarded by DEFAULT — the same source yields the payload tail too', + 'tails without the guard', JSON.stringify(['/api/v1/a', '/api/v1/payload', '/api/v1/b']), + JSON.stringify([...parseRouteSource(guardedBoundary).keys()])); + + // ⚠️ THE GUARD'S KNOWN LIMIT, PINNED RATHER THAN LEFT TO BE DISCOVERED. The signal is a + // PROPERTY-LIST test, so a payload written INSIDE a real declaration's property list + // inherits that declaration's verb and is admitted. That is a bounded over-admission and + // an acceptable one: it can only happen inside a file that is a route source already, so + // it widens which TAILS one source contributes — never which FILES are sources. It is + // also why the connector-action pin above is stated on the REAL file: `registry.ts` has + // 61 path sites and not one of them sits beside an HTTP method. + check('hasRouteMethodSignal', 'a payload nested INSIDE a declaration\'s property list does inherit its verb', + 'adjacent payload', true, hasRouteMethodSignal([ + " { method: 'GET', path: '/api/v1/a',", + " config: { input: { path: '/api/v1/payload' } } },", + ], 1)); + check('CALL_SITE_FILE_RE', 'and the call-site convention itself is unchanged by the rename', + 'packages/rest/src/rest-server.ts', true, CALL_SITE_FILE_RE.test('packages/rest/src/rest-server.ts')); + + // ── The isTestFile pin, stated over the ROUTE SOURCE population (#12965 / #11857) ── + // + // The card measured THREE non-registrars the evidence route admits. Two of them — a + // fixture under `test/fixtures/` and a `.bench.ts` benchmark — stopped being admitted + // when `isTestFile` grew those arms, so this card re-excludes NEITHER by name. The + // ruling's instruction was to PIN that rather than re-state it: these are the two real + // repo paths, asserted over the predicate the walk actually calls, so the day either arm + // is loosened the fixture and the benchmark come back as contract declarations and THIS + // says so — instead of two phantom route sources appearing in the census. + for (const [rel, why] of [ + ['packages/qa/dogfood/test/fixtures/endpoint-policy-fixture.ts', 'the #11857 fixture — kept out by isTestFile, NOT by name'], + ['packages/spec/src/benchmark.bench.ts', 'the #11857 benchmark — kept out by isTestFile, NOT by name'], + ]) { + check('isTestFile', `${why} — so the walk never offers it to EITHER kind`, rel, true, isTestFile(rel)); + } + + // ── LIVE pins: the three claims this card makes about the real tree ── + // + // Fixtures cannot carry these: each is a claim about what the REPO contains, and each is + // a number the PR body quotes. Read off the same scan the report prints from. + { + const live = scanRouteSurface(); + const liveKind = (k) => live.routeSources.filter((r) => r.kind === k).map((r) => r.file); + + // (1) The guard's target, on the real file rather than a reduced fixture. + check('scanRouteSurface', 'the connector-action input is NOT admitted as a route source', + 'packages/spec/src/conversions/registry.ts', false, + live.routeSources.some((r) => r.file === 'packages/spec/src/conversions/registry.ts')); + const registryText = (() => { + try { return readFileSync(join(repoRoot, 'packages/spec/src/conversions/registry.ts'), 'utf8'); } catch { return null; } + })(); + // The same counterfactual as the fixture pair above, on the real file: it is EVIDENCE + // that would admit it, and the guard is the only thing that does not. + check('scanRouteSurface', 'counterfactual: unguarded, that real file WOULD be admitted — the guard is what excludes it', + 'registry.ts tails, unguarded', true, registryText === null || parseRouteSource(registryText).size > 0); + + // (2) THE CONTRACT KIND ADMITS SOMETHING — the anti-vacuity floor, and the case that + // names kind (b) when it stops running. ⚠️ Without it the `every()` below passes on an + // EMPTY list: disabling the evidence route entirely left this whole battery green, + // measured, which is how this pin came to exist. A shape assertion over a population + // is only as strong as a floor under that population's size. + check('scanRouteSurface', 'the CONTRACT kind admits at least one route source — kind (b) is still running', + 'kind=contract count', true, liveKind('contract').length >= 1); + // …and the one the headline rests on by NAME: `storage.zod.ts` is what closes the + // storage ledger from 7-of-7 unreachable to 0-of-7. If it is legitimately renamed, + // move this pin with it — do not delete it, or the closure goes back to being asserted. + check('scanRouteSurface', 'and the declaration that closes the storage ledger is among them', + 'packages/spec/src/api/storage.zod.ts', true, + liveKind('contract').includes('packages/spec/src/api/storage.zod.ts')); + check('scanRouteSurface', 'every contract declaration admitted is a packages/spec API declaration', + 'kind=contract', true, liveKind('contract').every((f) => f.startsWith('packages/spec/src/api/'))); + + // (3) THE LEDGER EXCLUSION IS A MEASURED NO-OP, not an assumption. Most ledger names + // match the call-site convention (`route-ledger.ts` carries `route`), so before this + // card every ledger was also parsed as a registrar. Narrowing kind (a) to exclude them + // moves a tail only if some ledger declares a `path:` route — none does today, and + // this is the assertion that reds the day one starts. + let ledgerTails = 0; + for (const rel of live.conventionFiles.filter((f) => LEDGER_FILE_RE.test(f))) { + try { ledgerTails += parseRouteSource(readFileSync(join(repoRoot, rel), 'utf8')).size; } catch { /* unreadable contributes none */ } + } + check('scanRouteSurface', 'no route LEDGER declares a route tail — so excluding ledgers from kind (a) moved nothing', + 'tails declared by the live route ledgers', 0, ledgerTails); + } + // ── The floor: every declared battery RAN, and ran its cases (#13489) ─── // // Evaluated after every battery has had its chance and BEFORE the verdict, so @@ -5427,7 +5761,7 @@ for (const f of implementationChanges) { // Both guards run BEFORE the bridge, not after it, and that ordering is the fix rather // than a detail: the bridge answers "which routes mention this name", so a name left in // the set does not merely add a noisy row — it mints noisy ROUTE and SDK anchors from -// every registrar handler that happens to mention it. Measured both ways: `label` / +// every route-source handler that happens to mention it. Measured both ways: `label` / // `start` / `subject` (locals in the auth-email change 445ae4deb) pulled `/:object/import` // and `/forms/:slug` into an advisory about email templates, and `ObjectQL` did the same // to the objectql cascade fix 650cd3daa. @@ -5460,7 +5794,7 @@ for (const name of [...symbolAnchors].sort()) { if (!admitAnchor('symbol', name, symbolRe(name))) continue; // TWO KINDS OF SYMBOL ARE DOC ANCHORS BUT NOT BRIDGE SYMBOLS. The bridge's premise is // "this name IS some route's implementation, so the handler that implements that route - // mentions it" — and `parseRegistrarSource` tests that premise by scanning a handler + // mentions it" — and `parseRouteSource` tests that premise by scanning a handler // window for the BARE IDENTIFIER (over comment-masked source since #9432, so a name a // handler only WRITES ABOUT never satisfies it). Any name a handler mentions for some // OTHER reason satisfies the scan without satisfying the premise, and mints a route (and, @@ -5521,7 +5855,7 @@ const crossCuttingSymbols = []; // same rule), and a fabricated `0 of 0` here would read as "nothing to reach". let bridgeCoverage = { measured: false, reason: 'no bridgeable symbol in this change — the sdk route bridge did not run' }; if (bridgeSymbols.length) { - const { ledgers, ledgerRows, registrarByTail, sourceFiles } = scanRouteSurface(); + const { ledgers, ledgerRows, routeSourceByTail, sourceFiles } = scanRouteSurface(); // WITH THE CEILING (#11867). Until now this call omitted the third argument, so every // ledger's cause came back `unmeasured` and the three counts `null` — honest, but it // meant the PR comment, which is the surface a human actually reads, rendered all 177 @@ -5543,7 +5877,7 @@ if (bridgeSymbols.length) { // ⛔ Through `ceilingTailsFrom`, never a second inline census: `--bridge-coverage` and // this path now publish the same three buckets, and two spellings of the population they // are computed over is how the two surfaces start disagreeing about one repo. - bridgeCoverage = bridgeCoverageFrom(ledgers, registrarByTail.keys(), ceilingTailsFrom(sourceFiles).keys()); + bridgeCoverage = bridgeCoverageFrom(ledgers, routeSourceByTail.keys(), ceilingTailsFrom(sourceFiles).keys()); // symbol → route, capped: the bridge answers "which routes mention this name", and for // a CROSS-CUTTING helper that is every route it is wired into. Measured on the REST @@ -5551,7 +5885,7 @@ if (bridgeSymbols.length) { // families whose pages document nothing that change touched. Above the cap a symbol // contributes no route anchor — and says so in `crossCuttingSymbols`. const routesBySymbol = new Map(); - for (const [tail, ids] of registrarByTail) { + for (const [tail, ids] of routeSourceByTail) { for (const s of bridgeSymbols) { if (!ids.has(s)) continue; let tails = routesBySymbol.get(s); @@ -5563,7 +5897,7 @@ if (bridgeSymbols.length) { if (tails.size > MAX_ROUTES_PER_SYMBOL) { crossCuttingSymbols.push(`${s} (${tails.size} routes)`); continue; } for (const t of tails) { routeAnchors.add(t); - noteAnchorFrom('route', t, `bridged from symbol ${s} — its registrar handler names it`); + noteAnchorFrom('route', t, `bridged from symbol ${s} — its route source's handler names it`); } } // route → client method (the ledger's declared binding), and the reverse direction for