From e2effd57755d580318cce65ea1b2f98d896d5d40 Mon Sep 17 00:00:00 2001 From: speedy75015-crypto Date: Wed, 2 Sep 2026 09:14:59 +0200 Subject: [PATCH] fix(data): make catalog snapshot hashes line-ending independent (#478) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(data): regenerate stale catalog-summary snapshot hashes catalog-summary.json was not regenerated after google-fonts.csv, google-font-licenses.json, icons.csv and phosphor-icons-upstream.json changed, so `npm --prefix cli run verify:data` fails on a clean checkout of main: validate:semantic 4 stale snapshot errors validate:catalog-summary "catalog-summary.json is stale" test:python 1 failure / 153 check:assets 2 files out of sync Regenerated with the existing --verified-at 2026-08-26: only the four sha256 fields change. The date is a human attestation that the font catalog was checked against the upstream google/fonts repository, so it is deliberately left untouched -- no such verification was performed here. verify:data now exits 0. Note: prepublishOnly runs sync:assets before verify:data, which regenerates the snapshot at publish time. That is why released packages are unaffected and the drift stayed invisible on main. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015UidECV1wVBD8SW6Abuj71 * fix(data): make catalog snapshot hashes line-ending independent Root cause of the stale snapshot restored in the previous commit. bd19ab9 (#462) regenerated catalog-summary.json from a CRLF checkout. Every recorded sha256 was the CRLF hash of its source file, so the check failed on every LF platform. The four committed values are exactly sha256(crlf_bytes): google-fonts.csv committed d03194d2… = CRLF hash google-font-licenses.json committed 7c35e410… = CRLF hash icons.csv committed 272ccf0e… = CRLF hash phosphor-icons-upstream.json committed 81c37fb3… = CRLF hash Two conditions had to combine: the digest hashed raw bytes, and no .gitattributes pinned these files to LF, so Windows checkouts get CRLF by default. Restoring the hashes alone would let the next contributor on Windows reproduce the same commit. Three changes: - normalize line endings in generate-catalog-summary.py's digest(), so the snapshot no longer depends on the checkout - apply the same normalization in validate_data.py, which independently recomputes the hashes and has to agree with the generator - add .gitattributes pinning src/ui-ux-pro-max/data/*.{csv,json} to LF, so a Windows checkout matches the committed bytes in the first place sync-assets.mjs already normalizes to LF, so this only extends an existing project convention to the two places that were missing it. Adds test_catalog_summary_line_endings.py: LF and CRLF inputs must digest identically, the committed snapshot must match the normalized sources, and a simulated CRLF checkout must still produce the recorded hashes. The third case fails against the pre-fix digest. verify:data exits 0; the Python suite goes from 153 to 156 tests. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015UidECV1wVBD8SW6Abuj71 --------- Co-authored-by: Claude Opus 5 (1M context) --- .../ui-ux-pro-max/data/catalog-summary.json | 2 +- .../test_catalog_summary_line_endings.py | 78 +++++++++++++++++++ .../ui-ux-pro-max/scripts/validate_data.py | 6 +- .gitattributes | 6 ++ cli/assets/data/catalog-summary.json | 2 +- .../test_catalog_summary_line_endings.py | 78 +++++++++++++++++++ cli/assets/scripts/validate_data.py | 6 +- scripts/generate-catalog-summary.py | 4 +- src/ui-ux-pro-max/data/catalog-summary.json | 8 +- .../test_catalog_summary_line_endings.py | 78 +++++++++++++++++++ src/ui-ux-pro-max/scripts/validate_data.py | 6 +- 11 files changed, 264 insertions(+), 10 deletions(-) create mode 100644 .claude/skills/ui-ux-pro-max/scripts/tests/test_catalog_summary_line_endings.py create mode 100644 .gitattributes create mode 100644 cli/assets/scripts/tests/test_catalog_summary_line_endings.py create mode 100644 src/ui-ux-pro-max/scripts/tests/test_catalog_summary_line_endings.py diff --git a/.claude/skills/ui-ux-pro-max/data/catalog-summary.json b/.claude/skills/ui-ux-pro-max/data/catalog-summary.json index 4a8c764..0728f82 100644 --- a/.claude/skills/ui-ux-pro-max/data/catalog-summary.json +++ b/.claude/skills/ui-ux-pro-max/data/catalog-summary.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "verifiedAt": "2026-08-13", + "verifiedAt": "2026-08-26", "counts": { "styles": { "total": 88, diff --git a/.claude/skills/ui-ux-pro-max/scripts/tests/test_catalog_summary_line_endings.py b/.claude/skills/ui-ux-pro-max/scripts/tests/test_catalog_summary_line_endings.py new file mode 100644 index 0000000..056c8e5 --- /dev/null +++ b/.claude/skills/ui-ux-pro-max/scripts/tests/test_catalog_summary_line_endings.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +"""The catalog snapshot must not depend on the checkout's line endings. + +Regression test for bd19ab9 (#462), where catalog-summary.json was regenerated +on a CRLF checkout. Every recorded sha256 was the CRLF hash of the source file, +so `verify:data` failed on every LF platform, including CI. +""" + +import hashlib +import importlib.util +import json +import shutil +import tempfile +import unittest +from pathlib import Path + +REPO = next( + parent for parent in Path(__file__).resolve().parents + if (parent / "scripts" / "generate-catalog-summary.py").is_file() +) +DATA = REPO / "src/ui-ux-pro-max/data" +SNAPSHOT_FILES = ( + "google-fonts.csv", + "google-font-licenses.json", + "icons.csv", + "phosphor-icons-upstream.json", +) + + +def _load_generator(): + path = REPO / "scripts" / "generate-catalog-summary.py" + spec = importlib.util.spec_from_file_location("generate_catalog_summary", path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +class CatalogSummaryLineEndingsTest(unittest.TestCase): + def test_digest_is_identical_for_lf_and_crlf(self): + digest = _load_generator().digest + with tempfile.TemporaryDirectory() as tmp: + lf = Path(tmp) / "lf.csv" + crlf = Path(tmp) / "crlf.csv" + lf.write_bytes(b"id,name\n1,alpha\n2,beta\n") + crlf.write_bytes(b"id,name\r\n1,alpha\r\n2,beta\r\n") + self.assertEqual( + digest(lf), digest(crlf), + "snapshot hashes must not change with the checkout's line endings", + ) + + def test_committed_snapshot_matches_normalized_sources(self): + summary = json.loads((DATA / "catalog-summary.json").read_text(encoding="utf-8")) + for name in SNAPSHOT_FILES: + expected = hashlib.sha256( + (DATA / name).read_bytes().replace(b"\r\n", b"\n") + ).hexdigest() + self.assertEqual( + summary["snapshots"][name]["sha256"], expected, + f"{name}: committed snapshot hash does not match the LF-normalized source", + ) + + def test_crlf_checkout_produces_the_committed_hashes(self): + """Simulate a Windows checkout: the recorded hashes must still validate.""" + digest = _load_generator().digest + summary = json.loads((DATA / "catalog-summary.json").read_text(encoding="utf-8")) + with tempfile.TemporaryDirectory() as tmp: + for name in SNAPSHOT_FILES: + crlf_copy = Path(tmp) / name + raw = (DATA / name).read_bytes().replace(b"\r\n", b"\n") + crlf_copy.write_bytes(raw.replace(b"\n", b"\r\n")) + self.assertEqual( + digest(crlf_copy), summary["snapshots"][name]["sha256"], + f"{name}: a CRLF checkout would record a different hash", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/.claude/skills/ui-ux-pro-max/scripts/validate_data.py b/.claude/skills/ui-ux-pro-max/scripts/validate_data.py index c7797cd..8b6c794 100644 --- a/.claude/skills/ui-ux-pro-max/scripts/validate_data.py +++ b/.claude/skills/ui-ux-pro-max/scripts/validate_data.py @@ -663,7 +663,11 @@ def _check_catalog_summary(summary, licenses, phosphor, problems): problems.append(f"[catalog:summary] stale count for {key}") snapshots = summary.get("snapshots") if isinstance(summary.get("snapshots"), dict) else {} for name in ("google-fonts.csv", "google-font-licenses.json", "icons.csv", "phosphor-icons-upstream.json"): - digest = hashlib.sha256((DATA_DIR / name).read_bytes()).hexdigest() + # Line endings are normalized so the check matches + # generate-catalog-summary.py on CRLF checkouts too. + digest = hashlib.sha256( + (DATA_DIR / name).read_bytes().replace(b"\r\n", b"\n") + ).hexdigest() if snapshots.get(name) != {"sha256": digest}: problems.append(f"[catalog:summary] stale snapshot for {name}") policy = summary.get("promotionPolicy") diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..77b40e6 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,6 @@ +# catalog-summary.json records sha256 hashes of the files below, computed over +# their bytes. A CRLF checkout therefore produces different hashes than an LF +# one, which makes `verify:data` fail on every other platform (see #462/#478). +# Pin these files to LF so the snapshot is reproducible everywhere. +src/ui-ux-pro-max/data/*.csv text eol=lf +src/ui-ux-pro-max/data/*.json text eol=lf diff --git a/cli/assets/data/catalog-summary.json b/cli/assets/data/catalog-summary.json index 4a8c764..0728f82 100644 --- a/cli/assets/data/catalog-summary.json +++ b/cli/assets/data/catalog-summary.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "verifiedAt": "2026-08-13", + "verifiedAt": "2026-08-26", "counts": { "styles": { "total": 88, diff --git a/cli/assets/scripts/tests/test_catalog_summary_line_endings.py b/cli/assets/scripts/tests/test_catalog_summary_line_endings.py new file mode 100644 index 0000000..056c8e5 --- /dev/null +++ b/cli/assets/scripts/tests/test_catalog_summary_line_endings.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +"""The catalog snapshot must not depend on the checkout's line endings. + +Regression test for bd19ab9 (#462), where catalog-summary.json was regenerated +on a CRLF checkout. Every recorded sha256 was the CRLF hash of the source file, +so `verify:data` failed on every LF platform, including CI. +""" + +import hashlib +import importlib.util +import json +import shutil +import tempfile +import unittest +from pathlib import Path + +REPO = next( + parent for parent in Path(__file__).resolve().parents + if (parent / "scripts" / "generate-catalog-summary.py").is_file() +) +DATA = REPO / "src/ui-ux-pro-max/data" +SNAPSHOT_FILES = ( + "google-fonts.csv", + "google-font-licenses.json", + "icons.csv", + "phosphor-icons-upstream.json", +) + + +def _load_generator(): + path = REPO / "scripts" / "generate-catalog-summary.py" + spec = importlib.util.spec_from_file_location("generate_catalog_summary", path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +class CatalogSummaryLineEndingsTest(unittest.TestCase): + def test_digest_is_identical_for_lf_and_crlf(self): + digest = _load_generator().digest + with tempfile.TemporaryDirectory() as tmp: + lf = Path(tmp) / "lf.csv" + crlf = Path(tmp) / "crlf.csv" + lf.write_bytes(b"id,name\n1,alpha\n2,beta\n") + crlf.write_bytes(b"id,name\r\n1,alpha\r\n2,beta\r\n") + self.assertEqual( + digest(lf), digest(crlf), + "snapshot hashes must not change with the checkout's line endings", + ) + + def test_committed_snapshot_matches_normalized_sources(self): + summary = json.loads((DATA / "catalog-summary.json").read_text(encoding="utf-8")) + for name in SNAPSHOT_FILES: + expected = hashlib.sha256( + (DATA / name).read_bytes().replace(b"\r\n", b"\n") + ).hexdigest() + self.assertEqual( + summary["snapshots"][name]["sha256"], expected, + f"{name}: committed snapshot hash does not match the LF-normalized source", + ) + + def test_crlf_checkout_produces_the_committed_hashes(self): + """Simulate a Windows checkout: the recorded hashes must still validate.""" + digest = _load_generator().digest + summary = json.loads((DATA / "catalog-summary.json").read_text(encoding="utf-8")) + with tempfile.TemporaryDirectory() as tmp: + for name in SNAPSHOT_FILES: + crlf_copy = Path(tmp) / name + raw = (DATA / name).read_bytes().replace(b"\r\n", b"\n") + crlf_copy.write_bytes(raw.replace(b"\n", b"\r\n")) + self.assertEqual( + digest(crlf_copy), summary["snapshots"][name]["sha256"], + f"{name}: a CRLF checkout would record a different hash", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/cli/assets/scripts/validate_data.py b/cli/assets/scripts/validate_data.py index c7797cd..8b6c794 100644 --- a/cli/assets/scripts/validate_data.py +++ b/cli/assets/scripts/validate_data.py @@ -663,7 +663,11 @@ def _check_catalog_summary(summary, licenses, phosphor, problems): problems.append(f"[catalog:summary] stale count for {key}") snapshots = summary.get("snapshots") if isinstance(summary.get("snapshots"), dict) else {} for name in ("google-fonts.csv", "google-font-licenses.json", "icons.csv", "phosphor-icons-upstream.json"): - digest = hashlib.sha256((DATA_DIR / name).read_bytes()).hexdigest() + # Line endings are normalized so the check matches + # generate-catalog-summary.py on CRLF checkouts too. + digest = hashlib.sha256( + (DATA_DIR / name).read_bytes().replace(b"\r\n", b"\n") + ).hexdigest() if snapshots.get(name) != {"sha256": digest}: problems.append(f"[catalog:summary] stale snapshot for {name}") policy = summary.get("promotionPolicy") diff --git a/scripts/generate-catalog-summary.py b/scripts/generate-catalog-summary.py index ffd85ef..3cf7439 100644 --- a/scripts/generate-catalog-summary.py +++ b/scripts/generate-catalog-summary.py @@ -20,7 +20,9 @@ def rows(name): def digest(path): - return hashlib.sha256(path.read_bytes()).hexdigest() + # Normalize line endings so the snapshot does not depend on whether the + # working tree was checked out with LF or CRLF. + return hashlib.sha256(path.read_bytes().replace(b"\r\n", b"\n")).hexdigest() def load_json(name): diff --git a/src/ui-ux-pro-max/data/catalog-summary.json b/src/ui-ux-pro-max/data/catalog-summary.json index e02f199..0728f82 100644 --- a/src/ui-ux-pro-max/data/catalog-summary.json +++ b/src/ui-ux-pro-max/data/catalog-summary.json @@ -24,16 +24,16 @@ }, "snapshots": { "google-fonts.csv": { - "sha256": "d03194d2c35a2cdc4c6846ddde9beb54fdd1f81a3ddc80fead3c0ba2842b75fb" + "sha256": "1c8c3b2ea1faf6a1012da463756def8b3889db33f2226f0343bb4daa80307d03" }, "google-font-licenses.json": { - "sha256": "7c35e410dd8b5853ca86c3e0e3d3cfb73db1ffa3d9a2fa688c182bc44cd9a8d7" + "sha256": "35688523f2955795caa1a47c53b83099e60c1708461476f9cc3a050cf3b0148a" }, "icons.csv": { - "sha256": "272ccf0eb60e50ba7af55de3ef5c195d92e1862d411b6cb80d81dec4fe56deee" + "sha256": "50816c6012030178195a16ee481ebf58b47bd985d70e8ec58886cc83f6eddafc" }, "phosphor-icons-upstream.json": { - "sha256": "81c37fb3583eb43a91e93f1a62c4da3b4ff089624c06bfe53950b8205b54974d" + "sha256": "2399325233b277b5c97a80e6a5e8941154f5d057beee4e7613db87c87d700236" } }, "promotionPolicy": { diff --git a/src/ui-ux-pro-max/scripts/tests/test_catalog_summary_line_endings.py b/src/ui-ux-pro-max/scripts/tests/test_catalog_summary_line_endings.py new file mode 100644 index 0000000..056c8e5 --- /dev/null +++ b/src/ui-ux-pro-max/scripts/tests/test_catalog_summary_line_endings.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +"""The catalog snapshot must not depend on the checkout's line endings. + +Regression test for bd19ab9 (#462), where catalog-summary.json was regenerated +on a CRLF checkout. Every recorded sha256 was the CRLF hash of the source file, +so `verify:data` failed on every LF platform, including CI. +""" + +import hashlib +import importlib.util +import json +import shutil +import tempfile +import unittest +from pathlib import Path + +REPO = next( + parent for parent in Path(__file__).resolve().parents + if (parent / "scripts" / "generate-catalog-summary.py").is_file() +) +DATA = REPO / "src/ui-ux-pro-max/data" +SNAPSHOT_FILES = ( + "google-fonts.csv", + "google-font-licenses.json", + "icons.csv", + "phosphor-icons-upstream.json", +) + + +def _load_generator(): + path = REPO / "scripts" / "generate-catalog-summary.py" + spec = importlib.util.spec_from_file_location("generate_catalog_summary", path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +class CatalogSummaryLineEndingsTest(unittest.TestCase): + def test_digest_is_identical_for_lf_and_crlf(self): + digest = _load_generator().digest + with tempfile.TemporaryDirectory() as tmp: + lf = Path(tmp) / "lf.csv" + crlf = Path(tmp) / "crlf.csv" + lf.write_bytes(b"id,name\n1,alpha\n2,beta\n") + crlf.write_bytes(b"id,name\r\n1,alpha\r\n2,beta\r\n") + self.assertEqual( + digest(lf), digest(crlf), + "snapshot hashes must not change with the checkout's line endings", + ) + + def test_committed_snapshot_matches_normalized_sources(self): + summary = json.loads((DATA / "catalog-summary.json").read_text(encoding="utf-8")) + for name in SNAPSHOT_FILES: + expected = hashlib.sha256( + (DATA / name).read_bytes().replace(b"\r\n", b"\n") + ).hexdigest() + self.assertEqual( + summary["snapshots"][name]["sha256"], expected, + f"{name}: committed snapshot hash does not match the LF-normalized source", + ) + + def test_crlf_checkout_produces_the_committed_hashes(self): + """Simulate a Windows checkout: the recorded hashes must still validate.""" + digest = _load_generator().digest + summary = json.loads((DATA / "catalog-summary.json").read_text(encoding="utf-8")) + with tempfile.TemporaryDirectory() as tmp: + for name in SNAPSHOT_FILES: + crlf_copy = Path(tmp) / name + raw = (DATA / name).read_bytes().replace(b"\r\n", b"\n") + crlf_copy.write_bytes(raw.replace(b"\n", b"\r\n")) + self.assertEqual( + digest(crlf_copy), summary["snapshots"][name]["sha256"], + f"{name}: a CRLF checkout would record a different hash", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/ui-ux-pro-max/scripts/validate_data.py b/src/ui-ux-pro-max/scripts/validate_data.py index c7797cd..8b6c794 100644 --- a/src/ui-ux-pro-max/scripts/validate_data.py +++ b/src/ui-ux-pro-max/scripts/validate_data.py @@ -663,7 +663,11 @@ def _check_catalog_summary(summary, licenses, phosphor, problems): problems.append(f"[catalog:summary] stale count for {key}") snapshots = summary.get("snapshots") if isinstance(summary.get("snapshots"), dict) else {} for name in ("google-fonts.csv", "google-font-licenses.json", "icons.csv", "phosphor-icons-upstream.json"): - digest = hashlib.sha256((DATA_DIR / name).read_bytes()).hexdigest() + # Line endings are normalized so the check matches + # generate-catalog-summary.py on CRLF checkouts too. + digest = hashlib.sha256( + (DATA_DIR / name).read_bytes().replace(b"\r\n", b"\n") + ).hexdigest() if snapshots.get(name) != {"sha256": digest}: problems.append(f"[catalog:summary] stale snapshot for {name}") policy = summary.get("promotionPolicy")