Skip to content

Commit b29ead6

Browse files
fix(devx): init-service guard reads every service accessor, not just getService (#4835) (#4902)
`scripts/check-init-service-contract.mjs` (#4471, ADR-0116) matched one callee name — `getService` — while the kernel resolves named services through three: `getService` (PluginContext), `getServiceAsync` (ObjectKernel) and `getServiceScoped` (PluginContext, whose kernel body is the same `pluginLoader.getService(name, scopeId)` call `getServiceAsync` makes). The ordering hazard belongs to the registry, not to a method name, so the guard was answering its own question about a third of the surface. #4772 went through that gap: pre-fix `AuthPlugin.init()` (`f2eb85007^`) resolved the workspace-provided `cache` service via `(ctx as { getServiceAsync?: … }).getServiceAsync?.('cache')` with nothing in its declarations covering it — the exact verdict this guard prints — and the edge was never constructed. - `SERVICE_LOOKUP_CALLEES` is now the named vocabulary, with membership argued per accessor from `packages/core`. `hasService` stays out: `hasAnyService` is private and `PluginLoader.hasService` is not reachable from a plugin. - The `scan()` pre-filter derives from that set instead of hardcoding a substring that only accidentally covers today's names. - Each edge records the accessor it was made through; `--list` and the failure message quote it as written instead of normalising every reader to `getService('X')`. - Self-test grows to 19 cases. 13 is the #4772 pre-fix shape verbatim (optional call, cast `ctx`, best-effort try/catch) and asserts plugin, provider and call line in the message; narrowing the set back to `['getService']` turns it red. The repo audit stays green — today's `getServiceAsync` call sites are all on request-time paths, in no plugin's `init()`. Claude-Session: https://claude.ai/code/session_018iARDqtrhQgz6fVHDeDkbQ Co-authored-by: Claude <noreply@anthropic.com>
1 parent db82f2e commit b29ead6

2 files changed

Lines changed: 292 additions & 17 deletions

File tree

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
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

Comments
 (0)