From 04660e8e8d295c52809228b9086eb94af83ec71e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 19:05:53 +0000 Subject: [PATCH] build: merge driver for generator-owned spec artifacts (#4675) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 Claude-Session: https://claude.ai/code/session_01NKcGqCYCCpMkB5UW8jNPXx --- .changeset/spec-generated-merge-driver.md | 17 ++ .gitattributes | 37 +++ .githooks/pre-commit | 11 + AGENTS.md | 36 +++ package.json | 2 + packages/spec/scripts/check-generated.ts | 23 ++ scripts/check-regen-pending.mjs | 166 ++++++++++++++ scripts/git-merge-regen.mjs | 264 ++++++++++++++++++++++ scripts/regen-artifacts.mjs | 97 ++++++++ scripts/setup-git-hooks.mjs | 88 ++++++++ 10 files changed, 741 insertions(+) create mode 100644 .changeset/spec-generated-merge-driver.md create mode 100644 .gitattributes create mode 100755 .githooks/pre-commit create mode 100755 scripts/check-regen-pending.mjs create mode 100755 scripts/git-merge-regen.mjs create mode 100644 scripts/regen-artifacts.mjs create mode 100755 scripts/setup-git-hooks.mjs diff --git a/.changeset/spec-generated-merge-driver.md b/.changeset/spec-generated-merge-driver.md new file mode 100644 index 0000000000..15ebf7d221 --- /dev/null +++ b/.changeset/spec-generated-merge-driver.md @@ -0,0 +1,17 @@ +--- +"@objectstack/spec": patch +--- + +build: 为生成物加 `merge=os-regen` 合并驱动,把「集合运算被打成文本冲突」的返工消掉 (#4675) + +`packages/spec` 的生成物是排序数组与追加式登记表。两个 PR 各增删几行,语义上是集合并与集合差、完全可组合,git 却按三路文本合并报成需要人工解决的冲突 —— 2026-08-02 一个下午实测四次合并、九处冲突,**没有一次是真正的语义冲突**,每次的正确解法都是「丢掉两边、重新生成、重跑门禁」。 + +`.gitattributes` 现在把这些路径交给 `scripts/git-merge-regen.mjs`。 + +**驱动不做重算。** git 是在合并**过程中**按索引顺序调用 merge driver 的,那一刻工作区里还是合并前的源码:`packages/spec/spec-changes.json` 排在 `packages/spec/src/...` 之前,所以在驱动里跑生成器会读到缺了对方那半边改动的 `migrations/registry.ts`,写出一个自信而错误的产物 —— 比它取代的那个冲突更糟,因为冲突标记是可见的错误,而看起来合理的生成文件不是。改为**推迟**:驱动解析路径(不做文本合并、不留标记)并记入 `$GIT_DIR/os-regen-pending`,`pre-commit` 在产物重新生成之前拒绝提交。重算因此发生在合并后的完整树上 —— 唯一正确的时刻。 + +`check:generated --fix` 现在在 `dist` 比 `src` 旧时**拒绝**运行 `gen:api-surface`,而不再只是警告。陈旧 dist 下该生成器不会失败,它会写出一份缺失了上次构建以来所有新导出的、看似合理的 surface,并让 `gen:docs` 顺手为这个缺口棘轮一条基线豁免(#4687 实际发生过,只靠与 `main` 对比生成物才发现)。`--fix` 是唯一会**写入**的路径,所以是这个陷阱唯一不可幸存的地方。 + +只减不增的棘轮(`docs-import-surface.baseline.json`、`dual-source-exports.baseline.json`)与手写登记表刻意排除在外:重算一个只减不增的棘轮可能**放宽**它,等于把一条新豁免当作合并噪音洗进来。这些冲突仍然留给人看,逐条理由见 `scripts/regen-artifacts.mjs` 的 `NOT_DRIVER_MANAGED`。 + +驱动按 clone 注册(`pnpm install` 经 `prepare` 完成)。没注册的 clone 回退到 git 默认文本合并 —— 即 #4675 之前的行为,不是故障。`pnpm check:merge-driver` 双向核对 `.gitattributes` 与该表,并对真实 git 做端到端验证。 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000..60fb8aa457 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,37 @@ +# Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +# +# Merge semantics for generator-owned artifacts (#4675). +# +# These files are sorted arrays and append-only ledgers derived from source. When +# two PRs each add or drop a few lines the result is a set union — fully +# composable — but a three-way TEXT merge reports it as a conflict a human must +# resolve by hand. The correct resolution is always the same: discard both sides +# and re-run the generator. `authorable-surface.json` alone is a 8k-line sorted +# array, so any two PRs landing near each other collide. +# +# `merge=os-regen` hands those paths to `scripts/git-merge-regen.mjs`, which does +# NOT text-merge them. See that file for why it also does not regenerate them +# in place (git runs merge drivers BEFORE the sources are merged, so anything +# computed there describes a half-merged tree). +# +# The driver is registered per clone by `scripts/setup-git-hooks.mjs`, which +# `pnpm install` runs. A clone WITHOUT it registered falls back to git's default +# text merge — i.e. exactly today's behaviour — so committing this file cannot +# regress anyone. +# +# The single source of truth for this list is `scripts/regen-artifacts.mjs`; +# `node scripts/git-merge-regen.mjs --self-test` reconciles the two in both +# directions. Add a path there, not only here. +# +# Deliberately absent: docs-import-surface.baseline.json and +# dual-source-exports.baseline.json (shrink-only ratchets — recomputing can +# WIDEN them), variant-docs.json and the migrations/conversions registries +# (hand-written). Those conflicts are for a human. See NOT_DRIVER_MANAGED. + +packages/spec/spec-changes.json merge=os-regen +packages/spec/authorable-surface.json merge=os-regen +packages/spec/json-schema.manifest.json merge=os-regen +packages/spec/api-surface.json merge=os-regen +packages/spec/api-surface-signatures.json merge=os-regen +docs/protocol-upgrade-guide.md merge=os-regen +content/docs/references/** merge=os-regen diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000000..172d788fe4 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,11 @@ +#!/bin/sh +# Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +# +# Registered by `scripts/setup-git-hooks.mjs` via `core.hooksPath=.githooks`, +# which `pnpm install` runs. Cheap by construction: with no pending marker it +# exits before doing any work, which is every commit that did not just merge a +# generator-owned artifact (#4675). + +if [ -z "$OS_SKIP_REGEN_CHECK" ]; then + node "$(git rev-parse --show-toplevel)/scripts/check-regen-pending.mjs" || exit 1 +fi diff --git a/AGENTS.md b/AGENTS.md index 8595b99e96..73d47f3627 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -228,6 +228,42 @@ Even inside your own worktree, operate defensively: commit itself — that second CI round is where joint breakage surfaces, and the guards in `scripts/check-*.mjs` exist largely because this class of breakage is invisible to `git merge`. +11. **Generated artifacts don't text-merge — a driver defers them and + `pre-commit` collects the debt.** §10's "never trust git's textual merge of a + generated file" is now mechanical (#4675). `.gitattributes` routes the + generator-owned artifacts (`spec-changes.json`, `authorable-surface.json`, + `api-surface*.json`, `json-schema.manifest.json`, + `docs/protocol-upgrade-guide.md`, `content/docs/references/**`) to + `merge=os-regen`, so a merge that used to stop on conflicts across all of + them now stops only on the hand-written files that actually need you. + + The driver does **not** regenerate. Git runs merge drivers *while* it merges, + in index order, so the worktree still holds pre-merge sources — a generator + run there would describe a half-merged tree and write a confidently wrong + artifact, which is strictly worse than the conflict it replaced. Instead it + records each path in `$GIT_DIR/os-regen-pending`, and `pre-commit` refuses the + commit until those artifacts check clean. So the sequence after a merge is + unchanged from §9 — rebuild, then `check:generated --fix` — you just cannot + forget it. + + Two things worth knowing: + - **Registration is per clone.** `pnpm install` does it (`prepare` → + `scripts/setup-git-hooks.mjs`). A clone where that never ran falls back to + git's default text merge — pre-#4675 behaviour, not breakage — so nothing + depends on every machine being set up. + - **The ratchets are deliberately excluded** + (`docs-import-surface.baseline.json`, `dual-source-exports.baseline.json`, + the hand-written `migrations`/`conversions` registries, `variant-docs.json`). + Recomputing a shrink-only ratchet can *widen* it, which would launder a new + exemption in as merge noise. Those conflicts are yours to read. See + `NOT_DRIVER_MANAGED` in `scripts/regen-artifacts.mjs` for why, per path. + + Related: `check:generated --fix` now **refuses** to run `gen:api-surface` on a + stale `dist` rather than warning about it (§9's trap, made unsurvivable on the + one path that writes). + + `pnpm check:merge-driver` reconciles `.gitattributes` against that table in + both directions and proves the driver end to end against real git. --- diff --git a/package.json b/package.json index 253069c3c1..89d84d49ab 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,8 @@ "typecheck": "turbo run typecheck", "clean": "turbo run clean && rm -rf dist", "setup": "pnpm install && pnpm --filter @objectstack/spec build", + "prepare": "node scripts/setup-git-hooks.mjs", + "check:merge-driver": "node scripts/git-merge-regen.mjs --self-test && node scripts/check-regen-pending.mjs --self-test", "version": "changeset version && node scripts/sync-protocol-version.mjs && node scripts/sync-template-versions.mjs", "release": "pnpm run build && bash scripts/build-console.sh && bash scripts/release-publish.sh", "docs:dev": "pnpm --filter @objectstack/docs dev", diff --git a/packages/spec/scripts/check-generated.ts b/packages/spec/scripts/check-generated.ts index 50c2565faa..2c89b2a1eb 100644 --- a/packages/spec/scripts/check-generated.ts +++ b/packages/spec/scripts/check-generated.ts @@ -30,6 +30,11 @@ import { readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; +// One staleness rule, shared with the merge driver's pre-commit half (#4675) — +// two copies of "is dist older than src" would drift, and the direction they +// drift in is the one that writes a wrong artifact. +import { distIsStale } from '../../../scripts/check-regen-pending.mjs'; + const pkgRoot = join(dirname(fileURLToPath(import.meta.url)), '..'); /** @@ -232,6 +237,24 @@ if (!fix) { console.log(`\n--fix: regenerating the ${stale.length} stale artifact(s). Review the diff before committing.\n`); let failed = 0; for (const s of stale) { + // The `readsDist` warning above is advice a reader can ignore; here it must + // become a refusal. `gen:api-surface` on a stale dist does not fail — it + // writes a plausible surface with every export added since the last build + // missing, and `gen:docs` then ratchets a baseline exemption in to cover the + // hole. That landed unnoticed on #4687 and was caught only by diffing the + // generated files against `main`. --fix is the one path that WRITES, so it is + // the one place the trap is unsurvivable: a visible conflict is recoverable, + // a confidently wrong artifact is not (#4675). + if (s.readsDist && distIsStale()) { + failed++; + console.log(` ✗ ${s.gen} — REFUSED`); + console.error( + ` packages/spec/dist is missing or older than packages/spec/src.\n` + + ` Regenerating now would write a surface describing a build that no longer exists.\n` + + ` pnpm --filter @objectstack/spec build && pnpm --filter @objectstack/spec ${s.gen}`, + ); + continue; + } const { ok, output } = run(s.gen); console.log(` ${ok ? '✓' : '✗'} ${s.gen}`); if (!ok) { diff --git a/scripts/check-regen-pending.mjs b/scripts/check-regen-pending.mjs new file mode 100755 index 0000000000..3a28c2f6dd --- /dev/null +++ b/scripts/check-regen-pending.mjs @@ -0,0 +1,166 @@ +#!/usr/bin/env node +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The other half of the `merge=os-regen` driver (#4675): make the deferred + * regeneration **mandatory** instead of remembered. + * + * The driver resolves generator-owned artifacts without text-merging them and + * records each one in `$GIT_DIR/os-regen-pending`. It cannot regenerate them + * itself — git runs merge drivers before the sources are merged, so anything + * computed there describes a half-merged tree (see `git-merge-regen.mjs`). This + * runs from `pre-commit`, where the merged tree finally exists, and refuses the + * commit while any pending artifact is still stale. + * + * It **verifies, then clears** — it does not regenerate. Blanket regeneration + * from a hook would rewrite artifacts whose staleness nobody saw, which is the + * signal-destroying behaviour `check:generated` already refuses for the same + * reason. And a marker cannot get stuck: the moment the artifacts check clean, + * whether you regenerated them or the merge simply did not change them, the + * marker is removed and the commit proceeds. + * + * ## The dist trap + * + * `gen:api-surface` reads the BUILT `dist/*.d.ts`. On a stale dist it does not + * fail — it emits a plausible surface missing every export added since the last + * build. So for `readsDist` artifacts this refuses to even run the gate unless + * the build is newer than the sources, because a phantom "breaking removal" has + * cost real triage time before (#4687, and the trap is recorded in AGENTS.md). + * + * Usage: + * node scripts/check-regen-pending.mjs # pre-commit + * node scripts/check-regen-pending.mjs --self-test # no repo state touched + */ + +import { execFileSync, execSync } from 'node:child_process'; +import { existsSync, readFileSync, readdirSync, rmSync, statSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { PENDING_MARKER, entryForPath } from './regen-artifacts.mjs'; + +const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const SPEC_DIR = join(REPO_ROOT, 'packages/spec'); + +/** Newest mtime under `dir` for files matching `pred`, or 0 when there are none. */ +function newestMtime(dir, pred, depth = 0) { + if (depth > 12 || !existsSync(dir)) return 0; + let newest = 0; + for (const e of readdirSync(dir, { withFileTypes: true })) { + if (e.name === 'node_modules' || e.name.startsWith('.')) continue; + const p = join(dir, e.name); + if (e.isDirectory()) newest = Math.max(newest, newestMtime(p, pred, depth + 1)); + else if (pred(e.name)) newest = Math.max(newest, statSync(p).mtimeMs); + } + return newest; +} + +/** + * Is `packages/spec/dist` older than the sources it claims to describe? Missing + * counts as stale. Deliberately conservative: a false "stale" costs a build, a + * false "fresh" costs a silently wrong artifact. + */ +export function distIsStale(specDir = SPEC_DIR) { + const dist = newestMtime(join(specDir, 'dist'), (n) => n.endsWith('.d.ts')); + if (!dist) return true; + return newestMtime(join(specDir, 'src'), (n) => n.endsWith('.ts')) > dist; +} + +function markerPath() { + const gitDir = execFileSync('git', ['rev-parse', '--absolute-git-dir'], { encoding: 'utf8' }).trim(); + return join(gitDir, PENDING_MARKER); +} + +function readPending(marker) { + if (!existsSync(marker)) return []; + return [...new Set(readFileSync(marker, 'utf8').split('\n').map((l) => l.trim()).filter(Boolean))]; +} + +function runCheck(script) { + try { + execSync(`pnpm -s ${script}`, { cwd: SPEC_DIR, stdio: ['ignore', 'pipe', 'pipe'] }); + return { ok: true, output: '' }; + } catch (err) { + return { ok: false, output: `${err?.stdout?.toString() ?? ''}${err?.stderr?.toString() ?? ''}`.trim() }; + } +} + +function main() { + const marker = markerPath(); + const pending = readPending(marker); + if (!pending.length) return 0; + + const entries = pending.map((p) => ({ path: p, entry: entryForPath(p) })).filter((x) => x.entry); + const unknown = pending.filter((p) => !entryForPath(p)); + + console.error( + `\nos-regen: ${pending.length} generated artifact(s) were merged WITHOUT a text merge and must be ` + + `regenerated from the merged tree before this commit.\n`, + ); + + // Group by gate: `gen:schema` owns two artifacts, so running it twice is waste. + const byCheck = new Map(); + for (const { path, entry } of entries) { + const g = byCheck.get(entry.check) ?? { entry, paths: [] }; + g.paths.push(path); + byCheck.set(entry.check, g); + } + + let blocked = 0; + for (const [check, { entry, paths }] of byCheck) { + if (entry.readsDist && distIsStale()) { + blocked++; + console.error( + ` ✗ ${paths.join(', ')}\n` + + ` ${check} reads packages/spec/dist, which is older than src — NOT running it.\n` + + ` On a stale dist this gate reports phantom removals and the generator WRITES them.\n` + + ` pnpm --filter @objectstack/spec build && pnpm --filter @objectstack/spec ${entry.gen}`, + ); + continue; + } + const { ok, output } = runCheck(check); + if (ok) { + console.error(` ✓ ${paths.join(', ')} — current`); + continue; + } + blocked++; + const detail = output.split('\n').filter(Boolean).slice(0, 3).map((l) => ` ${l}`).join('\n'); + console.error(` ✗ ${paths.join(', ')} — stale\n${detail ? `${detail}\n` : ''}` + + ` pnpm --filter @objectstack/spec ${entry.gen}`); + } + + for (const p of unknown) { + blocked++; + console.error(` ✗ ${p} — recorded as pending but absent from scripts/regen-artifacts.mjs (cannot verify)`); + } + + if (blocked) { + console.error( + `\nRegenerate the ${blocked} stale artifact(s) above, \`git add\` them, and commit again.\n` + + ' This check clears itself the moment they are current — nothing to reset by hand.\n' + + ' Bypass with --no-verify only if you intend CI to catch it: every one of these has a\n' + + ' required gate on the PR.\n', + ); + return 1; + } + + rmSync(marker, { force: true }); + console.error('os-regen: all deferred artifacts are current — marker cleared.\n'); + return 0; +} + +// `check:generated --fix` imports `distIsStale` from here, so nothing may run on +// import — only when this file IS the entry point. +const invokedDirectly = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url); + +if (invokedDirectly) { + if (process.argv.includes('--self-test')) { + // Touches no repo state: the interesting logic is the staleness rule, and its + // dangerous direction is "says fresh when stale". + const ok = distIsStale(join(REPO_ROOT, 'scripts')) === true; + console.log(`${ok ? '✓' : '✗'} a directory with no dist/ reads as STALE (conservative default)`); + console.log(ok ? '\n✓ check-regen-pending self-test passed.' : '\n✗ self-test failed.'); + process.exit(ok ? 0 : 1); + } + process.exit(main()); +} diff --git a/scripts/git-merge-regen.mjs b/scripts/git-merge-regen.mjs new file mode 100755 index 0000000000..11e6d03b19 --- /dev/null +++ b/scripts/git-merge-regen.mjs @@ -0,0 +1,264 @@ +#!/usr/bin/env node +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `merge=os-regen` — the merge driver for generator-owned artifacts (#4675). + * + * ## The problem + * + * `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 sees a text conflict. Measured on one afternoon + * (2026-08-02): four merges, nine conflicts across those files, and NOT ONE of + * them was a real semantic conflict. Every correct resolution was the same + * three steps: throw away both sides, re-run the generator, re-run the gates — + * a dozen-plus minutes each, on a `main` moving fast enough that the rerun could + * itself go stale. + * + * ## Why this driver does not regenerate + * + * 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.json` sorts before `packages/spec/src/...` + * (`'p' < 'r'`), so a driver that shelled out to `gen:spec-changes` would read a + * `src/migrations/registry.ts` with the incoming side's retirements MISSING and + * write a confidently wrong artifact. Verified directly: + * + * driver invoked for packages/spec/spec-changes.json + * worktree src/registry.ts at that moment: + * + * That is worse than the conflict it replaces. A conflict marker is a visible + * error; a plausible-looking generated file is an invisible one, and this repo + * already has the scar: on #4687 a `gen:api-surface` run 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`. + * + * ## What it does instead + * + * Defer. The driver resolves the path (no text merge, no markers, exit 0) and + * records it in `$GIT_DIR/os-regen-pending`. Regeneration happens later, from + * the fully-merged tree — the only state in which it is correct — and the + * `pre-commit` hook refuses the commit until it has. So the rework disappears + * without the artifact's currency ever resting on someone remembering. + * + * The content left behind is OURS, chosen only because git pre-fills it there. + * It is a placeholder, not an answer: correctness comes from the mandatory + * regeneration, and three independent things enforce it — the pending marker, + * the `pre-commit` hook, and the `check:*` gates that already run on every PR. + * + * ## Usage + * + * node scripts/git-merge-regen.mjs %O %A %B %P # invoked by git, never by hand + * node scripts/git-merge-regen.mjs --self-test # reconcile + end-to-end merge proof + */ + +import { execFileSync } from 'node:child_process'; +import { appendFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { NOT_DRIVER_MANAGED, PENDING_MARKER, REGEN_ARTIFACTS, entryForPath } from './regen-artifacts.mjs'; + +const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); + +function gitDir(cwd = process.cwd()) { + // In a linked worktree this resolves to `.git/worktrees/`, which is what + // we want: the marker is per-worktree, so parallel agents never see each + // other's pending regenerations. + return execFileSync('git', ['rev-parse', '--absolute-git-dir'], { cwd, encoding: 'utf8' }).trim(); +} + +/** Record `path` as needing regeneration. Idempotent — a path is listed once. */ +function markPending(path, cwd = process.cwd()) { + const marker = join(gitDir(cwd), PENDING_MARKER); + const existing = existsSync(marker) ? readFileSync(marker, 'utf8').split('\n').filter(Boolean) : []; + if (existing.includes(path)) return marker; + appendFileSync(marker, `${path}\n`); + return marker; +} + +function drive(argv) { + // %O %A %B %P — ancestor, ours (also the OUTPUT file), theirs, pathname. + const path = argv[3]; + if (!path) { + console.error('git-merge-regen: no %P pathname — check the `merge.os-regen.driver` config.'); + return 1; + } + + const entry = entryForPath(path); + if (!entry) { + // `.gitattributes` routed a path here that the table does not own. Refusing + // is the only safe answer: resolving it would silently keep one side of a + // file nobody proved is regenerable. + console.error( + `git-merge-regen: ${path} is mapped to merge=os-regen but is absent from scripts/regen-artifacts.mjs.\n` + + ' Leaving it CONFLICTED rather than guessing. Run `node scripts/git-merge-regen.mjs --self-test`.', + ); + return 1; + } + + markPending(path); + + const dist = entry.readsDist + ? '\n (this one is built from dist/*.d.ts — the regeneration will refuse on a stale build)' + : ''; + console.error( + ` ⟳ ${path}\n` + + ` not text-merged — it is generated. Regenerate from the merged tree:\n` + + ` pnpm --filter @objectstack/spec ${entry.gen}${dist}\n` + + ` The pre-commit hook will not let this commit through until you do.`, + ); + return 0; +} + +/* ------------------------------------------------------------------ self-test */ + +function fail(msg) { + console.error(`✗ ${msg}`); + process.exitCode = 1; + return false; +} + +/** `.gitattributes` and the table must name the same paths — in both directions. */ +function reconcileAttributes() { + const file = join(REPO_ROOT, '.gitattributes'); + if (!existsSync(file)) return fail('.gitattributes is missing — the driver is mapped to nothing.'); + const mapped = readFileSync(file, 'utf8') + .split('\n') + .filter((l) => l.trim() && !l.trim().startsWith('#')) + .filter((l) => /\bmerge=os-regen\b/.test(l)) + .map((l) => l.trim().split(/\s+/)[0]); + + const declared = REGEN_ARTIFACTS.map((e) => e.path); + const missing = declared.filter((p) => !mapped.includes(p)); + const extra = mapped.filter((p) => !declared.includes(p)); + let ok = true; + if (missing.length) { + ok = fail(`declared in regen-artifacts.mjs but not mapped in .gitattributes: ${missing.join(', ')}\n` + + ' Those paths still text-merge — the table says otherwise.'); + } + if (extra.length) { + ok = fail(`mapped to merge=os-regen but not declared in regen-artifacts.mjs: ${extra.join(', ')}\n` + + ' The driver refuses unknown paths, so those merges would CONFLICT with no explanation.'); + } + // A path cannot be both driver-managed and deliberately excluded. + const overlap = NOT_DRIVER_MANAGED.map((e) => e.path).filter((p) => declared.includes(p)); + if (overlap.length) ok = fail(`listed as BOTH driver-managed and NOT_DRIVER_MANAGED: ${overlap.join(', ')}`); + if (ok) console.log(`✓ .gitattributes ↔ regen-artifacts.mjs agree on ${declared.length} path(s)`); + return ok; +} + +/** Every `gen:`/`check:` the table names must still exist, or the driver's advice is a dead command. */ +function reconcileScripts() { + const pkg = join(REPO_ROOT, 'packages/spec/package.json'); + if (!existsSync(pkg)) return fail('packages/spec/package.json not found'); + const scripts = JSON.parse(readFileSync(pkg, 'utf8')).scripts ?? {}; + const dead = []; + for (const e of REGEN_ARTIFACTS) { + if (!scripts[e.gen]) dead.push(`${e.path} → ${e.gen}`); + if (!scripts[e.check]) dead.push(`${e.path} → ${e.check}`); + } + if (dead.length) { + return fail(`script(s) named by the table no longer exist in @objectstack/spec:\n ${dead.join('\n ')}`); + } + console.log(`✓ all ${REGEN_ARTIFACTS.length * 2} gen:/check: names resolve in @objectstack/spec`); + return true; +} + +/** + * The hook must be executable **in the index**, not just on this disk. Git + * silently ignores a non-executable hook — it prints an `advice.ignoredHook` + * hint and proceeds, so the deferred regeneration stops being mandatory and + * nothing fails. Caught exactly that way while testing this change: both e2e + * commits went through with the hook installed and inert. + */ +function hookIsExecutable() { + try { + const mode = execFileSync('git', ['ls-files', '-s', '.githooks/pre-commit'], { + cwd: REPO_ROOT, + encoding: 'utf8', + }).trim().split(/\s+/)[0]; + if (mode !== '100755') { + return fail(`.githooks/pre-commit is mode ${mode || ''} in the index, not 100755.\n` + + ' Git IGNORES a non-executable hook — the pre-commit half is disarmed and says nothing.\n' + + ' Fix: git update-index --chmod=+x .githooks/pre-commit'); + } + console.log('✓ .githooks/pre-commit is executable in the index (100755)'); + return true; + } catch (err) { + return fail(`could not stat .githooks/pre-commit: ${err?.message ?? err}`); + } +} + +/** + * Prove the driver end to end against real git: a conflicting change on both + * sides of a mapped path must come out resolved, marker-free, and recorded. + * Asserting the behaviour rather than the wiring is the point — the wiring + * (`%P` order, git-dir resolution in a worktree) is exactly what silently rots. + */ +function endToEnd() { + const dir = mkdtempSync(join(tmpdir(), 'os-regen-selftest-')); + const git = (...args) => execFileSync('git', args, { cwd: dir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); + try { + git('init', '-q', '--initial-branch=main', '.'); + git('config', 'user.email', 'selftest@objectstack.ai'); + git('config', 'user.name', 'self-test'); + git('config', 'merge.os-regen.name', 'regenerate instead of text-merging'); + git('config', 'merge.os-regen.driver', `node ${join(REPO_ROOT, 'scripts/git-merge-regen.mjs')} %O %A %B %P`); + + const target = REGEN_ARTIFACTS[0].path; + mkdirSync(join(dir, dirname(target)), { recursive: true }); + writeFileSync(join(dir, '.gitattributes'), `${target} merge=os-regen\n`); + writeFileSync(join(dir, target), '["base"]\n'); + git('add', '-A'); + git('commit', '-qm', 'base'); + + git('checkout', '-qb', 'incoming'); + writeFileSync(join(dir, target), '["base","theirs"]\n'); + git('commit', '-qam', 'theirs'); + + git('checkout', '-q', 'main'); + writeFileSync(join(dir, target), '["base","ours"]\n'); + git('commit', '-qam', 'ours'); + + git('merge', 'incoming', '-m', 'merge'); + + const merged = readFileSync(join(dir, target), 'utf8'); + if (/^<{7}|^={7}$|^>{7}/m.test(merged)) return fail(`self-test: conflict markers survived in ${target}`); + if (git('status', '--porcelain').match(/^(UU|AA)/m)) return fail('self-test: path left conflicted after merge'); + + const marker = join(gitDir(dir), PENDING_MARKER); + if (!existsSync(marker)) return fail(`self-test: no pending marker written at ${marker}`); + if (!readFileSync(marker, 'utf8').includes(target)) return fail(`self-test: ${target} absent from the pending marker`); + + console.log(`✓ end-to-end: conflicting ${target} merged without markers and recorded as pending`); + return true; + } catch (err) { + return fail(`self-test: ${err?.stderr?.toString() || err?.message || err}`); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +if (process.argv.includes('--self-test')) { + console.log('git-merge-regen --self-test\n'); + const results = [reconcileAttributes(), reconcileScripts(), hookIsExecutable(), endToEnd()]; + console.log( + results.every(Boolean) + ? `\n✓ merge driver wiring is consistent (${NOT_DRIVER_MANAGED.length} path(s) deliberately excluded).` + : '\n✗ merge driver wiring is inconsistent — see above.', + ); +} else { + try { + process.exit(drive(process.argv.slice(2))); + } catch (err) { + // Non-zero leaves the path conflicted with OURS in it and no markers. That + // is a degraded state a human must look at — which is the right outcome for + // "the driver itself broke", and never a silently-wrong artifact. + console.error(`git-merge-regen: ${err?.message ?? err}\n Leaving the path conflicted.`); + process.exit(1); + } +} diff --git a/scripts/regen-artifacts.mjs b/scripts/regen-artifacts.mjs new file mode 100644 index 0000000000..f275cb619b --- /dev/null +++ b/scripts/regen-artifacts.mjs @@ -0,0 +1,97 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The one table of **generator-owned artifacts** — the files whose merge + * semantics are "recompute from the merged sources", not "three-way text merge" + * (#4675). + * + * Three consumers read this and nothing else: `.gitattributes` (which paths get + * `merge=os-regen`), the merge driver (`git-merge-regen.mjs`), and the + * `pre-commit` hook that makes the deferred regeneration mandatory. They must + * agree exactly, so `git-merge-regen.mjs --self-test` reconciles `.gitattributes` + * against this array in BOTH directions rather than trusting them to stay in + * step — the same reason `check:generated` reconciles its own ledger against + * `package.json` on every run. + */ + +/** + * Artifacts the driver takes over. `check` proves currency, `gen` restores it. + * Both names are verified against `packages/spec/package.json` by `--self-test`, + * so a renamed script fails loudly here instead of silently disarming a path. + */ +export const REGEN_ARTIFACTS = Object.freeze([ + { path: 'packages/spec/spec-changes.json', gen: 'gen:spec-changes', check: 'check:spec-changes' }, + { path: 'docs/protocol-upgrade-guide.md', gen: 'gen:upgrade-guide', check: 'check:upgrade-guide' }, + { path: 'packages/spec/authorable-surface.json', gen: 'gen:schema', check: 'check:authorable-surface' }, + { path: 'packages/spec/json-schema.manifest.json', gen: 'gen:schema', check: 'check:authorable-surface' }, + // `gen:api-surface` reads the BUILT `dist/*.d.ts`, never the source. On a + // stale dist it does not fail — it emits a *plausible* surface missing every + // export added since the last build, and `gen:docs` will ratchet a baseline + // exemption in to cover the hole it leaves. That happened on #4687 and was + // caught only by diffing the generated files against `main`. Hence + // `readsDist`: every path that would regenerate these refuses unless the + // build is newer than the sources it claims to describe. + { path: 'packages/spec/api-surface.json', gen: 'gen:api-surface', check: 'check:api-surface', readsDist: true }, + { + path: 'packages/spec/api-surface-signatures.json', + gen: 'gen:api-surface', + check: 'check:api-surface', + readsDist: true, + }, + { path: 'content/docs/references/**', gen: 'gen:docs', check: 'check:docs' }, +]); + +/** + * Tracked files that LOOK generator-owned and deliberately are not. Recorded + * rather than omitted: the dangerous mistake here is adding a path to + * `.gitattributes` because a generator writes it, without asking whether + * recomputing it can *lose* a decision a human made. + */ +export const NOT_DRIVER_MANAGED = Object.freeze([ + { + path: 'packages/spec/docs-import-surface.baseline.json', + why: + 'a SHRINK-ONLY ratchet. `gen:docs` writes it, but regenerating it can WIDEN it — ' + + 'a fresh gap gets a fresh exemption line, which is precisely how "a ratchet quietly stops ' + + 'ratcheting" (its own words). Recomputing that during a merge would launder a new ' + + 'exemption in as merge noise. A text conflict here deserves a human.', + }, + { + path: 'packages/spec/dual-source-exports.baseline.json', + why: + 'hand-ratcheted under review by design — check:generated already records that a `gen:` ' + + 'which rewrites it would admit a new dual-source via "run the fix command" instead of via ' + + 'a maintainer decision (#4446).', + }, + { + path: 'packages/spec/variant-docs.json', + why: 'hand-maintained map of the schema variants; `check:variant-docs` audits it against the code, no generator.', + }, + { + path: 'packages/spec/src/migrations/registry.ts', + why: + 'hand-written source. Conflicts here are two retirements appended at the same spot — ' + + 'keeping both is usually right, but "usually" is a human judgement, not a merge rule.', + }, + { + path: 'packages/spec/src/conversions/registry.ts', + why: 'hand-written source, same as the migrations registry.', + }, + { + path: 'docs/audits/**', + why: 'hand-written audit ledgers. `check:strictness-ledger` audits one against the code; there is no generator.', + }, +]); + +/** Marker file (inside the git dir, never the worktree) listing paths the driver deferred. */ +export const PENDING_MARKER = 'os-regen-pending'; + +/** The git config key pair that registers the driver in a clone. */ +export const DRIVER_NAME = 'os-regen'; + +/** Resolve the entry that owns a path, or undefined. Handles the one `**` entry. */ +export function entryForPath(p) { + return REGEN_ARTIFACTS.find((e) => + e.path.endsWith('/**') ? p.startsWith(e.path.slice(0, -2)) : e.path === p, + ); +} diff --git a/scripts/setup-git-hooks.mjs b/scripts/setup-git-hooks.mjs new file mode 100755 index 0000000000..c0be38bbc3 --- /dev/null +++ b/scripts/setup-git-hooks.mjs @@ -0,0 +1,88 @@ +#!/usr/bin/env node +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Register the repo's git integration in this clone (#4675). + * + * `.gitattributes` and `.githooks/` are committed, but neither takes effect on + * its own: a merge driver must be named in `.git/config`, and hooks must be + * pointed at by `core.hooksPath`. Both are per-clone by design — git will not + * let a repository execute code on you just because you cloned it. So something + * has to opt in locally, and `pnpm install` (`prepare`) is the one step every + * contributor and agent already runs. + * + * Failing is not an option this script takes. An unregistered driver falls back + * to git's default text merge — exactly the behaviour before #4675 — so a clone + * where this cannot run is no worse off than it was. It therefore warns and + * exits 0 on every failure path rather than breaking `pnpm install` (bare + * checkouts, tarball extracts, containers without git, CI images that install + * with `--ignore-scripts`). + * + * Idempotent: it writes only values that differ, so repeated installs are silent. + * + * Usage: + * node scripts/setup-git-hooks.mjs # `prepare` + * node scripts/setup-git-hooks.mjs --self-test # verify THIS clone is wired + */ + +import { execFileSync } from 'node:child_process'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { DRIVER_NAME } from './regen-artifacts.mjs'; + +const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); + +const SETTINGS = [ + { key: `merge.${DRIVER_NAME}.name`, value: 'regenerate generator-owned artifacts instead of text-merging' }, + // %O %A %B %P — ancestor, ours (the output file), theirs, pathname. `node` and + // a repo-relative path keep this working on Windows and in linked worktrees, + // where a bare `./scripts/...` would resolve against the wrong root. + { key: `merge.${DRIVER_NAME}.driver`, value: `node "${join(REPO_ROOT, 'scripts/git-merge-regen.mjs')}" %O %A %B %P` }, + { key: 'core.hooksPath', value: '.githooks' }, +]; + +function git(args, opts = {}) { + return execFileSync('git', args, { cwd: REPO_ROOT, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], ...opts }); +} + +function read(key) { + try { + return git(['config', '--local', '--get', key]).trim(); + } catch { + return ''; + } +} + +function main() { + try { + git(['rev-parse', '--is-inside-work-tree']); + } catch { + return; // Not a git worktree — nothing to register, nothing to warn about. + } + + // A linked worktree shares `.git/config` with its main checkout, so this + // registers once and covers every worktree an agent creates. + const changed = []; + for (const { key, value } of SETTINGS) { + if (read(key) === value) continue; + git(['config', '--local', key, value]); + changed.push(key); + } + if (changed.length) console.log(`git integration registered (${changed.join(', ')})`); +} + +if (process.argv.includes('--self-test')) { + const wrong = SETTINGS.filter(({ key, value }) => read(key) !== value); + for (const { key, value } of wrong) console.error(`✗ ${key} is "${read(key) || ''}", expected "${value}"`); + if (!wrong.length) console.log(`✓ this clone has all ${SETTINGS.length} git settings registered`); + else console.error('\n Run `node scripts/setup-git-hooks.mjs` (or `pnpm install`) to register them.'); + process.exit(wrong.length ? 1 : 0); +} + +try { + main(); +} catch (err) { + // Warn, never fail: see the header. A clone without this is pre-#4675, not broken. + console.warn(`git integration not registered (${err?.message ?? err}) — merges fall back to text merge.`); +}