Skip to content

fix(merge-policy): read a conflicting merge into the tip, wait on a conflict in a check's file; doctor checks git (#1077) - #1090

Merged
jayden1711 merged 9 commits into
mainfrom
issue-1077-merge-policy-followups
Oct 26, 2026
Merged

jayden1711 merged 9 commits into
mainfrom
issue-1077-merge-policy-followups

Conversation

@jayden1711

@jayden1711 jayden1711 commented Oct 25, 2026 •

Copy link
Copy Markdown
Owner

What and why

Part of #1077 (the clear items; skipped ones listed below). Follow-ups from the reviews of #1073.

  • Conflict residual, fail closed. On main, when git merge-tree --write-tree (tip, head) conflicts, the second reading adds nothing and the PR goes to the conflict paths. If GitHub's own merge completes where git conflicts and the combined check doesn't run, the PR lands unread from the tip. Now:
    • the tree merge-tree still writes for a conflict is measured from the tip like a clean one, its conflicted files left out (round 3: their markers gave false reasons);
    • a conflict on where a file the hold reads strictly goes or whether it stays (test, lint, guard, allowlist or table file, or a file with a mark at the tip; any merge-tree conflict type but CONFLICT (contents)) waits with a new reason: conflicts with the default branch's current tip on where a test, lint, guard, allowlist or table file goes or whether it stays, so what merging it makes there can't be read. A conflict on a file's lines holds nothing, as on main (round 3);
    • a conflict only in app code is still left to the conflict paths, so p1-6-unbounded-loops (a conflict in src/price.js) is unchanged.
  • mergedTree reads the conflicted paths (-z --name-only). It refuses output with no tree id, a conflict with no path, or an exit other than 0 or 1. On main, any exit 1 counted as a conflict.
  • Over-limit or failed second reading (R19c): the base→head reasons are now kept beside the bound's or "could not run" reason. Before, that one reason replaced them. A single input's output is unchanged.
  • Git version setup check (R19d): doctor gets a git line that fails below 2.40 and names the version needed. merge-tree --write-tree (2.38) is used for every PR, and check-attr --source (2.40) only for a PR that changes a .gitattributes. So below 2.38 every PR waits, and on 2.38 or 2.39 only a PR that changes a .gitattributes waits (reworded in round 2).
  • .gitattributes merge=union probe (R19c/R19f): added as a test. Git merges by the attributes of the checkout, not the PR's. With the checkout at the default branch, the union PR conflicts on the test file's lines, which is left to the conflict paths (round 3). With the checkout at the PR head, union keeps both sides and nothing is taken out (measured in the scratch repos).
  • Docs: docs/design.md drops a "not covered" clause merge policy: a PR that merges in a tip mark and reverts the file to base text removes the mark from main (net-zero diff) #1069 closed and describes the tip reading. The merge policy: a PR that merges in a tip mark and reverts the file to base text removes the mark from main (net-zero diff) #1069 fragment's residual sentence now points here. New fragment changelog.d/1077-tip-conflict-read.md.

Critical set: yes (merge policy). Two independent reviews are needed on the same head.

Fail-before / pass-after (measured)

  • test/merge-policy-tip-conflict.test.ts, built against main's src (scratch detached worktree at origin/main d5650da with only this test copied in): 3 pass, 8 fail. The 3 that pass are the app-code control, the D5 pin (a stale conflicting PR shrinking a tip-marked table) and the count pin. The mergedTree tests fail there partly because the return shape changed. Command: node --import ./dist/test/isolate.js --test dist/test/merge-policy-tip-conflict.test.js.
  • The same file on this head: 11/11 pass.
  • test/doctor-git-version.test.ts: 2/2 pass (new feature, not a bug fix).

Mutants (measured; dist patched in place, checksum verified restored)

