diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bcd4f44639..ef5efdb935 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -62,6 +62,7 @@ jobs: steps: - uses: actions/checkout@v4 - run: scripts/ci/test-difftest-baseline.sh + - run: python3 -I scripts/ci/check_record_ids.py fmt: runs-on: blacksmith-4vcpu-ubuntu-2404 diff --git a/scripts/ci/check_record_ids.py b/scripts/ci/check_record_ids.py new file mode 100755 index 0000000000..f27f9b70d8 --- /dev/null +++ b/scripts/ci/check_record_ids.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +"""Fails if two plan/upstream-issues/ records share an ID, or an addendum names no record. + +A record is `---.md` (e.g. `WR-262--extrusion-drop-point-fast.md`); its ID is +`-`. An addendum is `---addendum--.md` and must name +the ID of an existing record. Names without a numeric ID (`UB-new--`, `UB-unnumbered--`, +`HANG-unnumbered--`, `UNNUMBERED--`) and README.md are not checked. + +IDs that were already shared when this check was added are frozen in LEGACY below. Merged +records are never renamed (their IDs are cited elsewhere), so these stay, but no further +record may join them. plan/findings/ has no ID series (names are `--.md`), so +nothing there is checked. + +Usage: check_record_ids.py [] (default: the current directory) +""" +import re +import sys +from collections import defaultdict +from pathlib import Path + +ISSUES = Path("plan/upstream-issues") +RECORD = re.compile(r"^([A-Z]+-[0-9]+)--(.+)\.md$") +ADDENDUM = re.compile(r"^([A-Z]+-[0-9]+)--addendum-[0-9]{4}-[0-9]{2}-[0-9]{2}-.+\.md$") +ADDENDUM_LIKE = re.compile(r"--addendum") + +# Records sharing an ID when this check was added (2026-10-26). The `--dupN` names come from +# the archive split (README.md); the others were filed later under an ID already in use. +LEGACY = { + "UB-100": { + "UB-100--smderivsurfdefinition-getsamplevalues-dereferences-a-missing.md", + "UB-100--smpolybrep-calculateboundingbox-dereferences-the-null-point--dup2.md", + }, + "UB-175": { + "UB-175--smprototopologymanager-frees-proto-edges-before-the-proto.md", + "UB-175--tiny-booleans-reach-the-teardown-through-the-public-api.md", + }, + "UB-185": { + "UB-185--fillet-executive-initialized-flag-is-indeterminate-before--dup2.md", + "UB-185--smfilletvertex-calcmategeom-recurses-without-bound-through-a.md", + }, + "UB-204": { + "UB-204--smfilletsolver-getsurface-recurses-without-bound-through-the--dup2.md", + "UB-204--smmatrix-rows-cols-leaves-its-inline-storage-uninitialised.md", + }, + "UB-210": { + "UB-210--smbrep-deleteedge-reads-psurvf-outside-the-merge-branch-that.md", + "UB-210--smfilletedge-calcfilletintfillet-reads-the-context-of-a--dup2.md", + }, + "UB-238": { + "UB-238--setintersectioncurvethroughpoint-builds-the-new-uv-trim--dup3.md", + "UB-238--smbsplinecurve-createcanonical-corrupts-the-heap-for-a-nan.md", + "UB-238--smfilletgeom-makerailedgeuse-leaves-a-deleted-vertex-use--dup4.md", + "UB-238--vertex-vertex-and-edge-edge-topology-solvers-dereference-a--dup2.md", + }, + "UB-256": { + "UB-256--insert-point-class-stale-interval-reference.md", + "UB-256--offset-piecewise-merge-deleted-face-uaf.md", + }, + "WR-190": { + "WR-190--smfilletedge-calcblendingrail-builds-its-hermite-fallback-as--dup2.md", + "WR-190--smfilletsolver-recordfilleterror-reports-rail-0-for-rail-1.md", + }, + "WR-254": { + "WR-254--manifoldboolean-keeps-a-face-lying-in-the-slicing-sheet-in.md", + "WR-254--smfilletcorner-makeanalyticcornersurface-reports-its-ser-ner--dup2.md", + }, +} + + +def check(names, legacy=None): + """Returns the error messages for the file names of plan/upstream-issues/.""" + legacy = LEGACY if legacy is None else legacy + records = defaultdict(list) + addenda = [] + errors = [] + for name in sorted(names): + if ADDENDUM_LIKE.search(name): + m = ADDENDUM.match(name) + if m: + addenda.append((name, m.group(1))) + elif RECORD.match(name): + errors.append( + f"{name}: an addendum is named `--addendum--.md`" + ) + continue + m = RECORD.match(name) + if m: + records[m.group(1)].append(name) + for rid, files in sorted(records.items()): + if len(files) < 2 or set(files) <= legacy.get(rid, set()): + continue + new = [f for f in files if f not in legacy.get(rid, set())] + errors.append( + f"{rid} is used by {len(files)} records: {', '.join(files)}. " + f"Give {', '.join(new)} the next free ID in its series; to update a record, " + "add an addendum file instead." + ) + for name, rid in addenda: + if rid not in records: + errors.append(f"{name}: addendum names {rid}, but no record has that ID") + return errors + + +def main(argv): + root = Path(argv[1]) if len(argv) > 1 else Path(".") + names = [p.name for p in (root / ISSUES).iterdir() if p.is_file()] + errors = check(names) + for e in errors: + print(f"::error file={ISSUES}::{e}") + return 1 if errors else 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/scripts/ci/test_check_record_ids.py b/scripts/ci/test_check_record_ids.py new file mode 100644 index 0000000000..07248ce5c4 --- /dev/null +++ b/scripts/ci/test_check_record_ids.py @@ -0,0 +1,61 @@ +"""check_record_ids.py: duplicate record IDs and orphan addenda (file names only).""" +from pathlib import Path +import tempfile +import unittest + +import check_record_ids as c + + +class CheckRecordIds(unittest.TestCase): + def test_distinct_ids_pass(self): + names = ["README.md", "WR-1--a.md", "WR-2--b.md", "UB-1--c.md"] + self.assertEqual(c.check(names, legacy={}), []) + + def test_shared_id_fails(self): + errors = c.check(["WR-1--a.md", "WR-1--b.md"], legacy={}) + self.assertEqual(len(errors), 1) + self.assertIn("WR-1 is used by 2 records", errors[0]) + + def test_same_number_other_series_passes(self): + self.assertEqual(c.check(["WR-1--a.md", "UB-1--a.md"], legacy={}), []) + + def test_prefix_number_is_not_a_match(self): + self.assertEqual(c.check(["WR-1--a.md", "WR-12--b.md"], legacy={}), []) + + def test_addendum_does_not_count_as_record(self): + names = ["WR-1--a.md", "WR-1--addendum-2026-10-26-more.md"] + self.assertEqual(c.check(names, legacy={}), []) + + def test_addendum_without_record_fails(self): + errors = c.check(["WR-2--addendum-2026-10-26-more.md"], legacy={}) + self.assertEqual(len(errors), 1) + self.assertIn("no record has that ID", errors[0]) + + def test_malformed_addendum_fails(self): + errors = c.check(["WR-1--a.md", "WR-1--addendum-more.md"], legacy={}) + self.assertEqual(len(errors), 1) + self.assertIn("addendum is named", errors[0]) + + def test_unnumbered_names_are_skipped(self): + names = ["UB-new--a.md", "UB-new--b.md", "UNNUMBERED--c.md", "HANG-unnumbered--d.md"] + self.assertEqual(c.check(names, legacy={}), []) + + def test_legacy_duplicate_passes_but_cannot_grow(self): + legacy = {"UB-7": {"UB-7--a.md", "UB-7--b--dup2.md"}} + self.assertEqual(c.check(["UB-7--a.md", "UB-7--b--dup2.md"], legacy=legacy), []) + errors = c.check(["UB-7--a.md", "UB-7--b--dup2.md", "UB-7--c.md"], legacy=legacy) + self.assertEqual(len(errors), 1) + self.assertIn("UB-7--c.md the next free ID", errors[0]) + + def test_main_reads_directory(self): + with tempfile.TemporaryDirectory() as tmp: + d = Path(tmp) / c.ISSUES + d.mkdir(parents=True) + (d / "WR-1--a.md").write_text("x") + self.assertEqual(c.main(["x", tmp]), 0) + (d / "WR-1--b.md").write_text("x") + self.assertEqual(c.main(["x", tmp]), 1) + + +if __name__ == "__main__": + unittest.main()