Skip to content

fix(tools): detect cross-library export duplicates - #12784

Merged
oneandonlydean merged 5 commits into
boykopovar:mainfrom
xdCloudy:fix/pr-overlap-cross-library
Nov 2, 2026
Merged

oneandonlydean merged 5 commits into
boykopovar:mainfrom
xdCloudy:fix/pr-overlap-cross-library

Conversation

@xdCloudy

@xdCloudy xdCloudy commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

What

Add a preflight warning when a PR introduces an export whose canonical name is already exported by another PRX library in main.

The check distinguishes two cases:

  • Same library/NID: An export added by the PR already exists under the same (library, NID) in main.
  • Cross-library: An export's canonical name already exists in another library in main, even if the NIDs or library paths differ.

Cross-library findings are advisory because some duplicated exports are intentional. Known examples include libSceAgc/libSceAgcVsh and libSceSaveData/libSceSaveData.native. The warning explicitly states that these matches may be intentional.

False-positive and false-negative prevention

The implementation accounts for additions and removals within the same PR:

  • Rewritten definitions: Removing and re-adding an existing (library, NID) does not trigger a false same-key duplicate warning.
  • Genuine library moves: Moving an export from library A to library B does not trigger a false cross-library warning when the original export is removed.
  • Retained exports: Rewriting an export in library A while also introducing it in library B correctly triggers a cross-library warning.
  • Canonical-name matching: Move suppression operates on normalized export names and only excludes libraries where the canonical export is actually removed.

The latest change in 60a80d1 fixes the remaining false negative identified by @oneandonlydean.

Previously, the move-suppression logic included every removed definition, even if the same canonical export was retained or re-added in its original library. This could incorrectly suppress a cross-library warning.

The updated logic removes retained libraries from the move-suppression set before evaluating cross-library duplicates.

Existing same-key detection, genuine-move suppression and advisory cross-library reporting remain intact.

Overlap with #9539

I also reviewed the overlap bot's warning concerning #9539.

That PR modifies the probe/scan_all area of tools/pr_overlap.py, whereas this PR modifies main_export_findings, reporting logic and associated tests.

The overlap bot cannot complete its comparison because #9539 conflicts with the current base. Its warning remains a manual-review notice rather than confirmation of a merge conflict between these changes.

Tested

Windows x86_64, Python 3.11.2, head 898fe3211ead:

  • python -m unittest discover -s tools/tests -p 'test_pr_overlap*.py' -q: 127 tests run: 122 passed, 5 skipped.
  • Focused test_pr_overlap_exports.py: 22 tests passed.
  • python tools/check_conventions.py --base upstream/main: passed.
  • git diff --check upstream/main...HEAD: passed.
  • Object-only merge preview against current upstream main: clean.

Hosted relinker jobs for this head passed on Linux, Windows and both macOS architectures; relinker ASan passed. Full runtime builds are not required for this tools-only change and were skipped.

Checklist

  • Based on current main; no other open PR implements the same functions
  • One topic per PR; follow-ups go in a new PR
  • No comments in code except technical debt
  • Unimplemented paths throw (NotImplemented_nid_no_patch); silent stubs are listed under Silent stubs in their library's technical debt file
  • No notes, investigation .md files or images added to the repository (attach them to this PR)
  • New third-party code is a submodule built from source
  • Generic behaviour, not specific to one title
  • Depends on: none
  • Fixes: none
  • Unblocks: none
  • GPUs tested: n/a
  • AI-assisted: yes

@oneandonlydean oneandonlydean self-assigned this Oct 29, 2026
@oneandonlydean

Copy link
Copy Markdown
Collaborator

@xdCloudy main_export_findings in tools/pr_overlap.py stops reporting an export that main already has in the same library. This regresses a check that CI posts to every PR (.github/workflows/pr-overlap.yml runs the tool with --out).

Before: an export added by the PR at (library, NID) was reported when current_exports (the exports of main) already had the same key, under "Exports introduced here that are already present in main". That catches the case where main landed the same function in the same library after the PR was written; the merge is textually clean but the function ends up defined twice.

After: the finding only keeps names whose canonical name is exported by a different library (existing = by_name.get(canonical, set()) - {library}), so the same (library, NID) is silently dropped. The old test that pinned that case (sceKernelRead in libkernel on both sides) was replaced by the cross-library one, so nothing in the suite covers it now. The new negative test uses two different NIDs in one library, which is a different case.

Could you keep the same-key finding and add the cross-library one next to it (a union, or two lists), and keep a test for (library, NID) present on both sides?

