build: merge driver for generator-owned spec artifacts (#4675) - #4702
Merged
Conversation
`packages/spec`'s checked-in artifacts are sorted arrays and append-only ledgers. Two PRs each adding a few lines is a set union — semantically composable — but git reports a text conflict a human must resolve. Measured over one afternoon (2026-08-02): four merges, nine conflicts across those files, and NOT ONE was a real semantic conflict. Every correct resolution was the same three steps — discard both sides, re-run the generator, re-run the gates — at a dozen-plus minutes each, on a `main` moving fast enough that the rerun could itself go stale. `.gitattributes` now routes those paths to `merge=os-regen`. ## The driver deliberately does not regenerate The obvious implementation — "on conflict, run the generator" — is wrong, and measurably so. Git invokes merge drivers WHILE merging, in index order, and the worktree still holds pre-merge sources at that moment. Verified directly: `packages/spec/spec-changes.json` sorts before `packages/spec/src/...`, and a driver firing there sees a `migrations/registry.ts` with the incoming side's retirements missing. It would write a confidently wrong artifact. That is strictly worse than the conflict it replaces. A conflict marker is a visible error; a plausible generated file is an invisible one — and this repo already has the scar: on #4687 a `gen:api-surface` against an incomplete `dist` silently dropped an unrelated `./studio` export and ratcheted a baseline exemption in to cover the hole. Nothing failed; it was caught by diffing generated files against `main`. So the driver defers: it resolves the path (no markers, exit 0) and records it in `$GIT_DIR/os-regen-pending`. A `pre-commit` hook then refuses the commit until those artifacts check clean — regeneration happens on the fully-merged tree, the only state in which it is correct, and cannot be forgotten. The hook verifies and clears; it never regenerates, because blanket regeneration rewrites artifacts whose staleness nobody saw (the same reason `check:generated` refuses it). A marker cannot get stuck: it clears the moment the artifacts are current. ## The dist trap, made unsurvivable where it writes `check:generated --fix` now REFUSES `gen:api-surface` when `dist` is older than `src`, rather than printing advice a reader can skip. On a stale dist that generator does not fail — it emits a plausible surface missing every export added since the last build. `--fix` is the one path that writes, so it is the one place the trap cannot be survived. The staleness rule is shared with the pre-commit half rather than copied, because the direction two copies drift in is the one that writes a wrong artifact. ## Excluded on purpose Shrink-only ratchets (`docs-import-surface.baseline.json`, `dual-source-exports.baseline.json`), the hand-written migrations/conversions registries, and `variant-docs.json` stay on text merge. Recomputing a shrink-only ratchet can WIDEN it, laundering a new exemption in as merge noise. `NOT_DRIVER_MANAGED` records the reason per path. ## Verification `pnpm check:merge-driver` reconciles `.gitattributes` against the one table in both directions, pins that `.githooks/pre-commit` is mode 100755 in the index (git silently IGNORES a non-executable hook — caught exactly that way here, with two e2e commits sailing past an installed-but-inert hook), and proves the driver end to end against real git. Beyond the self-tests, the whole loop was exercised on real files and real generators: two branches each appending a retirement, then merged. Three conflicts became one (the hand-written registry), both generated files came out marker-free and recorded, committing without regenerating was BLOCKED with the exact commands, and after regenerating the commit went through with the marker self-cleared — with both sides' entries present, which is the set union the text merge could not express. Registration is per clone via `prepare`; an unregistered clone falls back to git's default text merge — pre-#4675 behaviour, not breakage. Closes #4675 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NKcGqCYCCpMkB5UW8jNPXx
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckNo hand-written docs reference the 0 changed package(s). ✅ |
os-zhuang
marked this pull request as ready for review
August 2, 2026 19:41
This was referenced Aug 2, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #4675.
packages/spec's checked-in artifacts are sorted arrays and append-only ledgers. Two PRs each adding a few lines is a set union — semantically composable — but git reports a text conflict a human must resolve. Measured over one afternoon (2026-08-02): four merges, nine conflicts across those files, and not one was a real semantic conflict. Every correct resolution was the same three steps — discard both sides, re-run the generator, re-run the gates — at a dozen-plus minutes each, on amainmoving fast enough that the rerun could itself go stale..gitattributesnow routes those paths tomerge=os-regen.The driver deliberately does not regenerate
This is the design decision in the change, and it inverts what the issue proposed.
The obvious implementation — "on conflict, run the generator" — is wrong, and measurably so. Git invokes merge drivers while it is merging, in index order, and the worktree still holds the pre-merge sources at that moment.
packages/spec/spec-changes.jsonsorts beforepackages/spec/src/…('p' < 'r'), so a driver firing there reads amigrations/registry.tswith the incoming side's retirements missing. Verified directly rather than assumed:It would write a confidently wrong artifact — strictly worse than the conflict it replaces:
This repo already has that scar. On #4687 a
gen:api-surfacerun against an incompletedistsilently dropped an unrelated./studioexport and ratcheted adocs-import-surface.baseline.jsonexemption in to cover the hole. Nothing failed. It was caught only by diffing generated files againstmain.So the driver defers: it resolves the path (no text merge, no markers, exit 0) and records it in
$GIT_DIR/os-regen-pending. Apre-commithook then refuses the commit until those artifacts check clean — so regeneration happens on the fully-merged tree, the only state in which it is correct, and cannot be forgotten.The hook verifies and clears; it never regenerates. Blanket regeneration from a hook would rewrite artifacts whose staleness nobody saw, which is the signal-destroying behaviour
check:generatedalready refuses for the same reason. And a marker cannot get stuck: it clears the moment the artifacts are current, whether you regenerated them or the merge simply did not change them.The dist trap, made unsurvivable where it writes
check:generated --fixnow refusesgen:api-surfacewhendistis older thansrc, instead of printing advice a reader can skip. On a stale dist that generator does not fail — it emits a plausible surface missing every export added since the last build.--fixis the one path that writes, so it is the one place the trap cannot be survived. The staleness rule is shared with the pre-commit half rather than copied: the direction two copies drift in is the one that writes a wrong artifact.Excluded on purpose
Shrink-only ratchets (
docs-import-surface.baseline.json,dual-source-exports.baseline.json), the hand-writtenmigrations/conversionsregistries, andvariant-docs.jsonstay on text merge. Recomputing a shrink-only ratchet can WIDEN it, laundering a new exemption in as merge noise.NOT_DRIVER_MANAGEDrecords the reason per path — the point being that "a generator writes it" is not sufficient grounds to let a merge recompute it.Verification
pnpm check:merge-driver:.gitattributesagainst the one table in both directions (a path in only one place fails);.githooks/pre-commitis mode 100755 in the index — git silently ignores a non-executable hook, and this was caught exactly that way here, with two e2e commits sailing past an installed-but-inert hook;Beyond the self-tests, the whole loop was exercised on real files and real generators — two branches each appending a retirement to
migrations/registry.ts, then merged:spec-changes.json,protocol-upgrade-guide.mdgen:command per artifact;HEADunmovedAlso verified: fresh build →
distIsStale = false; stale src →true(no false positive from branch switching, since git only rewrites files whose content actually differs). The refusal path was confirmed to fire by forcing a stale artifact against a stale dist.Gates:
pnpm typecheck(exit 0),pnpm lintclean,check:generated8/8 current,check:adr-anchorsOK, changeset validators pass.Deployment note
Registration is per clone —
.gitattributesand.githooks/are committed, but git will not let a repository execute code on you just because you cloned it.pnpm installdoes it viaprepare. A clone where that never ran falls back to git's default text merge — verified: pre-#4675 behaviour, not breakage — so committing.gitattributescannot regress anyone, andsetup-git-hooks.mjswarns-and-exits-0 on every failure path rather than breakingpnpm install.Refs: #4675, #4663, #4650, #4687
Generated by Claude Code