Skip to content

Commit 9a5d72c

Browse files
committed
refactor(spec): 三个热点生成物按 category / entrypoint 分片,拆掉合并队列的串行税 (#5837)
`merge=os-regen`(#4675)只在本地 git 生效;合并队列在服务端重建 PR 时不跑自定义 merge driver,所以两个都动过 `authorable-surface.json`(310KB / 7941 行排序数组)的 PR 在队列里是纯文本冲突,第二个必然被踢。内容本来就按 `<category>/<Def>[:<prop>]` 天然分组,按它拆开,动不同 category 的两个 PR 从此文件不相交。 S1 布局 + 生成器:新增 `scripts/lib/sharded-artifacts.ts`(布局、writer、完整性检查、 历史基线读取一处收口);`gen:schema` 写 `authorable-surface/` 与 `json-schema.manifest/`,`gen:api-surface` 写 `api-surface/`(`.` → `root.json`)。 S2 门禁 + 锚点:所有闸读**整个目录**当一个集合,ratchet 语义逐条不变; `authorable-surface.base.json` 保持聚合单文件(只有 `--update-base` 写它,不在 churn 路径上,且 `baseRev` 是整个 surface 的一个 commit),其比对改读基线 commit 的分片并 聚合,authenticity 判据两半未动。 S3 消费方:build-docs / build-spec-changes / root-index.test / release-spec-changes.sh / check-published-files / package.json `files` 全量改点。 S4 收尾:`.gitattributes` 与 `regen-artifacts.mjs` 路由到分片路径,单体文件退役, AGENTS.md / 两个内部 skill / lint.yml 注释 / ADR-0059 与 ADR-0087 路径修订、 `docs/spec-generated-artifact-sharding.md`。 新增 `scripts/sharded-artifacts.test.ts`(23 例):局部性与幂等各成常驻断言 —— 「只改 category X → 只有 X.json 变化,其余分片逐字节不变」,以及「同一 key 集合重写 零文件」。#5976 的 def key 撞名守卫按剥后缀 schema 名判定,与输出路径无关,其反向 验证测试同步改为断言分片目录名(否则该断言会变成空跑)。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011M7UwH25Unfi73UHim7ajY
1 parent 44106d9 commit 9a5d72c

76 files changed

Lines changed: 15815 additions & 14313 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
refactor(spec): 三个热点生成物按 category / entrypoint 分片,合并队列的串行税消失 (#5837)
6+
7+
`merge=os-regen`(#4675)只在**本地** git 生效。合并队列在服务端重建 PR 时不跑自定义
8+
merge driver,所以两个都动过 `authorable-surface.json`(310KB、7941 行排序数组,每个
9+
spec PR 都会重写)的 PR 在队列里是纯文本冲突,第二个必然被踢——spec 车道一次只能放行
10+
一单。driver 自己的注释里记着这笔税的形状:一个下午 4 次合并 9 处冲突,****真语义
11+
冲突,全部是集合并集。付的是「单体文件」这个形状的钱,不是分歧的钱。
12+
13+
**布局变化(发布产物路径,见下方档位说明)**
14+
15+
| 之前 | 现在 | 分片键 |
16+
|:---|:---|:---|
17+
| `packages/spec/authorable-surface.json` | `packages/spec/authorable-surface/< category >.json` | def key 的 category 段 |
18+
| `packages/spec/json-schema.manifest.json` | `packages/spec/json-schema.manifest/< category >.json` | 同上 |
19+
| `packages/spec/api-surface.json` | `packages/spec/api-surface/< entry >.json` | 已发布入口(`.``root.json`) |
20+
21+
有意维持单体:`spec-changes.json`(按版本键控)、`api-surface-signatures.json`(1.3KB)、
22+
`authorable-surface.base.json`(只有显式 `--update-base` 会写,从不在 churn 路径上,且
23+
它的 `baseRev` 是整个 surface 的**一个** commit——分片会让不同分片镜像不同 revision,
24+
那是任何上游 commit 都没有过的状态)。
25+
26+
**ratchet 语义逐条不变。** 所有门禁读**整个目录**当作一个集合,而不是「这次构建会写的
27+
那些分片」:删掉一整个分片文件 = 删掉它的 key,checks (a)/(c) 看到的缺失 key 与从前删
28+
单体文件里的行时一模一样;没人重生成的分片报**陈旧**而不是被跳过;#4662 的逐字节规范
29+
形式比对现在是逐分片做的,手改因此还能被**指名到文件**#5976 的 def key 撞名守卫不受
30+
影响——它按剥后缀的 schema 名判定(`shared/HttpMethod`),与输出路径无关,且仍在两个
31+
ratchet 之前运行。
32+
33+
**对消费者的可见影响,以及为什么是 patch。** `@objectstack/spec``files`
34+
`api-surface.json` 改成了 `api-surface`,所以 npm 包内该快照的路径变了(它不在
35+
`exports` 里,不是可 import 的子路径,是给工具读的文件)。导出面本身**零变化**
36+
(`check:api-surface` 实测 0 breaking / 0 added),运行时行为、类型、schema 一律未动,
37+
因此不是 major;仓内唯一的读点(release 工作流的 surface diff)随之更新,并且会按上游
38+
tarball 实际携带的形状读取——`api-surface/` 目录(本次起)、`api-surface.json` 单文件
39+
(protocol 15 起至本次)、两者皆无(protocol 15 之前)。外部若有直接读
40+
`node_modules/@objectstack/spec/api-surface.json` 的工具,改读 `api-surface/` 目录并把
41+
各分片的 `exports``entry` 合并即可,内容逐条相同。

.claude/skills/pm-dispatch/SKILL.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1385,9 +1385,10 @@ file-disjoint;它管不到**先后两单都碰 `packages/spec` 生成物**的情
13851385

13861386
```
13871387
packages/spec/spec-changes.json
1388-
packages/spec/authorable-surface.json
1389-
packages/spec/json-schema.manifest.json
1390-
packages/spec/api-surface.json
1388+
packages/spec/authorable-surface/**
1389+
packages/spec/authorable-surface.base.json
1390+
packages/spec/json-schema.manifest/**
1391+
packages/spec/api-surface/**
13911392
packages/spec/api-surface-signatures.json
13921393
docs/protocol-upgrade-guide.md
13931394
docs/audits/2026-07-unknown-key-strictness-ledger.counts.md
@@ -1423,7 +1424,7 @@ content/docs/references/**
14231424

14241425
> 断言 `pnpm --filter @objectstack/spec check:authorable-surface` **绿**即可。锚点
14251426
> authenticity 的定义是两件事:`baseRev``origin/main`**祖先**,且它记录的 keys
1426-
> **该 commit**`authorable-surface.json` 逐行一致(`verifyCommittedSurfaceBase`
1427+
> **该 commit** 的 authorable surface 逐行一致(`verifyCommittedSurfaceBase`
14271428
> 就查这两条)。`baseRev` **允许滞后** —— `gen:schema` 只在 keys 真的漂移时才重写它
14281429
> (在 `main` 上 merge base 就是 HEAD,该文件**必然**落后自己的 surface 一个 PR),
14291430
> 滞后只打一行 `ℹ️`,不是错误。⛔ 禁止为了凑「相等」手改锚点文件 —— 那正是 #4650

.claude/skills/spec-property-retirement/SKILL.md

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,8 @@ Note template for a tombstone entry (verbatim house style, e.g.
147147
- 把「整 def 删除」的零变化**判成正常** → 放过一个**根本没真正删掉**的 def。
148148

149149
所以验收顺序是:**先确定路线,再决定该期待什么读数**,不要反过来用读数去猜路线。
150-
整 def 删除还有一条自证信号:`json-schema.manifest.json` 的 ratchet(#2978)会先开火,
150+
整 def 删除还有一条自证信号:`json-schema.manifest/`(#5837 起按 category 分片)的
151+
ratchet(#2978)会先开火,
151152
要求你**有意删除**对应的 manifest key;删完重跑,per-key ratchet 会自行判定为 #4650
152153
路径 3(`def no longer emitted by this build`)。这串输出本身就是路线的证据,留在 PR 里。
153154

@@ -190,7 +191,7 @@ follows. Write both.
190191
reference untouched when nothing matched.
191192
- [ ] **A `RETIRED_KEYS_BY_MAJOR` entry** in
192193
`packages/spec/src/migrations/registry.ts` — the literal
193-
`'<defKey>:<name>'` as `authorable-surface.json` spells it, minus the
194+
`'<defKey>:<name>'` as `authorable-surface/<category>.json` spells it, minus the
194195
`[RETIRED]` mark, under this major. This is the string gate (b) reads, by
195196
exact set membership; nothing is inferred and nothing radiates from a
196197
neighbouring key. The gate's failure prints the line to paste. ⚠ Do **not**
@@ -257,7 +258,7 @@ Work top to bottom; each line has a gate behind it.
257258
consumer goes with it (`PerformanceConfigSchema`, `AIKnowledgeSchema`,
258259
`ToolCategorySchema`). An exported schema with no consumer is read as a
259260
capability by whoever finds it (#3950 precedent). This — and *only* this —
260-
moves `api-surface.json`: that snapshot prints type *references*, not
261+
moves `api-surface/`: that snapshot prints type *references*, not
261262
expanded shapes, so it is blind to key-level narrowing (#3883 removed three
262263
keys from `defineAction`'s input and the snapshot did not change). Its gate
263264
also lives in a different workflow (`TypeScript Type Check`, not
@@ -268,9 +269,12 @@ Work top to bottom; each line has a gate behind it.
268269
once; regenerate the counts with the python snippet in the README rather
269270
than hand-editing).
270271
- [ ] **Generated baselines**`pnpm --filter @objectstack/spec gen:schema`
271-
moves `authorable-surface.json` (tombstone → a new `… [RETIRED]` line;
272-
strict removal → the line **vanishes**, which is gate (a)'s trip wire, so
273-
delete it in the same PR deliberately) and `json-schema.manifest.json`.
272+
moves `authorable-surface/<category>.json` (tombstone → a new
273+
`… [RETIRED]` line; strict removal → the line **vanishes**, which is gate
274+
(a)'s trip wire, so delete it in the same PR deliberately) and
275+
`json-schema.manifest/<category>.json`. Both are sharded by category since
276+
#5837 — the gates read the whole directory as one set, so the retirement
277+
procedure is unchanged; only which file the line lives in moved.
274278
Then `gen:spec-changes`, `gen:upgrade-guide`, `gen:api-surface`,
275279
`gen:docs`. See AGENTS.md for the you-changed-X → regenerate-Y table.
276280
- [ ] **Forms** — prune the `{ field: '<key>' }` input from

.gitattributes

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,16 @@
66
# two PRs each add or drop a few lines the result is a set union — fully
77
# composable — but a three-way TEXT merge reports it as a conflict a human must
88
# resolve by hand. The correct resolution is always the same: discard both sides
9-
# and re-run the generator. `authorable-surface.json` alone is a 8k-line sorted
10-
# array, so any two PRs landing near each other collide.
9+
# and re-run the generator.
10+
#
11+
# ⚠️ The driver is a LOCAL git facility, and that bounds what it can fix (#5837).
12+
# The GitHub merge queue rebuilds each PR server-side, where no custom merge
13+
# driver runs — so for the three hottest artifacts a driver could not stop the
14+
# queue from evicting the second PR that touched them. Those three are now
15+
# SHARDED, one file per category / entry point, so PRs touching different
16+
# categories touch disjoint files and the queue has nothing to conflict on. They
17+
# stay routed here as directories: the driver still owns a same-category
18+
# collision, which is the residue sharding cannot remove.
1119
#
1220
# `merge=os-regen` hands those paths to `scripts/git-merge-regen.mjs`, which does
1321
# NOT text-merge them. See that file for why it also does not regenerate them
@@ -36,10 +44,10 @@
3644
# ledger's prose is hand-written and must never be resolved by regenerating.
3745

3846
packages/spec/spec-changes.json merge=os-regen
39-
packages/spec/authorable-surface.json merge=os-regen
47+
packages/spec/authorable-surface/** merge=os-regen
4048
packages/spec/authorable-surface.base.json merge=os-regen
41-
packages/spec/json-schema.manifest.json merge=os-regen
42-
packages/spec/api-surface.json merge=os-regen
49+
packages/spec/json-schema.manifest/** merge=os-regen
50+
packages/spec/api-surface/** merge=os-regen
4351
packages/spec/api-surface-signatures.json merge=os-regen
4452
docs/protocol-upgrade-guide.md merge=os-regen
4553
docs/audits/2026-07-unknown-key-strictness-ledger.counts.md merge=os-regen

.github/workflows/lint.yml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -548,7 +548,7 @@ jobs:
548548
# generated page and the filter kept watching the old path, so hand-edits
549549
# went unchecked for months; #3855 listed specific spec paths but no schema
550550
# dirs, so `check:authorable-surface` went dormant on exactly the PRs that
551-
# remove an authorable key; and `json-schema.manifest.json` — the #2978
551+
# remove an authorable key; and the json-schema manifest — the #2978
552552
# ratchet that is the ONLY durable record of every emitted schema, since
553553
# json-schema/ is gitignored — was never watched at all, so a PR retiring a
554554
# key from it skipped its own verifier. Six gates had already escaped to
@@ -570,7 +570,7 @@ jobs:
570570
run: pnpm --filter @objectstack/spec check:upgrade-guide
571571

572572
# The authorable KEY surface — what a metadata author may write, which for
573-
# this platform is the third-party API. `api-surface.json` records exported
573+
# this platform is the third-party API. `api-surface/` records exported
574574
# names and `api-surface-signatures.json` hashes factory types as TypeScript
575575
# PRINTS them (a reference, never structurally expanded), so neither sees a
576576
# key added to or removed from a schema. #3883 removed three authorable keys
@@ -690,7 +690,7 @@ jobs:
690690
- name: Check @objectstack/spec public API surface
691691
run: pnpm --filter @objectstack/spec run check:api-surface
692692

693-
# Same surface, the other axis: api-surface.json records that an export
693+
# Same surface, the other axis: api-surface/ records that an export
694694
# EXISTS, never what it resolves to — so four exported types sat at `any`
695695
# across a whole major with every gate green (#4171). #4115 tells consumers
696696
# to replace a local declaration with the spec import, which for those four
@@ -703,7 +703,7 @@ jobs:
703703
- name: Check no exported spec type resolves to `any`
704704
run: pnpm --filter @objectstack/spec run check:exported-any
705705

706-
# Third axis on the same surface: api-surface.json shows a name on two
706+
# Third axis on the same surface: api-surface/ shows a name on two
707707
# entries but not whether the two are ONE declaration re-exported (fine)
708708
# or TWO declarations sharing a name — the #4411 trap, where which type a
709709
# consumer gets depends on nothing but the import path and the copy that

AGENTS.md

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -251,12 +251,28 @@ Even inside your own worktree, operate defensively:
251251
11. **Generated artifacts don't text-merge — a driver defers them and
252252
`pre-commit` collects the debt.** §10's "never trust git's textual merge of a
253253
generated file" is now mechanical (#4675). `.gitattributes` routes the
254-
generator-owned artifacts (`spec-changes.json`, `authorable-surface.json`,
255-
`api-surface*.json`, `json-schema.manifest.json`,
254+
generator-owned artifacts (`spec-changes.json`, `authorable-surface/**`,
255+
`authorable-surface.base.json`, `api-surface/**`,
256+
`api-surface-signatures.json`, `json-schema.manifest/**`,
256257
`docs/protocol-upgrade-guide.md`, `content/docs/references/**`) to
257258
`merge=os-regen`, so a merge that used to stop on conflicts across all of
258259
them now stops only on the hand-written files that actually need you.
259260

261+
**The driver is a LOCAL facility, and #5837 is where that bound showed.** The
262+
GitHub merge queue rebuilds each PR server-side, where no custom merge driver
263+
runs — so two PRs that both touched `authorable-surface.json` (a 310KB sorted
264+
array every spec PR rewrites) were a plain textual conflict there and the
265+
second was evicted, capping the spec lane at one PR at a time. The three
266+
hottest artifacts are therefore **sharded**: `authorable-surface/<category>.json`,
267+
`json-schema.manifest/<category>.json`, `api-surface/<entry>.json`. PRs
268+
touching different categories now touch disjoint files, and the driver keeps
269+
the residue (two PRs in the same category). Every gate reads the whole
270+
directory as one set, so the ratchet semantics are unchanged — see
271+
`packages/spec/scripts/lib/sharded-artifacts.ts`. Deliberately still single
272+
files: `spec-changes.json` (keyed by version), `api-surface-signatures.json`
273+
(1.3KB) and `authorable-surface.base.json` (written only by an explicit
274+
`--update-base`, so never on the churn path).
275+
260276
The driver does **not** regenerate. Git runs merge drivers *while* it merges,
261277
in index order, so the worktree still holds pre-merge sources — a generator
262278
run there would describe a half-merged tree and write a confidently wrong
@@ -382,7 +398,7 @@ regenerate up front:
382398
| The react-blocks contract | `check:react-blocks` | `gen:react-blocks` |
383399

384400
A `.describe()` string counts — it is not "just a comment", it lands in
385-
`content/docs/references/`. Adding one export counts — it lands in `api-surface.json`.
401+
`content/docs/references/`. Adding one export counts — it lands in `api-surface/`.
386402
Both were learned the hard way in #4040: two separate red builds, neither a logic error.
387403

388404
Don't match by hand — one command runs **every** gate and reports **all** stale
@@ -420,7 +436,7 @@ inline when that gate is the one failing.)
420436
pure checks with no generator — a failure there is a real finding to fix, not an artifact
421437
to regenerate. `check:generated` names them as deliberately not run, so its "all up to
422438
date" never reads as "everything passed". The last one asks the third question about the
423-
export surface (#4446): `api-surface.json` shows a name on two entries but not whether
439+
export surface (#4446): `api-surface/` shows a name on two entries but not whether
424440
that is one declaration re-exported (fine) or two declarations sharing a name — the #4411
425441
trap, judged by symbol identity against the built dist, with the accepted cases in the
426442
shrink-only `dual-source-exports.baseline.json` (hand-edited under review, never
@@ -454,7 +470,7 @@ re-adding a skip.
454470

455471
`check:exported-any` is the one of those that also reads the built `dist/*.d.ts`, so the
456472
stale-`dist` caveat above applies to it too. It asks the other half of the
457-
`api-surface.json` question: that snapshot records an export *exists*, never what it
473+
`api-surface/` question: that snapshot records an export *exists*, never what it
458474
*resolves to*, which is how five exported symbols sat at `any` for a whole major with
459475
every gate green (#4171). A recursive Zod schema needs an annotation to break its
460476
circular inference, and `z.ZodType<any>` compiles, validates correctly, and silently
@@ -732,7 +748,7 @@ it to `OPEN_CAPABILITY_REGISTRIES` in the same PR that fixes it.
732748
working tree.
733749
3. **Add a changeset for feature work.** When the change is a feature or functional improvement, run `pnpm changeset` (or add a `.changeset/*.md` entry) describing it before committing. Pure bug fixes do **not** require a changeset.
734750
**Breaking changesets must carry their migration.** If the change removes or renames anything an author can write (a spec key, an export, a config field), the changeset body must state the FROM → TO mapping and the one-line fix — this text ships to consumers as `CHANGELOG.md` inside the npm package and is what an upgrading agent greps after the tombstone error. Removing an authorable spec key also requires a tombstone so the rejection itself carries the prescription — `retiredKey()` (`packages/spec/src/shared/retired-key.ts`) on a non-strict schema, or an entry in the relevant `UNKNOWN_KEY_GUIDANCE` / `*_RETIRED_KEY_GUIDANCE` map (see `object.zod.ts`, `ai/tool.zod.ts`) when the schema is `.strict()`. The changeset is one of fourteen surfaces a retirement touches — follow the `spec-property-retirement` skill (`.claude/skills/`) rather than reconstructing the kit, and note the two routes imply **opposite** liveness-ledger dispositions.
735-
4. **Added or removed a `packages/spec` export? Run `pnpm --filter @objectstack/spec gen:api-surface` and commit the result.** The `TypeScript Type Check` job diffs spec's built export surface against `api-surface.json`; a new export makes the snapshot stale and turns the job red. It reads the **built `dist` declarations**, so `OS_SKIP_DTS=1` — the flag you reach for to make local builds fast — skips exactly the artifact the gate inspects, and the check passes locally while failing in CI. Same shape for the other generated-artifact gates in that job (`check:docs`, `check:skill-refs`, `check:react-blocks`), which read `src/` and so do reproduce locally.
751+
4. **Added or removed a `packages/spec` export? Run `pnpm --filter @objectstack/spec gen:api-surface` and commit the result.** The `TypeScript Type Check` job diffs spec's built export surface against `api-surface/` (one shard per entry point since #5837); a new export makes the snapshot stale and turns the job red. It reads the **built `dist` declarations**, so `OS_SKIP_DTS=1` — the flag you reach for to make local builds fast — skips exactly the artifact the gate inspects, and the check passes locally while failing in CI. Same shape for the other generated-artifact gates in that job (`check:docs`, `check:skill-refs`, `check:react-blocks`), which read `src/` and so do reproduce locally.
736752
5. Update `CHANGELOG.md` / `ROADMAP.md` if user-facing or architectural.
737753
6. **Delete temporary artifacts** — screenshots, traces, scratch logs, `.playwright-mcp/`, throwaway `tmp*.ts`, ad-hoc scripts. Repo must look identical to before, minus intended changes.
738754

0 commit comments

Comments
 (0)