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")