fix(rest): 清扫 REST 组合根的槽查找 —— 16 处类型化,契约由 implements 背书 (#4251 B4) - #5953
Merged
Merged
Conversation
#4251 的 B4 批次:REST 组合根的全部槽查找擦除。`rest-api-plugin.ts`(15)与 `external-datasource-routes.ts`(1)改为传入槽的契约类型,棘轮基线 159 → 143 站点 / 34 → 32 文件,两个文件退出祖父名单。无行为变更。 `email` / `sharing` / `sharingRules` / `reports` / `approvals` / `external-datasource` 六个槽在 packages/spec 里本来就有契约,而各自 provider 注册进槽的那个类都写了 implements(`EmailService implements IEmailService` 等), 所以生产侧的形状由编译器每次构建校验,本文件只需具名 —— 即 #4404 用一份受检 claim 取代七个未受检 stand-in 的纪律。`auth` / `objectql` / `i18n` / `analytics` / `security` / `metadata` 取自 ServiceSlotContracts 台账;`objectql` 是 IObjectQLEngine 而非 IDataEngine,因为消费方够到的是完整引擎。 十处查找位于 `Promise<any | undefined>` 的 provider 内,只改查找会在下一行把 契约重新擦掉(规则看不见的包装返回注解残留),故 provider 返回类型一并收窄。 三个无契约的槽分别用三种方式如实表达:`env-registry` 用 RestServer 构造函数 自己声明的 RestEnvRegistry(实参因此受检);`settings` 按 B2 决策给具名本地 surface(service-settings 可选,REST 层不能对它产生依赖),返回类型复用公开的 ResolvedSettingValue;`default-project` 只声明本文件读的那一个字段。服务存在性 探测的槽名是运行期参数,用 unknown —— 它只问槽里有没有东西,从不碰形状。 本批未发现死探测,如实记录而非暗示:逐一核对了 emailService.send、 authService.getApi/isAuthGateActive、svc.queryDataset、ql.transaction、六个 approval 动词、五个 security 方法与五个 federation 方法,全部命中真实成员与 真实 arity。 新增的钉子是运行期测试,这是刻意选择:packages/rest 的 tsconfig 排除 test 文件 且该包无 typecheck script,没有任何 tsc program 编译它们,写类型级断言会是 #5286 / #5449 付过代价的 phantom check。真正的风险是接线 —— provider 是二十参 构造函数的第 6..19 个位置参数、形状完全相同,接错槽处处可赋值且编译器看不见, 所以测试驱动每个 provider 并断言它取回自己那个槽注册的实例,钉住 boot 解析的 槽名集合,以及全部可选槽为空时的降级路径。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GX3sL71LFq8m2usg6VqTSE
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 1 package(s): 11 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
os-zhuang
marked this pull request as ready for review
August 6, 2026 13:02
akarma-synetal
pushed a commit
to akarma-synetal/framework
that referenced
this pull request
Sep 17, 2026
…g the slot-lookup pin's figures (objectstack-ai#17812) Fixes objectstack-ai#17716 Clause-②: no ## What was wrong `packages/rest/src/rest-api-plugin-slot-lookups.test.ts` pins the REST composition root's positional wiring. Its `PROVIDERS` table stopped one argument short of the constructor, exactly at `tenancyServiceProvider` — the parameter objectstack-ai#15256 appended LAST on purpose, because inserting one mid-list *"would silently re-bind every positional argument after it"*. That is the exact failure this pin exists to catch, and it did not cover the parameter that reasoning protected. The header also stated two figures in the present tense with no date: a spelled-out slot count, and an "arguments A..B of an N-argument constructor" span. ## The hole, measured rather than argued Control: bind `authServiceProvider` at the LAST constructor position in `rest-api-plugin.ts`, where `tenancyServiceProvider` belongs. Nothing else moves, so the only argument a row for that index could observe is the one that is wrong. Same mutated blob in both legs (`a44973312542d74da442b4aae51908e9240fd5c4`); the only variable is the test file. | leg | tree | result | |:--|:--|:--| | A | `PROVIDERS` without the row (pre-fix) | `Tests 5 passed (5)` — **green against a deliberately mis-bound positional** | | B | `PROVIDERS` with the row (this PR) | `Tests 2 failed \| 4 passed (6)` | Leg B's two failures: ``` AssertionError: tenancyServiceProvider must resolve 'tenancy': expected { __slot: 'auth' } to be { __slot: 'tenancy' } AssertionError: expected Set{ 'manifest', 'http.server', …(17) } to deeply equal Set{ 'manifest', 'http.server', …(18) } ``` The second one is the answer to the laziness question the card and the triage ruling both flagged. **The row does enter the compared lookup set, so the last case needs one more row and not a different comparison — the hard fork does not fire.** Laziness was never why the provider was invisible: `objectQLProvider` at index 7 has the identical kernel-first / sync-fallback shape and has always been covered. It was invisible because it was absent from the table, so nothing ever drove it and the slot was never asked for. `packages/rest/src/rest-api-plugin.ts` is read-only for this card. That mutation was temporary, trap-guarded, and verified restored byte-identical to HEAD (`restored blob == HEAD blob`, `git diff HEAD` empty) after each leg. It is not in this diff. ## The new pin is itself driven Leg C ablates the new row out of `PROVIDERS` on this PR's tree and runs again: ``` × accounts for every positional argument from the first provider to the last Tests 1 failed | 5 passed (6) AssertionError: a positional argument in the provider span is covered by neither PROVIDERS nor NON_PROVIDERS_IN_SPAN — add a row (provider) or an entry (not a provider): expected [ 20 ] to deeply equal [] ``` Exactly one case reds, and it is the new one — the other five stay green, which is the card's defect reproduced in isolation. That case is the machine that would have caught objectstack-ai#15256's append. ## Both figures derived, neither re-typed Derived with the TypeScript compiler API over `rest-server.ts` and `rest-api-plugin.ts`. ⛔ Not a bracket-depth counter: an arrow token and a generic type argument close a naive depth counter early, which is the instrument the triage ruling recorded as broken. | figure | derived on this tree | the header said | |:--|:--|:--| | `RestServer` declared parameters | **21** | "N-argument" was accurate at B4, stale by one since | | provider-shaped arrow-to-Promise parameters | **13**, at indices `6 7 8 9 10 11 12 13 14 15 17 19 20` | — | | provider span | **6..20** | `6..19`, so the appended provider fell outside it | | distinct slots `rest-api-plugin.ts` resolves | **20** | a count matching none of them | | arguments `new RestServer(...)` passes | **21** — equal to the declared count | — | The 20 distinct slots: `analytics approvals auth default-project email env-registry http.server i18n kernel-manager kernel-resolver manifest metadata objectql protocol reports security settings sharing sharingRules tenancy`. Three `ctx.getService(name)` call sites are pass-throughs (the kernel-resolver bridge and the presence probe) and resolve no fixed slot; `objectql` and `tenancy` are each reached twice (a `kernel.getServiceAsync` leg and a `ctx.getService` leg) and count once. ### The slot count was never right — it was not a figure that drifted Both figures were written by one commit, `1216dcc73` (objectstack-ai#4251 B4, objectstack-ai#5953). Re-running the same derivation against that commit's tree: - declared parameters **20**, provider span **6..19** ⇒ the span figures were **correct the day they were written** and went stale when the constructor grew. - distinct slots resolved by `rest-api-plugin.ts`: **20**, not the count the header claimed. That commit's diff retypes **14** lookups in this file, and the file then held **21** `getService` call sites carrying a type argument (20 non-`any`). The header's figure matches none of those, then or now. So it was not "already right", and it was not drift either: it was a number with no derivation behind it from the first commit. That is worth its own sentence, because the two failure modes are indistinguishable to a reader — which is precisely the argument for not writing either figure as a numeral again. ### Preferred outcome: not a corrected constant Per the ruling's order (compute > pin > hand-write with a date), both figures are removed from the prose rather than corrected: - The span is now pinned by `accounts for every positional argument from the first provider to the last`, which derives the span from the captured call (`args.length`) and requires every index in it to be covered by `PROVIDERS` or excused in the new `NON_PROVIDERS_IN_SPAN` ledger. An argument appended to `RestServer` lands inside the span by construction and forces a decision. - The slot count is now the set `BOOT_SLOTS ∪ {default-project} ∪ PROVIDERS`, which the existing `asks for exactly the slots it declares` case already compares against the real `asked` list. Its size is the derived 20. A recorded failed instrument, so nobody repeats it: a runtime `RestServer.length` pin is **not** available. `config: RestServerConfig = {}` is parameter index 2, and `Function.prototype.length` counts only the parameters before the first defaulted one — it reports **2**, not 21. ## Nothing was weakened `git diff` for this PR removes exactly seven lines, all of them docblock prose carrying the two stale figures. No `expect`, no assertion argument, no existing case body and no table row is touched; every other change is an addition. The two claims the ruling named read byte-identical after: - *"every provider resolves the slot it is NAMED for"* - *"the exact set of slot names the boot asks for"* ## Verification | what | result | |:--|:--| | `pnpm --filter @objectstack/rest test` | exit 0 — `Test Files 190 passed (190)`, `Tests 3182 passed \| 1 skipped (3183)` | | `pnpm --filter @objectstack/rest typecheck` | exit 0 — `check:test-typecheck: OK … 0 file(s) / 0 error(s) / 0 pinned signature(s)` | | `pnpm --filter '@objectstack/rest^...' build` | exit 0 | | `pnpm lint` (full repo, `eslint . --no-inline-config`) | exit 0 — run whole, not narrowed | | derived gate families (`scripts/pm/dispatch-gates.mjs --commands`) | 52 derived, **50 exit 0**, 2 NOT MEASURED | | `pnpm check:slot-lookup` | exit 0 — `106 unswept site(s) in 25 file(s), none new` | The two NOT MEASURED are `check:dual-build-cjs-loads` and `check:type-check-debt`, both exit **3** = `PREREQUISITE NOT MET`: each needs the whole workspace built, which is a farm-scale run CI owns. Neither is a red, and neither can be moved by a change confined to one `.test.ts` file. Reconciled with `--ran`: *"52 derived famil(ies) accounted for — 50 run, 2 NOT-MEASURED (2 DERIVED from a recorded exit 3)"*. All numbers above were taken at `034629082`, the final commit. ## No changeset — measured, not assumed `@objectstack/rest` is published (`"private"` absent) and ships `files[]: ["dist", "README.md", "CHANGELOG.md"]`. The one changed path is `packages/rest/src/rest-api-plugin-slot-lookups.test.ts`; `src/` is not in `files[]`, and CI's *No compiled test files in any dist* step holds the other direction. Grepped over all three `files[]` paths after `pnpm --filter @objectstack/rest build`: | probe | symbol | hits | |:--|:--|:--| | negative | `NON_PROVIDERS_IN_SPAN` | 0 | | negative | `accounts for every positional argument` | 0 | | negative | `rest-api-plugin-slot-lookups` | 0 | | positive control | `createRestApiPlugin` | 6 | | positive control | `tenancyServiceProvider` | 4 | | positive control | `RestServer` | 6 | The positive controls prove the grep reaches the shipped bytes; the negatives are therefore absences, not a broken probe. Nothing published moves ⇒ `skip-changeset`. ## Acceptance notes - **A bounded residual in the new pin, stated so it is not mistaken for coverage it does not have.** The span is derived from the arguments the composition root actually *passes*. A parameter appended to `RestServer` that the composition root never passes leaves `args.length` unchanged and the span pin green. That is a different defect (a parameter wired nowhere), the declared scope of the new case says exactly what it covers, and closing it would mean parsing `rest-server.ts` from inside a unit test. Noted, not filed. - `packages/rest/CHANGELOG.md` quotes the old docblock verbatim in two released entries (the B4 entry and its re-publication). Those are RELEASE-OWNED and are accurate records of what the code said at that release, not factual errors in the entries. Left untouched. - `rest-api-plugin.ts` and `rest-server.ts` are unchanged in this diff. objectstack-ai#14251, which holds `rest-api-plugin.ts`, is unaffected. The in-flight surface of objectstack-ai#17672 (`packages/rest/src/index.ts`, `query-multiplicity.ts`, `packages/runtime/src/domains/packages.ts`) is disjoint from this one-file diff. --- _Generated by [Claude Code](https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c)_ Co-authored-by: Claude <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Part B4 of #4251 —— REST 组合根批次。本 PR 不 Close 本单,B5–B11 批次留后续。
棘轮基线 159 → 143 站点 / 34 → 32 文件,两个文件全清并退出祖父名单,无新增 key(
baseline key set verified against 9ce0ca9: no files added)。无行为变更。站点分布(实测 16 处,比派发单的 15 处多 1)
派发范围写的是
rest-api-plugin.ts14 +external-datasource-routes.ts1;基线文件记的是 15 + 1,实测也是 16。多出来的那一处是env-registry:同一行同时命中第四形态(let envRegistry: any;分体声明)与类型实参形态,两条规则各报一次。auth/objectql/i18n/analytics/security/metadataServiceSlotContractsemail/sharing/sharingRules/reports/approvals/external-datasourceimplementsenv-registryRestEnvRegistrysettingsdefault-projectunknownobjectql按 #4404 落地的IObjectQLEngine(而非IDataEngine):消费方够到的是完整引擎——batch 路由背后探测的是ql.transaction。六个"无契约"的槽其实都有契约,而且是受检的
这批最大的发现不是缺陷而是盘点结果:
email/sharing/sharingRules/reports/approvals/external-datasource在packages/spec/src/contracts/里本来就有契约,并且各自 provider 注册进槽的那个类都声明了implements(EmailService implements IEmailService、ExternalDatasourceService implements IExternalDatasourceService…)。所以生产侧形状由编译器每次构建校验,本文件只需具名——即 #4404 用一份受检 claim 取代七个未受检 stand-in 的纪律。没有为它们新增 ledger 条目(不动packages/spec),入账与否留给后续决策。包装函数返回注解一并收窄
十处查找位于
async (environmentId?) => Promise< any | undefined >的 provider 内。只改查找会在下一行把契约重新擦掉——正是规则文档里记的 KNOWN RESIDUAL(注解在外层函数上,AST 规则原理上看不见)。每个 provider 的返回类型因此一并收窄为自己那个槽的契约。本批未发现死探测 —— 如实记录,而不是凑一个产出
这一族历史上每批都挖出真缺陷(#4361 的
getMetaItem指着一个从来没有该方法的服务、#4321 的registerInMemory),所以我逐一核对了类型化后消费方做的每一处探测:emailService.send、authService.getApi/isAuthGateActive、svc.queryDataset、ql.transaction、六个 approval 动词、五个 security 方法、五个 federation 方法——全部命中真实成员与真实 arity。一个值得记的次级结论:
external-datasource-routes.ts的五处svc?.method探测在类型化后显出是冗余但正确的——契约的方法都是必需成员,所以只要服务解析成功探测必为真,503 分支只由"服务不存在"到达,而那正是它的用途。不是恒假分支,不改行为。钉子为什么是运行期测试,而不是类型级断言
packages/rest/tsconfig.json排除自己的 test 文件,且该包没有typecheckscript(它是 DEBT / TEST_DEBT 台账条目)——所以没有任何 tsc program 编译这些文件。在这里写@ts-expect-error或Assert< Equal< … > >会永不求值,删掉它每道闸门照样绿:正是 AGENTS.md 禁止、#5286 / #5449 付过代价的 phantom check。而这次改动真正携带的风险恰好是运行期可测的:provider 是二十参构造函数的第 6..19 个位置参数,形状全都是
(environmentId?) => Promise< unknown >,所以接错槽处处可赋值、编译器一声不响。新测试(rest-api-plugin-slot-lookups.test.ts,5 例)因此钉住:'sharingRules'手滑成'sharing-rules'在这里红,而不是在生产上让一条路由永久 501);env-registry/default-project两个 seam 按 RestServer 声明的形态传入;undefined且不抛。反向验证(方向先定后验)
预期方向是红——因为这正是类型抓不到的那一类。把
getService< IReportService >('reports')改成'report'一个字符,5 例中 2 例失败,报文直接点名:已还原。
验证
pnpm --filter @objectstack/rest testtsc --noEmit -p packages/rest/tsconfig.jsonerrors: 2精确相等,且都在我没动的package-routes.ts;两个改动文件 0 errorpnpm check:slot-lookup(强制模式)pnpm eslint(三个改动文件)node scripts/check-nul-bytes.mjs合并 main(2 个 commit)后复跑:
rest-meta-save-receipt-envelope.test.ts(merge 带进来的 #5265 新测试)先报 2 例失败,根因是本 worktree 的metadata-protocoldist 过期(该测试经其 dist 走 save-receipt 路径),重建依赖后 838/838 全绿——与本单勘误记录的同源陷阱,不是 main 红也不是本改动所致。范围外发现
default-project槽有 3 个生产消费方、0 个生产注册方:rest-api-plugin.ts、runtime/http-dispatcher.ts:618、cloud 侧objectos-runtime/kernel-resolver.ts:193;而 6 处注释(含rest-server.ts两处、http-dispatcher.ts两处、domain-handler-registry.ts)都把注册方写成createSingleEnvironmentPlugin,该函数在 objectstack 与 cloud 两个仓库里都不存在,cloud 仅在一处测试里 stub 了这个槽。三个消费方都有后续 fallback、降级路径完整,所以今天没有用户可感的故障——但"单环境默认"这一级解析永远不会触发。本 PR 只按本文件实际读到的字段具名类型化,不改行为;已另行开单,不在本批扩面。