Skip to content

test(cli): #5000 前提被证伪 —— build/validate 一直按注册表 schema 解析,补上它缺的那份证据 - #5380

Merged
baozhoutao merged 3 commits into
mainfrom
claude/issue-5000-cli-validate-page-schema
Aug 5, 2026
Merged

test(cli): #5000 前提被证伪 —— build/validate 一直按注册表 schema 解析,补上它缺的那份证据#5380
baozhoutao merged 3 commits into
mainfrom
claude/issue-5000-cli-validate-page-schema

Conversation

@baozhoutao

Copy link
Copy Markdown
Contributor

Fixes #5000

结论先说:前提不成立,所以本单没有实现新门

#5000objectstack build / validate 不会把 page 元数据按 PageSchema 解析,因而 ADR-0089 D3a 早该拒绝的键一路通过并写进产物。按 Prime Directive「issue 是线索不是规格」,先在 origin/main(2f6516e)上复核 —— 复现不出来。两条命令都把整个 stack 过 ObjectStackDefinitionSchema,而它的 pages 元素就是 getMetadataTypeSchema('page') 返回的那个 schema,和 MetadataManager.validate / GET /api/v1/meta / Studio 表单同一道门。

PM 裁定里「让 os validate 变成真门」的那件事,已经是现状,所以本 PR 不加门,只补 #5000 真正指出的那个缺口:没有任何东西钉住这件事

实测证据(均在 origin/main + 本分支,2026-08-05)

  1. issue 自己的复现(未声明键加在 region 组件顶层)。为把命令自己的门和 definePage 的即时 parse 分开,配置写成纯对象字面量(不走 defineStack / definePage):
os validate  exit=1
  pages:
    ✗ pages.0.regions.0.components.0
      unrecognized_keys: Unrecognized key(s) on this view/page schema:
      `aKeyPageComponentHasRejectedSinceADR0089`. Before ADR-0089 D3a these
      were dropped silently, shipping inert metadata; ...
os build     exit=1,且 dist/objectstack.json 根本没写出来

