Repository navigation
refactor(spec): step 18 rationale as key-sorted fragments, conversionIds derived, so two retirements merge clean - #20572
Conversation
…ersionIds derived
Every major-18 retirement appended to two tails of `step18` in
migrations/registry.ts: the `+`-chained rationale (rewriting its closing
line) and the `conversionIds` list. Two retirements in flight therefore
conflicted in GitHub's driver-free merge. A plain array appended at its end
does not fix that: git conflicts on any two insertions into the same gap.
The rationale is now 46 fragments `{ id, order, text }` kept SORTED BY KEY,
so two retirements insert at different lines, and rendered by `order`
(joined with one space). `conversionIds` is read off
`CONVERSIONS_BY_MAJOR[18]`, of which it was a value-identical copy.
The rendered rationale, the conversion ids, the chain hop and the whole
MIGRATIONS_BY_MAJOR value are byte-identical to the parent.
Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx
…ith no driver A repo-project test beside count-shards-merge: the real registry keeps STEP18_RATIONALE sorted by key and renders it by `order`, conversionIds is derived, and two retirement-shaped insertions into the REAL file (one existing fragment apart, same next `order`) merge clean under `git merge-tree` and equal both insertions applied together. Lit controls: a same-gap pair, the same pair appended at the list's end, and the old `+`-chain tail rewrite each conflict. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx
Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx
📓 Docs Drift Check5 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. What this run could not see
Coarse fallback — 137 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 3dfb2ee2ea494039df1df15f69e7c89de0be979b && git checkout 3dfb2ee2ea494039df1df15f69e7c89de0be979b
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f572a7eb3c272a5be85e3ec64d2516c944c3b243 bcb255881a805a006a3255c2b5b85fb4e405e341 && git checkout -B drift-repro f572a7eb3c272a5be85e3ec64d2516c944c3b243 && git merge --no-ff bcb255881a805a006a3255c2b5b85fb4e405e341
node scripts/docs-audit/affected-docs.mjs --json f572a7eb3c272a5be85e3ec64d2516c944c3b243 |
Contract reviewServed-tier: Inputs: card #20535 (body; triage ① Derived judgmentsRendered text — byte-identical: RIGHT. Probe: base rationale 48,953 chars, sha256
Deriving Route change — the delivered shape merges in the common case: RIGHT, with a named residue. Git conflicts on two insertions into one gap; an end-appended array is one gap (triage's example mechanism, falsified by the pin's end-append control at exit 1). Key-sorted fragments put two retirements into different gaps whenever one existing fragment separates them — the pin's clean case (exit 0, merged bytes equal both insertions applied together; both take the same next The pin — a real proof, and a test rather than a gate: RIGHT. Authoring burden — loud and actionable. An end-append (unsorted) fails the sortedness assertion, which names the out-of-place pair and the remedy ("A fragment added at the END puts every retirement in one gap … Move it to where its id sorts."). A non-kebab id, a leading or trailing space, a non-positive Generated regions — untouched: RIGHT. Three hunks only: the header comment and the new import (:36–57); the new block between the close of the semantic:17 generated region (:4888) and Public surface: none changes. Gate coverage on this head (read once, 04:22Z). Success: Build Core (hosts ② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
…ith an explicit application order, so two retirements merge clean (objectstack-ai#20574) (objectstack-ai#20685) Fixes objectstack-ai#20574 Clause-②: no Every major-18 retirement that carries a D2 conversion touched two tails of `packages/spec/src/conversions/registry.ts`: it appended its conversion to the end of `CONVERSIONS_BY_MAJOR[18]`, and most of them defined the conversion at the end of the definitions, directly above that table. So any two such retirement PRs in flight conflicted in GitHub's driver-free merge. This PR reshapes both spots so that two retirements no longer insert into the same gap. Nothing a consumer reads changes: the list the loader replays is value-identical. This is the sibling of objectstack-ai#20535 (PR objectstack-ai#20572), which reshaped `step18.rationale` in `migrations/registry.ts`. It uses the same instrument. ## What changed - **`CONVERSIONS_BY_MAJOR[18]` is `inApplicationOrder(MAJOR_18_CONVERSIONS)`.** `MAJOR_18_CONVERSIONS` holds one `{ conversion, order }` entry per conversion. The list is kept **sorted by the conversion's identifier** (code-unit order). `order` is the application order: `inApplicationOrder` sorts by ascending `order`, ties broken by the conversion's `id`, and returns the plain `readonly MetadataConversion[]` it always was. Orders 1 to 46 are the 46 entries' old positions, so the replayed sequence is unchanged. - **The definitions' insertion point, the other shared tail.** I measured it. On `origin/main` `9a4b2bb38f`, 57 of the last 80 first-parent commits that touched this file added a conversion definition. Together they added 66 definitions, and 42 of them were added after every other definition, in that one gap. The list's doc comment now says where a new definition goes: directly above the definition of the entry that follows it in `MAJOR_18_CONVERSIONS`. A conversion that sorts last goes after every other conversion. Two retirements in different list gaps therefore also define their conversions in different gaps. No existing definition moved. - `OrderedConversion` and `inApplicationOrder` are module-private, and `CONVERSIONS_BY_MAJOR` keeps its explicit type annotation. `api-surface/` and `export-origins/` are unchanged, and `check:api-surface` is green. The released majors (11 to 17) stay plain arrays. The next major takes the same shape when it opens. - Consumers are untouched. `ALL_CONVERSIONS`, step 18's `conversionIds` (`CONVERSIONS_BY_MAJOR[18]!.map((c) => c.id)`, pinned verbatim by the objectstack-ai#20572 test), `spec-changes.ts` and `build-upgrade-guide.ts` all read `CONVERSIONS_BY_MAJOR[18]` as before. No generated projection moved: `check:generated` reports 15 of 15 up to date and regenerated nothing. ## Why sorted by identifier, with an explicit order Git reports a conflict whenever two branches insert into the **same gap** between unchanged lines, whatever they insert. A list appended at its end is one gap. An explicit `order` alone would not change that: the end-append lit control below conflicts with the order key present. Kept sorted by identifier, two retirements insert into different gaps, and one existing entry between them is enough. The application order cannot depend on where an insertion lands, so it lives in `order`. Two PRs in flight may both take the next number. They then apply in `id` order, which is deterministic whatever the textual positions are. ## Verification record ### Lit controls on the parent (`c6b37cd08d`) This was a one-shot run with git 2.43.0 in a scratch repo that held the real file, with no attributes and no driver. Each side made the edit a major-18 retirement makes: | pair | `git merge-tree --write-tree` | |---|---| | two list-tail appends (a new identifier after `viewListTabsRemoved,`) | **exit 1**, CONFLICT (content) in `registry.ts` | | two definitions added directly above `export const CONVERSIONS_BY_MAJOR` | **exit 1**, CONFLICT (content) | | both edits on each side | **exit 1**, CONFLICT (content) | ### Dark control and the permanent pin The pin is `packages/spec/scripts/conversions-major18-merge.test.ts`. It is in the repo project beside `step18-rationale-merge.test.ts`, and it works against the REAL file. It is a test, not a new gate. It asserts: - **The premise.** `CONVERSIONS_BY_MAJOR` wires `18: inApplicationOrder(MAJOR_18_CONVERSIONS)`, with no `18: [` array. The entries are strictly sorted by identifier. Each entry names a major-18 conversion defined in the file, and the list is all of them. `order` is positive, and the application order parsed from the entries (by `order`, ties by `id`) equals `CONVERSIONS_BY_MAJOR[18]`, the tail of `ALL_CONVERSIONS`, and `MIGRATIONS_BY_MAJOR[18].conversionIds`. The anti-vacuity check: that order differs from key order. A conversion added after this change must be defined directly above its list successor. The 46 entries that predate the rule are exempt, and that set is closed: its size is pinned. - **The card's reproduction, now clean.** Two retirement-shaped edits, each adding a definition above its successor's and an entry where its identifier sorts, one existing entry apart, both taking the same next `order`: **exit 0**. The merged bytes equal both edits applied together. The merged file still satisfies both rules, and its application order is the old 46 followed by the two new ids in `id` order. - **Lit controls**, all **exit 1** with conflicted path `registry.ts`: - two identifiers in the same gap, the residue a key sort cannot remove; - the same two entries appended at the list's END; - the same two conversions defined after every other conversion; - a synthetic model of the old array-plus-tail shape. The pin also proves that its two rule checks fire. The end-appended side breaks the sort rule, and the tail-defined side breaks the placement rule with the expected message. ### Byte-identical replay | value | parent `c6b37cd08d` | after the restructure | |---|---|---| | `CONVERSIONS_BY_MAJOR[18]` ids | 46, sha256 `9e9666c56a5e6314…` | identical | | majors 11, 13, 14, 15, 17 ids | 4 / 3 / 1 / 2 / 57 | identical, each | | `ALL_CONVERSIONS` ids | 113, `c0dee67fbecf705e…` | identical | | every conversion's body (JSON with function source) | `fbba8d0066fb121c…` | identical | | `MIGRATIONS_BY_MAJOR[18].conversionIds` | `9e9666c56a5e6314…` | identical | | the whole `MIGRATIONS_BY_MAJOR` value as JSON | `72f0c698a5bfcc09…` | identical | The two fingerprint files, from tsx over `src` at `c6b37cd08d` and at `ed1c54db8d` (the restructure commit), are byte-identical. After merging `origin/main` `9a4b2bb38f`, which carries objectstack-ai#20305's change to one conversion's body, I compared head `63af2cb5f6` with main's plain arrays, read from main's source text. Every major's id sequence, `ALL_CONVERSIONS` (113) and step 18's `conversionIds` are identical. The built CJS and ESM bundles (`dist/index.{js,mjs}` and `dist/browser/index.{js,mjs}`) all load with the same id hashes. This branch's delta to `registry.ts` against main has the same `git patch-id --stable` as the restructure commit's own diff. ### Ablations (one-shot, at `d316f458a6`, registry blob `f9d797be1e80`) Each ablation went through `scripts/ablation-replace.mjs`, with the anchor proven to hit (1 → 0). Each was restored to the HEAD blob with `git diff HEAD` empty. 1. Replacing the `order` sort in `inApplicationOrder` with `.slice()` (apply in list position) turned the pin **red**: 2 failed, 10 passed. The two failures were the application-order assertion and the merged-order assertion. 2. Swapping the first two entries turned the pin **red**: 3 failed, 9 passed. The failures were the sortedness assertion and the two assertions that need a sorted base. The observed direction was the usual one: the pin turns red. No build or dist leg was needed, because the pin imports `../src` directly and reads the source text. ## Tests and gates (final commit `63af2cb5f6`) - `@objectstack/spec` `local` project: 575 files, 16,946 passed, 1 todo. `repo` project: 44 files, 773 passed. `pnpm --filter @objectstack/spec typecheck`: exit 0. That covers `tsc`, `check:scripts-typecheck` (the pin is in that program) and `check:test-typecheck`. All ran through `os-verify-lock` with `--maxWorkers=2` on a shared box. - `check:generated`: 15 of 15 up to date, against the `dist` built at this head. `check:migration-registry`: exit 0. - `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` was reconciled with `--ran`: 84 derived, **80 exit 0**, 4 NOT MEASURED, 0 unrun. The 4 exited 3 with `PREREQUISITE NOT MET` because they need the whole-repo build closure, which CI builds: `check:doc-formula-expressions`, `check:dual-build-cjs-loads`, `check:lean-entry-closure` and `check:type-check-debt`. - The first gate round, at `b4f0179259`, caught `check:comment-mask-adoption` red. It flagged the pin's own line-comment regex as a private comment stripper. Commit `0000e4ab13` fixed it: the pin now reads the list and the definitions through `scripts/js-comment-mask.mjs`'s `maskComments`. - Declared to CI: - the CLI integration tier (this diff touches no CLI file); - the tests in other packages that read `ALL_CONVERSIONS` (`metadata-core`, `metadata-protocol`, `metadata`, `service-automation`). The public surface is byte-unchanged, and the values are proven identical above. - Repo-level `pnpm lint` is CI-owned. - Changeset: `@objectstack/spec` `patch`. I measured this rather than assumed it. After the build, `inApplicationOrder` appears in 6 files under `dist/` and `MAJOR_18_CONVERSIONS` in 8, with the positive control `view-list-tabs-removed` also present. So the published bundle moves, while no value it exports does. ## How a retirement adds its conversion once this lands 1. Define the conversion directly above the definition of the entry that will follow it in `MAJOR_18_CONVERSIONS`. If it sorts last, define it after every other conversion, directly above `OrderedConversion`. 2. Add ONE line, `{ conversion: IDENT, order: N }`, where `IDENT` sorts. Never add it at the end. 3. `N` is one more than the highest present. A conversion that must apply before an existing one takes a number between its neighbours' (for example `23.5`) instead of renumbering them. A branch cut before this lands meets the change once, on its next base merge: its appended `18: [` line becomes one entry at its sorted position. ## Acceptance notes - **Governed wording, routed to the skills lane (not edited here).** `.claude/skills/spec-property-retirement/SKILL.md`'s registration checklist tells a retirement author to add a `MetadataConversion` in this file. For major 18 that now means the entry-and-placement rule above. objectstack-ai#20575 already carries the step-18 rewording of the same checklist, and its body asks to fold this sibling's wording in at the same time. - **Measured residue, not reshaped here.** 8 of the same 80 commits also changed the file's import block (its first 60 lines). Two retirements that each add an import at the same point would still conflict there. The import block is outside the two spots this card names, and the frequency is about one in ten. - **Same-gap residue.** Two identifiers that sort into the same gap still conflict. That is pinned as a lit control, as in objectstack-ai#20572. - The pin copies the small hermetic `FileRepo` harness from `step18-rationale-merge.test.ts` rather than extracting a shared helper. That keeps the objectstack-ai#20572 pin untouched. - The sortedness and placement checks are assertions in a repo-project test, not a `check:*` gate. They are what make an end-append, or a tail definition, fail loudly instead of quietly bringing the conflict back. --- _Generated by [Claude Code](https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #20535
Clause-②: no
Every major-18 retirement appended to two tails of
step18inpackages/spec/src/migrations/registry.ts: the+-chainedrationale(by rewriting its closing line) and theconversionIdslist. So any two retirement PRs in flight conflicted in GitHub's driver-free merge. This PR reshapes both tails so that two retirements no longer touch the same line. Nothing a consumer reads changes: the values are byte-identical.What changed
rationaleis nowSTEP18_RATIONALE. It holds 46 fragments of the form{ id, order, text }, one per retirement. The list is kept sorted byid, and the rationale renders byorder(ties broken byid), joined with one space (joinRationale). Of the 46 keys, 40 are the retirement's own D3 semantic entry id. The other 6 are kebab-case names for retirements with no entry of their own:compliance-deadline-keys-retired,cron-positions-deleted,duration-keys-unit-in-key,element-filter-retired,element-form-retired,page-component-filter-record-to-rule-array. The fragments keep the original literal source bytes; only the 45 boundary spaces moved into the join.conversionIdsis derived: the ids ofCONVERSIONS_BY_MAJOR[18], in its order. It was a value-identical copy of that list (same 45 ids, same order), so a retirement now adds its conversion in one place only. This adds a second value import toregistry.ts. It creates no cycle:conversions/registry.tsimports nothing frommigrations/, and it was already in the migrations barrel's graph throughchain.ts.MigrationStep's type is unchanged, and no reader ofrationalechanged.Why sorted, and not the plain array the triage sketched (mechanism measured, then the route changed)
Git reports a conflict whenever two branches insert into the same gap between unchanged lines, whatever they insert. A list appended at its end is a single gap, so a plain array conflicts exactly as the old tail did. I measured this on a toy file and again on the real file (the pin's end-append control below): exit 1. With the list kept sorted by key, two retirements insert into different gaps and merge clean. One existing fragment between them is enough, the same property
.gitattributesrecords for the sorted generated tables. Two keys that land in the same gap still conflict. That residue is pinned as a lit control.Rendered-text proof (byte-identical)
6154165484MIGRATIONS_BY_MAJOR[18].rationale797afbe924eef185…75828e10MIGRATIONS_BY_MAJOR[18].conversionIds55d56175bf7c109c…rationale(whatmigrate meta --stepprints)797afbe924eef185…MIGRATIONS_BY_MAJORvalue as JSONd989a2b827fd7f93…dist/index.js+dist/browser/index.js(CJS) anddist/index.mjs(ESM)797afbe9…/ 45 ids55d56175…No committed artifact embeds step 18's rationale: the upgrade guide prints majors up to
PROTOCOL_MAJOR(17).check:upgrade-guide,check:spec-changesandcheck:migration-registryare green. A closure check on the built bundles: all four bundles that carry step 18 (dist/index.{js,mjs}anddist/browser/index.{js,mjs}) already carried the conversions registry. The marker waspage-kind-jsx-to-html, which no other non-testsrcmodule contains. So the new import widens no entry's closure.Merge measurement: the card's instrument
A one-shot run on the parent
6154165484, with git 2.43.0, in a scratch repo holding the real file with no attributes and no driver. Each side makes the edit a retirement PR makes:git merge-tree --write-treeconversionIdstail appendsThe permanent pin is
packages/spec/scripts/step18-rationale-merge.test.ts, in the repo project besidecount-shards-merge.test.ts(PR #20532), and it works against the REAL file. It asserts:order, joined with one space (so this compares the join, not the list); andconversionIdsis the derived expression.order→ exit 0. The merged bytes equal both insertions applied together, and the two render last, in key order.registry.ts: a same-gap pair; the same two fragments appended at the list's END; and the old+-chain tail rewrite (a synthetic model of the parent shape).The one open PR on this file, PR #20504 (a step-18 semantic entry in a generated region), merges clean with this head: bare shared-clone probe with no driver,
merge-treeexit 0.How a retirement adds its sentence once this lands
Add ONE element to
STEP18_RATIONALE:idis the retirement's D3 semantic entry id.idsorts, never at the end.orderis one more than the highest present. Two PRs in flight may take the same number; they then render inidorder.texthas no leading or trailing space.Add the D2 conversion to
CONVERSIONS_BY_MAJOR[18]only. A branch cut before this lands meets the change once, on its next base merge: its appended sentence becomes one new fragment, and itsconversionIdsline is dropped.Tests and gates (final commit
bcb255881a;registry.tsblob2f010628be9aunchanged since2e6251af0c)@objectstack/speclocalproject: 574 files, 16,879 passed, 1 todo (exit 0).repoproject: 41 files, 725 passed (exit 0). Both ran throughos-verify-lock,--maxWorkers=2, on a shared box.pnpm --filter @objectstack/spec typecheck: exit 0 (includescheck:scripts-typecheckandcheck:test-typecheck).check:generated: 15 of 15 artifacts up to date, measured against thedistbuilt at this head.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands, reconciled with--ran(exit codes recorded): 88 derived, 84 exit 0, 4 NOT MEASURED, 0 unrun.PREREQUISITE NOT MET: they need the whole-repo build closure, which CI builds):check:doc-formula-expressions: needs@objectstack/formulaand@objectstack/lintbuilt.check:dual-build-cjs-loads: needs all packages built. Narrowed reading: spec's own built CJS entries load (table above).check:lean-entry-closure: needs@objectstack/objectqlbuilt.check:type-check-debt: needs the whole-repo build closure. Spec's own typecheck is green.packages/cli/test/migrate-meta-default-range.test.ts. It spawns the CLI (integration tier, and this diff touches no CLI file), it passes no--step, and it reads the spec values proven identical above. Repo-levelpnpm lintis CI-owned.scripts/ablation-replace.mjswith the anchor proven to hit, and restored to the HEAD blob withgit diff HEADempty):ordersort injoinRationale(render by position): the pin went red, 1 failed / 8 passed, on the render-order assertion.action-aria-retiredtozz-action-aria-retired: red, 3 failed / 6 passed. The sortedness assertion failed, plus the two that depend on a sorted list. The first attempt was a no-op the tool refused, because the anchor also matched the D3 entry of the same id and nothing was written. It was re-run with a longer anchor.Acceptance notes
.claude/skills/spec-property-retirement/SKILL.md:215-216reads 「把 id 加进MIGRATIONS_BY_MAJOR[N].conversionIds,扩写该步的rationale。」. For N = 18 that becomes: add aSTEP18_RATIONALEfragment at its sorted position, and add the conversion only toCONVERSIONS_BY_MAJOR[18]. Lines 217-219 (a misspelled step id is silently skipped at replay) no longer apply to step 18, whose ids are derived.packages/spec/src/conversions/registry.tshas the same tail. Every retirement with a D2 conversion appends toCONVERSIONS_BY_MAJOR[18](and usually defines its conversion just above the previous last one). Two synthetic appends to that tail, on parent6154165484:merge-treeexit 1, CONFLICT (content). So after this lands, such PRs still conflict in that file. Only the migrations-registry half is removed here. The order of that list is application order, so the shape there is its own decision. Reported to the seat, not filed.conversionIdsinstead of giving it the keyed fragment treatment. A keyed copy would keep a second hand-kept order, which can drift from the loader's: step 17's copy names the same 57 ids in a different order from index 21 on. It would also let two concurrent conversions tie-break by key instead of by the author's chosen application order.check:*gate. It is what makes an end-append fail loudly instead of quietly bringing the conflict back.+chain of 614 literals, and its longest chain is now 28. Step 17's 970-literal chain (theeslint.config.mjsstack-size note) is untouched.Generated by Claude Code