Skip to content

fix(devx): init-service 门禁认全部服务访问器,不只 getService (#4835) - #4902

Merged
xuyushun441-sys merged 1 commit into
mainfrom
claude/issue-4835-init-contract-getserviceasync
Aug 3, 2026
Merged

fix(devx): init-service 门禁认全部服务访问器,不只 getService (#4835)#4902
xuyushun441-sys merged 1 commit into
mainfrom
claude/issue-4835-init-contract-getserviceasync

Conversation

@xuyushun441-sys

Copy link
Copy Markdown
Contributor

Fixes#4835

为什么是「词表漏了一个入口」,而不是别的

scripts/check-init-service-contract.mjs(#4471 / ADR-0116)问的是一个问题:插件在 init() 期间解析了别的 workspace 插件提供的服务,却没有声明排序,对不对?它只对一个 callee 名字问这个问题:

if(ts.isPropertyAccessExpression(callee)&&callee.name.text==='getService'){

而 kernel 解析具名服务有三个入口(读 packages/core 实际导出,不是照议题措辞抄):

访问器声明处说明
getService(name)PluginContext(core/src/types.ts)同步读注册表 —— 原有唯一词条
getServiceAsync(name, scopeId?)ObjectKernel(core/src/kernel.ts:505)插件经 ctx.getKernel() 可达;#4772 里直接对 ctx 做了 duck-typing
getServiceScoped(name, scopeId)PluginContext(core/src/types.ts:53)kernel 实现体与 getServiceAsync 逐字相同:this.pluginLoader.getService< T >(name, scopeId)

排序风险是注册表的属性,不是某个方法名的属性;ADR-0116 的 dependencies / optionalDependencies / requiresServices 对三者一视同仁。所以修的不是某个调用点、不是判定语义、也不是加宽豁免 —— 是词表本身少了两条,门禁在三分之一的面上回答了自己的问题。

#4772 正是从这里溜过去的。 修复前的 AuthPlugin.init()(f2eb85007^,auth-plugin.ts:346):

cache=await(ctxas{getServiceAsync?: (n: string)=>Promise<unknown>}).getServiceAsync?.('cache');

cacheCacheServicePlugin(providesServices = ['cache'])提供;AuthPlugin 当时的声明是 requiresServices = ['data', 'manifest'] + dependencies = ['com.objectstack.engine.objectql'],没有任何一条覆盖 cache。这是本门禁存在的意义所对应的那个判定,而它连这条边都没有构造出来。代价见 #4772:21ms 的冷启顺序差把 undefined 冻进 better-auth 配置,限流计数永远到不了共享存储(ADR-0069 D2 宣称了运行时没交付的能力)。

双向证明

只观察到绿的门禁,与一个什么都匹配不上的门禁,从外部无法区分(#4690#4804 两次先例)。所以下面两段是同一个 fixture同一条真实命令,只有脚本词表不同。

fixture 是把 f2eb85007^AuthPlugin.init() 原样放回 packages/plugins/plugin-auth/src/__issue4835-repro.ts(可选调用 + 强转 ctx + best-effort try/catch,一字不改),验证完即删除,不在本 PR 的 diff 里。

之前(main 的词表,只有 getService)——判绿

$ git stash push scripts/check-init-service-contract.mjs # 脚本回到 main
$ node scripts/check-init-service-contract.mjs
✓ init-service contract: 41 declared / 1 self-provided / 3 without a workspace provider (75 plugin unit(s) scanned).
exit=0
$ node scripts/check-init-service-contract.mjs --list | grep -c "__issue4835-repro"
0

注意 75 plugin unit(s)(基线是 74):fixture 文件被扫到了,类也被识别成了插件单元 —— 只是那次调用完全不可见,一条边都没构造。这正是 #4772 当时的处境:门禁跑了,绿了,什么都没看见。

之后(本 PR 的词表)——判红,且指得出插件、服务、行号

$ git stash pop
$ node scripts/check-init-service-contract.mjs
✗ init-service declaration guard (#4471, ADR-0116)
packages/plugins/plugin-auth/src/__issue4835-repro.ts:17 — AuthPlugin
init() resolves getServiceAsync('cache') (directly or via a helper init() calls),
'cache' is provided by 'com.objectstack.service.cache', and NOTHING declares that ordering.
This is the #4085/#4420 failure class: it works only under lucky composition order,
and the miss hides inside best-effort logging. Declare it (ADR-0116):
- dependencies: ['com.objectstack.service.cache'] if this plugin cannot run without it;
- optionalDependencies: ['com.objectstack.service.cache'] if it degrades on purpose when the
provider is not composed (declared tolerance — the #4460 reference shape);
- requiresServices: ['cache'] if the service must exist at init regardless
of which plugin provides it.
If the consumption can wait until every init() has finished, move it to start().
1 undeclared init-time service consumption(s).
exit=1

插件(AuthPlugin)、服务(cache)、提供者(com.objectstack.service.cache)、行号(:17,即调用行而非类声明行)四项齐全。

内建 self-test 也做同一个证明

self-test 从 12 例扩到 19 例。case 13 就是上面那个形状的内存版,并断言消息里含插件名、accessor 原文、提供者名和调用行号。把词表缩回 ['getService']:

$ sed -i "s/…'getService', 'getServiceAsync', 'getServiceScoped'…/…'getService'…/" scripts/check-init-service-contract.mjs
$ node scripts/check-init-service-contract.mjs --self-test
✗ self-test: #4772 pre-fix getServiceAsync shape is caught (got 0 problems)
exit=1

也就是说,这几条 case 是真的在判别词表,而不是陪跑。

--list 输出的修正

边名此前把调用点硬编码写成 getService('X'),与实际 callee 无关。词表一扩,这个输出就会把 getServiceAsync 的调用点标成 getService —— 一个会说谎的机器可读面(Route & surface ownership §4)。现在每条边记录自己的 accessor,--list 与报错文案都按原文引用:

undeclared packages/plugins/plugin-auth/src/__issue4835-repro.ts:17 AuthPlugin → getServiceAsync('cache')

self-test case 18 钉住这一点(三种 accessor 混排,断言记录顺序 getService,getServiceAsync,getServiceScoped)。

哪些没有加进词表,以及为什么

议题提示「hasService 之类如果确认存在就一并加」。核对 packages/core 的实际导出后,没有加:

  • hasService —— 不在插件可达面上。ObjectKernel.hasAnyServiceprivate(kernel.ts:602);PluginLoader.hasService 虽然随 export * from './plugin-loader.js' 导出,但 kernel 的 loader 实例是私有的,插件拿不到。加它只会误伤别的对象上同名的方法,而覆盖不到任何真实边。self-test case 19 把这条「不臆造」钉住。
  • getServices() —— 枚举整张表,调用点没有服务名字面量,静态无从判定(与既有的动态服务名 case 11 同理)。
  • replaceService(name, impl) —— 是写不是读。它确实有自己的排序要求,但判定与补救都不同,混进来会让一条消息回答两个问题。

顺带把 scan() 的预过滤从硬编码 'getService' 改为从词表派生:今天三个名字碰巧都以 getService 开头,下一个加进来的未必,那会在 AST 看到它之前就把文件过滤掉 —— 与本 issue 是同一类静默洞。

现存代码仍然全绿

$ pnpm check:init-service-contract
✓ self-test: 19 cases
✓ init-service contract: 41 declared / 1 self-provided / 3 without a workspace provider (74 plugin unit(s) scanned).

边数与 main 一致(41/1/3),没有因为扩词表而把现存代码判红 —— 今天 packages/** 里的 getServiceAsync 调用点(rest/src/rest-server.tsruntime/src/http-dispatcher.tsruntime/src/dispatcher-plugin.ts)都在请求期路径上,不在任何插件的 init() 里;getServiceScopedpackages/** 里只出现在 core 自身的定义与转发处。这是补一个潜伏的洞,不是报出一个现存的红。

npx eslint scripts/check-init-service-contract.mjs 干净。脚本是 .mjs,不进 tsc --noEmit 覆盖面,typecheck 不受影响。附纯 tooling changeset(空 frontmatter,按 adr-anchors-guard.md / check-i18n-fails-on-undeclared-authoring-key.md 先例,不发版)。


Generated by Claude Code

…getService (#4835)
`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()`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018iARDqtrhQgz6fVHDeDkbQ
@vercel

vercelBot commented Aug 3, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectstackIgnoredIgnoredAug 3, 2026 3:07pm

Request Review

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

check:init-service-contract 只认 getService,不认 getServiceAsync —— #4772 就是从这个洞里溜过去的

2 participants

@xuyushun441-sys@claude