Two smaller points:

  • The new rule does not look at whether the export in the other library is implemented. Names that are legitimately exported by two libraries on main today (e.g. libSceAgc and libSceAgcVsh, libSceSaveData and libSceSaveData.native; a rough grep of APS5_VABI definitions finds about 258 such names) will be reported as "also exported by" when a PR adds one of them to the other library. The wording is advisory, so this may be acceptable, but please say in the PR which of those you expect to be flagged.
  • The "Tested" section shows the discover run (110 passed, 5 skipped) but not the case above; please add it once the finding is restored.

@github-actions

github-actions Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

Some comparisons could not be completed because a pull request conflicts with the base branch. Review these shared files manually before concluding that there is no overlap.

Pull request Shared files not compared
PR 988 tools/pr_overlap.py
PR 9539 tools/pr_overlap.py

@xdCloudy

xdCloudy commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor Author

@oneandonlydean Thanks, you're right: my first change regressed the existing same-(library, NID) warning. I restored that check alongside the cross-library name warning and added a regression test that pins the same-key case. The report now distinguishes the exact same-key finding from cross-library matches and labels the latter as potentially intentional; the PR description calls out the known Agc/AgcVsh and SaveData/SaveData.native pairs.

The focused suite now passes with 111 passed and 5 skipped, and the conventions check passes. I also inspected the overlap bot's #9539 warning: its patch changes the probe/scan_all section, while this PR changes main_export_findings/report. GitHub still marks #9539 unresolved against the current base, so I left that bot warning as a manual-review notice rather than claiming the PRs are merge-clean.

@rooklz rooklz left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checked on macOS arm64, head 3075dc6398. Merge-tree against main 8958e22ad9 is clean, check_conventions.py --head passes, and git diff --check passes. test_pr_overlap*.py passes on Python 3.9, 3.13 and 3.15. With pr_overlap.py from main, the new tests fail (1 error, 1 failure), so they do pin the change.

Regression: rewritten definition lines are reported as duplicates of main

On main, candidates = added - removed drops a (library, NID) that the PR both removes and adds. That is what happens when a PR edits the line that defines an export (new signature, splitting a one-line body, and so on). The new loop sets same_key whenever the key is in current_exports, without checking removed, so every in-place edit of an existing export is now reported under "same library/NID already present".

