fix(devx): partition os-regen-merge.sh's step-1 conflict message by class - #14733
Conversation
…lass
os-regen-merge.sh step 1 called every merge conflict "in NON-generated
files" and told the operator "do not resolve generated files textually" --
correct for a plain non-regen conflict, but exactly backwards for a
`merge=os-regen` path the driver text-merged and left conflicted (a MIXED
row `git-merge-regen.mjs` declined to defer because the deferral would have
silently dropped hand-written prose). Following that line's instruction on
a MIXED conflict -- taking one side whole -- is precisely the outcome the
driver refused to allow.
Partition the conflicted set (`git diff --diff-filter=U`) against the
`merge=os-regen` path list the script already reads at :220, reusing it as
a pathspec the same way step 2 already does. Three messages:
- non-regen only: today's message, unchanged.
- regen-path only (MIXED, driver-declined): "hand-resolve the prose, take
either side of the anchors" -- the "do not resolve generated files
textually" line is suppressed, since it is about deferrable regen paths
and is actively wrong advice for a MIXED one.
- both classes: name each file's class; the suppressed line stays
suppressed since some of the generated conflicts present DO need
hand-resolution.
The merge's own output (including the driver's remedy, e.g. its printed
regeneration command) is now captured and echoed on both the success and
failure paths, so the operator sees the driver's own notice rather than a
generic pointer to "step 4".
Pinned in --self-test: a MIXED-conflict fixture (a real driver that
text-merges and conflicts, not the existing fixture's `true` no-op) for the
regen-only and both-classes cases, whose key assertion is the ABSENCE of
the "do not resolve generated files textually" line -- proven
discriminating by mutating the script back to the old unconditional message
and watching that assertion red.
scripts/git-merge-regen.mjs (the driver) is untouched; it behaved correctly
throughout. No relaxation of the deferral rules.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLJQhde67SeTccsmnBVarV
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 33695084575 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 33696601161 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
…ss — a NOT_DRIVER_MANAGED path is resolved by regeneration, not by hand (objectstack-ai#18047) (objectstack-ai#18089) Fixes objectstack-ai#18047 `scripts/pm/os-regen-merge.sh`'s step-1 conflict report partitioned the conflicted set against the `merge=os-regen` routing list alone, so a generated path that is deliberately **not** routed was labelled `NON-generated` and sent to a hand merge — while the third line of the same message forbids resolving a generated file textually. The operator could satisfy neither sentence, and nothing in the output said which governed. ## Premise readings, taken before writing (worktree at `57343f761`) | # | premise | reading | when | |:--|:--|:--|:--| | P1 | the partition keys solely on routing membership; the script references `regen-artifacts.mjs` 0 times | **holds** — `grep -c 'regen-artifacts' scripts/pm/os-regen-merge.sh` ⇒ `0`; `non_regen_conflicts` was the set difference at `:368-374` | 2026-09-14T00:28Z | | P2 | `NOT_DRIVER_MANAGED` is exported as data and names `packages/spec/src/migrations/registry.ts` | **holds, and is not sufficient** — see “the design call” below | 2026-09-14T00:31Z | | P3 | `git check-attr merge` reads `unspecified` for `registry.ts`, `os-regen` for the lit control | **holds** — `packages/spec/src/migrations/registry.ts: merge: unspecified`, `packages/spec/authorable-surface/system.json: merge: os-regen` | 2026-09-14T00:29:43Z | | P4 | no self-test fixture covers a generated + NOT-routed conflict | **holds** — case 6 builds only driver-routed MIXED rows; the word `NOT_DRIVER_MANAGED` did not appear in the file | 2026-09-14T00:30Z |⚠️ The worktree was cut from `origin/main` after a sibling fetch advanced it past the sha in the dispatch: base is `57343f761`, with `7ef05f997` an ancestor of it (`git merge-base --is-ancestor` ⇒ exit 0). ## The design call triage fenced — no new marker was invented, and P2 needed one more reading Triage left open whether the generated-but-unrouted set is machine-readable or stays prose, and fenced it: *「⚠️ If the implementer finds the three-case partition **cannot** be done without inventing that marker, that is a new surface ⇒ report it rather than inventing one silently.」* `NOT_DRIVER_MANAGED` does name `registry.ts`, so membership is readable as data and the script reads it. But membership alone **cannot carry class 3's message**, and that is a reading of the ledger rather than a judgement call: of its 30 tracked entries, *“resolve by regeneration”* is correct for **three** and wrong for the other 27. - `packages/spec/src/migrations/registry.ts`, `skills/README.md` and `content/docs/ai/skills-reference.mdx` are MIXED — a generator owns the text between a marker pair, a human owns everything outside it. The module's own header names exactly these three as the files `NOT_DRIVER_MANAGED` *“turns away”*. One more of that shape (`content/docs/permissions/tenant-audit-census.mdx`) is reached through a directory entry. - the fifteen `test-typecheck-debt.json` ledgers, `docs-import-surface.baseline.json` and their neighbours are **shrink-only ratchets whose own entries say a merge must never recompute them**. `packages/sdui-parser/objectui-lockstep.json` cannot be regenerated here at all (it needs a sibling checkout); the scaffold templates' generator refuses a file it did not already stamp; `packages/spec/src/conversions/registry.ts` and `docs/audits/**` have no generator whatsoever. ⛔ The entry's `gen` field is **not** the discriminator either. It is an accounting field — recorded where the generator appears in no `REGEN_ARTIFACTS` row — so every ratchet above carries one while `docs-import-surface.baseline.json`, which `gen:docs` really does write, carries none. Keying the message on `gen` would send **seventeen** paths to a regeneration their own ledger entry forbids: this card's defect again, one class over. The discriminator used instead is the **generated-region marker pair in the conflicted file**, which is the property the message actually depends on. ⛔ That is not a new marker: both vocabularies are already written by the tree's own generators, and `scripts/check-role-word.mjs` spells the second one once as a consumer and states the rule — *“a rename happens at the generators and arrives here, not the other way round.”* So no new surface was created and nothing was added to `NOT_DRIVER_MANAGED`; the diff is one file.⚠️ **Reported rather than assumed:** class 3 therefore prints **two** per-path readings, not one. The card's suggested wording is class 3's *marked* shape; the *unmarked* shape gets the opposite instruction. A single blanket “resolve by regeneration” for all 30 entries would have been a new unobeyable instruction of exactly the reported kind. ## The three printed cases Classes 1 and 2 are **byte-for-byte unchanged** and, when no class-3 path is present, the branch they live in is the pre-existing `if/elif/else` verbatim — all 51 existing self-test cases pass untouched. 1. **neither routed nor declared** → today's message, unchanged; and now the only one carrying the blanket “⛔ Do not resolve generated files textually” line, which is true of a set that by construction holds no generated file. 2. **routed and conflicted (MIXED)** → today's message, unchanged (objectstack-ai#14733's fix, and it is correct). 3. **declared in `NOT_DRIVER_MANAGED`** → new, one reading per path. Real output, from the new fixture: ```text ✗ merge stopped on conflicts, and some are in files a generator writes which are deliberately NOT driver-managed — ⛔ neither the non-generated rule nor the MIXED one governs those, so each is named below with its own: ⚠ generated/marked.txt — GENERATED IN MARKED REGIONS, deliberately NOT driver-managed. ⛔ Do NOT hand-merge the generated regions — resolve them by REGENERATION: take either side to reach a committable state, commit the merge (step 3), then run its generator and commit that as its own commit: pnpm gen:fixture-marked ⚠ THE TWO SIDES DIFFER OUTSIDE THE GENERATED REGIONS (4 line(s)). Taking a side DROPS the other side's hand-written text there — silently, and with every gate green: a `check:` on this file proves it equals its generated sources and is no witness for the prose. ⛔ Carry those lines over BEFORE you regenerate. ⚠ generated/regions-only.txt — GENERATED IN MARKED REGIONS, deliberately NOT driver-managed. ⛔ Do NOT hand-merge the generated regions — resolve them by REGENERATION: take either side to reach a committable state, commit the merge (step 3), then run its generator and commit that as its own commit: pnpm gen:fixture-regions ✓ the two sides are identical outside the generated regions, so taking either side drops no hand-written text. ⚠ ledgers/whole.json — generator-touched and deliberately NOT driver-managed. It carries no generated-region markers, so there is no half a regeneration would restore: resolve it BY HAND (semantic merge, both intents stack). ⛔ Do NOT regenerate it as part of this merge — the ledger keeps the driver off it because a mid-merge recompute describes the half-merged tree; its `why` in scripts/regen-artifacts.mjs is the authority on what it may be regenerated from, and when. non-generated (resolve by hand — semantic merge, both intents stack): build/ignored.json src/plain.txt Resolve every class above by ITS OWN rule, then rerun this script to redo the generated-artifact half. ``` **The addendum's caveat is answered, not delegated.** Comment `5654282996` asked for the check the tooling never makes, and the objectstack-ai#18062 transplant `5654438150` supplied its live cost: on PR objectstack-ai#17835 `registry.ts` was resolved take-a-side-and-regenerate, and `step18.conversionIds` / `step18.rationale` — hand-authored regions **outside** the markers — were dropped silently, so a 17→18 hop stopped applying while a 115-family gate sweep stayed green. The script now reads both sides out of the index it already holds (`:2:` ours, `:3:` theirs), strips the generated regions from each, and reports whether the remainders differ, with a line count. The clean case prints its own `✓`, so the finding is falsifiable rather than decorative. ## How the script learns generatedness `NOT_DRIVER_MANAGED` is read **at run time** from `scripts/regen-artifacts.mjs` — one `node --input-type=module -e` call importing the module through `pathToFileURL` — for the same reason `.gitattributes` is read at run time. ⛔ No hard-coded path list. The regeneration command is built by the module's own `ownerRunCommand`, never assembled in the shell, so the string this script prints stays the command the `pre-commit` gate spawns. Three things the reader is deliberate about: - **`untracked: true` rows are dropped.** They are gitignored build output git never merges; the day one becomes tracked, `git-merge-regen.mjs --self-test` refuses, so this skip hides nothing. Pinned: a tracked file at such a path gets no class-3 reading. - **An unreadable ledger is loud.** A silent empty list would restore the defect with the evidence removed, so the run says the ledger could not be read and makes no class-3 claim it cannot support. Pinned in case 9d. - **One `git diff` per ledger row, and ⛔ never with an empty pathspec** — `git diff --diff-filter=U --` with no pathspec matches *everything*, which would promote every conflict into class 3. An empty ledger runs no `git diff` at all. ## The class-3 fixture (P4's gap), and the discriminating reading `--self-test` gains `st_fixture_ndm_conflict`, a synthetic repo whose conflicts are all unrouted and whose ledger declares four of the five. It carries every reading class 3 has to make in one run, including both halves of the card's own dark-control warning — *an unlisted path reads exactly like a non-generated one from the routing side alone*: | fixture path | declared? | markers? | expected class | |:--|:--|:--|:--| | `generated/marked.txt` | yes | yes, sides differ **outside** them | 3 — regenerate + the carry-over finding | | `generated/regions-only.txt` | yes | yes, sides differ only **inside** | 3 — regenerate, `✓` no prose at stake (the firing control) | | `ledgers/whole.json` | yes | no | 3 — ⛔ do **not** regenerate in this merge | | `build/ignored.json` | yes, `untracked` | no | 1 — the row is dropped | | `src/plain.txt` | ⛔ **no** | no | 1 — today's message, unchanged | Two mutations keep the new cases falsifiable, in the style cases 6b and 8b already use. **9b** empties the ledger read and the whole set collapses back into class 1: `GENERATED IN MARKED REGIONS` ⇒ 0, `conflicts in NON-generated files` ⇒ 1, `Do not resolve generated files textually` ⇒ 1 — the reported defect, reproduced on demand. **9c** pins the real ledger rather than the fixture that models it: the module still declares `packages/spec/src/migrations/registry.ts`, still records `gen:migration-registry` for it, a fabricated path is absent (the control), and `git check-attr` still reads `unspecified` for it — so routing it or dropping its entry reddens here instead of silently reverting the label. ## Verification ```text ✓ os-regen-merge self-test: all cases pass. ``` 79 cases, at `1f8f50dd9`: the 51 that existed before, unchanged and unweakened (no case deleted, no expectation loosened), plus 28 new ones. Before the change the same file ran 51/51. `node scripts/pm/dispatch-gates.mjs --commands scripts/pm/os-regen-merge.sh` derived 26 families; all 26 ran, all exited 0, and `--ran` reconciles: ```text Run reconciliation — 26 derived, 26 run, 0 NOT-MEASURED, 0 UNRUN. ✓ dispatch-gates --ran: 26 derived famil(ies) accounted for — 26 run, 0 NOT-MEASURED ``` Named in that set and worth quoting: `pnpm check:bash32-floor`, `pnpm check:nul-bytes`, `pnpm check:parse-guard`, `pnpm check:entry-guard`, `pnpm check:pnpm-filter-targets`, `node scripts/check-self-test-wired.mjs`, `node scripts/check-scripts-symbol-anchors.mjs` — each exit 0. ⛔ **shellcheck is not a family here**: it is installed nowhere in this container and nothing in `.github/workflows/` or `package.json` invokes it, so there was no shell-escape residue check to derive. `bash -n` parses clean, as do both mutated copies the self-test builds. `pnpm lint` is CI's run, not this PR's, and the narrowing is measured rather than asserted: ① the population read from eslint's own config — every `files` glob in `eslint.config.mjs` is `**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` or a narrower JS/TS subset, and no shell extension appears in any of them; ② the file count from `--format json` — this PR's one changed path returns `errorCount: 0` with `"File ignored because no matching configuration was supplied."`, i.e. it contributes **zero** files to the linted population; ③ invariance for untouched files — `eslint.config.mjs:327` records that the repo *“never enables type-aware linting (no `parserOptions.project`, no typed `@typescript-eslint` rules) for ANY file”*, so no file this diff does not touch can change verdict. ## Changeset ⛔ None owed, and the mechanism is the label rather than a path rule: `changeset-check` in `pr-automation.yml` counts **added** `.changeset/*.md` files and errors when the count is zero unless the PR carries `skip-changeset` (or is the changesets release PR). There is no path-based exemption in the gate, so the label is the declaration. `scripts/pm/os-regen-merge.sh` ships in no package's `files[]` — it is a PM-loop tool run by hand, invoked by no workflow — so nothing published moves and `skip-changeset` is the correct declaration. ## Acceptance notes Out of scope, noted and ⛔ not filed: - **objectstack-ai#8360** (open, `pm:on-hold`) — *“`migrations/registry.ts` still text-merges”*. Adjacent and named by triage as possibly making this moot: that card is about the file conflicting **at all**, this one about what the script says when it does. Whoever takes objectstack-ai#8360 lands on a path this PR's class-3 reading already covers; nothing here blocks or pre-empts it. Successor: objectstack-ai#8360's implementer. - **objectstack-ai#17602** (open, p1) — the driver exiting 0 while discarding one side. Same family (*“a zero from this tooling is not evidence”*), different file (`scripts/git-merge-regen.mjs`). Untouched here. Successor: objectstack-ai#17602's implementer. - `content/docs/permissions/tenant-audit-census.mdx` carries a `BEGIN GENERATED:` region and is declared only through the `content/docs/permissions/**` directory entry, so it now reads as class 3 — correctly, since its region is regenerated by `scripts/tenant-audit-census.mjs` and guarded by `check-tenant-audit-census.mjs`. Noted because it is the one class-3 path the card does not name. Successor: none; no change is owed. `Clause-②: no` --- _Generated by [Claude Code](https://claude.ai/code/session_01DAcomhvR9kKizeYgg89Vo8)_ Co-authored-by: Claude <noreply@anthropic.com>
Fixes #14671
os-regen-merge.shstep 1 called everygit mergeconflict "in NON-generatedfiles" and told the operator "do not resolve generated files textually" —
correct for an ordinary conflict, but exactly backwards for a
merge=os-regenpath the driver text-merged and left conflicted (a MIXED row
git-merge-regen.mjsdeclined to defer because deferring would silently drophand-written prose). Following that line's instruction on a MIXED conflict —
take one side whole — is precisely the outcome the driver refused to allow.
scripts/git-merge-regen.mjs(the driver) is untouched — it behavedcorrectly throughout; the defect was entirely in the wrapper's summary of it.
No relaxation of the deferral rules, no "generated always takes ours"
shortcut.
The fix
Partition the conflicted set (
git diff --name-only --diff-filter=U) againstthe
merge=os-regenpath list the script already reads at:220, reusing itas a
git diffpathspec the same way step 2 already does forbranch_edited/main_edited. No new inputs, no new config.Any conflict on a regen path is necessarily one the driver declined to defer
(a non-
mixedrow always resolves with exit 0, no markers), so a regen-pathconflict always means "hand-resolve the prose," never "take one side" — the
opposite of what a wholly-generated deferred path calls for.
Before (every conflict, regardless of class):
After — non-regen conflicts only (unchanged):
After — regen-path conflicts only (MIXED, driver-declined; the
suppressed-line case):
The "⛔ Do not resolve generated files textually" line is gone here — it is
correct advice for a deferrable regen path and actively wrong, silently
destructive advice for a MIXED one. The driver's own notice (including the
regeneration command it prints, e.g.
pnpm gen:system-context-census) iscaptured from the merge's output and echoed above this message rather than
scrolling past unread — so the operator is pointed at the driver's own
remedy, not a generic "step 4".
After — both classes present:
Each file is named under its own class; the suppressed line stays suppressed
here too, since some of the generated conflicts present in this run do need
hand-resolution.
Self-test
Added
st_fixture_regen_conflict— a fixture whose merge driver is a smallscript that runs a real
git merge-file(not the existing fixture'strueno-op) and, on a genuine 3-way conflict, prints a driver-shaped remedy(mirroring
git-merge-regen.mjs's actual "NOT deferred… take either side andthen run:
pnpm gen:fixture-mixed" notice) before exiting non-zero — the samemove the real driver makes for a MIXED row it cannot losslessly defer.
Three new case blocks:
say "NON-generated files", and — the assertion that matters — the
absence of "Do not resolve generated files textually"; also asserts the
driver's own notice and its regeneration command are still shown.
vacuous: a literal (non-regex,
perl -0777 \Q..\E) replacement of theregen-branch message back to the old unconditional one, run against the
same fixture, and the suppressed line reappears in that run's output —
i.e. the pin reds on the original bug.
src/prose.txt) alongsidethe regen-path MIXED conflict (
gen/mixed.txt); asserts both files arenamed under their own class and the suppressed line stays absent.
(38 cases total pass, including all pre-existing ones — none were changed.)
Scope
Only
scripts/pm/os-regen-merge.shchanged.scripts/git-merge-regen.mjs(the merge driver) is untouched — confirmed by the diff above, and by design:
it classified the MIXED file, declined to defer, explained why, and printed
the right remedy throughout. The defect was entirely in the wrapper's summary
of a correctly-behaving driver.
Gates
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands scripts/pm/os-regen-merge.shderived 14 families; all 14 ran(under
scripts/pm/os-verify-lock.sh) and passed —node scripts/pm/dispatch-gates.mjs --ran ran.listconfirms14 derived, 14 run, 0 NOT-MEASURED, 0 UNRUN(check-test-completeness.mjsis aPREREQUISITE-NOT-MET/NOT-MEASURED by its own design when run without a saved
turbo run testlog — not a red, per its own printed text — and CI suppliesthat log).
bash scripts/pm/os-regen-merge.sh --self-test(the always-runslint.yml step) passes directly.
skip-changesetapplies: this PR touches onlyscripts/pm/**.🤖 Generated with Claude Code
https://claude.ai/code/session_01WLJQhde67SeTccsmnBVarV
Generated by Claude Code