顺带说明 issue 当时为何会看到 exit 0 的一个可能:它改的是 examples/app-showcase,那里每个 page 都走 definePage,而 definePage 会先抛;真要区分「命令闸没闸」必须绕开工厂。另一个更可能的机制是当时 packages/spec/dist 陈旧(AGENTS.md §9 那个陷阱)—— CLI 消费 dist,而它们的对照脚本走 src,同一份 schema 两个判决,正好长成 issue 描述的那个不对称。两者都无法事后证实,只作为记录。

  1. issue 自己的阴性对照(未知键静默剥离仍是全仓默认:把 #3405 的 strict 收紧从一个 schema 推广到整个可授权面(ADR-0078 完整性闸门) #4001 批 13 的 responsiveStyles.large 写成 .lg)现在也是红的,且带处方:
✗ pages.0.regions.0.components.0.responsiveStyles
  unrecognized_keys: Unrecognized key(s) on this per-breakpoint style map: `lg`.
  ... Did you mean `lg` -> `large`?
  1. 逐类型测量(24 个已注册元数据类型):21 个在 stack 根部由同一个 schema 实例承载(objects / pages / flows / …);另外 3 个是结构性差异而非漏洞 —— field 嵌在 objects[].fieldstranslation 在 stack 上是 locale 束(TranslationBundleSchema)而注册表是行形状 TranslationItemSchemaview 注册表是 view metadata type-schema is a no-op for runtime shapes: ViewItem/personalization bodies strip to {} under ViewSchema #3095 的四成员联合而 stack 承载其中的容器成员。三者的未声明键被 CLI 这条路拒绝(objects.0.fields.title / translations.0.en-US / views.0)。

  2. 三个示例应用(PM 要求的 build 侧测量):app-showcase / app-crm / app-todovalidate 全部 exit 0(warning 43 / 4 / 4 条,均为既有 author-time 建议)。也就是说 build 侧没有存量违规需要豁免,不需要降级成 warning,也不需要开硬化后续单 —— build 已经是硬门(拒绝即不出产物,上面第 1 条实测)。

本 PR 做了什么

只加一个测试文件 packages/cli/test/metadata-type-schema-gate.test.ts(7 个用例,无生产代码改动、无 spec 改动):

A 与 B 分开写是因为它们独立失效:A 绿 B 红 = schema 严格但命令吞了判决(#3782 的形状);B 绿 A 红 = 命令在一个没人核对过是否规范的 schema 上闸(#4409 量到的 23/26)。

反向验证(方向事先声明)

预测:把 validate.ts 里 parse 失败的分支临时短路,只有 B 的两条 validate 用例变红,A 的 schema 层用例与 build 用例保持绿。实测完全一致 —— 且值得记一笔:短路后命令仍以非零退出(下游 result.data 为 undefined 引发崩溃),光断言退出码是抓不住的,是那条「输出里必须出现那个键 / ADR-0089 D3a」的断言在干活。这就是这两条用例不只看 exit code 的原因。

为什么用行为等价而不是实例同一性

@objectstack/spec 按 entry 分别打包,root entry 内嵌的 PageSchema@objectstack/spec/kernel 注册表里的那份在 dist 上永远不 ===(只有走 src 才成立)。这条约束已写进测试注释,并单独记为观察类 finding #5379,免得下一个人先写一遍 identity 断言再怀疑自己。

未做 / 需维护者裁断

测试

pnpm --filter @objectstack/cli typecheck        # tsc --noEmit,通过
vitest run (packages/cli 全量)                   # Test Files 71 passed, Tests 648 passed
vitest run test/metadata-type-schema-gate.test.ts # 7 passed

Generated by Claude Code

…y schemas (#5000)

#5000 measured that `os build` / `os validate` never parse `page` metadata
through `PageSchema`, so an undeclared page-component key shipped into the
artifact and #4001's "all three example apps validate clean" was empty
evidence on the page surface.

Re-measured on origin/main the claim does not hold: both commands parse the
whole stack through `ObjectStackDefinitionSchema`, whose `pages` element is
the schema `getMetadataTypeSchema('page')` returns. The issue's repro and its
own negative control (`responsiveStyles.large` -> `.lg`) both exit non-zero
today, and `os build` writes no artifact for a rejected stack.

Nothing pinned either half of that, so this adds the evidence the issue found
missing:

  - per registered metadata type, one undeclared key gets the same verdict
    from the write-path gate and from the schema the CLI parses through
    (the three structurally-different carriers -- field, translation, view --
    are named and asserted at their real positions), plus a reconciliation
    that fails when a newly registered type is classified by nobody;
  - the two commands act on that parse, run through the real binary, with a
    control case (same stack, no planted key) that must exit 0.

Behavioural rather than schema-instance identity on purpose: spec ships one
bundle per entry point, so the root entry's embedded `PageSchema` and
`@objectstack/spec/kernel`'s registry copy are never `===` (filed as #5379).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VkPSGsX9o17MsGv3Lbxu2w
@vercel

vercel Bot commented Aug 5, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 5, 2026 3:19am

Request Review

@github-actions github-actions Bot added the tests label Aug 5, 2026
@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

No hand-written docs reference the 0 changed package(s). ✅

@github-actions github-actions Bot added the size/m label Aug 5, 2026
…ate ledger (#5000)

`api` landed in DEFAULT_METADATA_TYPE_REGISTRY / BUILTIN_METADATA_TYPE_SCHEMAS
on main (#5271 -> #5312) after this branch was cut, and the reconciliation
case did its job: an unclassified registered type fails until someone decides
how the CLI reaches it.

Measured rather than assumed. The stack DOES carry it (`apis:`, ADR-0121, still
declared on main), and the CLI and the write path reach the same schema — but
`ApiEndpointSchema` is a plain `z.object`, so an undeclared key on an endpoint
is dropped on BOTH paths. That is not a CLI divergence, it is a #4001 shape the
campaign has not reached; the strictness ledger still files all of `api/` as
"wire, tolerant by design", which stopped being true when the type was
registered. Filed as #5384, a sub-issue of #4001.

So `api` gets its own ledger row with the honest claim: the CLI is no looser
than the write path, and the #3786 pre-parse layer still names the key so the
author is not left with silence. When #5384 closes the shape, the agreement
assertion goes red and the row moves into GATED_AT — the ratchet working.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VkPSGsX9o17MsGv3Lbxu2w

Copy link
Copy Markdown
Contributor Author

已合入新 main 并处理 api —— 但分类理由与 PM 给的不同,这里说清楚

CI 那条红(classifies every registered metadata type,expected [ 'api' ] to deeply equal [])确实是对账用例在履职:api 在本分支切出去之后由 #5271#5312 落进 DEFAULT_METADATA_TYPE_REGISTRY / BUILTIN_METADATA_TYPE_SCHEMAS。已 git merge origin/main(未 rebase、未 force-push),并补上分类。

一处更正:PM 复核里给的理由是「apis: 键已随 #4936 的裁决在 #5065 从 stack schema 整面退役,api 是注册表专属类型,stack 根部有意不承载它」。在合并后的 origin/main(cdfbee2)上核对,这条不成立:

所以 api 不是「注册表专属」,它是 stack 根部实打实承载的类型。按 PM 给的理由把它塞进「结构性例外」会在测试里写下一条与代码相反的记述 —— 这正是本单要治的那种「被当作证据引用、实际不成立」的形状,所以没有照做。

实际测量出来的情况(这才是 api 需要单独一行的原因):

getMetadataTypeSchema('api').safeParse({ …合法端点…, aKeyThatIsNotDeclared: 1 })
  → 不报 unrecognized_keys(ApiEndpointSchema 是裸 z.object,endpoint.zod.ts:56)
ObjectStackDefinitionSchema.safeParse({ manifest, apis: [ …同上… ] })
  → 同样不报
os validate(端点合法)
  → ✓ Validation passed
  → ⚠ apis.probe_endpoint.aKeyThatIsNotDeclared: … is not a declared api key, so its value is dropped at load.

即:两条路一致地收下未声明键(warn 而非 reject)。这不是 CLI 与写路径的分歧(#5000 关心的那种),而是 #4001 还没收到这块 —— 严格性账本至今把 api/ 整目录记作 wire | tolerant by design(ledger:1161),而 #5312 之后 endpoint.zod.ts 已经同时是授权面了。已作为 #5384 立单,并挂成 #4001 的子单(修复落在它的完成范围内)。

测试里因此加了第三张表 NOT_YET_CLOSED,断言改成诚实的两条:①CLI 不比写路径松;②#3786 那层仍然点名这个键(不是静默)。等 #5384 把 schema 关上,第①条会变红 —— 那时把 api 挪进 GATED_AT,是一次刻意的棘轮推进而不是意外。

复跑(持 flock /tmp/os-heavy-verify.lock)

pnpm --filter @objectstack/cli typecheck   # tsc --noEmit,通过
vitest run(packages/cli 全量)              # Test Files 71 passed,Tests 649 passed
vitest run test/metadata-type-schema-gate.test.ts  # 8 passed

同 job 里 sys_organization no such table / Unable to acquire a connection 已确认是通过用例的 stderr 噪音,未追。PR 保持 draft。


Generated by Claude Code

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

Labels

size/m skip-changeset PR has no user-facing published change; bypasses the changeset gate tests

Projects

None yet

2 participants