Skip to content

fix(spec): build-schemas.ts --check 不再写 json-schema.manifest.json (#4711) - #4724

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-4711-check-mode-manifest-write
Aug 2, 2026
Merged

fix(spec): build-schemas.ts --check 不再写 json-schema.manifest.json (#4711)#4724
os-zhuang merged 2 commits into
mainfrom
claude/issue-4711-check-mode-manifest-write

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes #4711

packages/spec/scripts/build-schemas.ts 的 manifest ratchet 段落没有 CHECK 判别:
check:authorable-surface(即 build-schemas.ts --check)一旦算出新增的 def 或仍挂在
manifest 上的 renamed-away def,就无条件重写 tracked 的 json-schema.manifest.json
并 exit 0
。一个 if 缺失,两个后果:

  1. 「检查」在改工作区。 一条只该看的命令覆盖了开发者本地的文件内容 —— git stash pop
    worktree、合并冲突处理会因此莫名其妙地失败,而没人会把原因追到一个门禁头上。这也是 spec 生成物没有 merge driver:两个 PR 各改几行,语义上是集合运算,却每次都打成文本冲突 #4675
    合并驱动那个坑的另一个入口:合并进行中跑任何 check,都可能把半合并的树算出的 manifest
    写进磁盘 —— build: merge driver for generator-owned spec artifacts (#4675) #4702 的 commit message 恰好用一整段解释了为什么合并驱动故意不在那一刻
    重新生成:「a plausible generated file is an invisible error」。
  2. additions 分支在 CI 里永远红不了。 八个生成物里其余七个都是「stale ⇒ 失败,去跑生成器」,
    只有这一个是「stale ⇒ 我帮你写了」,而且混在同一个 check:generated 汇总里。

改法是把这段改成和紧随其后的 authorable-surface ratchet 同构:--check 打印未记录的 key

  • gen:schema 提示,然后 process.exit(1);非 --check 照旧写。missing(已发布 schema
    消失)那条一个字没动 —— 它本来就是对的,新单测把它钉住以防被改坏。

改动面:packages/spec/scripts/build-schemas.ts(控制流)+ 一个新单测文件 + 一个 changeset。
没有碰任何生成物(authorable-surface.json / api-surface.json /
json-schema.manifest.json / spec-changes.json),也没有碰 src/ 下的任何 schema。

1. 缺陷复现(修复前的代码,真实输出)

json-schema.manifest.json 改成陈旧状态(删掉 ui/View 这一个 key),只跑 --check:

$ node -e "...drop ui/View..."          # dropped ui/View, now 1687
$ git status --porcelain
 M packages/spec/json-schema.manifest.json
$ md5sum json-schema.manifest.json
94234b0acb9f79122580537e997dd84b  json-schema.manifest.json

$ pnpm check:authorable-surface
real	0m6.481s
EXIT=0
📒 json-schema.manifest.json updated (+1 schema(s)) — commit it.
✅ Successfully generated 1705 schemas.

$ md5sum json-schema.manifest.json
94ddfeb6032474c1e62661a0fd6dca03  json-schema.manifest.json     ← 变了
$ git status --porcelain
                                                                ← 我的本地改动被吃掉了

一条「检查」退出码 0,却把 tracked 文件的内容改了:本地那处修改被无声地覆盖回 HEAD。
issue 里 git stash popYour local changes ... would be overwritten 是同一件事的另一面
(那边的树里 HEAD manifest 本身就与构建结果不同,于是写出来的是第三种内容)。

2. 修复后,同一场景

同样删掉 ui/View 再跑 --check(隔离沙箱):

manifest md5 before: 94234b0acb9f79122580537e997dd84b
EXIT=1
manifest md5 after:  94234b0acb9f79122580537e997dd84b     ← 一字节没动

❌ json-schema.manifest.json is out of date (1 schema(s) not recorded).
     + json-schema/ui/View.json

   Run `pnpm --filter @objectstack/spec gen:schema` and commit the result. A schema
   absent from the manifest is one this ratchet can never report as disappeared later,
   because it was never in the baseline (#2978).

同一件事在真实 worktree 里再做一遍,用 git status 作证(工作区被改没被改,git 说了算):

$ node -e "...drop ui/View..."
$ git status --porcelain
 M packages/spec/json-schema.manifest.json          ← 我的本地改动
 M packages/spec/scripts/build-schemas.ts
?? .changeset/check-mode-manifest-no-write.md
?? packages/spec/scripts/build-schemas-check-mode.test.ts

$ pnpm --filter @objectstack/spec check:authorable-surface
❌ json-schema.manifest.json is out of date (1 schema(s) not recorded).
     + json-schema/ui/View.json

   Run `pnpm --filter @objectstack/spec gen:schema` and commit the result. A schema
   absent from the manifest is one this ratchet can never report as disappeared later,
   because it was never in the baseline (#2978).

 ERR_PNPM_RECURSIVE_RUN_FIRST_FAIL  @objectstack/spec@17.0.0-rc.1 check:authorable-surface:
 `OS_EAGER_SCHEMAS=1 tsx scripts/build-schemas.ts --check`
 Exit status 1
EXIT=1

$ git status --porcelain
 M packages/spec/json-schema.manifest.json          ← 还在,内容一字未动
 M packages/spec/scripts/build-schemas.ts
?? .changeset/check-mode-manifest-no-write.md
?? packages/spec/scripts/build-schemas-check-mode.test.ts

对照修复前:同样的 M 会在跑完 check 之后消失(见第 1 节)。

3. 非 check 模式行为不变(gen:schema 仍然写)

同一份陈旧 manifest,去掉 --check:

md5 before: 94234b0acb9f79122580537e997dd84b
EXIT=0
md5 after:  94ddfeb6032474c1e62661a0fd6dca03
📒 json-schema.manifest.json updated (+1 schema(s)) — commit it.
ui/View present again: true | keys: 1688

4. missing 分支(已发布 schema 消失)未被改坏

往 manifest 里塞一个任何构建都不会产出的 key:

md5 before: 70f01dd6651b61b99f81814b74c34603
EXIT=1
md5 after:  70f01dd6651b61b99f81814b74c34603

❌ 1 previously published schema(s) disappeared from this build:
     - json-schema/ui/ZzzNeverEmitted.json

   A schema listed in json-schema.manifest.json was not emitted. ...

单测

新增 packages/spec/scripts/build-schemas-check-mode.test.ts,5 条,全部是运行时 e2e:
spawn 真正的 build-schemas.ts,断言退出码文件字节,而不是断言算术
(diff 的算术本来就一直是对的,缺陷在副作用和退出码上)。第 5 条是负控制:manifest 最新时
--check 必须 exit 0 且不抱怨,否则「永远红」也能骗过前四条。

选择运行时 e2e 而不是编译期 pin 是有意的:本包 tsconfig.jsoninclude 只有 src/**/*
exclude**/*.test.ts,vitest 也不开 typecheck —— 也就是 #4642 说的「编译期 pin
在本包空转」,scripts/ 与测试文件都不在 tsc --noEmit 的视野里。所以断言必须真的被执行到。

为什么用沙箱而不是直接跑真包:脚本所有路径都从自己的 __dirname 解析,就地跑会改到仓库里
tracked 的 json-schema.manifest.json;而 turbo run test 期间 @objectstack/spec
build(第一步就是 gen:schema)可能正在写同一个文件,测试会既有破坏性又 flaky。于是每次
运行都在临时目录里进行:复制 scripts/(让 __dirname 落在沙箱里),软链只读输入
src/ / node_modules/ / package.json,再放一份 json-schema.manifest.json
authorable-surface.json 的副本。这样生产代码路径一字未改 —— 没有给门禁加任何 test-only 接缝,
因为接缝本身就是「门禁与 CI 实际跑的东西产生差异」的地方。

sabotage 矩阵 —— 每条断言都被证明会红

「加完是绿的」不算证明。11 轮定向破坏,每轮只破坏一件事,贴真实失败行:

破坏内容 变红的断言(行号)
0 无(基线) Tests 5 passed (5)
1 还原本 issue 的缺陷:check 模式无条件写 + exit 0 122, 151 退出码 expected +0 to be 1
2 check 分支打印了但忘了 process.exit(1) 122, 151 退出码
3 check 分支无条件触发(manifest 明明是最新的) 204 负控制:输出不该抱怨 manifest
4 missing 分支丢掉 process.exit(1) 168 退出码
5 非 check 模式不再写文件 184 📒 … updated 不见了
6 renamedAway 从 staleness 条件里掉出去 151 退出码
7 check 分支退出码正确,但仍然写文件 128, 156 字节断言
8 missing 分支退出码正确,但仍然写文件 171 字节断言
9 报错里不再提 gen:schema 这个补救命令 126
10 报错标题不再点名是哪个文件陈旧 123, 152
11 只报数量、不再列出是哪些 key 124, 155

7/8 两轮是关键:1/2/4 轮都停在退出码断言上(vitest 一条断言失败即中止该用例),
所以单靠它们证明不了「文件没被动过」那条 —— 于是 7/8 轮保留正确的退出码、只多加一次写,
让唯一可能变红的就是字节断言:

### ROUND 7 — check branch exits 1 correctly but writes the manifest anyway
    AssertionError: expected '{\n  "schemas": [\n    "ai/AIModelCon…' to be '{\n  "description": "Ratchet manifest…'
     ❯ scripts/build-schemas-check-mode.test.ts:128:30
    AssertionError: expected '{\n  "schemas": [\n    "ai/AIModelCon…' to be '{\n  "description": "Ratchet manifest…'
     ❯ scripts/build-schemas-check-mode.test.ts:156:30
     Tests  2 failed | 3 passed (5)

### ROUND 8 — disappearance branch exits 1 correctly but writes the manifest
    AssertionError: expected '{\n  "schemas": [\n    "ai/AIModelCon…' to be '{\n  "description": "Ratchet manifest…'
     ❯ scripts/build-schemas-check-mode.test.ts:171:30
     Tests  1 failed | 4 passed (5)

第 1 轮(直接 git checkout origin/main -- scripts/build-schemas.ts)是最直白的一轮 ——
新单测放到修复前的代码上就是红的:

### ROUND 1 — the #4711 defect restored: --check writes unconditionally
    AssertionError: expected +0 to be 1 // Object.is equality
     ❯ scripts/build-schemas-check-mode.test.ts:122:22
    AssertionError: expected +0 to be 1 // Object.is equality
     ❯ scripts/build-schemas-check-mode.test.ts:151:22
     Tests  2 failed | 3 passed (5)

每轮结束都校验源文件被完整还原(md5sum 与修复版一致),最后工作区只剩本 PR 的三个文件。

未单独 sabotage 的少数断言(如两处 not.toContain('📒')、负控制里的字节断言)都是同一用例中
已被证明会红的断言之后的冗余护栏,不是独立主张。

changeset 定级:@objectstack/spec patch

packages/specfiles 字段不含 scripts/,所以这次改动不进 npm 包、不改任何公开
契约
(没有 API、schema、authorable key 变化)。定 patch 而不是「不写 changeset」,是因为
改变了一个 CI 门禁的成败语义,受影响的是贡献者:

  • 之前:新增一个 schema export 却忘了跑 gen:schemacheck:authorable-surface 静默把
    manifest 写好并 exit 0,CI 绿。
  • 之后:同样场景 ⇒ 该门禁 exit 1,提示跑 pnpm --filter @objectstack/spec gen:schema,
    与其余七个生成物完全一致。

check:generated --fixcheck:docs(它本来就先跑一遍 gen:schema)行为不变;干净 checkout
且 manifest 是最新的情况下,门禁照常是绿的(负控制那条单测就是钉这一点的)。
scripts/regen-artifacts.mjsjson-schema.manifest.jsoncheck 正是
check:authorable-surface —— 这次改动让那条「check proves currency」从名义上变成
事实上成立(在此之前它是靠把文件改成 current 来「证明」current 的)。

门禁(全部实跑)

跑了两轮:一轮在原始 base 上,一轮在合并 origin/main 之后(期间 #4651 / PR #4718
#4463 / PR #4715 落地,packages/spec 在对面动过 —— 正是 AGENTS.md §10 点名要重跑的情况)。
两轮都是单锁串行(本机重验证全局互斥,flock /tmp/os-heavy-verify.lock)。下表是合并后的:

门禁 结果
pnpm install --frozen-lockfile + pnpm --filter @objectstack/spec build exit=0
pnpm --filter @objectstack/spec check:generated exit=08/8 全部 up to date
pnpm --filter @objectstack/spec check:authorable-surface exit=0
pnpm --filter @objectstack/spec test exit=0Test Files 294 passed (294) / Tests 7387 passed (7387)
pnpm --filter @objectstack/spec typecheck exit=0
pnpm turbo run typecheck --concurrency=2 exit=0Tasks: 122 successful, 122 total
pnpm turbo run test --concurrency=2 exit=0Tasks: 133 successful, 133 total
  ✓ check:spec-changes         spec-changes.json
  ✓ check:upgrade-guide        docs/protocol-upgrade-guide.md
  ✓ check:skill-docs           skill docs (from SKILL.md frontmatter)
  ✓ check:skill-refs           skill references
  ✓ check:react-blocks         react-blocks contract
  ✓ check:authorable-surface   authorable-surface.json + JSON schemas
  ✓ check:api-surface          api-surface.json
  ✓ check:docs                 content/docs/references/**
✓ All 8 generated artifacts are up to date.

合并后 git diff --name-only origin/main...HEAD 恰好是本 PR 的三个文件:

.changeset/check-mode-manifest-no-write.md
packages/spec/scripts/build-schemas-check-mode.test.ts
packages/spec/scripts/build-schemas.ts

跑完整套门禁之后工作区没有任何计划外改动 —— 这本身就是本 PR 的论点。

顺带发现,已单独立单:#4723(本 PR 修)

"check:docs": "pnpm gen:schema && tsx scripts/build-docs.ts --check" —— 第一步是生成器
所以「检查改工作区」这件事,在 check:generated 这条路径上只被本 PR 消除了一半:

manifest staled by hand:  94234b0acb9f79122580537e997dd84b
 M packages/spec/json-schema.manifest.json
$ pnpm --filter @objectstack/spec check:docs
check:docs exit=0
manifest after check:docs: 94ddfeb6032474c1e62661a0fd6dca03
$ git status --porcelain packages/spec/json-schema.manifest.json
                          ← 空:被这条「检查」写回去了

而且 check:generatedcheck:authorable-surface 在前、check:docs 在后且不会因前者失败
而停
,于是修完之后的组合行为是:先红着报「manifest 陈旧」,再被后一条悄悄写好。这比修之前更
需要解释 —— 但那要动 build-docs.ts 对磁盘上 json-schema/ 的依赖,是另一个决定,写在 #4723
里等维护者拍板,没有顺手扩面。

顺带说明(改动)

--check 仍然会清掉并重新生成 gitignored 的 packages/spec/json-schema/ 目录。那不是 tracked
文件,两个 ratchet 读的都是内存里的 generatedSchemas,与本 issue 无关,故意不动。

本 PR 不触碰 issue 里提到的关联单(#4650 / #4666 / #4659 / #4663),它们各自有单。
与并行进行的 #4651 文件零重叠:只改 packages/spec/scripts/build-schemas.ts 的控制流,加一个
新测试文件和一个 changeset;没有碰 src/、生成物、ADR-0087 注册表、活性账本或严格性台账。


Generated by Claude Code

claude added 2 commits August 2, 2026 22:12
The manifest ratchet in scripts/build-schemas.ts had no CHECK discriminator.
`check:authorable-surface` (build-schemas.ts --check) — one of the eight
generated-artifact gates `check:generated` runs — recomputed the emitted schema
set and, on any addition or renamed-away def, rewrote the tracked
json-schema.manifest.json in place and exited 0. Two defects, one missing `if`:

1. A check edited the working tree. Whatever the file held locally was
   overwritten by a command whose entire job is to look, which is how a
   `git stash pop` / worktree / merge-conflict operation fails for a reason
   nobody traces back to a gate. It is the #4675 merge-driver trap from the
   other side too: run any check mid-merge and a manifest computed from a
   half-merged tree lands on disk.
2. The additions branch could never go red in CI. Seven of the eight artifacts
   mean "stale => fail, run the generator"; this one meant "stale => I'll write
   it for you", inside the same `check:generated` summary.

The ratchet is now isomorphic to the authorable-surface ratchet immediately
below it: in --check it prints the unrecorded keys plus the `gen:schema`
remedy and exits 1; outside --check it writes exactly as before. The `missing`
branch (a published schema disappeared) is untouched — it already exited 1.

New runtime e2e tests (scripts/build-schemas-check-mode.test.ts) pin the exit
code AND the file bytes for all three branches, plus a negative control so
"always red in check mode" cannot pass. They spawn the real script in a temp
sandbox that copies scripts/ and symlinks src/, node_modules and package.json,
so no test-only seam is added to the gate and a concurrent `gen:schema` from a
turbo build cannot race the repo's tracked manifest. Runtime rather than
compile-time because this package type-checks neither scripts/ nor *.test.ts
(#4642).

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

vercel Bot commented Aug 2, 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 2, 2026 10:37pm

Request Review

@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Aug 2, 2026
@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

check:authorable-surface 在 --check 模式下仍会写 json-schema.manifest.json —— 一个「检查」在改工作区

2 participants