|
| 1 | +--- |
| 2 | +--- |
| 3 | + |
| 4 | +chore(devx): `check:init-service-contract` now sees every service accessor, not just `getService` (#4835) |
| 5 | + |
| 6 | +Releases nothing — the change is confined to `scripts/check-init-service-contract.mjs`. |
| 7 | + |
| 8 | +The #4471 / ADR-0116 guard asks one question: does a plugin resolve, during |
| 9 | +`init()`, a service another workspace plugin provides, without declaring the |
| 10 | +ordering? It asked that question of exactly one accessor: |
| 11 | + |
| 12 | +```js |
| 13 | +if (ts.isPropertyAccessExpression(callee) && callee.name.text === 'getService') { |
| 14 | +``` |
| 15 | +
|
| 16 | +The kernel has three. `getServiceAsync` (`ObjectKernel`, `packages/core/src/kernel.ts`) |
| 17 | +and `getServiceScoped` (`PluginContext`, `packages/core/src/types.ts`) both resolve |
| 18 | +a named service out of the same registry — `getServiceScoped`'s kernel body is the |
| 19 | +same `pluginLoader.getService(name, scopeId)` call `getServiceAsync` makes. The |
| 20 | +ordering hazard is a property of the registry, not of a method name, and ADR-0116's |
| 21 | +`dependencies` / `optionalDependencies` / `requiresServices` apply to all three |
| 22 | +identically. The guard saw one. |
| 23 | +
|
| 24 | +**#4772 is what went through the gap.** Pre-fix `AuthPlugin.init()` |
| 25 | +(`f2eb85007^`) resolved the workspace-provided `cache` service with |
| 26 | +`await (ctx as { getServiceAsync?: … }).getServiceAsync?.('cache')` while |
| 27 | +declaring `requiresServices = ['data', 'manifest']` and depending only on |
| 28 | +objectql — textbook undeclared init-time consumption, and precisely the verdict |
| 29 | +this guard exists to print. It never constructed the edge. The cost: `undefined` |
| 30 | +frozen into the better-auth config on a 21ms ordering margin, rate-limit counters |
| 31 | +that never reached the shared store, and ADR-0069 D2 advertising a capability the |
| 32 | +runtime did not deliver. |
| 33 | +
|
| 34 | +The vocabulary is now a named set (`SERVICE_LOOKUP_CALLEES`) with membership |
| 35 | +argued per accessor, and the file pre-filter derives from it rather than hardcoding |
| 36 | +a substring that only happens to cover today's three names. `hasService` is |
| 37 | +deliberately **out**: `ObjectKernel.hasAnyService` is private and |
| 38 | +`PluginLoader.hasService` is only reachable from a loader instance the kernel never |
| 39 | +hands a plugin, so adding it would flag unrelated objects while covering no real |
| 40 | +edge. `getServices()` (no service-name argument) and `replaceService` (a mutation, |
| 41 | +different remedy) are out for their own stated reasons. |
| 42 | +
|
| 43 | +Two things follow from a widened vocabulary: |
| 44 | +
|
| 45 | +- **`--list` stopped lying.** Every edge printed its call site as `getService('X')` |
| 46 | + regardless of which accessor made it. Each edge now records its accessor and both |
| 47 | + `--list` and the failure message quote it as written. |
| 48 | +- **The self-test proves both directions.** A guard only ever observed green is |
| 49 | + indistinguishable from a guard that matches nothing (#4690, #4804). Cases 13-19 |
| 50 | + include the #4772 pre-fix shape verbatim — optional call, cast `ctx`, best-effort |
| 51 | + `try/catch` — and assert it is caught, that the message names the plugin, the |
| 52 | + provider and the call's line, and that `start()` and declared coverage still pass. |
| 53 | + Narrowing the set back to `['getService']` turns case 13 red. |
| 54 | +
|
| 55 | +The repo audit stays green: today's `getServiceAsync` call sites |
| 56 | +(`rest/src/rest-server.ts`, `runtime/src/http-dispatcher.ts`, |
| 57 | +`runtime/src/dispatcher-plugin.ts`) are all on request-time paths, in no plugin's |
| 58 | +`init()`. This closes a latent hole, it does not report an existing one. |
0 commit comments