diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a502c58..3596c21 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -26,6 +26,10 @@ jobs: - name: Regenerate data model + coverage badge run: python3 tools/gen_diagram.py + # Rebuild the UI / governance projection from the committed datadict. + - name: Build UI projection + run: python3 tools/build_ui_dict.py + # Assert semantic invariants (not git-diff byte-identity, which is noisy # because of timestamps in the DB and the report's generated-date line). - name: Verify invariants diff --git a/CHANGELOG.md b/CHANGELOG.md index 0218093..422734a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,12 @@ chronological development log see [`PROGRESS.md`](PROGRESS.md). source mask (e.g. GS1) or generated from type/allowed-values/scale. `datadict.db` is read-only input; the projection is rebuilt from scratch. +### Changed + +- CI now rebuilds the UI projection and `tools/ci_check.py` validates + `ui_datadict.db` (completeness, FK integrity, `ByteLength = CharLength*4`, + positive `CharLength`, unique `(GroupID, Name)`). + ## [1.2.1] - 2026-06-27 ### Added diff --git a/tools/ci_check.py b/tools/ci_check.py index a7c5a5c..b835cf6 100644 --- a/tools/ci_check.py +++ b/tools/ci_check.py @@ -24,6 +24,55 @@ from curated_descriptions import CURATED # noqa: E402 DB = os.path.join(ROOT, "datadict.db") +UI_DB = os.path.join(ROOT, "ui_datadict.db") + + +def check_ui(datadict_items): + """Invariants for the derived UI projection (ui_datadict.db).""" + fail = [] + if not os.path.exists(UI_DB): + return [f"{UI_DB} not found (run build_ui_dict.py)"] + u = sqlite3.connect(UI_DB) + u.row_factory = sqlite3.Row + items = u.execute("SELECT COUNT(*) n FROM UI_DataItems").fetchone()["n"] + groups = u.execute("SELECT COUNT(*) n FROM Groups").fetchone()["n"] + orphan_group = u.execute( + "SELECT COUNT(*) n FROM UI_DataItems x WHERE NOT EXISTS " + "(SELECT 1 FROM Groups g WHERE g.GroupID = x.GroupID)").fetchone()["n"] + bad_groupcat = u.execute( + "SELECT COUNT(*) n FROM Groups g WHERE NOT EXISTS " + "(SELECT 1 FROM Categories c WHERE c.CategoryID = g.CategoryID)" + ).fetchone()["n"] + bad_bytes = u.execute( + "SELECT COUNT(*) n FROM UI_DataItems " + "WHERE ByteLength IS NOT CharLength * 4").fetchone()["n"] + bad_charlen = u.execute( + "SELECT COUNT(*) n FROM UI_DataItems " + "WHERE CharLength IS NULL OR CharLength <= 0").fetchone()["n"] + dupes = u.execute( + "SELECT COUNT(*) n FROM (SELECT GroupID, Name FROM UI_DataItems " + "GROUP BY GroupID, Name HAVING COUNT(*) > 1)").fetchone()["n"] + + print(f"ui_items={items} groups={groups} orphan_groups={orphan_group} " + f"bad_group_category={bad_groupcat} byte!=char*4={bad_bytes} " + f"bad_charlength={bad_charlen} dup_(group,name)={dupes}") + + if groups < 1: + fail.append("UI: no groups") + if items != datadict_items: + fail.append(f"UI: item count {items} != datadict {datadict_items} " + "(projection should keep every item)") + if orphan_group: + fail.append(f"UI: {orphan_group} items reference a missing group") + if bad_groupcat: + fail.append(f"UI: {bad_groupcat} groups reference a missing category") + if bad_bytes: + fail.append(f"UI: {bad_bytes} rows where ByteLength != CharLength*4") + if bad_charlen: + fail.append(f"UI: {bad_charlen} rows with null/non-positive CharLength") + if dupes: + fail.append(f"UI: {dupes} duplicate (GroupID, Name) pairs") + return fail def main(): @@ -68,6 +117,8 @@ def main(): fail.append(f"{orphan_curated} curated descriptions reference a " "non-existent item Name") + fail += check_ui(total) + if fail: print("FAIL: " + "; ".join(fail)) return 1