From 2bea4a3f064ec420d2503dd6b9c229169c91f5e9 Mon Sep 17 00:00:00 2001 From: Gabrieljr42 Date: Tue, 20 Oct 2026 08:45:12 -0300 Subject: [PATCH 1/2] feat(tools): report per-library progress in PR comparisons --- docs/dev/PROGRESS.md | 4 ++ tools/progress.py | 36 +++++++++++++++++ tools/tests/test_progress.py | 78 ++++++++++++++++++++++++++++++++++++ 3 files changed, 118 insertions(+) diff --git a/docs/dev/PROGRESS.md b/docs/dev/PROGRESS.md index a1257f85a9..7c5836a2a9 100644 --- a/docs/dev/PROGRESS.md +++ b/docs/dev/PROGRESS.md @@ -22,6 +22,10 @@ Comparisons identify functions by library and name. When a head group becomes a Real removals and transitions back to stubs remain visible. Percentages retain the original snapshot totals, so eliminating duplicate accounting can change a percentage without changing available functionality. Older JSON snapshots without `shared_sources` are accepted. +PR reports include a table of changed libraries with implemented export counts, declared totals, percentages before and after, and changes in percentage points (`pp`). The table uses the same two JSON snapshots as the global report. A library appears when its counts, export names or shared-source owner change, including when its counts stay equal. Unchanged libraries are omitted, and the table lists at most 100 libraries in name order with a count of omitted rows. + +New or removed libraries show `Not present` on the missing side. Empty libraries show `No exports`, and wrappers show `Shared sources` with the source library's name. These rows show `n/a` for the percentage change. The table retains the scanner's counting rules; it does not measure compatibility. + Implemented definitions are also classified as behaviourally empty when their body holds only `(void)name;` discards and at most one constant return, bare `return;` or `throw`, including a return of a local `static` wrapper that is itself behaviourally empty; wrapper calls whose arguments contain calls do not qualify. Comparisons list newly implemented behaviourally empty functions without changing any count. Older JSON snapshots without `inert_names` are accepted. ## One library diff --git a/tools/progress.py b/tools/progress.py index 7cda38feea..a03367db7e 100644 --- a/tools/progress.py +++ b/tools/progress.py @@ -441,8 +441,44 @@ def compare(title, column, unit, base, head): return lines + [""] +def library_progress(group): + if group is None: + return "Not present", None + if "shared_sources" in group: + return f'Shared sources: `{group["shared_sources"]}`', None + total = group["done"] + group["todo"] + if not total: + return "No exports", None + percent = 100 * group["done"] / total + return f'{group["done"]}/{total} ({percent:.2f}%)', percent + + +def library_report(base, head): + before, after = ({group["name"]: group for group in data["groups"]} for data in (base, head)) + fields = ("done", "todo", "done_names", "todo_names", "shared_sources") + rows = [] + for name in sorted(before.keys() | after.keys(), key=str.casefold): + old, new = before.get(name), after.get(name) + if old is not None and new is not None and all(old.get(field) == new.get(field) for field in fields): + continue + old_text, old_percent = library_progress(old) + new_text, new_percent = library_progress(new) + delta = "n/a" + if old_percent is not None and new_percent is not None: + delta = f'{round(new_percent - old_percent, 2) or 0:+.2f} pp' + rows.append(f"| {name} | {old_text} | {new_text} | {delta} |") + if not rows: + return [] + lines = ["#### Changed libraries (implemented exports)", "", + "| Library | Before | After | Change |", "| - | - | - | - |"] + rows[:REPORT_ROWS] + if len(rows) > REPORT_ROWS: + lines.append(f"| ... | {len(rows) - REPORT_ROWS} more libraries | | |") + return lines + [""] + + def report(base, head): lines = compare("System libraries", "Library", "functions", base["libraries"], head["libraries"]) + lines += library_report(base["libraries"], head["libraries"]) lines += compare("GPU shader instructions", "Encoding", "instructions", base["shaders"], head["shaders"]) return "\n".join(lines) diff --git a/tools/tests/test_progress.py b/tools/tests/test_progress.py index 9755ae2139..6b85331a5e 100644 --- a/tools/tests/test_progress.py +++ b/tools/tests/test_progress.py @@ -11,6 +11,84 @@ import progress class ProgressTests(unittest.TestCase): + def report_library(self, name, done=(), todo=(), **extra): + return {"name": name, "label": name, "done": len(done), "todo": len(todo), + "done_names": list(done), "todo_names": list(todo), **extra} + + def report_snapshot(self, *groups): + return {"libraries": progress.summarize(list(groups)), "shaders": progress.summarize([])} + + def test_report_shows_each_changed_library_and_keeps_global_details(self): + pad = self.report_library("libScePad", ["Ready"], ["Pending"]) + video = self.report_library("libSceVideoOut", ["A", "B", "C"], ["D"]) + unchanged = self.report_library("libSceUnused", ["Keep"]) + base = self.report_snapshot(video, unchanged, pad) + head = self.report_snapshot(self.report_library("libScePad", ["Ready", "Pending"]), unchanged, + self.report_library("libSceVideoOut", ["A", "B"], ["C", "D"])) + report = progress.report(base, head) + self.assertIn("**System libraries**", report) + self.assertIn("| Library | Name |", report) + self.assertIn("| libScePad | 1/2 (50.00%) | 2/2 (100.00%) | +50.00 pp |", report) + self.assertIn("| libSceVideoOut | 3/4 (75.00%) | 2/4 (50.00%) | -25.00 pp |", report) + table = report.split("#### Changed libraries (implemented exports)")[1] + self.assertNotIn("libSceUnused", table) + self.assertLess(table.index("libScePad"), table.index("libSceVideoOut")) + + def test_report_includes_changed_function_names_when_counts_stay_equal(self): + base = self.report_snapshot(self.report_library("libScePad", ["Old"], ["Pending"])) + head = self.report_snapshot(self.report_library("libScePad", ["New"], ["Pending"])) + self.assertIn("| libScePad | 1/2 (50.00%) | 1/2 (50.00%) | +0.00 pp |", progress.report(base, head)) + + def test_report_marks_new_removed_and_empty_libraries_without_percentages(self): + base = self.report_snapshot(self.report_library("libSceRemoved", ["Ready"]), + self.report_library("libSceEmptied", ["Old"])) + head = self.report_snapshot(self.report_library("libSceAdded", ["New"], ["Pending"]), + self.report_library("libSceEmpty"), self.report_library("libSceEmptied")) + report = progress.report(base, head) + self.assertIn("| libSceAdded | Not present | 1/2 (50.00%) | n/a |", report) + self.assertIn("| libSceRemoved | 1/1 (100.00%) | Not present | n/a |", report) + self.assertIn("| libSceEmpty | Not present | No exports | n/a |", report) + self.assertIn("| libSceEmptied | 1/1 (100.00%) | No exports | n/a |", report) + + def test_report_labels_shared_sources_without_duplicate_counts(self): + owner = self.report_library("libScePad", ["Ready"], ["Pending"]) + alias = self.report_library("libScePad.native", ["Ready"], ["Pending"]) + shared = self.report_library("libScePad.native", shared_sources="libScePad") + base, head = self.report_snapshot(owner, alias), self.report_snapshot(owner, shared) + report = progress.report(base, head) + self.assertIn("| libScePad.native | 1/2 (50.00%) | Shared sources: `libScePad` | n/a |", report) + self.assertNotIn("removed", report) + reverse = progress.report(head, base) + self.assertIn("| libScePad.native | Shared sources: `libScePad` | 1/2 (50.00%) | n/a |", reverse) + changed = self.report_snapshot(owner, self.report_library("libScePad.native", shared_sources="libSceOther")) + self.assertIn("| libScePad.native | Shared sources: `libScePad` | Shared sources: `libSceOther` | n/a |", + progress.report(head, changed)) + + def test_report_omits_unchanged_libraries_and_preserves_shader_report(self): + library = self.report_library("libScePad", ["Ready"]) + base, head = self.report_snapshot(library), self.report_snapshot(dict(library, inert_names=["Ready"])) + self.assertEqual(progress.report(base, head), "") + head["shaders"] = progress.summarize([self.report_library("VOP1", ["V_MOV_B32"])]) + report = progress.report(base, head) + self.assertEqual(report, "\n".join(progress.compare("GPU shader instructions", "Encoding", "instructions", + base["shaders"], head["shaders"]))) + self.assertNotIn("Changed libraries", report) + + def test_report_rounds_percentage_point_change_without_negative_zero(self): + base = self.report_snapshot(self.report_library("libScePad", ["Ready"], ["A", "B"])) + head = self.report_snapshot(self.report_library("libScePad", ["Ready", "A"], ["B"])) + self.assertIn("| libScePad | 1/3 (33.33%) | 2/3 (66.67%) | +33.33 pp |", progress.report(base, head)) + before = dict(self.report_library("libSceLarge", ["Ready"]), todo=100000) + after = dict(before, todo=100001) + self.assertIn("| +0.00 pp |", progress.report(self.report_snapshot(before), self.report_snapshot(after))) + + def test_report_limits_library_rows(self): + head = self.report_snapshot(*(self.report_library(f"libSce{i:03}", ["Ready"]) for i in range(103))) + report = progress.report(self.report_snapshot(), head) + table = report.split("#### Changed libraries (implemented exports)")[1] + self.assertEqual(sum(line.startswith("| libSce") for line in table.splitlines()), progress.REPORT_ROWS) + self.assertIn("3 more libraries", table) + def test_raw_string_example_does_not_declare_an_export(self): source = 'const char* example = R"help(\nint APS5_VABI Fake() { return 0; }\n)help";\n' source += 'int APS5_VABI Real() { return 7; }\n' From e25647d68646b7c071105a1a3589a4137e900371 Mon Sep 17 00:00:00 2001 From: Gabrieljr42 Date: Wed, 21 Oct 2026 08:33:04 -0300 Subject: [PATCH 2/2] fix(progress): handle snapshots without library counts --- docs/dev/PROGRESS.md | 2 +- tools/progress.py | 2 ++ tools/tests/test_progress.py | 21 +++++++++++++++++++++ 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/docs/dev/PROGRESS.md b/docs/dev/PROGRESS.md index 7c5836a2a9..4e451630a3 100644 --- a/docs/dev/PROGRESS.md +++ b/docs/dev/PROGRESS.md @@ -24,7 +24,7 @@ Real removals and transitions back to stubs remain visible. Percentages retain t PR reports include a table of changed libraries with implemented export counts, declared totals, percentages before and after, and changes in percentage points (`pp`). The table uses the same two JSON snapshots as the global report. A library appears when its counts, export names or shared-source owner change, including when its counts stay equal. Unchanged libraries are omitted, and the table lists at most 100 libraries in name order with a count of omitted rows. -New or removed libraries show `Not present` on the missing side. Empty libraries show `No exports`, and wrappers show `Shared sources` with the source library's name. These rows show `n/a` for the percentage change. The table retains the scanner's counting rules; it does not measure compatibility. +New or removed libraries show `Not present` on the missing side. Libraries whose snapshots contain both counts and report zero exports show `No exports`; if either count is absent, the table shows `Counts unavailable`. Wrappers show `Shared sources` with the source library's name. These rows show `n/a` for the percentage change. The table retains the scanner's counting rules; it does not measure compatibility. Implemented definitions are also classified as behaviourally empty when their body holds only `(void)name;` discards and at most one constant return, bare `return;` or `throw`, including a return of a local `static` wrapper that is itself behaviourally empty; wrapper calls whose arguments contain calls do not qualify. Comparisons list newly implemented behaviourally empty functions without changing any count. Older JSON snapshots without `inert_names` are accepted. diff --git a/tools/progress.py b/tools/progress.py index 5c8045ad98..d2fa7a2960 100644 --- a/tools/progress.py +++ b/tools/progress.py @@ -446,6 +446,8 @@ def library_progress(group): return "Not present", None if "shared_sources" in group: return f'Shared sources: `{group["shared_sources"]}`', None + if "done" not in group or "todo" not in group: + return "Counts unavailable", None total = group["done"] + group["todo"] if not total: return "No exports", None diff --git a/tools/tests/test_progress.py b/tools/tests/test_progress.py index 8e380cd829..5f1a4fdb48 100644 --- a/tools/tests/test_progress.py +++ b/tools/tests/test_progress.py @@ -146,6 +146,27 @@ class ProgressTests(unittest.TestCase): self.assertIn("| libSceEmpty | Not present | No exports | n/a |", report) self.assertIn("| libSceEmptied | 1/1 (100.00%) | No exports | n/a |", report) + def test_report_marks_missing_counts_unavailable_with_no_delta(self): + complete = {"name": "libSceLegacy", "done": 1, "todo": 0, "done_names": ["Current"]} + for missing in ({"done_names": ["Legacy"]}, {"done": 0}, {"todo": 0}, {}): + minimal = {"name": "libSceLegacy", **missing} + for before, after in ((minimal, complete), (complete, minimal)): + with self.subTest(before=before, after=after): + base = {"libraries": {"groups": [before], "percent": 0}, + "shaders": {"groups": [], "percent": 0}} + head = {"libraries": {"groups": [after], "percent": 0}, + "shaders": {"groups": [], "percent": 0}} + report = progress.report(base, head) + self.assertIn("Counts unavailable", report) + self.assertIn("| n/a |", report) + self.assertNotIn("No exports", report) + + def test_report_omits_unchanged_minimal_snapshots(self): + minimal = {"name": "libSceLegacy"} + snapshot = {"libraries": {"groups": [minimal], "percent": 0}, + "shaders": {"groups": [], "percent": 0}} + self.assertEqual(progress.report(snapshot, snapshot), "") + def test_report_labels_shared_sources_without_duplicate_counts(self): owner = self.report_library("libScePad", ["Ready"], ["Pending"]) alias = self.report_library("libScePad.native", ["Ready"], ["Pending"])