fix(pm): os-regen-merge.sh partitions step-1 conflicts by generatedness — a NOT_DRIVER_MANAGED path is resolved by regeneration, not by hand (#18047) - #18089
Conversation
The step-1 conflict report partitioned the conflicted set against the `merge=os-regen` routing list alone, so `non_regen_conflicts` was the set difference and nothing asked whether a path was generated. A generated path that is deliberately NOT routed — `NOT_DRIVER_MANAGED` in `scripts/regen-artifacts.mjs` — therefore drew the "NON-generated files, resolve those by hand" message together with its "do not resolve generated files textually" line, two sentences about one file with nothing saying which governs. The conflicted set is now partitioned by generatedness first and routing second, into three classes: unrouted and undeclared (today's message, unchanged), routed and MIXED (today's message, unchanged), and declared in `NOT_DRIVER_MANAGED`, which gets a new per-path reading. Class 3 is not one instruction, and the ledger is what shows it: "resolve by regeneration" is right for three of its thirty tracked entries and wrong for the rest, whose own entries say a merge must never recompute them. The discriminator is the generated-region marker pair in the conflicted file, not the entry's `gen` field, which is an accounting field carried by every ratchet in the list. For a marked file the report also answers the caveat instead of delegating it: it reads both sides out of the index, strips the generated regions from each, and says whether the remainders differ — the PR #17835 shape, where taking a side dropped two hand-authored regions silently with every gate green. Claude-Session: https://claude.ai/code/session_01DAcomhvR9kKizeYgg89Vo8 Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewHead: ① derived judgments (seat-measured on the fetched head, ⛔ not taken from the report):
② semver: unchanged — PM tooling, nothing published. ③ boundary flags: none binding. Adjacent, not touched: #8360 ( Implemented-by: Verdict: PASS — ready + auto-merge SQUASH by this seat. Generated by Claude Code |
Fixes #18047
scripts/pm/os-regen-merge.sh's step-1 conflict report partitioned the conflicted set against themerge=os-regenrouting list alone, so a generated path that is deliberately not routed was labelledNON-generatedand 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)regen-artifacts.mjs0 timesgrep -c 'regen-artifacts' scripts/pm/os-regen-merge.sh⇒0;non_regen_conflictswas the set difference at:368-374NOT_DRIVER_MANAGEDis exported as data and namespackages/spec/src/migrations/registry.tsgit check-attr mergereadsunspecifiedforregistry.ts,os-regenfor the lit controlpackages/spec/src/migrations/registry.ts: merge: unspecified,packages/spec/authorable-surface/system.json: merge: os-regenNOT_DRIVER_MANAGEDdid not appear in the fileorigin/mainafter a sibling fetch advanced it past the sha in the dispatch: base is57343f761, with7ef05f997an 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_MANAGEDdoes nameregistry.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.mdandcontent/docs/ai/skills-reference.mdxare 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 filesNOT_DRIVER_MANAGED“turns away”. One more of that shape (content/docs/permissions/tenant-audit-census.mdx) is reached through a directory entry.test-typecheck-debt.jsonledgers,docs-import-surface.baseline.jsonand their neighbours are shrink-only ratchets whose own entries say a merge must never recompute them.packages/sdui-parser/objectui-lockstep.jsoncannot 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.tsanddocs/audits/**have no generator whatsoever.⛔ The entry's
genfield is not the discriminator either. It is an accounting field — recorded where the generator appears in noREGEN_ARTIFACTSrow — so every ratchet above carries one whiledocs-import-surface.baseline.json, whichgen:docsreally does write, carries none. Keying the message ongenwould 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.mjsspells 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 toNOT_DRIVER_MANAGED; the diff is one file.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/elseverbatim — all 51 existing self-test cases pass untouched.NOT_DRIVER_MANAGED→ new, one reading per path. Real output, from the new fixture:The addendum's caveat is answered, not delegated. Comment
5654282996asked for the check the tooling never makes, and the #18062 transplant5654438150supplied its live cost: on PR #17835registry.tswas resolved take-a-side-and-regenerate, andstep18.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_MANAGEDis read at run time fromscripts/regen-artifacts.mjs— onenode --input-type=module -ecall importing the module throughpathToFileURL— for the same reason.gitattributesis read at run time. ⛔ No hard-coded path list. The regeneration command is built by the module's ownownerRunCommand, never assembled in the shell, so the string this script prints stays the command thepre-commitgate spawns.Three things the reader is deliberate about:
untracked: truerows are dropped. They are gitignored build output git never merges; the day one becomes tracked,git-merge-regen.mjs --self-testrefuses, so this skip hides nothing. Pinned: a tracked file at such a path gets no class-3 reading.git diffper 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 nogit diffat all.The class-3 fixture (P4's gap), and the discriminating reading
--self-testgainsst_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:generated/marked.txtgenerated/regions-only.txt✓no prose at stake (the firing control)ledgers/whole.jsonbuild/ignored.jsonuntrackedsrc/plain.txtTwo 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 declarespackages/spec/src/migrations/registry.ts, still recordsgen:migration-registryfor it, a fabricated path is absent (the control), andgit check-attrstill readsunspecifiedfor it — so routing it or dropping its entry reddens here instead of silently reverting the label.Verification
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.shderived 26 families; all 26 ran, all exited 0, and--ranreconciles: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/orpackage.jsoninvokes it, so there was no shell-escape residue check to derive.bash -nparses clean, as do both mutated copies the self-test builds.pnpm lintis CI's run, not this PR's, and the narrowing is measured rather than asserted: ① the population read from eslint's own config — everyfilesglob ineslint.config.mjsis**/*.{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 returnserrorCount: 0with"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:327records that the repo “never enables type-aware linting (noparserOptions.project, no typed@typescript-eslintrules) 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-checkinpr-automation.ymlcounts added.changeset/*.mdfiles and errors when the count is zero unless the PR carriesskip-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.shships in no package'sfiles[]— it is a PM-loop tool run by hand, invoked by no workflow — so nothing published moves andskip-changesetis the correct declaration.Acceptance notes
Out of scope, noted and ⛔ not filed:
migrations/registry.tsstill text-merges: two ADR-0087 entries with adjacent ids conflict server-side, which is the residue #7297's source sharding could not reach #8360 (open,pm:on-hold) — “migrations/registry.tsstill 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 takesmigrations/registry.tsstill text-merges: two ADR-0087 entries with adjacent ids conflict server-side, which is the residue #7297's source sharding could not reach #8360 lands on a path this PR's class-3 reading already covers; nothing here blocks or pre-empts it. Successor:migrations/registry.tsstill text-merges: two ADR-0087 entries with adjacent ids conflict server-side, which is the residue #7297's source sharding could not reach #8360's implementer.merge=os-regendriver exited 0 on a live merge while DISCARDING main's side ofcontent/docs/references/ui/view.mdx— caught and repaired in-round, but a zero exit from that driver is not evidence the merge happened #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: [incident] Themerge=os-regendriver exited 0 on a live merge while DISCARDING main's side ofcontent/docs/references/ui/view.mdx— caught and repaired in-round, but a zero exit from that driver is not evidence the merge happened #17602's implementer.content/docs/permissions/tenant-audit-census.mdxcarries aBEGIN GENERATED:region and is declared only through thecontent/docs/permissions/**directory entry, so it now reads as class 3 — correctly, since its region is regenerated byscripts/tenant-audit-census.mjsand guarded bycheck-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-②: noGenerated by Claude Code