mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-08 04:52:10 +00:00
ci: fail when two upstream-issue records share an ID or an addendum names no record
scripts/ci/check_record_ids.py runs in the ci-scripts job. IDs that were already shared on main (UB-100, UB-175, UB-185, UB-204, UB-210, UB-238, UB-256, WR-190, WR-254) are frozen as legacy: merged records are not renamed, and no new record may join them. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PJzQKZ9sjU1DhgS253e8J5
This commit is contained in:
@@ -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
|
||||
|
||||
Executable
+114
@@ -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 `<SERIES>-<N>--<slug>.md` (e.g. `WR-262--extrusion-drop-point-fast.md`); its ID is
|
||||
`<SERIES>-<N>`. An addendum is `<SERIES>-<N>--addendum-<YYYY-MM-DD>-<slug>.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 `<unit>--<slug>.md`), so
|
||||
nothing there is checked.
|
||||
|
||||
Usage: check_record_ids.py [<repo root>] (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 `<ID>--addendum-<YYYY-MM-DD>-<slug>.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))
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user