Suites: merge-policy-tip-conflict, merge-policy-tip-merged-in, merge-policy-tip, merge-policy, policy-only-registration (92 tests).

  • Killed (from Merge policy: follow-ups from #1073's reviews (conflict residual, mutants, git version check) #1077's list):
    • three-place cap across measurements (M8);
    • merge-tree arguments swapped (M14);
    • no guard against an option-like revision (M15);
    • no tree-id check (M16);
    • any non-zero exit taken as a conflict (R5; also the older-git shim test).
  • Killed (new code):
    • conflicts not passed;
    • no conflict hold;
    • every conflict holds;
    • failure replaces own reasons;
    • conflict skips the second reading;
    • conflicted files' marks not read;
    • bound reason lost;
    • exit/path consistency check removed.
  • Not killed, equivalent (inferred from the code):
    • M10 (Math.max without into.first.length): "and N more" shows only when n > first.length. Dropping a term that is never above the final first.length can't change that test or its value.
    • M5 (passing the tip and meet point to the second reading, whose from is already the tip): the tip's marks equal from's, and no tip-only table or lint branch fires. No test can tell them apart.

Tests run (measured; TMPDIR a 0700 directory directly under /var/tmp)

  • After the rebase onto 009ec24:
    • merge-policy*, policy-only-registration, doctor-git-version, change-files*, changelog*, docs-guardrails and p1-6-unbounded-loops: 247/247;
    • npm run typecheck exit 0;
    • check:denylist 1663 files clean;
    • push gate over origin/main..HEAD: 3 commits, 1 tip clean.
  • Before the rebase, 71 files (all repro-state-graph, coordinator, conflicts, light-check*, pr-batch, decision-gates, change-files*, vacuity, merge-policy*, adversarial/*, pr-hygiene-coordinator, failure-modes, estop-merge-gate): 1022 tests, 975 pass, 3 fail, 4 skipped, 40 todo. All 3 failures are sandbox artifacts:
    • merge-vacuity-r10-shutdown (2 tests): fails in the sandbox on main's build too, and passes outside it on both builds;
    • coordinator "dependency cache": the known PIP_CACHE_DIR artifact (R19e).
  • Full npm test on 9acf107 in the builder sandbox (measured): 5877 tests, 5584 pass, 134 fail, 119 skipped, 40 todo.
    • Where the failures are: the receipts signer (about 100), add-project sudoers, home links to /dev, Homebrew node paths, the shutdown-fetch tests above, PIP_CACHE_DIR, the symlinked-node_modules backstop, and init's systemctl queries.
    • None is in a file this PR touches. That they come from the sandbox is inferred, not rerun on main.
  • CI on 9acf107 (measured, gh pr view 1090 --json statusCheckRollup): every check is green or skipped, including all 8 test shards on Node 22 and 24, push-gate, denylist and dco.

The engine's own weakening scan of this diff (measured, weakeningOf from this head's dist)

  • removes or changes a line of a guard or lint: refactors in src/coordinator/change-files.ts (mergedTree, gitVersion) and src/merge-policy-weakening.ts (findings' bound, weakeningOf). No reason is dropped: the mutant and fail-before runs above show each path still holds.
  • adds a line with a skip, only or lint or type-check suppression mark: the eslint-disable-next-line strings in test/merge-policy-tip-conflict.test.ts are fixture text written into a scratch repo (the suppression test), not a real suppression.

Skipped, and why

Inferred / guess

  • Inferred: the coordinator's checkout is the default branch, so merge-tree reads its attributes.
  • Inferred: with a GitHub-reported conflict, the conflict fix still runs first.
  • Guess: whether GitHub's merge honors a PR's merge=union or merge.directoryRenames.
  • New wait (inferred, documented): a stale PR that conflicts with the tip in a check's file now waits for the owner while GitHub still reports it clean, for example one adding a test under a folder the tip renamed. While GitHub's mergeability is unknown it is deferred instead (round 2, below).
  • New wait (measured by R90b, disclosed in round 2): F3, a directory the tip split in two (base lib/a.js lib/b.js, the tip moves them to one/ and two/, the PR adds lib/c.js). merge-tree exits 1 with no conflicted path, so the second reading "could not run" and the PR waits for the owner, where main gives no reason. It fails closed. GitHub probably reports it dirty anyway (guess). Not changed here.

Round 2 (R90a PASS, R90b FAIL at 9acf107)

Head: 3fe6375, which is 9acf107 plus three commits (a fast-forward, not rebased).

  • The runs below were made on the same commits rebased onto origin/main a4b16ac (8a5e5358 → 0e01bd7c).

  • git diff 009ec24e 3fe63751 and git diff a4b16ac7 0e01bd7c are identical, and git merge-tree --write-tree origin/main HEAD exits 0 (measured). That the results carry over to 3fe6375 was inferred, and was wrong against newer main: CI on 3fe6375 merged with fba8946 failed the adversarial control (round 3).

  • "Before" below means 8a5e5358 (9acf107 rebased).

  • F1 (regression vs main): fixed.

    • The bug: while GitHub's mergeability is unknown/null (the first poll after every merge), an ordinary PR conflicting with the tip in a test file got merge.decided auto:false, a needs-owner label, a comment and a review request. Main defers there.
    • The fix, in mergeOrWait (src/coordinator/merge-flow.ts): a decision held only by tip-conflict reasons (or the still-to-come coverage run) is deferred while mergeability is unknown. It uses the same mergeable: poll counter and MERGEABLE_CAP as the auto branch. GitHub's dirty on a later poll sends the PR to the conflict fix, as on main. A clean answer, or none at the cap, still holds it for the owner. A PR with any other hold is decided as before.
    • isTipConflict is exported from src/merge-policy-weakening.ts.
    • Overlap with fix(vacuity): pair moved code git doesn't pair, only ever taking measure away (#1014) #1068: that PR also edits merge-flow.ts, which it owns. This edit is 8 lines in mergeOrWait plus one import, far from fix(vacuity): pair moved code git doesn't pair, only ever taking measure away (#1014) #1068's hunks. git merge-tree --write-tree origin/issue-1014-vacuity-moved-code HEAD exits 0 (measured).
  • F2: fixed. gitTooOld (doctor's message) now says the right thing per version:

    • below 2.38: every PR waits;
    • 2.38 or 2.39: every PR that changes a .gitattributes waits;
    • unreadable version: both cases are named.

    The 2.40 threshold is kept. The changelog, the test header and the doc comment are reworded to match.

  • isNonUtf8Path mutant: killed. New unit test: a conflict in web/x\uFFFD.bin gives tip-conflict, and web/x.bin gives nothing. Measured by deleting isNonUtf8Path(p) || in dist: 11 pass, 1 fail (the new test). The file was restored and checked with cmp.

  • F3: disclosed (above, in the changelog and in docs/design.md), not changed.

Fail-before / pass-after (measured)

Each command was run from the worktree after npm run build, with TMPDIR a 0700 directory under /var/tmp.

  • F1: node --import ./dist/test/isolate.js --test dist/test/merge-tip-conflict-unknown.test.js (new file, 3 tests).
    • Before, with the src/ changes reverted to 8a5e5358: 2 pass, 1 fail. The failing test is the unknown-mergeability one: "no merge decision while GitHub is still computing".
    • The 2 that pass are the controls: the cap still reaches the owner, and GitHub saying clean still holds.
    • After: 3/3 pass.
  • F2: node --import ./dist/test/isolate.js --test dist/test/doctor-git-version.test.js.
    • Before, with 8a5e5358's change-files.ts: 1 pass, 1 fail (gitTooOld: 2.38 and 2.39 said "every PR waits").
    • After: 2/2 pass.
  • Non-UTF-8: dist/test/merge-policy-tip-conflict.test.js gives 12/12 pass. The mutant result is above.

Tests run in round 2 (measured)

  • npm run typecheck: exit 0.
  • npm run check:denylist: 1674 files clean.
  • check-dco.mjs origin/main HEAD: all commits signed off.
  • Targeted run, 32 files: merge-policy*, change-files*, doctor*, all repro-state-graph, coordinator, docs-guardrails*, changelog*, brand*, and the new file. Result: 417 tests, 412 pass, 1 fail, the rest skipped or todo.
    • The one failure is coordinator "dependency cache".
    • It fails outside the sandbox too while the shell exports PIP_CACHE_DIR. It passes outside the sandbox with that and the other *_CACHE_DIR variables unset (measured).
  • Full suite: flock /var/tmp/wl-coord/fulltest.lock nice -n 15 ionice -c3 npm test, run outside the sandbox with the *_CACHE_DIR variables unset. Result: 5913 tests, 5787 pass, 17 fail.
    • 16 of the 17 fail the same way on a scratch build of origin/main a4b16ac (measured, the same names):
      • the sudoers files (machine-sudoers-closed, machine-sudoers-narrow, machine-sudoers-removal);
      • desktop-removed's symlinked-node_modules backstop.
    • The 17th, browser-run "a browser that can't start", hit a 30 s timeout under the full run's load. Run alone on this head it passed 12/12, twice (measured).
    • None is in a file this PR touches.

Round 3 (R90d FAIL, R90c FAIL at 3fe6375)

Head: db43bf9 = 3fe6375 + a merge of origin/main c41f37c (signed off; a fast-forward, since a force push is blocked here) + 2 commits.

  • R90c blocking (CI red, measured): the adversarial control control-default-branch-marked-table-conflicting-pr (from adversarial: tip-table cases for use outside the list, hidden tables and the rule-name floor #1074, on main since c2a8bd8) failed: a comment added to a test file whose table the tip marked conflicts on its lines, and it got the tip-conflict hold plus a false "mark removed or changed" reason. Round 2 tested against a4b16ac, which lacks c2a8bd8.
  • R90d F1 (regression vs main, measured): a word each side adds to one marked table got a second reason from the conflict markers ("line N is at its indent"), so the round-2 wait didn't apply.
  • Fix (one change for both): the coordinator's option 1, plus a narrowing the control requires.
    • The tip reading leaves the conflicted files out (their markers aren't lines either side wrote).
    • mergedTree now parses merge-tree's messages. The tip-conflict hold applies only to a conflict on where a strict file goes or whether it stays: any type but CONFLICT (contents), or a conflicted path no message names (fail closed). A conflict on a file's lines holds nothing in the decision, as on main. Every hold main has is kept: main held nothing on any conflict (inferred from the code; the control passes on main, measured by R90c).
    • Rationale: GitHub merges the same commits, so a content conflict it reports dirty (inferred; whether its merge matches git's on renames or attributes is a guess). The case Merge policy: follow-ups from #1073's reviews (conflict residual, mutants, git version check) #1077 targets, a file added under a folder the tip renamed, is a file location conflict and still holds.
    • F3 resolved, not just disclosed: a conflict only on a directory (the tip split it) names no path; it is read and decided as a clean merge (it was "could not run").
    • This narrows round 1. A content conflict in a test file and a suppression kept inside a content conflict no longer hold. Main didn't hold either, and GitHub's dirty routes both to the conflict fix, whose head is read again.
  • R90d mutants of the wait, all killed (dist patched, restored, checked with cmp):
    • drop every(...): killed by a new test, a tip-conflict hold beside a design hold is decided at once while unknown (5 pass, 1 fail);
    • key without the head: killed by a new test, polls counted under the number alone don't count (5 pass, 1 fail);
    • < to <= at the cap: killed, the cap test now pins after 10 polls (5 pass, 1 fail).
  • New-code mutants, both killed: not leaving the conflicted files out (13 pass, 1 fail); holding content-only conflicts (12 pass, 2 fail).
  • wait.capped is emitted at the cap with GitHub was still computing whether it can merge after N polls, as the auto branch does.
  • Changelog: it now says exactly what was measured. Only a conflict on where a check's file goes holds; a PR held only by that reason waits for dirty; a PR with another hold is decided at once; content conflicts hold nothing.

Fail-before / pass-after (measured)

Commands, from the worktree after npm run build:

  • node --import ./dist/test/isolate.js --test dist/test/merge-tip-conflict-unknown.test.js dist/test/merge-policy-tip-conflict.test.js
  • node --import ./dist/test/isolate.js --test --test-name-pattern=control-default-branch-marked-table-conflicting-pr dist/test/adversarial/adversarial.test.js

Results:

  • Before (src at 87bce2b, i.e. 3fe6375 merged with main; tests from this round): 9 pass, 11 fail.
    • The R90d table test fails with exactly R90d's two reasons (BAD_WORDS: line 4 is at its indent ... and the conflict hold).
    • Most other failures come from the renamed reason text and the wait.capped pin.
    • The adversarial control: 0/1.
  • After (db43bf9's src): 20/20, and the adversarial control 1/1.

Tests run in round 3 (measured; outside the sandbox, *_CACHE_DIR unset)

  • npm run typecheck: exit 0.
  • check:denylist: 1680 files clean.
  • DCO: all commits signed off.
  • Push gate: 3 commits and 1 tip clean.
  • 48 files: merge-policy*, change-files*, doctor*, all repro-state-graph, coordinator, docs-guardrails*, changelog*, brand*, all adversarial, conflicts*, light-check*, and the new file. Result: 796 tests, 782 pass, 0 fail, 4 skipped, 10 todo.
  • The full suite was not rerun this round: the brief allows it once, and it ran in round 2. CI is authoritative.

… a conflict in a check's file (#1077)

A merge into the default branch's tip that git conflicts on got no second
reading, so a PR GitHub merges cleanly where git conflicts (a file added
under a folder the tip renamed) could land unread from the tip when the
combined check didn't run. Now the tree merge-tree still writes for a
conflict is measured from the tip like a clean one, and a conflict in a
test, lint, guard, allowlist or table file waits (fail closed). A conflict
elsewhere is still left to the conflict paths.

mergedTree reads the conflicted paths (-z --name-only) and refuses output
that names no tree, a conflict with no path or an exit other than 0 or 1:
before, any exit 1 counted as a conflict. When the second measurement
can't run or passes its bound, the first one's reasons are kept beside its
reason instead of being replaced.

Tests kill the surviving mutants listed in #1077: the three-place cap
across measurements, merge-tree's argument order, the guard against an
option-like revision, the tree-id check, and an older git that must wait.

Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
…1077)

The merge decision measures a merge with git merge-tree --write-tree (2.38) and reads a tree's attributes with check-attr --source (2.40). With an older git every PR waits with "could not run". doctor now says so up front: its git line fails below 2.40, naming the version needed.

Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
…he git it needs (#1077)

docs/design.md still named entries a PR merges in and takes out as not covered; it now describes the reading from the default branch's tip (#1069) and how a conflict is read. The #1069 changelog fragment's residual sentence pointed at a case #1077 now holds.

Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
@jayden1711

Copy link
Copy Markdown
Owner Author

Independent final check (R90a), posted by the coordinator.

VERDICT: PASS @9acf107447c6dcfc903eb901b171cc978a4dccef (final check)

Reviewer R90a. Detached worktrees: head at <tmp>; current main e80bac6 plus only the two new test files at <tmp>. TMPDIR was under /var/tmp. The head was still 9acf107 at the end (gh pr view --json headRefOid).

Findings

  • Fail-before / pass-after (measured). Command: node --import ./dist/test/isolate.js --test dist/test/merge-policy-tip-conflict.test.js dist/test/doctor-git-version.test.js.
    • On main: the conflict file gives 3 pass and 8 fail. The 3 that pass are the app-code control, the D5 pin and the count pin, as the body says. The core case fails on an assertion: [] comes back where the tip-conflict reason is expected. The doctor file fails to load because gitTooOld doesn't exist on main.
    • On the head: 13/13 pass.
  • No regression versus main (measured). merge-policy*, policy-only-registration, change-files*, doctor*, p1-6-unbounded-loops, docs-guardrails and changelog*: 230/230 on both trees. git merge-tree --write-tree origin/main HEAD exits 0, and nothing main changed since 009ec24 touches these files.
  • Other checks (measured). npm run typecheck exits 0. check:denylist: 1663 files clean. gh pr checks 1090: every check passes or is skipped (8 shards on Node 22 and 24, push-gate, denylist, dco, scope).
  • Body claims hold (inferred from the diff). These are true: the conflict hold covers strict files and tip-marked files only; mergedTree refuses an exit other than 0 or 1, a missing tree id, or an exit/path mismatch; the bound and failure reasons now sit beside the PR's own reasons; doctor fails below 2.40. A GitHub-reported conflict still goes to maybeFixConflict before the merge decision (merge-flow.ts:415-417), and that function never skips a PR that already has a hold.
  • Changelog and docs (measured). A new fragment is present, the merge policy: a PR that merges in a tip mark and reverts the file to base text removes the mark from main (net-zero diff) #1069 fragment is updated, and design.md describes the new tip reading.

Weakening flags

Scan: weak-scan.mjs … 9acf1074 (base 009ec24, tip e80bac6). It raised 2 reasons.

  1. "removes or changes a line of a guard or lint" (31 lines in change-files.ts and merge-policy-weakening.ts): STRENGTHENING. I mutated the head's built code, ran 90 tests (tip-conflict, tip-merged-in, tip, merge-policy), and restored the files (sha256 identical afterwards). Every mutant was killed:

    • conflict hold removed: 3 fail;
    • a failed second reading replaces the PR's own reasons: 1 fail;
    • exit/path consistency check removed: 1 fail;
    • conflicted files' marks not read: 1 fail;
    • conflicts not passed to the second reading: 3 fail;
    • a conflict skips the second reading (main's behaviour): 4 fail.

    The gitVersion to versionOf split keeps the same regex and is equivalent (inferred). Leaving an app-code conflict to the conflict paths is a LIMIT, unchanged from main and pinned by the p1-6 control test. No reason that main gives is dropped.

  2. "adds a skip/only/suppression mark" (test/merge-policy-tip-conflict.test.ts): STRENGTHENING. The eslint-disable-next-line text is fixture content that the test writes into a scratch repo. It is not a real suppression (measured: the matches are the base and head fixture strings in the scan output).

Follow-ups (not FAIL)

  • Wording overstates the hold for git 2.38-2.39 (inferred). gitTooOld's message and the changelog both say "with an older git every PR waits". On git 2.38 or 2.39, only PRs that change a .gitattributes fail closed (check-attr --source is used only in attributeOutcome). This overstates the hold rather than claiming protection that isn't there, and the 2.40 threshold itself is right. macOS's Apple git 2.39 will fail doctor.
  • Possible owner ping while GitHub is still computing (inferred). If mergeable is still unknown, conflictTrigger returns recheck and the decision still runs. A PR that conflicts in a test file then gets the new owner hold once for that head, before the conflict fix runs on the next poll. This is a variant of the "new wait" the body names.

@jayden1711

Copy link
Copy Markdown
Owner Author

Independent adversarial review (R90b), posted by the coordinator.

VERDICT: FAIL @9acf107447c6dcfc903eb901b171cc978a4dccef (adversarial)

Findings

F1 (FAIL, bars c and d: regression versus main, measured). An ordinary PR that conflicts with the tip in a test file or package.json is now handed to the owner while GitHub is still computing whether it can merge. That window is the first poll after every merge, since prPolled.clear() runs after merge.done. watchPr routes only dirty to the conflict fix. For unknown/null it goes on to mergeOrWait, and mergeDecisionFor now gets the tip-conflict reason.

  • Probe: the coordinator.test.ts harness, changeOnMain adding a different test/repro-qty.test.js, then setPr({mergeableState:'unknown'}) and green(t).
  • Head: merge.decided auto:false, reason "conflicts with the default branch's current tip in a test ... (test/repro-qty.test.js)", and a needs-owner label. waitForOwner also posts a comment and requests review. Nothing ever removes the label, and pr_hygiene.hold_labels skips labelled PRs.
  • Main e80bac6: no decision and no label. Both builds start the conflict fix once GitHub says dirty.
  • The PR discloses the new wait only for "while GitHub still reports it clean".
  • Ordinary shapes, measured with weakeningOf on both builds: both sides change the same assertion line, both append a test at the end of a test file, or both bump one dependency in package.json. Head gives [tip-conflict]; main gives [].
  • Suggested fix: when GitHub's mergeability is unknown, defer the decision, as main's mergeable === null branch does. Or put tip-conflict through the combined check's capped conflict wait.

F2 (FAIL, bar a: overclaim). For git 2.38 and 2.39, doctor's message (pinned by doctor-git-version.test.ts) and the changelog say "with an older git every PR waits for the owner". With git version shimmed to 2.39.5, an ordinary PR gives weakeningOf → [] (measured). This is expected: merge-tree --write-tree exists from 2.38, and check-attr --source runs only when a .gitattributes changes (change-files.ts:280). On those versions only PRs that touch .gitattributes wait (inferred). Failing doctor below 2.40 is fine; the sentence needs rewording.

F3 (follow-up, measured). A directory rename split on the tip (base lib/a.js lib/b.js, tip moves them to one/ and two/, PR adds lib/c.js) makes merge-tree exit 1 with no conflicted path. The head then gives "could not run" and waits; main gives []. Fail-closed and unusual, but it contradicts "a conflict only in app code is left to the conflict paths". GitHub likely reports this dirty anyway (guess).

No fail-open found (measured). Each of these holds with tip-conflict: modify/delete of a test, rename/rename of a test, add/add and directory-rename location conflicts in tests, and the package.json table. The app-code control stays [].

Mutants:

  • Dropping the tip-mark clause: killed (test 6).
  • Dropping the marked check: killed (tests 2, 3 and 5).
  • Not adding conflicts to markable: killed.
  • Dropping isNonUtf8Path(p) || from the conflict hold: survives (follow-up).

What I ran (measured)

  • Head dist: merge-policy-tip-conflict, doctor-git-version, merge-policy-tip-merged-in and merge-policy-tip: 35/35 pass.
  • npm run typecheck: exit 0.
  • Fail-before: the new test against main's src gives 3 pass, 8 fail, matching the PR body.
  • gh pr checks 1090: 19 pass, 7 skipping.
  • The head hasn't moved.
  • git 2.43.0 throughout. Probe scripts are in <tmp>; the coordinator probe file was deleted afterwards.

Weakening flags

weak-scan.mjs on 9acf107 (base 009ec24, tip e80bac6) gives 2 reasons:

  1. "removes or changes a line of a guard or lint" (31 lines):
    • change-files.ts gitVersion/versionOf: a refactor with no change in behaviour. STRENGTHENING: adds gitTooOld.
    • mergedTree: refuses output with no tree id, a conflict with no path, or an exit other than 0 or 1. STRENGTHENING.
    • merge-policy-weakening.ts, findings' bound: it now keeps the others' reasons. STRENGTHENING.
    • weakeningOf: reads conflicting merges, adds the conflict hold and keeps its own reasons when the second reading fails. STRENGTHENING.
    • Conflicts only in app code are still left to the conflict paths. LIMIT: unchanged from main, and named.
    • I found no case where main holds and the head passes (inferred from the code), so no RELAXATION.
  2. "adds a ... suppression mark": the eslint-disable text sits inside string literals in the merge-policy-tip-conflict.test.ts fixtures, which test that the hold fires. STRENGTHENING, not a real suppression.

…y is unknown (#1077)

While GitHub is still computing whether a PR can merge (the first poll after
every merge), a PR held only by the tip-conflict reason was handed to the owner
(needs-owner label, comment, review request) before GitHub could say dirty and
route it to the conflict fix, as main does. It now waits, up to MERGEABLE_CAP
polls; a clean answer, or none at the cap, still holds it for the owner.
Also discloses the directory-rename split that merge-tree reports as a
conflict with no path.

Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
…tributes waits (#1077)

Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
…#1077)

Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
@jayden1711

Copy link
Copy Markdown
Owner Author

Independent adversarial review (R90d), posted by the coordinator.

VERDICT: FAIL @3fe63751caf448dc947285c5624bbaf9cc2c620a (adversarial)

Findings

F1 (FAIL, bar d: regression versus main, measured). R90b's F1 is still there for a conflict inside a registered table. Shape: src/words.js holds // check-table: never-shrink and BAD_WORDS = ['alpha']. The PR adds 'pr-word' and main adds 'main-word' on the same line. That is two ordinary additions to one list.

  • Reading the conflicted tree from the tip adds a second reason: can't read a change ... (src/words.js: "BAD_WORDS: line 4 is at its indent but ..."). A conflict marker at column 0 sits inside the table.
  • So d.reasons.every(isTipConflict ...) is false, and the new wait doesn't apply.
  • Head, on the unknown poll: merge.decided auto:false, a needs-owner label and an owner comment.
  • Main fba8946 on the same poll: no decision, no label, no comment. Both builds start the conflict fix once GitHub says dirty.
  • The changelog says a table-file conflict is "held only by that reason" while GitHub computes. It isn't, because its own reading adds the other reason.
  • Suggested fix: when a path is in conflicts, drop the tip reading's other reasons for that path, since tip-conflict already holds it. Or defer on every reason the tip reading of a conflicting merge produced.

Wait checks (measured, coordinator harness probe, head and main):

  • No merge with a weakened check. merge.done was 0 in all 13 probes. In code, the block only returns or falls through with d.auto false (inferred).
  • Main's routing is restored for these ordinary shapes. Each gives no decision while unknown and the conflict fix on dirty, identical on both builds:
    • both append a test to test/price.test.js;
    • both change one setup line in a test;
    • the app-only control.
  • Other reasons route as on main. These shapes are decided for the owner while unknown on both builds:
    • both change the same assertion line (the base-to-head assertion reason);
    • both bump left-pad in package.json ("dependency manifests change");
    • the PR adds test.skip( (the skip reason).
  • Cap. With unknown forever and the combined check off, the owner gets the PR at poll 10 on both builds. Head gives the tip-conflict reason but emits no wait.capped (follow-up).
  • Unknown, then clean, then unknown. The PR is decided at the clean poll and stays held.
  • New heads. The key is mergeable:<n>:<headSha>, so each head starts its own count (inferred). My harness push of a new head wasn't approved by the evaluator, so it never reached the wait (measured).
  • Combined-check wait. If GitHub reports a stale clean, head holds on that poll. Main runs the light check (light_check.finished 1), waits, and then goes to the conflict fix on dirty. The PR body discloses this, so it is a follow-up.

Mutants of the wait (dist, restored and checked with cmp), against merge-tip-conflict-unknown:

  • Killed: the wait removed; the cap raised by 5.

  • Survived:

    • dropping the every(...) clause;
    • the key without the head;
    • < changed to <=;
    • dropping mergeable === null (equivalent in the file backlog).

    All are follow-ups.

What I ran (measured)

  • New tests on head, 17/17 pass: merge-tip-conflict-unknown, merge-policy-tip-conflict and doctor-git-version.
  • Fail-before on 9acf107: 2 pass, 1 fail. The failing test is the unknown-mergeability one, matching the body.
  • npm run typecheck: exit 0.
  • gh pr checks 1090: 14 pass, 3 pending, 7 skipping.
  • The head hadn't moved at the end.
  • git 2.43.0.
  • Probe: test/zz-r90d-probe.test.ts, copied into both worktrees and since deleted from the head worktree. Outputs are in <tmp>*-{r90d,r90d-main}.txt, and the mutant script is r90d-mut.sh.

Weakening flags

weak-scan.mjs on 3fe6375 (base 009ec24, tip fba8946) gives 2 reasons:

  1. "removes or changes a line of a guard or lint" (32 lines):
    • gitVersion/gitTooOld: a refactor plus a per-version message. STRENGTHENING.
    • mergedTree's checks and the findings bound: STRENGTHENING.
    • isTipConflict export and its import: neutral.
    • The unknown-mergeability deferral: LIMIT. Versus round 1 it delays a hold, capped at MERGEABLE_CAP. It never sets auto, and it still holds more than main.
    • I found no RELAXATION versus main.
  2. "adds a ... suppression mark":
    • eslint-disable sits inside fixture strings. STRENGTHENING: those tests check that the hold fires.
    • { skip } in the new coordinator test is the harness's skip when gitleaks is missing, a repo-wide convention. Not a weakening.

@jayden1711

Copy link
Copy Markdown
Owner Author

Independent final check (R90c), posted by the coordinator.

VERDICT: FAIL @3fe63751caf448dc947285c5624bbaf9cc2c620a (final check)

Reviewer R90c. Detached worktrees under <tmp>*, TMPDIR <tmp>. The head hadn't moved at the end (gh pr view 1090 --json headRefOid).

Blocking

Required CI is red because of the change (measured). test shard 4/4 fails on Node 22 (job 121347874835) and Node 24 (job 121347874953), and so do both test (ubuntu-latest, …) aggregates. The cause is the adversarial control control-default-branch-marked-table-conflicting-pr, which says "the control should merge". It gets:

  • conflicts with the default branch's current tip … (test/rules.test.ts)
  • removes or changes the mark that registers a check … "// check-table: never-shrink (CHECKS)"

That control is from c2a8bd8 (#1074, now on main). CI tested Merge 3fe63751 into fba8946e, and the builder's round 2 used a4b16ac, which lacks c2a8bd8 (git merge-base --is-ancestor).

Reproduced: node --import ./dist/test/isolate.js --test --test-name-pattern=control-default-branch-marked-table-conflicting-pr dist/test/adversarial/adversarial.test.js. The head merged into fba8946 fails (0/1); fba8946 alone passes (1/1); main's CI on fba8946 is green. This hits the final-check bar (red required CI caused by the change), and also bar (d): an ordinary comment added to a test file, which main merges, is now held.

The second reason, the mark "removed or changed", is a false reason that comes from reading the conflict tree (inferred). It names something the PR didn't do.

Also, the body's "the results carry over to 3fe6375 (inferred)" no longer holds against current main.

Confirmed (measured)

  • F1 fixed. Fail-before: merge-tip-conflict-unknown with round-1 merge-flow.ts gives 2 pass, 1 fail ("no merge decision while GitHub is still computing"). Pass-after: 3/3, none skipped (gitleaks present).
    • My harness probes on head and main: app-code conflict, unknown for good, decided at 10 polls on both; unknown ×5 then dirty, conflict fix, no decision on both; unknown ×3 then clean, head holds for the owner (the disclosed new hold).
    • The new test's "then dirty" case passes on main.
  • F2 fixed. With round-1 change-files.ts, doctor-git-version gives 1 pass, 1 fail; on the head, 2/2. The wording matches the code: check-attr --source runs only when a .gitattributes changes.
  • isNonUtf8Path mutant killed: 11 pass, 1 fail.
  • fix(vacuity): pair moved code git doesn't pair, only ever taking measure away (#1014) #1068 unaffected.
  • Other checks. Head: 17/17 targeted tests pass. npm run typecheck exits 0. check:denylist: 1664 files clean.

Weakening flags

weak-scan.mjs <repo> 3fe63751 (base 009ec24, tip fba8946) gives 2 reasons:

  1. "removes or changes a line of a guard or lint" in change-files.ts and merge-policy-weakening.ts, 32 lines.
    • gitTooOld is rewritten: the threshold is still 2.40 and only the message changed, pinned by doctor-git-version. STRENGTHENING (wording) / equivalent.
    • The mergedTree, bound and weakeningOf lines are as R90a and R90b classified them. STRENGTHENING.
    • The merge-flow.ts deferral only delays a hold within MERGEABLE_CAP. LIMIT, named.
  2. "adds a skip/only/suppression mark":
    • the eslint-disable fixture strings in merge-policy-tip-conflict.test.ts: STRENGTHENING;
    • { skip } in merge-tip-conflict-unknown.test.ts: the harness's conditional skip when gitleaks is missing, as across repro-state-graph. It is not a real skip: 0 skipped here and in CI.

Follow-ups (not FAIL)

Surviving mutants of the deferral, all against merge-tip-conflict-unknown, 3/3 still pass:

  • dropping the .every(...) clause, which would defer a PR that has other holds too;
  • null-only and state-only, likely equivalent in the harness;
  • dropping .some(isTipConflict), equivalent because a lone VACUITY_UNMEASURED returns earlier.

…cy-followups

Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
… paths, and its markers unread (#1077)

Reading the conflicted tree from the tip read conflict markers as lines: in a
registered table they gave a second reason ("line N is at its indent") beside
the conflict hold, and in a test file a false "mark removed". So a PR whose
merge conflicts on a table's or test's lines was handed to the owner while
GitHub was still computing, and the adversarial control
control-default-branch-marked-table-conflicting-pr went red.

The tip reading now leaves the conflicted files out. Only a conflict on where a
check's file goes or whether it stays (any merge-tree conflict type but
CONFLICT (contents), or a conflicted path no message names) holds, since
GitHub's merge may complete there; a conflict on lines holds nothing, as on
main. merge-tree's messages are parsed for that, and a conflict only on a
directory (a split) names no path and is read as a clean merge.

Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
… cap and lone-hold check (#1077)

Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
@jayden1711

Copy link
Copy Markdown
Owner Author

Independent adversarial review (R90f), posted by the coordinator.

VERDICT: PASS @db43bf9895e04b5df011d9c3b2850118340effd2 (adversarial)

Reviewer R90f. My worktrees: rev/r90f (head) and rev/r90f-main (c41f37c). TMPDIR is a 0700 directory under /var/tmp. git 2.43.0. The head hadn't moved at the end (gh pr view 1090 --json headRefOid).

(a) Conflict shapes and message parsing (measured: rev/r90f-probe.mjs, real git, head vs main weakeningOf)

  • Held with the tip-conflict reason (main gives [] for each):
    • rename/rename of a test;
    • modify/delete of a test, of a workflow and of eslint.config.js;
    • file location with spaces and quotes in the path (test/co"re/b "q".test.js);
    • file location with a newline in the path;
    • file location with a non-UTF-8 name;
    • file/directory in test/;
    • a binary fixture (git emits CONFLICT (binary) beside contents);
    • location plus add/add at the new path;
    • rename/delete.
  • Hold nothing, as on main:
    • content conflicts in tests, including after a tip rename;
    • add/add, and rename/add;
    • app-only modify/delete.
  • Directory split: decided as clean, with the PR's own reasons kept. This matches the changelog and design.md. It is not a held shape, despite the prompt's summary.
  • Head reasons ⊇ main reasons in all 27 shapes. I found no merge of a weakening that main holds.
  • Rewritten output through a PATH shim (rev/r90f-shim.mjs):
    • German message text: unchanged result, because only the type field is read;
    • a type renamed (KONFLIKT ...) or an unknown CONFLICT (...): held, fail closed;
    • messages missing: held;
    • a contents message naming another path: held;
    • a count that isn't a number, a count that claims too many paths, or a NUL dropped: "could not run" beside the PR's own reasons, which waits.
    • Real localized git: none is installed here, so this wasn't measured. That git keeps the type strings untranslated is inferred.

(b) Ordinary PRs

  • Dirty goes to the conflict fix before mergeOrWait on both builds (conflictTrigger in watchPr; inferred from the code, and R90c/R90d measured it).
  • The batcher only uses the decision to batch a held PR alone.
  • Artifacts from leaving conflicted files out (follow-up):
    • A PR that renames a test into a path the tip also created (rename collision or rename/add) gets a false deletes a test file reason.
    • A file/directory conflict gets an extra can't read reason, which skips the unknown-mergeability wait.
    • The first doesn't reach the merge decision: in the coordinator harness, the tamper guard reads the rename as a deleted test, sets the level to L3 and asks the owner to approve landing before a PR opens, on both builds (measured, harness probe since deleted).
    • Both shapes are rare and fail closed, so this is not a regression of an ordinary shape.

(c) The wait (measured: dist mutants, rev/r90f-mut2.sh, against merge-tip-conflict-unknown)

  • Killed: removing the wait.capped emit, the cap minus 1, not pushing the reason, and dropping every(...).
  • Survived (follow-ups):
    • a key by head only;
    • a different key prefix: the body's "same mergeable: counter as the auto branch" is true in the code but no test pins it;
    • unknown-only, and dropping some(...): equivalent (inferred).
  • wait.capped fires once: the next pass returns on prior (inferred).

(d) Tests and mutants (measured: rev/r90f-mut.sh, 42 tests in 4 files, all passing at baseline)

  • Parser and hold mutants: 11 of 12 killed.
  • Survives: decoding message paths without nameOf. It fails closed, giving a false hold on a non-UTF-8 content conflict. Follow-up.
  • No vacuous test found.

Weakening flags (weak-scan.mjs <repo> db43bf98: base c41f37c, tip 1a5f8f8, 2 reasons)

  1. "Removes or changes a line of a guard or lint" (32 lines):
    • gitVersion/versionOf: a refactor. STRENGTHENING (adds gitTooOld).
    • mergedTree: it now reads conflicts and its validation is stricter. STRENGTHENING.
    • The bound no longer drops the other reasons. STRENGTHENING.
    • weakeningOf reads conflicting merges. STRENGTHENING.
    • The unknown-mergeability deferral: LIMIT. It is capped and never sets auto.
    • No RELAXATION versus main.
  2. "Adds a suppression mark": the eslint-disable strings are inside fixtures, and { skip } is the harness's skip when gitleaks is missing. STRENGTHENING, not a real skip.

Other checks (measured)

  • gh pr checks: 18 pass, 1 pending (dco), 7 skipping.
  • The head merges cleanly into origin/main 1a5f8f8 (git merge-tree exits 0), and main hasn't changed the three source files since c41f37c.

@jayden1711

Copy link
Copy Markdown
Owner Author

Independent final check (R90e), posted by the coordinator.

VERDICT: PASS @db43bf9895e04b5df011d9c3b2850118340effd2 (final check)

Reviewer R90e. Detached worktrees rev/r90e (head), rev/r90e-main (86dd7df), rev/r90e-fb (87bce2b + head's tests), rev/r90e-merge (head merged into 86dd7df, local scratch commit). TMPDIR <tmp> (0700). Head unchanged at the end (gh pr view --json headRefOid).

(1) CI and merge (measured)

  • gh pr checks 1090: every test shard (Node 22 and 24), both aggregates, push-gate, denylist, scope, scan, new-deps and agentshield pass. dco shows pending, but its job log reads check-dco.mjs c41f37cb db43bf98 → "all commits signed off". Locally, check-dco.mjs 86dd7df9 HEAD passes too.
  • git merge-tree --write-tree origin/main db43bf98 exits 0 against 86dd7df, and again against 028013d (main moved during the review; that change only touches the denylist and push-gate, no overlap).
  • npm run typecheck exits 0. check:denylist: 1681 files clean.

(2) R90c and R90d fixed (measured)

node --import ./dist/test/isolate.js --test dist/test/merge-tip-conflict-unknown.test.js dist/test/merge-policy-tip-conflict.test.js, plus the adversarial control by --test-name-pattern:

  • Before (87bce2b src): 9 pass, 11 fail. The R90d table test fails with exactly the two reasons ("BAD_WORDS: line 4 is at its indent…" and the conflict hold). The R90c control: 0/1.
  • After (head): 20/20, and the control 1/1. On main, the control is 1/1.
  • Harness probe (test/zz-r90e-probe.test.ts, 19 shapes) on before, head and main: table-both-add while unknown gave a decision and needs-owner before; on the head it gives no decision, the same as main.
  • Mutants in dist, restored and checked with cmp: keeping the conflicted files in the reading gives 19 pass, 1 fail. Holding content-only conflicts gives 18 pass, 2 fail.

(3) Against #1077

  • Delivered: the residual's named example (a file under a folder the tip renamed, a file location conflict) holds, and so do modify/delete and rename. M8, M14, M15 and M16 are killed (body's numbers, not rerun). Also delivered: the git version check, the over-limit reasons kept, the union probe, and R19f's R4 and R5 tests.
  • Not delivered: R19f item 1 (the light check reads the PR's attributes), items 3–5, and the content-conflict part of the residual, which relies on GitHub reporting dirty (inferred). So "Part of Merge policy: follow-ups from #1073's reviews (conflict residual, mutants, git version check) #1077" is correct and must stay: the issue should remain open. The body says "Part of", and no commit message has a closing keyword (measured, git log | rg).
  • The changelog and design.md describe the narrowed hold accurately (read). The body's round sections supersede older text.

(4) No regression versus main (measured)

  • Probe, head vs main: identical on every ordinary shape (append-test, same-assertion, test-setup-line, package-dep, table-both-add, app-only, skip-plus-conflict, cap, flip, newhead).
  • The probe differs only where the head holds more: location-add-test (main auto-merges it while GitHub stays clean), modify-delete-test, and an extra reason on binary-fixture, which main already holds.
  • 54-file targeted run on the merge tree (merge-policy*, merge-tip*, change-files*, doctor*, conflicts*, light-check*, merge-vacuity*, adversarial/, changelog, docs-guardrails*, brand*, and others): 887 tests, 861 pass, 0 fail, 26 todo. Main: 865 tests, 839 pass, 0 fail, 26 todo. The same 26 todos are in both.

(5) Weak-scan (weak-scan.mjs <repo> db43bf98: base c41f37c, tip 86dd7df, 2 reasons)

  1. Guard/lint lines changed (32 lines):
    • the gitVersion/versionOf refactor and gitTooOld: STRENGTHENING;
    • mergedTree, which refuses more output: STRENGTHENING;
    • the bound now keeps the other reasons: STRENGTHENING;
    • the conflicting merge is now read: STRENGTHENING (main read nothing).
    • Leaving out the conflicted files and the content-only conflicts narrows round 1, not main.
    • The merge-flow deferral is a LIMIT: capped, and never sets auto.
    • No RELAXATION versus main.
  2. Suppression marks: the eslint-disable strings are fixture text, and { skip } is the harness's gitleaks skip (0 skipped here). STRENGTHENING.

Follow-ups (not FAIL)

  • The changelog lists the held types as "file location, rename or modify/delete", but CONFLICT (binary) in a test file also gets the tip-conflict reason, whose words ("where it goes") don't fit. design.md's "any type but contents" is accurate, and main holds that shape anyway (measured).
  • Body hygiene before merge: "Inferred / guess" still says F3 "waits … Not changed here", and round 1's "3 pass, 8 fail / 11/11" are stale. Round 3 corrects both.
  • A PR's merge=union, if GitHub honors it (guess), could still land a test-file content conflict unread from the tip. Name this residual as open in Merge policy: follow-ups from #1073's reviews (conflict residual, mutants, git version check) #1077.

@jayden1711

Copy link
Copy Markdown
Owner Author

Weakening scan decision for head db43bf9895e04b5df011d9c3b2850118340effd2, recorded by the coordinator under the owner's delegation.

The owner's instruction (verbatim): "Stp asking me quesgtions and just take recommended option each time". This PR is in the critical set. Both independent reviews of this exact head passed: the final check (R90e) and the adversarial review (R90f). Both classified the scan's flags the same way.

The weakening scan reports 2 reasons (measured, R90e and R90f):

  1. Changed guard and lint lines (32): strengthening, plus one limit.
    • The gitVersion/gitTooOld refactor, mergedTree refusing more output, and the bound keeping the other reasons are strengthenings.
    • Reading the conflicting merge from the tip is also a strengthening, since main read nothing.
    • The unknown-mergeability wait is a LIMIT: it is capped, and it never sets auto.
    • Neither review found a relaxation versus main. Leaving content-only conflicts unheld narrows round 1 of this PR, not main.
  2. Suppression marks: not a weakening. The eslint-disable strings are fixture text, and { skip } is the harness's skip when gitleaks is absent; nothing was skipped in CI.

This PR is "Part of #1077"; #1077 stays open for the parts not delivered (R90e).

Decision: proceed (recommended option). This covers only this head; any new push needs a fresh scan and review.

@jayden1711
jayden1711 merged commit cde08a6 into main Oct 26, 2026
42 checks passed
@jayden1711
jayden1711 deleted the issue-1077-merge-policy-followups branch October 26, 2026 12:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant