在 #5093 (#5040 E6)实施中发现,越范围,未认领。基线:origin/main @ 81e2744。
事实(可逐条复核)
packages/spec/scripts/build-openapi.ts 生成的 json-schema/openapi.json —— 也就是 GET /api/v1/openapi.json 真正发布出去的那份文档的 base spec —— components.schemas 恒为 {} ,而 paths 里有 6 个指向它的 $ref 全部悬空 :
refs used: ListRecordResponse, ApiError, CreateRequest,
SingleRecordResponse, UpdateRequest, DeleteResponse
defined schemas: [] ← 空
生成器自己的收尾日志就把这件事印在屏幕上,只是没人把它当断言:
$ pnpm --filter @objectstack/spec gen:openapi
✅ Generated OpenAPI spec: .../json-schema/openapi.json
Paths: 7
Components: 0 ← 应为 9
覆盖面不是边角:这 6 个引用覆盖 /api/{object} 与 /api/{object}/{id} 上 全部 CRUD 操作的请求体与响应体 ,即文档里除了 discovery/meta 之外的所有 schema 内容。
根因(已用对照实验坐实,不是推断)
build-openapi.ts:255 的收集判据:
if ( schema && typeof schema === 'object' && '_zod' in schema ) {
schemas [ name ] = z . toJSONSchema ( schema , { target : 'draft-2020-12' } ) ;
}
而这 9 个契约 schema(CreateRequestSchema / ApiErrorSchema / …)都经 lazySchema() 包装。lazySchema 的 Proxy target 是 const target = function lazyZod() {},于是 typeof proxy === 'function',不是 'object' —— 判据第一段就短路,9 个全部落空,循环一个都没加,components.schemas 留空;paths 那边的 $ref 是手写字面量,不受影响,照常写出去。
对照实验(OS_EAGER_SCHEMAS=1 正是 lazySchema 自带的「绕过 Proxy」应急开关):
$ npx tsx scripts/build-openapi.ts → Components: 0
$ OS_EAGER_SCHEMAS=1 npx tsx scripts/build-openapi.ts → Components: 9
同一份源码、同一条命令,唯一变量是 Proxy 在不在。根因就是这一处 typeof 判据。
运行时探针:
const API = await import ( './packages/spec/dist/api/index.mjs' ) ;
typeof API . ApiErrorSchema // 'function' ← 判据期待 'object'
用户可见性
/api/v1/openapi.json 是对外发布的机器可读契约,两类消费者直接受影响:
GET /api/v1/docs(Scalar viewer,rest-server.ts 注册)加载这份文档 —— 6 个悬空 $ref 上的请求/响应示例与 schema 面板无内容可渲染;
任何从该文档做客户端代码生成的集成方(openapi-generator / orval / …)会在解析期就报 unresolvable reference,或生成出 any 化的 CRUD 客户端。
严重度不预判(#4949 :立单时的严重度判断两个方向都不可靠),交分诊定级。
为什么没被任何门禁挡住
check:generated 自己的收尾行点名了这件事:
Generated but ungated (2): gen:openapi, gen:sbom — nothing verifies these are current.
gen:openapi 是全仓两个完全无门禁 的生成器之一 —— 既没有「产物是否最新」的校验,也没有「产物是否自洽」的校验。#5078 结尾把前者记为旁注;本单是后者的一个实锤:产物不自洽了三个层次(空 components、悬空 ref、日志里明晃晃的 Components: 0),没有任何一处红。
建议处置(供分诊,未预设)
修判据 :'_zod' in schema 这一段对 Proxy 是有效的(lazySchema 专门做了 _zod facade,注释里写明是为 toJSONSchema 遍历准备的),问题只在前面的 typeof === 'object'。放宽为 (typeof schema === 'object' || typeof schema === 'function') 即可,不需要动 lazySchema;
补门禁 (这才是防复发的部分):生成后断言 paths 里出现的每个 #/components/schemas/X 都能在 components.schemas 里解析到,不能则 exit 非零 。这类「产物自洽」断言比「产物最新」更便宜也更值钱,且能顺带覆盖将来新增的 $ref;
是否把 gen:openapi 一并纳入 check:generated 的最新性门,属更大的一步,建议单独定夺(与 GET /openapi.json 有两个属主:rest-server 真serve,http-dispatcher 的 generateOpenApi 分支全仓无实现(ADR-0076 D1 影子重复) #5078 旁注同源)。
lazySchema 的 typeof === 'function' 形状会不会在别处也撞上同类判据,本单没有 普查,不作声称。
关联
Generated by Claude Code
在 #5093(#5040 E6)实施中发现,越范围,未认领。基线:
origin/main@81e2744。事实(可逐条复核)
packages/spec/scripts/build-openapi.ts生成的json-schema/openapi.json—— 也就是GET /api/v1/openapi.json真正发布出去的那份文档的 base spec ——components.schemas恒为{},而paths里有 6 个指向它的$ref全部悬空:生成器自己的收尾日志就把这件事印在屏幕上,只是没人把它当断言:
覆盖面不是边角:这 6 个引用覆盖
/api/{object}与/api/{object}/{id}上 全部 CRUD 操作的请求体与响应体,即文档里除了 discovery/meta 之外的所有 schema 内容。根因(已用对照实验坐实,不是推断)
build-openapi.ts:255的收集判据:而这 9 个契约 schema(
CreateRequestSchema/ApiErrorSchema/ …)都经lazySchema()包装。lazySchema的 Proxy target 是const target = function lazyZod() {},于是typeof proxy === 'function',不是'object'—— 判据第一段就短路,9 个全部落空,循环一个都没加,components.schemas留空;paths那边的$ref是手写字面量,不受影响,照常写出去。对照实验(
OS_EAGER_SCHEMAS=1正是lazySchema自带的「绕过 Proxy」应急开关):同一份源码、同一条命令,唯一变量是 Proxy 在不在。根因就是这一处
typeof判据。运行时探针:
用户可见性
/api/v1/openapi.json是对外发布的机器可读契约,两类消费者直接受影响:GET /api/v1/docs(Scalar viewer,rest-server.ts注册)加载这份文档 —— 6 个悬空$ref上的请求/响应示例与 schema 面板无内容可渲染;any化的 CRUD 客户端。严重度不预判(#4949:立单时的严重度判断两个方向都不可靠),交分诊定级。
为什么没被任何门禁挡住
check:generated自己的收尾行点名了这件事:gen:openapi是全仓两个完全无门禁的生成器之一 —— 既没有「产物是否最新」的校验,也没有「产物是否自洽」的校验。#5078 结尾把前者记为旁注;本单是后者的一个实锤:产物不自洽了三个层次(空 components、悬空 ref、日志里明晃晃的Components: 0),没有任何一处红。建议处置(供分诊,未预设)
'_zod' in schema这一段对 Proxy 是有效的(lazySchema专门做了_zodfacade,注释里写明是为toJSONSchema遍历准备的),问题只在前面的typeof === 'object'。放宽为(typeof schema === 'object' || typeof schema === 'function')即可,不需要动lazySchema;paths里出现的每个#/components/schemas/X都能在components.schemas里解析到,不能则 exit 非零。这类「产物自洽」断言比「产物最新」更便宜也更值钱,且能顺带覆盖将来新增的$ref;gen:openapi一并纳入check:generated的最新性门,属更大的一步,建议单独定夺(与GET /openapi.json有两个属主:rest-server真serve,http-dispatcher的generateOpenApi分支全仓无实现(ADR-0076 D1 影子重复) #5078 旁注同源)。lazySchema的typeof === 'function'形状会不会在别处也撞上同类判据,本单没有普查,不作声称。关联
generateOpenApi死分支 + 修正台账注记(并入 #5078) #5093 / 17.x 立项:建设声明式 ApiEndpoint 执行器(挂载 + matchEndpoint + authRequired/cacheTtl/inputMapping/outputMapping 逐键接线) #5040 E6(发现于其实施:该单为/openapi.json加端点条目时,必须先确认不能$ref任何 components —— 因为一个都不存在)GET /openapi.json有两个属主:rest-server真serve,http-dispatcher的generateOpenApi分支全仓无实现(ADR-0076 D1 影子重复) #5078(/openapi.json属主与gen:openapi无门禁的旁注)Generated by Claude Code