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 |
…nner reads, and pins the attribution-failure path (objectstack-ai#18055) (objectstack-ai#18093) Fixes objectstack-ai#18055 `scripts/pm/check-governed-merges.mjs` `main()` read `rearm.hint` out of a scope that never bound `rearm`, and it did so inside the **argument** of the `console.error` that prints the sweep's `sweep INCOMPLETE` banner. An argument that throws means the call never happens: the `ReferenceError` deleted the one warning saying the printed list must not read as clean, and the `return EXIT_INCOMPLETE` behind it. ## What changed (one file, `scripts/pm/check-governed-merges.mjs`) 1. **The binding, in the scope that reads it.** `main()` now builds the re-arm plan at the point the banner is built, from the same three inputs `rearmProxyOrNull` builds its own from (`process.env`, `process.execArgv`, `process.allowedNodeEnvironmentFlags.has(PROXY_FLAG)`), and the banner reads `.hint` / `.reason` from that binding. One binding; nothing widened. 2. **The banner owns a function**, `reportSweepIncomplete(state, emit = console.error)` — the same words, the same order, the same stream, the same `EXIT_INCOMPLETE`. It is extracted for exactly one reason: `main()` reads git and the network and cannot be driven down the attribution-failure path offline, and a banner that dies while being built is pinnable only by something that builds it. 3. **A battery of its own**, 11 cases, declared in `SELF_TEST_BATTERIES` with its floor, so a case that stops registering names itself instead of going quiet. ## Premise readings — measured before writing, worktree at base `d438b3a9a` | premise | reading | when (UTC) | |:--|:--|:--| | **P1** — line 3087 reads `attributionFailed && rearm.hint` in `main()` and no `rearm` exists in that scope | **HOLDS.** `git grep -n rearm` over the file returns hits only at `:2273`-`:2281` (the `proxyRearmPlan` return objects), `:2821`-`:2838` (`rearmProxyOrNull`'s own `const rearm`), `:3087`-`:3088` (the banner) and `:3951`-`:3958` (self-test, all calling `proxyRearmPlan` directly). Nothing binds `rearm` in `main()`. | 2026-09-14T01:13Z | | **P2** — the expression sits inside the `console.error(...)` argument, so a throw loses the banner AND the `return EXIT_INCOMPLETE` after it | **HOLDS.** Read at `:3084`-`:3093`: the ternary is the last operand of the string concatenation passed as the sole argument, and `return EXIT_INCOMPLETE;` is the next statement. | 2026-09-14T01:13Z | | **P3** — no existing self-test case drives `main()` down the `attributionFailed` path | **HOLDS, with one refinement worth recording.** Probe on a scratch copy (never the worktree file; copy removed by an `EXIT` trap, verified absent): replacing only `rearm.hint` with an unconditionally throwing IIFE — so the throw fires exactly when `attributionFailed` is true — left the battery **green: 317 assertions, exit 0**. Refinement: replacing the **whole** condition `attributionFailed && rearm.hint` with a throwing IIFE reds one pre-existing case, `a-mixed-sweep-attributes-the-dropped-repo-in-the-INCOMPLETE-footer-itself`, which drives `main()` down the INCOMPLETE path for an **unaudited repo**, never for attribution. So the banner was reached by the battery; the `attributionFailed` branch of it was not. | 2026-09-14T01:14Z (whole condition) and 01:17Z (hint only) | Baseline for both probes: `node scripts/pm/check-governed-merges.mjs --self-test` on the unmodified tree, `✓ ... 317 assertions`, exit 0, empty stderr (2026-09-14T01:17Z). ## The battery, and the falsifiability pair `node scripts/pm/check-governed-merges.mjs --self-test` → `✓ check-governed-merges --self-test: 328 assertions`, exit 0, empty stderr (2026-09-14T01:21Z). 317 before, 328 after: the 11 new cases, battery only grows, nothing deleted or weakened. **Ablation** (one-shot, on the committed fix, restore proven by state): delete the `const rearm = proxyRearmPlan({...})` binding `main()` now carries. On-disk proof, not an editor exit code — `git hash-object` before `eab2fbd5ec0010fe84584f8acd61d2022dc7e2cf` (equal to the `HEAD` blob), after `c455a42e43f704f1bd504a34cccdc1f235eca9ff`; the file still parses (`node --check` clean), as it did before the fix, because the defect is a runtime `ReferenceError`. Ablated run: **exit 1, 4 failures, by name** — ``` ✗ check-governed-merges --self-test — 4 failure(s) • a-mixed-sweep-attributes-the-dropped-repo-in-the-INCOMPLETE-footer-itself • a-sweep-whose-every-attribution-channel-fails-exits-EXIT_INCOMPLETE-not-a-crash • ⭐ and-the-INCOMPLETE-banner-REACHES-STDERR-the-one-thing-the-crash-deleted • and-nothing-on-either-stream-is-a-ReferenceError ``` with the card's own stack reproduced inside the failure detail: `ReferenceError: rearm is not defined at main (.../check-governed-merges.mjs:3128:73)`.⚠️ Direction stated as measured, not as predicted: the ablation is **broader** than the original defect. Removing the binding makes the reference throw on **every** INCOMPLETE path (the plan is now passed as a call argument), where the shipped defect threw only when `attributionFailed` was true — which is why the pre-existing unaudited-path case reds here too and did not red on `main`. The three new end-to-end cases are the ones that answer for this card. The six offline cases stay green under the ablation by construction: they call the banner function directly and do not go through `main()`, which is exactly why the end-to-end case exists. **Restored** (`git checkout HEAD -- scripts/pm/check-governed-merges.mjs`): worktree blob back to `eab2fbd5ec0010fe84584f8acd61d2022dc7e2cf`, `git status --porcelain` empty, `git diff HEAD` empty, and the battery green again at 328 assertions, exit 0 (2026-09-14T01:23Z). No ablation artefact is left in the tree. ## The live invocation the card measured — a reading, not the pin `node scripts/pm/check-governed-merges.mjs --since 2026-09-13T13:00:00Z`, stdout and stderr captured to separate files by redirect, exit captured on the command itself (2026-09-14T01:23Z): | string | stdout | stderr | |:--|--:|--:| | `attribution unavailable` | 0 | 0 | | `merged_by names an ACCOUNT` | 1 | 0 | | `PR #` | 3 | 0 | | `sweep INCOMPLETE` | 0 | **1** | | `ReferenceError` | 0 | 0 | `LIVE_EXIT=2` — `EXIT_INCOMPLETE`. ⛔ **The `attributionFailed` path is NOT taken in this container**, so this run does not discriminate the fix from the defect: the re-exec worked (`ℹ️ re-exec with --use-env-proxy ...`), both lookups resolved through the env token, and the card's positive control `attribution unavailable` reads **0/0** here where the card measured **1/0**. The INCOMPLETE this run reports is the other kind — 4 governed repos unaudited (one stale mirror, three with no checkout in this container). What the run does show is that the banner now reaches stderr and the exit is 2 on a path that reaches the same statement. **The pin is the self-test case**, which drives the attribution-failure path deliberately: a fixture governed merge in the window and `GITHUB_API_URL` pointed at a port nothing listens on, so every channel fails at the transport, offline, in milliseconds. ## Gates `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` from the worktree derived **34 commands**; every one was run, each exit captured by redirect-then-capture, each recorded as `command :: exit N`, all **exit 0**. Reconciliation: ``` Run reconciliation — 34 derived, 34 run, 0 NOT-MEASURED, 0 UNRUN. ✓ dispatch-gates --ran: 34 derived famil(ies) accounted for — 34 run, 0 NOT-MEASURED (a DERIVED zero — all 34 recorded an exit code and none of them is 3). ``` Included in that 34 and green: `pnpm check:pm-governed-merges` (this script's own self-test), `pnpm check:pm-dispatch-gates`, `pnpm check:nul-bytes`, `pnpm check:entry-guard`, `pnpm check:parse-guard`, `node scripts/check-self-test-wired.mjs`, `node scripts/check-scripts-symbol-anchors.mjs`, `node scripts/check-declaration-mirrors.mjs`. The tool's own accounting names what stays outside that total (50 artifact-roster families, 11 declared-wide families, 14 pending-changeset families, 1 path-scheduled CI job and the always-runs tail); those are CI's run, not a local claim. Beyond the derived set, the repo-wide `grep -naP` control-character scan over the edited file returned nothing and `pnpm check:nul-bytes` passed over 8634 tracked text files. ## Changeset No changeset; this PR takes **`skip-changeset`**. What the check reads: the changeset gate is `pr-automation.yml`'s `changeset-check` job, and it has **no path exemption** — its only two exemptions are the `skip-changeset` label (read live from the PR, not from the frozen event payload) and the changesets release branch. The diff is one file under `scripts/pm/`, repo tooling that sits outside every published package directory and therefore outside every package's `files[]`; nothing published moves. Same declaration shape as PR objectstack-ai#18089. ## Acceptance notes - **Extraction scope.** `reportSweepIncomplete` is module-local, not exported: the self-test lives in the same module, and the exported surface (which `scripts/pm/ci-failure.mjs` imports) is unchanged. - **The banner text is untouched**, deliberately, including its `objectstack-ai#4690` citation and the `NODE_OPTIONS` prescription. The only textual additions are the new battery name in `SELF_TEST_BATTERIES`, its floor (11), and one clause appended to the self-test's verdict enumeration so that line stays an honest inventory. - **`rearm.hint` is still read strictly** (no `?.`): a defensive optional-chain would hide the next missing binding instead of failing, and the new end-to-end case is what catches it by name. - Noted, not filed: the live sweep reports this worktree's own checkout as `[stale-mirror]` because `origin/main` moved while this card was in flight. That is the audit working as designed, not a defect. Carrier: none — it is a property of the container, not of the tree. `Clause-②: no` — no contract accept/reject behaviour changes and no public surface widens; the edit is repo tooling. --- _Generated by [Claude Code](https://claude.ai/code/session_01DAcomhvR9kKizeYgg89Vo8)_ Co-authored-by: Claude <noreply@anthropic.com>
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