I compared main and head main_export_findings on the first 80 open PRs that touch core/libs/prx/*.cpp, each diffed against its merge base:

PRs with a finding
main 3 (#12700, #12688, #12682: recvfrom/sendto added to libkernel while the old definitions are still there)
head 19

The 16 extra findings are all edits of the definition line itself, for example #12681 scalbnf (one-liner split into a body), #12791 sceAudioOutSetPadSpkVolume (parameter names added), #12692 UCNV_*_CALLBACK_*, #12674 drawup texture (ABI change), #12730 and #12729 (40 and 32 SSL getters), and #12642 pthread_suspend_*. CI posts this report on every PR, so these warnings would show up as noise.

Same issue in the cross-library check: #12644 moves sem_setname from libkernel_sys (removes the stub) to libkernel (adds the implementation). canonical not in removed_names only looks at the same key, so the move is reported as "also exported by another library in main: libkernel_sys".

Suggested fix (18 lines including 2 tests)

--- a/tools/pr_overlap.py
+++ b/tools/pr_overlap.py
@@ -521,6 +521,10 @@
     for number, pr in prs.items():
         added = pr.get("added_exports", {})
         removed = pr.get("removed_exports", {})
+        moved = {}
+        for (library, nid), removed_from in removed.items():
+            for name in removed_from:
+                moved.setdefault(NID_DISAMBIG.sub("", nid_names.strip_nid_postfix(name)), set()).add(library)
         names = {}
         for library, nid in added:
             added_names = {
@@ -533,10 +537,10 @@
             }
             for canonical in added_names:
                 finding = names.setdefault(canonical, {"same_key": set(), "other_libraries": set()})
-                if (library, nid) in current_exports:
+                if (library, nid) in current_exports and (library, nid) not in removed:
                     finding["same_key"].add((library, nid))
                 if canonical not in removed_names:
-                    finding["other_libraries"].update(by_name.get(canonical, set()) - {library})
+                    finding["other_libraries"].update(by_name.get(canonical, set()) - {library} - moved.get(canonical, set()))
                 if not finding["same_key"] and not finding["other_libraries"]:
                     names.pop(canonical)
         if names:
--- a/tools/tests/test_pr_overlap_exports.py
+++ b/tools/tests/test_pr_overlap_exports.py
@@ -140,6 +140,18 @@
         self.assertEqual(pr_overlap.main_export_findings(prs, main), {})
 
 
+    def test_rewritten_definition_line_is_not_a_main_duplicate(self):
+        nid = nid_names.compute_nid("sceKernelRead")
+        key = ("core/libs/prx/libkernel", nid)
+        prs = {9: {"added_exports": {key: {"sceKernelRead"}}, "removed_exports": {key: {"sceKernelRead"}}}}
+        self.assertEqual(pr_overlap.main_export_findings(prs, {key: {"sceKernelRead"}}), {})
+
+    def test_export_moved_between_libraries_is_not_a_cross_library_duplicate(self):
+        nid = nid_names.compute_nid("sem_setname")
+        old, new = ("core/libs/prx/libkernel_sys", nid), ("core/libs/prx/libkernel", nid)
+        prs = {9: {"added_exports": {new: {"sem_setname"}}, "removed_exports": {old: {"sem_setname"}}}}
+        self.assertEqual(pr_overlap.main_export_findings(prs, {old: {"sem_setname"}}), {})
+
     def test_explicit_export_aliases_are_collected_by_library_and_nid(self):

What I checked with the fix applied:

  • The diff applies with git apply on 3075dc6398.
  • test_pr_overlap*.py passes on Python 3.9, 3.13 and 3.15.
  • On the current head, both new tests fail.
  • On the same 80 PRs, findings drop back to the 3 that main reports (#12700/#12688/#12682, recvfrom/sendto), and #12644 is no longer reported.
  • The PR's own new tests (cross-library FT_Stroker_New, same-key sceKernelRead, same library only) still pass.

Non-blocking:

  • Over those 80 PRs, the head did not produce a single cross-library finding except the #12644 move above. The new warning is quiet in practice, which is good.
  • The "may already be present" heading now also covers the exact same-key case, which is a certain duplicate. It might be worth keeping "already present" for the same-key lines.

AI-assisted: yes

@xdCloudy

Copy link
Copy Markdown
Contributor Author

Checked on macOS arm64, head 3075dc6398. Merge-tree against main 8958e22ad9 is clean, check_conventions.py --head passes, and git diff --check passes. test_pr_overlap*.py passes on Python 3.9, 3.13 and 3.15. With pr_overlap.py from main, the new tests fail (1 error, 1 failure), so they do pin the change.

Regression: rewritten definition lines are reported as duplicates of main

On main, candidates = added - removed drops a (library, NID) that the PR both removes and adds. That is what happens when a PR edits the line that defines an export (new signature, splitting a one-line body, and so on). The new loop sets same_key whenever the key is in current_exports, without checking removed, so every in-place edit of an existing export is now reported under "same library/NID already present".

I compared main and head main_export_findings on the first 80 open PRs that touch core/libs/prx/*.cpp, each diffed against its merge base:

PRs with a finding
main 3 (#12700, #12688, #12682: recvfrom/sendto added to libkernel while the old definitions are still there)
head 19

The 16 extra findings are all edits of the definition line itself, for example #12681 scalbnf (one-liner split into a body), #12791 sceAudioOutSetPadSpkVolume (parameter names added), #12692 UCNV_*_CALLBACK_*, #12674 drawup texture (ABI change), #12730 and #12729 (40 and 32 SSL getters), and #12642 pthread_suspend_*. CI posts this report on every PR, so these warnings would show up as noise.

Same issue in the cross-library check: #12644 moves sem_setname from libkernel_sys (removes the stub) to libkernel (adds the implementation). canonical not in removed_names only looks at the same key, so the move is reported as "also exported by another library in main: libkernel_sys".

Suggested fix (18 lines including 2 tests)

--- a/tools/pr_overlap.py
+++ b/tools/pr_overlap.py
@@ -521,6 +521,10 @@
     for number, pr in prs.items():
         added = pr.get("added_exports", {})
         removed = pr.get("removed_exports", {})
+        moved = {}
+        for (library, nid), removed_from in removed.items():
+            for name in removed_from:
+                moved.setdefault(NID_DISAMBIG.sub("", nid_names.strip_nid_postfix(name)), set()).add(library)
         names = {}
         for library, nid in added:
             added_names = {
@@ -533,10 +537,10 @@
             }
             for canonical in added_names:
                 finding = names.setdefault(canonical, {"same_key": set(), "other_libraries": set()})
-                if (library, nid) in current_exports:
+                if (library, nid) in current_exports and (library, nid) not in removed:
                     finding["same_key"].add((library, nid))
                 if canonical not in removed_names:
-                    finding["other_libraries"].update(by_name.get(canonical, set()) - {library})
+                    finding["other_libraries"].update(by_name.get(canonical, set()) - {library} - moved.get(canonical, set()))
                 if not finding["same_key"] and not finding["other_libraries"]:
                     names.pop(canonical)
         if names:
--- a/tools/tests/test_pr_overlap_exports.py
+++ b/tools/tests/test_pr_overlap_exports.py
@@ -140,6 +140,18 @@
         self.assertEqual(pr_overlap.main_export_findings(prs, main), {})
 
 
+    def test_rewritten_definition_line_is_not_a_main_duplicate(self):
+        nid = nid_names.compute_nid("sceKernelRead")
+        key = ("core/libs/prx/libkernel", nid)
+        prs = {9: {"added_exports": {key: {"sceKernelRead"}}, "removed_exports": {key: {"sceKernelRead"}}}}
+        self.assertEqual(pr_overlap.main_export_findings(prs, {key: {"sceKernelRead"}}), {})
+
+    def test_export_moved_between_libraries_is_not_a_cross_library_duplicate(self):
+        nid = nid_names.compute_nid("sem_setname")
+        old, new = ("core/libs/prx/libkernel_sys", nid), ("core/libs/prx/libkernel", nid)
+        prs = {9: {"added_exports": {new: {"sem_setname"}}, "removed_exports": {old: {"sem_setname"}}}}
+        self.assertEqual(pr_overlap.main_export_findings(prs, {old: {"sem_setname"}}), {})
+
     def test_explicit_export_aliases_are_collected_by_library_and_nid(self):

What I checked with the fix applied:

Non-blocking:

  • Over those 80 PRs, the head did not produce a single cross-library finding except the fix(libSceSaveData.native): validate directory name in sceSaveDataBackup #12644 move above. The new warning is quiet in practice, which is good.
  • The "may already be present" heading now also covers the exact same-key case, which is a certain duplicate. It might be worth keeping "already present" for the same-key lines.

AI-assisted: yes

Thanks @rooklz for the thorough review and detailed analysis!

Both regressions have now been addressed in commits 19225aa and 5ed1aa4.

Fixes

  • Same-key false positives: Exports removed and re-added under the same (library, NID) are no longer incorrectly flagged when an existing definition is rewritten.
  • Cross-library false positives: Export detection now accounts for functions moved between libraries, preventing misleading warnings such as the sem_setname case in fix(libSceSaveData.native): validate directory name in sceSaveDataBackup #12644.
  • Report clarity: Updated the warning heading to distinguish canonical-name matches from existing library/NID matches.

Regression coverage

Added two tests:

  • test_rewritten_definition_line_is_not_a_main_duplicate
  • test_export_moved_between_libraries_is_not_a_cross_library_duplicate

Validation: 55 unit tests passed, including both new regression tests. The filesystem-dependent integration suite has not yet been rerun.

The original cross-library duplicate detection and same-key checks remain intact.

Thanks again for testing against 80 open PRs and identifying the additional false positives. Really appreciate the detailed review!

AI-assisted: yes

@rooklz rooklz left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-checked on macOS arm64 at head 5ed1aa4afe, which adds 19225aabc0 and 5ed1aa4afe on top of 3075dc6398 that I reviewed before. Main is e347ab1f95.

  • test_pr_overlap*.py: 113 tests pass on Python 3.9, 3.13 and 3.15. With pr_overlap.py from 3075dc6398, the two new tests fail: test_rewritten_definition_line_is_not_a_main_duplicate and test_export_moved_between_libraries_is_not_a_cross_library_duplicate.
  • Real-PR corpus: I ran main_export_findings from main and from this head on the first 80 open PRs that touch core/libs/prx/*.cpp. Each PR was diffed against its merge base, with base_exports(upstream/main) as the current exports. Both versions report the same 3 PRs: #12700 (recvfrom/sendto), #12688 and #12682 (recvfrom). All three are same-key findings and none is cross-library. The 16 extra findings from definition-line rewrites at 3075dc6398 are gone.
  • #12644 no longer touches sem_setname, since that move is on main now. The new unit test covers the move case instead.
  • Merge-tree against main is clean. check_conventions.py --head and git diff --check pass.

The heading now reads "Exports with a name or library/NID already present in main", which resolves my earlier wording nit. Approving.

AI-assisted: yes

@oneandonlydean

Copy link
Copy Markdown
Collaborator

@xdCloudy the same-library/NID finding and its regression are restored, and the cross-library warning now clearly says it may be intentional. Those requests are addressed.

One new false-negative remains in the move suppression at tools/pr_overlap.py:524-544. moved includes every removed definition, even when that same canonical name is re-added in the original library as a rewrite. Consider this case:

  • Main exports foo in library A.
  • The PR rewrites that definition in A, so its old line is removed and its new line added.
  • The PR also adds foo in library B.

The A entry is correctly treated as a rewrite, but the B entry also loses its warning because moved["foo"] contains A. The final source still exports the name in both libraries; this is not a move.

Please suppress only libraries where the canonical export is actually removed after accounting for the PR's retained/re-added names. Add a regression combining the rewrite in A with the addition in B and require the cross-library finding. Keep the separate rewrite-only, genuine-move and same-key tests.

@xdCloudy

Copy link
Copy Markdown
Contributor Author

@oneandonlydean Thanks for catching this!

Addressed in commit 60a80d1.

The move-suppression logic now accounts for canonical exports retained or re-added in their original library.

  • Rewriting an existing export in library A no longer suppresses a legitimate cross-library warning when the same export is introduced in B.
  • Genuine A → B moves remain exempt.
  • Existing same-key duplicate detection remains unchanged.

Added test_rewritten_export_does_not_hide_a_new_cross_library_duplicate.

The new regression fails against the previous implementation and passes with the fix. All 20 export unit tests pass on Python 3.12.

The full GitHub Actions checks are still pending.

AI-assisted: yes

@babyhuey

Copy link
Copy Markdown
Contributor

Windows run of this head 60a80d152fc6, merged onto main ba5b5330b37 (clean merge, local only), with the selection CI's skipped Windows product job uses: Windows 11, Ryzen 7 5800X, RTX 3080, WinLibs GCC 15.2, Release, BUILD_TESTING=ON. The tree was configured fresh after the merge, then select_tests.py select / ninja -f select.ninja selected / --target libs / select_tests.py ctest --parallel 4 --timeout 120.

  • Selected 559 tests: 551 pass, 2 fail, 6 skip. No failures from this PR: check_main_sync (120 s) and select_tests_SelectArgumentTests (30 s) timed out under --parallel 4; alone on the same merged tree, rebuilt, both passed 3 of 3 (53-55 s and 15-18 s). For reference, select_tests_SelectArgumentTests alone on ba5b5330b37 itself takes 17-25 s on this machine, close to its 30 s limit.

AI-assisted: yes (Claude Code)

@oneandonlydean

Copy link
Copy Markdown
Collaborator

The same-key finding and the combined rewrite-in-A/add-in-B regression are fixed. The focused export suite passes at this head (20 tests) and with the current main tool/test source composed (22 tests).

One integration update remains: head 60a80d152fc6 conflicts with current main e67cc818696f in CONTRIBUTING.md, in the paragraph immediately before the local conventions-check command. Main now explains the default base selection (upstream/main, then origin/main, then main); this branch carries the older paragraph. An object-only trial merge reports that content conflict; the feature source merges without a conflict.

Please rebase onto current main and preserve its conventions-check guidance together with this PR's changes, then rerun the relevant checks on the new head. The earlier branch-only results do not cover that new integration.

@xdCloudy
xdCloudy force-pushed the fix/pr-overlap-cross-library branch from 60a80d1 to 898fe32 Compare November 1, 2026 11:19
@xdCloudy

xdCloudy commented Nov 1, 2026

Copy link
Copy Markdown
Contributor Author

@oneandonlydean The branch is rebased onto current main (e67cc818696f). I preserved the updated default-base selection guidance in CONTRIBUTING.md alongside the PR changes.

Checks on the rebased source:

  • python -m unittest discover -s tools/tests -p 'test_pr_overlap*.py' -q — 127 passed, 5 skipped.
  • python tools/check_conventions.py --base FETCH_HEAD — passed against e67cc818696f.
  • git diff --check — passed.

The updated branch is pushed; the new head is 898fe3211e.

@xdCloudy

xdCloudy commented Nov 1, 2026 •

Copy link
Copy Markdown
Contributor Author

The integration fix is present at 898fe3211ead. I verified a clean merge preview against current upstream main and reran the overlap suites on Windows/Python 3.11.2: 127 tests run: 122 passed and 5 skipped; the focused export suite passed all 22 tests. Conventions and diff whitespace checks passed. The description now records these final-head results. Hosted Linux/Windows/macOS relinker and ASan checks are green. AI-assisted: yes.

@oneandonlydean
oneandonlydean merged commit 2dcc7a9 into boykopovar:main Nov 2, 2026
16 checks passed
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.

4 participants