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 |
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