feat(spec): 执行器契约面 matchEndpoint? + setFallbackHandler?(#5040 E1) - #5097
Merged
Conversation
…andler? (#5080) Part of #5040 (E1, contract-first). Pure declaration: two OPTIONAL contract members plus one exported type. No implementation, no wiring, zero behavior change — declared `apis:` are still hard-rejected at publish in v17 (#4936). IMetadataService.matchEndpoint?(query: { path, method }) Resolves a request's method+path to the declared `api` item that owns it, or undefined on a miss — the dispatcher step between "no built-in domain claimed this" and "answer a semantic 404". New exported type ApiEndpointMatch: - `endpoint` is the ApiEndpointSchema.parse-d shape, schema defaults MATERIALIZED, so a consumer can never read a missing `authRequired` as permissive. - `params` is always {} in 17.x. The frozen ApiEndpointSchema vocabulary (ADR-0121) defines no template syntax and this contract deliberately does not invent one — a syntax living only inside an implementation is the hidden dialect Prime Directive #12 forbids. The slot is declared now so path templates would be an additive vocabulary change rather than a breaking contract change. IHttpServer.setFallbackHandler?(handler: RouteHandler) The last-resort handler, invoked only after every explicitly registered route has missed. Structurally incapable of shadowing a registered route, hence zero registration-order dependency — unlike the wildcard-route alternative, whose ownership is decided by first-registration-wins across plugin start() order (the ADR-0076 D11 hazard). Second guarantee, also in the contract: the handler's `req.body` IS readable, in contrast with the use() middleware contract which explicitly does not populate it. That difference is why the middleware seam cannot carry dynamic endpoints. Both members are optional and feature-detected with typeof === 'function', matching watch? / subscribe? / getRawApp?. No migration for implementors. Contract tests mirror the existing contracts-test style: optional-member presence/absence probing, and type-level shape assertions via typed literals. Generated: api-surface.json gains exactly one line, ApiEndpointMatch (interface) — 0 breaking, 1 added. The two members are interface members, not exports, so the other seven artifacts are untouched.
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 1 package(s): 107 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 4, 2026 04:15
os-zhuang
enabled auto-merge
August 4, 2026 04:15
This was referenced Aug 4, 2026
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.
Fixes #5080
Part of #5040(执行器 E 系列第 1 单,contract-first 首件)
这个 PR 做什么
纯声明,零行为变更。 只在
packages/spec/src/contracts/增加两个可选契约成员与一个导出类型。仓内没有任何实现体、没有任何接线;声明式ApiEndpoint在 v17 仍被 publish 硬拒(#4936 裁决),本单落的只是它未来得以执行所需的契约前件。E2/E3 的实现单Blocked-by:本单。按 #5080 的范围收窄评论,
IMetadataService.generateOpenApi?已从范围中剔除(#5078 实测:GET /openapi.json由packages/rest独占应答,在 metadata 契约上加该成员会造出 ADR-0076 禁止的第二属主),本 PR 未实现它。#5080 正文的Blocked-by: v17 切版已由维护者放行(见认领评论)。两个成员
1.
IMetadataService.matchEndpoint?(query: { path: string; method: string })把一次请求的
method+path解析为拥有该路由的api元数据条目,未命中返回undefined。这正是 HTTP dispatcher 在「内建 domain 均未认领」与「答语义 404」之间空出的那一步。随之导出新类型ApiEndpointMatch:endpoint是ApiEndpointSchema.parse之后的形状 —— 默认值已物化,而非存储里的原始 JSON。作者漏写authRequired时消费端拿到的是true(schema 默认值),因此消费端永远读不到「缺省」这个中间态,也就不可能把一个缺失的安全默认误读成放行。这是「防 AI 写元数据犯错」轴上的主要设计点。params在 17.x 恒为{}。ApiEndpointSchema.path词表已冻结(ADR-0121),既未定义:param也未定义{param},本契约刻意不发明模板语法 —— 只存在于实现里的语法就是隐藏方言(Prime Directive Add comprehensive test suite for Zod schema validation #12)。槽位现在就声明出来,是为了将来真要加路径模板时,那是词表的加法,而不是本契约的破坏性变更。匹配维度(#5040 设计 §2)一并写进 doc-comment:
method大小写不敏感;path去尾斜杠后整串精确比较,17.x 不做百分号解码、不做 Unicode 规整、不做大小写折叠。另注明作用域即实例(无 env 参数,与仓内其余 metadata 消费同构),以及「未命中 ≠ 故障」——读不到存储必须 throw,不得把故障伪装成 404(与loadDiagnosed同一区分)。2.
IHttpServer.setFallbackHandler?(handler: RouteHandler)设计 §1 方案 C 的传输层兜底 seam。doc-comment 载入两条语义保证:
start()顺序下的 first-registration-wins 决定,即 ADR-0076 D11「一条路由一个属主」要防的病灶。实现方映射到框架自身的 not-found 钩子(Hono 的app.notFound),而非映射到一条路由。req.body可读,与use()中间件契约明确的「body 不填充」相反(在use()处解析 body 会在真正拥有它的路由 handler 之前吃掉请求流,见packages/plugins/plugin-hono-server/src/adapter.ts:362)。这条差异正是中间件 seam 无法承载动态端点、必须新增本成员的原因:由 flow 或create操作支撑的声明式端点必须读 body。另注明「重复调用是替换而非追加(只有一个兜底器,不是链)」,以及「handler 不写响应时,适配器既有的 404/405 未匹配语义保持不变」。
可选性
两者均为可选成员,消费端按仓内既有惯例以
typeof x === 'function'探测(同watch?/subscribe?/getRawApp?)。不实现它的metadata槽位占用者、无法表达 not-found 钩子的适配器,都仍然满足契约。对现有实现方无迁移动作。契约测试
与既有 contracts 测试同风格同位置(
packages/spec/src/contracts/*.test.ts):可选成员的在场/缺席探测(typeof === 'function')、以带类型字面量做的类型层形状断言。setFallbackHandler侧另有两条行为性断言 —— 已注册路由不被兜底器遮蔽、兜底 handler 读得到 body;matchEndpoint侧真正走一遍ApiEndpointSchema.parse,断言作者漏写的authRequired在返回值里已物化为true。生成物 regen 范围
按 AGENTS.md 的 os-regen 纪律执行(
build→check:generated→check:generated --fix,只重生成被证明陈旧的那一件,不整套刷)。api-surface.json新增一行ApiEndpointMatch (interface),0 breaking / 1 added。两个新成员是 interface 成员而非导出,不动其余七件生成物 ——check:generated复跑 8/8 全绿。无本次改动之外的漂移。验证结果(实跑)
pnpm --filter @objectstack/spec testTest Files 302 passed (302)/Tests 7663 passed (7663)pnpm --filter @objectstack/spec typechecktsc --noEmit无输出pnpm --filter @objectstack/spec check:generatedAll 8 generated artifacts are up to date.check:exported-anyno exported type resolves to any: 1843 types + 1594 schemas across 16 entry pointscheck:dual-source-exportsno new dual-source exports: 4243 names across 16 entry pointsturbo typecheck(metadata / runtime / plugin-hono-server / rest 及其依赖)29 successful, 29 totaleslint四个改动文件补充:
packages/spec/tsconfig.json既有地exclude了**/*.test.ts(仓内记录在案的 TEST_DEBT),所以typecheck门读不到测试文件。为确认新增的类型层断言真的编译得过,另用一份临时 tsconfig 单独对两个测试文件跑了tsc:新增代码零报错,仅剩 4 条改动前就存在的 TS6133 未用形参(status: function (code)等)。临时 tsconfig 已删除。未做的事
ApiEndpointSchema(词表冻结,ADR-0121);content/docs/releases/(Prime Directive —— 发版说明由 changeset 集中编译)。Changeset:
.changeset/executor-contract-surface-e1.md(@objectstack/specminor)。🤖 Generated with Claude Code
https://claude.ai/code/session_01EYGdmvWP1ieZSLqvAW6uyd
Generated by Claude Code