Skip to content

feat(scripts): pairwise PR file-set collision instrument (closes #2072) - #2073

Merged
dem-extra1 merged 6 commits into
mainfrom
feat/pr-overlap-instrument
Aug 24, 2026
Merged

feat(scripts): pairwise PR file-set collision instrument (closes #2072)#2073
dem-extra1 merged 6 commits into
mainfrom
feat/pr-overlap-instrument

Conversation

@dem-extra1

@dem-extra1 dem-extra1 commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

Closes #2072.

Adds scripts/pr-overlap.py, a sibling of scripts/pr-sweep.py that answers the other set-level question: which pairs of open PRs share a file.

shared/workflow/batch-merge-and-resolve.md requires a pairwise collision check across open PRs, and CLAUDE.md's merge-order section requires deriving each PR's file set and intersecting them rather than recalling what each PR is "about". Neither had a committed instrument, so the check was run by hand every time.

Reproduces the issue's cited figures exactly

repo open PRs pairs examined pairs colliding issue says
d-morrison/altdoc 12 66 17 12 / 66 / 17
d-morrison/rme 6 15 8 6 / 15 / 8

It also reproduces the substantive findings: the four altdoc PRs with identical file sets (#105, #108, #111, #112) surface as one duplicate cluster, and rme's _sec_linreg_mle_est.qmd shows as edited by four separate PRs with #1091 the only independently mergeable one.

Where the prototype's approach turned out to be wrong

Three things, each of which the committed version does differently.

Its negative control could not fail. The prototype's control was f and (f & f) == f, which in Python reduces to bool(f) for every set --- exactly the "perfect impostor" batch-merge-and-resolve.md describes, which "runs the real command, against real refs, and returns exactly the clean result a working detector would". It validated nothing about the collision detector. The committed control runs the real pair classifier over fixtures known to collide, to be identical, and to be disjoint, plus the real completeness guard in both directions, and refuses to examine any repo if one is misreported. A test asserts the control fails against a deliberately broken classifier, which is the property the prototype's version lacked.

A gh failure silently shrank the population. The prototype caught the error per PR, printed a line, and dropped that PR from the comparison --- so the examined count quietly fell and the run still exited 0. An underivable file set is now reported by name, the pairs it would have participated in are counted as not examined, and the run exits 2.

It could not see a rename. Both the prototype's gh pr diff --name-only and GraphQL report a renamed file by its new path only, so a PR renaming foo.yml and a PR still editing foo.yml derive disjoint sets and are reported mergeable in any order, while the merge produces a rename/modify conflict. The committed version folds the pre-rename path back in from REST, for renaming PRs only.

Design

  • Derives the set live per repo, several repos per run, paginating the pullRequests connection (GraphQL caps first: at 100).
  • Distinguishes duplicates from collisions. Identical file sets are reported separately and grouped into clusters, since four PRs sharing one file set is one duplicate group rather than six unrelated pairs. The issue's evidence is the reason: for a duplicate the action is to close all but one, not to pick an order.
  • Reports pairs examined beside pairs colliding, with a negative control first.
  • Names the PRs sharing no file with any other, and separately those with an empty diff --- an empty-diff PR shares nothing by construction, so listing it as "mergeable in any order" would state a conclusion nothing tested.
  • Three-valued exit status, per fail-fast.md and fully-clean.md's account of collapsing "could not run" into "found something": 0 ran/advisory, 1 a real collision under --strict, 2 could not answer --- and 2 is never suppressed by the absence of --strict.
  • Prints its own boundary on every run, including runs that find nothing, since a zero is the case most likely to be misread as a merge-order all-clear.

Tests

scripts/test_pr_overlap.py, 91 checks, mirroring scripts/test_pr_sweep.py's structure (plain Python, check(name, condition), exit 1 on failure) and wired into validate.yml beside it. Only the tests run in CI, for the same reason the sibling gives: the script needs an authenticated gh.

Two rounds of adversarial self-review ran against the diff. Round 1 returned "Needs more work" with reproductions of two cases where a broken fetch printed a PASSED control banner, reported "mergeable in any order", and exited 0 under --strict; both are fixed in e88f2eec and both now have regression tests. Round 1 also caught four false claims in the prose, including "a file-set intersection is a superset of the textual conflicts" (renames refute it) and "identical file sets merge cleanly because both sides carry the same content" (set equality is not content equality, and the error pointed in the harmful direction of closing a PR as a duplicate).

Merge order

Derived with the script itself rather than recalled:

$ python3 scripts/pr-overlap.py -R Morrison-Lab/ai-config
pairs examined: 28; pairs sharing >=1 file: 12
  #2073 x #2074: 1 shared -- CLAUDE.md
  #2073 x #2079: 1 shared -- CLAUDE.md

This PR collides with #2074 and #2079 on CLAUDE.md only, in different sections (this one appends a sentence to the merge-order section). Whichever merges second resolves a one-file conflict; no ordering between them changes the result. Per the boundary the script prints, that is a statement about collisions and not about dependencies --- I am not aware of a dependency in either direction.

Boundary

File-set intersection finds collisions. It cannot see a dependency, where one PR asserts something another makes true, since those PRs' file sets never overlap. CLAUDE.md's merge-order section already says so; the script prints it on every run rather than letting its own silence imply otherwise.


Note

Low Risk
New read-only reporting script plus docs and CI tests; it never mutates PRs or secrets. Failure modes are designed to fail closed (exit 2) rather than report a false all-clear.

Overview
Adds scripts/pr-overlap.py, a read-only sibling of pr-sweep.py that derives every open PR's file set and reports which pairs collide — the check batch-merge-and-resolve.md required but previously ran by hand.

It classifies pairs as identical (duplicate clusters), overlap (needs a merge order), or disjoint, and never treats an empty diff as "mergeable in any order." Drafts are included by default (the inverse of pr-sweep.py), because a draft collides at merge time the same way a ready PR does. Incomplete fetches, truncated PR lists, and gh failures exit 2 rather than looking like a clean queue. Renames fold in the pre-rename path from REST so a rename/modify pair is not reported disjoint.

A negative control that can actually fail runs before any repo is examined. Offline tests (test_pr_overlap.py) are wired into validate.yml. Docs in CLAUDE.md, batch-merge-and-resolve.md, and derive-dont-enumerate.md point at the script and restate its boundary: intersection finds collisions, not dependencies.

Reviewed by Cursor Bugbot for commit 71bf6f9. Bugbot is set up for automated code reviews on this repo. Configure here.

…instrument

Closes #2072.

batch-merge-and-resolve.md requires a pairwise collision check across open
PRs and CLAUDE.md's merge-order section requires deriving each PR's file set
and intersecting them, and neither had a committed instrument -- so the
check was run by hand every time, which is deterministic-tools.md's stated
bar for building the tool.

Sibling of scripts/pr-sweep.py, sharing its argument shape and its habit of
reporting what it examined. The two answer different questions: pr-sweep
asks which PRs are stalled, a property of each PR; this asks which PRs
collide, a property of the set.
Round 1 of self-review reproduced two cases where a broken fetch printed a
PASSED control banner, reported "mergeable in any order", and exited 0 under
--strict -- defeating the one property this script exists to guarantee.

  1. The completeness guard tested for an EMPTY file set, not a COMPLETE
     one, so a PR reporting 3 changed files and returning 1 was accepted
     and the missing file was the colliding one. Now compares the derived
     count against the API's own totalCount.
  2. hasNextPage with a null endCursor entered remaining_files with no
     cursor, whose `while cursor:` never ran, silently dropping every
     remaining file. Now raises.
  3. A missing `gh` raised FileNotFoundError, which escaped main() and
     exited 1 -- the "collision found" verdict. Now exits 2, matching
     check-pr-fully-clean.py, with a top-level guard so no exception can
     exit 1.
  4. GraphQL reports a renamed file by its NEW path only, so a PR renaming
     foo.yml and a PR editing foo.yml derived disjoint sets while
     conflicting at merge time. previous_filename is folded in from REST,
     for renaming PRs only.
  5. The pullRequests connection caps first: at 100, so "raise --limit" was
     unfollowable advice above that. The PR connection is now paginated.

Also: the negative control now drives the real completeness guard in both
directions rather than only the pure classifier (the classifier was never
where failures lived), and its banner no longer claims to cover fetching;
JSON emits PRs as a list of objects rather than dicts keyed by stringified
ints; a wholly underivable sweep no longer prints "(no open PRs to
compare)"; MAX_FILE_PAGES is a named constant since it bounds the
configurable --files-per-page.

Prose corrections in the same round: "a file-set intersection is a superset
of the textual conflicts" was false (renames refute it); "identical file
sets merge cleanly because both sides carry the same content" conflated set
equality with content equality, in the harmful direction of closing a PR as
a duplicate; a forward reference and two now-stale downstream claims in
batch-merge-and-resolve.md; and "deriving the same live set" was false,
since the draft default is deliberately inverted from pr-sweep.py's.

Tests: 65 -> 91, covering the fetch and pagination layer that previously had
none.
@dem-extra1

Copy link
Copy Markdown
Collaborator Author

Working on this --- paws off until I'm done.

…ine semicolon

The local semantic-line-breaks.py joins the two clauses onto one line and
reports no change needed; CI's check-new-line-breaks rejects exactly that
shape. Filed the instrument disagreement as ai-config#2085; this splits the
sentence so both agree.
…ion loop

Two holes found reviewing my own round-1 fixes rather than reported:

  1. The RENAMED test ran over page one's nodes only, and remaining_files
     returned just paths -- so a PR whose rename fell past the first page
     skipped the REST lookup and lost its pre-rename path, reinstating the
     blind spot for large PRs exactly where it is hardest to notice.
     remaining_files now reports whether it saw a rename.
  2. The new PR-pagination loop terminated only on hasNextPage going false,
     with no runaway guard, unlike the file loop beside it. A hung sweep
     reports nothing at all, which is worse than one that fails.

Completeness is deliberately checked BEFORE the rename fold: a pre-rename
path is an addition totalCount never counted, so comparing after the fold
could let a short set make up its shortfall with rename aliases.
@dem-extra1
dem-extra1 marked this pull request as ready for review August 24, 2026 06:02
@cursor

cursor Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Bugbot couldn't run - usage limit reached

Bugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit.

A user or team admin can review and increase usage limits in the Cursor dashboard.

(requestId: serverGenReqId_cc65315b-f690-4a65-9fe4-a76fa3b83d22)

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

…alse-clean

Round 2 of adversarial self-review, against 71bf6f9. Three findings reopened
the one property this script exists to guarantee -- that a broken or
incomplete fetch cannot read as "no collision".

  1. FILES_QUERY never selected `changeType`, so the page-two rename fold
     added in 71bf6f9 could not fire: `saw_rename` was permanently False
     past the first file page. The fix existed and never executed.
  2. The test certifying that fix passed against a fixture that INVENTED
     `changeType`, a field the real query did not request. This is
     fixtures-are-not-evidence exactly -- the fixture was named after real
     output and vouched for a shape it did not have. Fixed by selecting the
     field, and pinned by new checks asserting every field the fixtures
     inject is actually selected by the query they claim to imitate, so the
     fixture cannot drift ahead of the query again.
  3. `fetch` counted raw nodes rather than distinct PR numbers, so a
     connection re-serving a page inflated the count past `totalCount` and
     set `ok` on a sweep that had seen 100 distinct PRs out of 250 --
     exiting 0 while the headline said "examined 100 of 250". Now keyed by
     PR number, so the runaway guard catches it loudly instead.

Also from that round:

  - `run_gh` could return None. `text=True` decodes with the platform
     encoding, and a byte cp1252 cannot decode leaves stdout=None with
     returncode 0, so the return-code test passed and callers got None,
     crashing outside SweepError and killing the whole sweep rather than
     reporting one PR underivable. Now decodes UTF-8 explicitly and treats
     a None stdout as an error. This is the same defect ai-config#2086 is
     currently removing from pr-sweep.py.
  - `-F owner=`/`-F name=` applied gh's magic type conversion, so a repo
     named `owner/2024` was rejected by the String! variable. Now `-f`.
  - A null or number-less PR node crashed; it is now reported as
     incompleteness, since skipping it would shrink the population silently.
  - The null-cursor end-to-end test used total=200, so the completeness
     guard fired first and the test passed with the guard it names removed.
     Now total=1, and mutation-tested: reverting the guard turns it red.
  - Off-by-one in the MAX_FILE_PAGES comment (101 files, not 100).
  - pr-status-all/SKILL.md now lists the instrument beside its sibling.
  - CLAUDE.md leads with the script rather than the hand method, and says
    the hand method misses renames.

Both critical fixes are mutation-tested: reverting FILES_QUERY's changeType
selection, or the null-cursor guard, each turns the suite red.

Tests: 92 -> 103.
@dem-extra1

Copy link
Copy Markdown
Collaborator Author

Adversarial self-review round 2 found three critical defects at 71bf6f9d --- the commit the automated review approved

Recording this because the two verdicts disagree at the same head, and the disagreement is checkable rather than a matter of taste.

The claude-review run on 71bf6f9d returned Ready for merge, reporting that it had run the suite (92/92) and "verified the central GraphQL API claim". A read-only adversarial subagent, briefed on the same commit, returned Needs more work with three findings that each reopened the single property this script exists to guarantee --- that a broken or incomplete fetch cannot read as "no collision":

  1. The rename fix was dead code. FILES_QUERY never selected changeType, so the page-two rename fold added in 71bf6f9d could not fire. The fix existed and never executed.
  2. The test certifying it passed against an invented field. The fixture injected changeType while the query requested path alone, so the assertion held against the fixture and failed against reality. This is fixtures-are-not-evidence exactly: a fixture named after real output, vouching for a shape it did not have.
  3. Pagination could manufacture a false clean. fetch counted raw nodes rather than distinct PR numbers, so a connection re-serving a page inflated the count past totalCount and marked a sweep complete that had seen 100 distinct PRs out of 250 --- exiting 0 while its own headline said "examined 100 of 250".

Point 2 is why the passing suite was not evidence: the 92/92 the automated review cites includes the test that could not fail. A suite is only as good as its fixtures' fidelity to the queries they imitate, and nothing was checking that. 03c3eba2 adds checks asserting every field the fixtures inject is actually selected by the query it claims to imitate, so the fixture cannot drift ahead of the query again.

Both critical fixes are mutation-tested rather than merely asserted --- reverting FILES_QUERY's changeType selection, or the null-cursor guard, each turns the suite red:

FAIL: FILES_QUERY selects changeType too, so a rename on page two is seen
FAIL: every field files_page() injects is selected by FILES_QUERY

Round 2 also found that run_gh could return None (a text=True decode failure leaves stdout=None with returncode 0, so the return-code test passes) --- the same defect #2086 is currently removing from scripts/pr-sweep.py, reintroduced in a new file by someone reading that file as a model.

Tests 92 -> 103. Figures still reproduce exactly: altdoc 12 / 66 / 17, rme 6 / 15 / 8.

Filed #2085 separately: scripts/semantic-line-breaks.py and CI's check-new-line-breaks disagree on mid-line semicolons, so the local instrument reports clean on a line the gate rejects.

@github-actions

github-actions Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor
PR Preview Action v1.8.1
Preview removed because the pull request was closed.

2026-08-24 06:30 UTC

@github-actions

Copy link
Copy Markdown
Contributor

Claude finished reviewView run


Code Review — PR #2073

Scope of this round: The PR's most recent Claude review (posted 2026-08-24T06:09:47Z, commit 71bf6f9d) gave a clean verdict. Since then, one further commit landed — 03c3eba2f0bd ("fix(pr-overlap): the rename fix was dead code, and pagination could false-clean"), pushed 2026-08-24T06:19:51Z. That commit is new since the last review, so I reviewed it in full rather than treating the prior verdict as still covering the current head.

What changed: scripts/pr-overlap.py, scripts/test_pr_overlap.py, CLAUDE.md, skills/pr-status-all/SKILL.md (57/+160/+7/+4 lines respectively). The commit fixes three real bugs the author found in a second round of self-review against their own prior commit:

  1. FILES_QUERY never selected changeType, so the page-two rename-detection fold added in the previous commit could never fire — dead code. Fixed by adding changeType to both QUERY and FILES_QUERY. Verified: both queries now contain the field, and remaining_files/file_set_for correctly OR the per-page saw_rename values together.
  2. The test fixture (files_page()) had been injecting a changeType field the real FILES_QUERY never requested — a fixtures-are-not-evidence case, where the fixture vouched for a shape the query didn't have. Fixed with new pinning assertions ("changeType" in pr_overlap.FILES_QUERY, and a check that every field a fixture injects is actually selected by the query it imitates).
  3. fetch() counted raw GraphQL nodes instead of distinct PR numbers, so a connection re-serving a page could inflate the count past totalCount and report a sweep "complete" having actually seen a subset. Fixed by keying accumulation on PR number (a dict) and adding a StuckConnection test that pins the runaway guard.

Also bundled: run_gh now decodes explicitly as UTF-8 (text=True uses the platform encoding, which silently yields stdout=None on an undecodable byte under cp1252 while returncode stays 0 — this was crashing outside SweepError handling rather than being reported as one PR's failure); -F owner=/-F name= switched to -f so a repo name that looks numeric (e.g. owner/2024) isn't coerced to an int by gh's magic type conversion; a null/number-less PR node now raises rather than being silently skip-derived; the null-cursor end-to-end test was tightened from total=200 to total=1 so it actually exercises the guard it claims to (with total=200 the completeness guard fired first, so the test passed even with the null-cursor guard removed).

Verification performed:

  • Ran the full test suite in a fresh worktree at the PR head (03c3eba2f0bd): 103/103 pass (up from 92 at the prior review, consistent with the commit's own "Tests: 92 → 103" claim).
  • Confirmed python3 -m py_compile succeeds on both changed Python files.
  • Grepped both call sites of the owner=/name= GraphQL parameters — both consistently switched to -f.
  • Traced every run_gh() call site — both treat the return value as a string (.strip(), .splitlines()), so raising on None instead of returning it is the correct fix, not a behavior change that breaks a caller.
  • Confirmed the fetch() PR-number-keyed accumulation doesn't change final output order: sweep() re-sorts prs by PR number regardless of the order fetch() returns nodes in, so switching the connection's ordering (creation-date → numeric) is inert.
  • Ran python3 scripts/pr-overlap.py --help against the PR head — output matches what CLAUDE.md's updated prose describes.
  • Read the CLAUDE.md and skills/pr-status-all/SKILL.md diffs — both are accurate to the script's actual behavior and consistent with the prior round's already-verified claims (e.g., PullRequestChangedFile having no previousFilename field, which is why the REST fallback in previous_filenames() exists).

No correctness bugs, no CLAUDE.md violations, and no hallucinated APIs/fields found in this incremental commit. The fixes are genuine (not cosmetic) and each is backed by a new test that would fail if the fix were reverted, per the commit's own mutation-testing claim, which I found plausible on inspection of the guard logic (e.g. total=1 in the null-cursor test now makes the completeness guard insufficient on its own to pass the test, forcing the null-cursor guard to be what's actually exercised).

Verdict

Ready for merge

Reviewed commit: 03c3eba

Reviewed commit: 03c3eba

@github-actions

Copy link
Copy Markdown
Contributor

💰 Cost: $2.1480 (review) — run

@dem-extra1

Copy link
Copy Markdown
Collaborator Author

Merge order: no constraint, derived rather than recalled

Re-derived at head 03c3eba2 with this PR's own instrument, then confirmed with a real merge test.

$ python3 scripts/pr-overlap.py -R Morrison-Lab/ai-config
pairs examined: 28; pairs sharing >=1 file: 2
  #2073 x #2079: 1 shared -- CLAUDE.md
  #2073 x #2097: 1 shared -- skills/pr-status-all/SKILL.md

The second one is new since the earlier review, and this PR created it: addressing a review finding added an entry to skills/pr-status-all/SKILL.md, which #2097 also edits. Worth naming, because it is the ordinary way a collision appears mid-round rather than at the start.

A shared file is not a conflict, so I ran the merge rather than stopping at the intersection. git merge-tree, legacy three-arg form --- --write-tree needs git 2.38 and this box runs 2.37.2, where the flag is rejected with 128 and writes nothing to stdout, so keying on exit status would have reported every pair clean. Grepped unanchored for <<<<<<< , per batch-merge-and-resolve.

Negative control first, since a zero matrix is otherwise indistinguishable from a detector that never ran:

NEGATIVE CONTROL (known-conflicting pair): markers=1 -> DETECTOR WORKS
known-clean control (ref against itself): markers=0

#2073 x #2079: merge-base=47e4189d  markers=0  -> merges cleanly
#2073 x #2097: merge-base=47e4189d  markers=0  -> merges cleanly

pairs examined: 2; conflicting: 0

Both share a file in disjoint regions: #2079 edits CLAUDE.md around line 178 (model tier) while this PR edits around line 486 (merge order), and #2097 does not touch the related-instruments list this PR appends to. No merge order is required in either direction.

Per the boundary this PR's own script prints, that is a statement about collisions and not about dependencies. Neither PR asserts anything the other makes true, so far as I can tell.


One aside worth recording, since it landed on the exact defect under discussion. The first attempt at the merge test above crashed with the cp1252 bug this PR fixes --- my throwaway script used subprocess.run(..., text=True), git's output carried byte 0x9d, and the decode failure left stdout=None with returncode 0:

UnicodeDecodeError: 'charmap' codec can't decode byte 0x9d in position 34574
AttributeError: 'NoneType' object has no attribute 'strip'

That is the same failure #2086 removes from pr-sweep.py and that 03c3eba2 removes from pr-overlap.py, reproduced live on this repo's own content. It is not a hypothetical on Windows.

@dem-extra1
dem-extra1 merged commit df4676c into main Aug 24, 2026
9 checks passed
@dem-extra1
dem-extra1 deleted the feat/pr-overlap-instrument branch August 24, 2026 06:29
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.

No committed instrument for the pairwise PR file-set collision check the corpus requires

1 participant