refactor(spec): major 18's conversions as identifier-sorted entries with an explicit application order, so two retirements merge clean (#20574) - #20685
Conversation
…ith an explicit application order
CONVERSIONS_BY_MAJOR[18] was an end-appended array: every major-18
retirement with a D2 conversion inserted into its one tail gap, so any
two in flight conflicted in GitHub's driver-free merge. It is now
inApplicationOrder(MAJOR_18_CONVERSIONS): one { conversion, order }
entry per conversion, kept sorted by identifier, applied by ascending
order (ties by conversion id). Orders 1..46 are the old positions, so
the replayed sequence is unchanged.
Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx
Co-authored-by: Claude <noreply@anthropic.com>
…ge probe A repo-project test that parses MAJOR_18_CONVERSIONS from the real registry, holds it sorted by identifier, holds a new conversion's definition directly above its list successor's, checks the replayed order against CONVERSIONS_BY_MAJOR[18], ALL_CONVERSIONS and step 18's conversionIds, and merges two retirement-shaped edits of the real file with git merge-tree (exit 0), beside four lit controls that must still conflict: the same gap, the list's end, the definitions' end, and the old array shape. Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx Co-authored-by: Claude <noreply@anthropic.com>
…s shape Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx Co-authored-by: Claude <noreply@anthropic.com>
…nversions-merge-clean
check:comment-mask-adoption flagged the pin's own line-comment regex as a private comment stripper. The list and the definitions are now read through scripts/js-comment-mask.mjs maskComments, which keeps offsets, so a comment between entries is a blank line to the parser. Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx Co-authored-by: Claude <noreply@anthropic.com>
…nversions-merge-clean
…nversions-merge-clean
📓 Docs Drift Check4 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 f3500e547bd489bcbea3b23d51e2642f938c0ebe && git checkout f3500e547bd489bcbea3b23d51e2642f938c0ebe
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 6981abfd26cd518174db432766580e37b29a6566 63af2cb5f62051dd433904eeff8aac01bf947620 && git checkout -B drift-repro 6981abfd26cd518174db432766580e37b29a6566 && git merge --no-ff 63af2cb5f62051dd433904eeff8aac01bf947620
node scripts/docs-audit/affected-docs.mjs --json 6981abfd26cd518174db432766580e37b29a6566 |
Contract reviewServed-tier: Inputs: card #20574 (body, triage ① Derived judgments
② Semver level
③ Boundary flagsDev deviations, each answered:
Out-of-scope findings, dispositions:
No new gate: nothing in the diff adds a workflow step or a Implemented-by: VERDICT: PASS Generated by Claude Code |
Fixes #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 ofCONVERSIONS_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 #20535 (PR #20572), which reshaped
step18.rationaleinmigrations/registry.ts. It uses the same instrument.What changed
CONVERSIONS_BY_MAJOR[18]isinApplicationOrder(MAJOR_18_CONVERSIONS).MAJOR_18_CONVERSIONSholds one{ conversion, order }entry per conversion. The list is kept sorted by the conversion's identifier (code-unit order).orderis the application order:inApplicationOrdersorts by ascendingorder, ties broken by the conversion'sid, and returns the plainreadonly MetadataConversion[]it always was. Orders 1 to 46 are the 46 entries' old positions, so the replayed sequence is unchanged.origin/main9a4b2bb38f, 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 inMAJOR_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.OrderedConversionandinApplicationOrderare module-private, andCONVERSIONS_BY_MAJORkeeps its explicit type annotation.api-surface/andexport-origins/are unchanged, andcheck:api-surfaceis green. The released majors (11 to 17) stay plain arrays. The next major takes the same shape when it opens.ALL_CONVERSIONS, step 18'sconversionIds(CONVERSIONS_BY_MAJOR[18]!.map((c) => c.id), pinned verbatim by the refactor(spec): step 18 rationale as key-sorted fragments, conversionIds derived, so two retirements merge clean #20572 test),spec-changes.tsandbuild-upgrade-guide.tsall readCONVERSIONS_BY_MAJOR[18]as before. No generated projection moved:check:generatedreports 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
orderalone 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 inorder. Two PRs in flight may both take the next number. They then apply inidorder, 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:
git merge-tree --write-treeviewListTabsRemoved,)registry.tsexport const CONVERSIONS_BY_MAJORDark control and the permanent pin
The pin is
packages/spec/scripts/conversions-major18-merge.test.ts. It is in the repo project besidestep18-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_MAJORwires18: inApplicationOrder(MAJOR_18_CONVERSIONS), with no18: [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.orderis positive, and the application order parsed from the entries (byorder, ties byid) equalsCONVERSIONS_BY_MAJOR[18], the tail ofALL_CONVERSIONS, andMIGRATIONS_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 inidorder.Lit controls, all exit 1 with conflicted path
registry.ts: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
c6b37cd08dCONVERSIONS_BY_MAJOR[18]ids9e9666c56a5e6314…ALL_CONVERSIONSidsc0dee67fbecf705e…fbba8d0066fb121c…MIGRATIONS_BY_MAJOR[18].conversionIds9e9666c56a5e6314…MIGRATIONS_BY_MAJORvalue as JSON72f0c698a5bfcc09…The two fingerprint files, from tsx over
srcatc6b37cd08dand ated1c54db8d(the restructure commit), are byte-identical.After merging
origin/main9a4b2bb38f, which carries #20305's change to one conversion's body, I compared head63af2cb5f6with main's plain arrays, read from main's source text. Every major's id sequence,ALL_CONVERSIONS(113) and step 18'sconversionIdsare identical. The built CJS and ESM bundles (dist/index.{js,mjs}anddist/browser/index.{js,mjs}) all load with the same id hashes. This branch's delta toregistry.tsagainst main has the samegit patch-id --stableas the restructure commit's own diff.Ablations (one-shot, at
d316f458a6, registry blobf9d797be1e80)Each ablation went through
scripts/ablation-replace.mjs, with the anchor proven to hit (1 → 0). Each was restored to the HEAD blob withgit diff HEADempty.ordersort ininApplicationOrderwith.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.The observed direction was the usual one: the pin turns red. No build or dist leg was needed, because the pin imports
../srcdirectly and reads the source text.Tests and gates (final commit
63af2cb5f6)@objectstack/speclocalproject: 575 files, 16,946 passed, 1 todo.repoproject: 44 files, 773 passed.pnpm --filter @objectstack/spec typecheck: exit 0. That coverstsc,check:scripts-typecheck(the pin is in that program) andcheck:test-typecheck. All ran throughos-verify-lockwith--maxWorkers=2on a shared box.check:generated: 15 of 15 up to date, against thedistbuilt at this head.check:migration-registry: exit 0.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandswas reconciled with--ran: 84 derived, 80 exit 0, 4 NOT MEASURED, 0 unrun. The 4 exited 3 withPREREQUISITE NOT METbecause they need the whole-repo build closure, which CI builds:check:doc-formula-expressions,check:dual-build-cjs-loads,check:lean-entry-closureandcheck:type-check-debt.b4f0179259, caughtcheck:comment-mask-adoptionred. It flagged the pin's own line-comment regex as a private comment stripper. Commit0000e4ab13fixed it: the pin now reads the list and the definitions throughscripts/js-comment-mask.mjs'smaskComments.ALL_CONVERSIONS(metadata-core,metadata-protocol,metadata,service-automation). The public surface is byte-unchanged, and the values are proven identical above.pnpm lintis CI-owned.@objectstack/specpatch. I measured this rather than assumed it. After the build,inApplicationOrderappears in 6 files underdist/andMAJOR_18_CONVERSIONSin 8, with the positive controlview-list-tabs-removedalso present. So the published bundle moves, while no value it exports does.How a retirement adds its conversion once this lands
MAJOR_18_CONVERSIONS. If it sorts last, define it after every other conversion, directly aboveOrderedConversion.{ conversion: IDENT, order: N }, whereIDENTsorts. Never add it at the end.Nis one more than the highest present. A conversion that must apply before an existing one takes a number between its neighbours' (for example23.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
.claude/skills/spec-property-retirement/SKILL.md's registration checklist tells a retirement author to add aMetadataConversionin this file. For major 18 that now means the entry-and-placement rule above. [finding]spec-property-retirementSKILL.md tells a step-18 author to append toconversionIdsand extend the rationale string: both change shape when PR #20572 lands #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.FileRepoharness fromstep18-rationale-merge.test.tsrather than extracting a shared helper. That keeps the refactor(spec): step 18 rationale as key-sorted fragments, conversionIds derived, so two retirements merge clean #20572 pin untouched.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