From c5aa869bbd58a109d8733decbbec4db21216351a Mon Sep 17 00:00:00 2001 From: vib-tools Date: Wed, 26 Aug 2026 17:23:47 -0700 Subject: [PATCH] fix(repo): separate source integrity from workspace artifacts --- SHA256SUMS.txt | 112 --------------------- scripts/test/audit.py | 8 ++ scripts/test/repository_hygiene.py | 138 ++++++++++++++++++++++++++ tests/test_repository_hygiene.py | 153 +++++++++++++++++++++++++++++ 4 files changed, 299 insertions(+), 112 deletions(-) delete mode 100644 SHA256SUMS.txt create mode 100644 scripts/test/repository_hygiene.py create mode 100644 tests/test_repository_hygiene.py diff --git a/SHA256SUMS.txt b/SHA256SUMS.txt deleted file mode 100644 index 2aae21b..0000000 --- a/SHA256SUMS.txt +++ /dev/null @@ -1,112 +0,0 @@ -0c7b7d478b92dc2434d6220166ecc1bf5bf02bf1365963464d5757a954a7d3b5 .github/workflows/ci.yml -a1cc3e6fab2db9c78caffcfc88142bb0ddcb9f939e90bd2900e9ce8d4fe35495 CHANGELOG.md -99979d4e3c1ebe09a48052b735d6ca8415056d7f2e1e8b23c82bb9c3b2a0fc01 COMPATIBILITY.md -6eadb3692c25fc835e2619ee4d5a17b75b5d5b85dfb31bb23e0f37e7583de3f9 docs/api/provider-manifest.md -cceeb1759c1355a0910c65d7961671548407e5e3b6438ad8c4cf4d1bdf93f817 docs/configuration/index.md -0327dda9da2d5c8cd63b6d23cdb52a3588c02f1ef997b92ad9e319853d5cf664 docs/developer/ACTUAL_IMPLEMENTATION_STATUS.md -da5ca9d3651ee05bbf893b6ea8c04752e7c90abbce8495778dc27975724a470a docs/developer/architecture.md -9b35dd974f33f0c615566188c48182d714059d52de080461cf0e3d709999dd33 docs/developer/ERROR_HANDLING.md -cdd3adc6c8e22b7444bcc3b62febed70b252bd79deb2eb61bae990e5033c8f92 docs/docs.manifest.ygit -55da16cbbfc8e062056c83e22b07d1a59ef4f527728e24018e12bc3253c6d4df docs/getting-started/installation.md -4ba3b1bce5ecf484df1796c55fb33cc957c9e18b0f73da353ecc3d3361744499 docs/guides/providers.md -5d78cf1fa9259e4ce7a373821f7049cf7bb0f1413b702a89b27cff04f2859c74 docs/guides/tasks.md -6bd039fb1adfba3241affd7078a98b09096512dcaa9fcd85a3925e1abebd02ed docs/index.md -1a171bcdeb62bab98991240d94ad0b1fbfb387005fa2c7911a95ac238af4109f docs/release-notes/1.0.0.1.49.4.md -72188ace21f4826575365cbe4da21dc38753e8adfa5b4ae31384ba264842e8cc docs/release-notes/1.0.0.1.49.5.md -e96af1443044f8d9bfbfb1d65c7f3d81848408ed92f5d247eb3a360e267b001f docs/release-notes/1.0.0.1.49.6.md -9aec6f74c1c06e27c75d3ece012660d0c327402c836793294747289aa45e39eb docs/release-notes/1.0.0.1.49.7.md -65ecf79a018c8a0e6b69583bce1307132c55fe4f9d4290dc0dcbd185191484d2 docs/release-notes/1.0.0.1.49.8.md -4669444187ec932a9a2f3f70cd5835d216a0db443ac71d737ed0a287be713d09 docs/release-notes/1.0.0.1.49.9.md -43a334496857dfd3282df4ed0c1ae2b354e3839061ce6e2ac6d3d5e30b49f7ca docs/release-notes/1.0.0.1.50.md -365b3da68f0e90be9be9dbf4e1455a975e9d58bbc07c25a0af4391dfc42495f2 docs/troubleshooting/index.md -40c30d92507a7a257b13fde009428690fc119b75014191c98e8419eb8d67f4db docs/user/usage.md -d3623aac00859803bc388e5e78803026feb7b75c51d3107aa295821d19e0b9c6 examples/provider_ivx_layout.md -ce8d4c57deece125d445d4699fbef8d7530c209cd1726998e0ce190976f68962 PATCH_MANIFEST_v1.0.0.1.49.4.md -3eb602d1c856c8917ce521a878ea3f0ea3e1434370b31af6ec08e8b69cc0cd8c PATCH_MANIFEST_v1.0.0.1.49.5.md -60d3a200edc063db32e05da03fb8d4e24d0ddea6fac28f764b611ce78a666b78 PATCH_MANIFEST_v1.0.0.1.49.6.md -58bb169ad0080118021f6762f1c44c8cf1cf33801e0fc3eb23f9a6fef779e8ef PATCH_MANIFEST_v1.0.0.1.49.7.md -2dd1173436b44e4b9304602df508e1935407449ebabbe85dca09559cd9fdc360 PATCH_MANIFEST_v1.0.0.1.49.8.md -9ddd15817954389d0f5f63c32d1e3fc30ae5d3a986af099f3c820d47ae9d9547 PATCH_MANIFEST_v1.0.0.1.49.9.md -543567a193fd2d592c8c605ba1dee6ebb4131d17e0834f3eae062fd2f22c17dc PATCH_MANIFEST_v1.0.0.1.50.md -d9236f605c9efeb6198c64b9e9da8671521097e3bef07f57a02a80e25a8f55e2 project/architecture/ARCHITECTURE.md -83f47716d7687a3069884350a212507a77c9084022f3a7f2e763e9d7c2ba089f project/planning/PHASE_01_RDP_TLS_COMPLETION_v1.0.0.1.49.5.md -041504aa46b12e34029f7404f9e947de79a9311e978ed7d432778ea21f555bbd project/planning/PHASE_02_PROVIDER_FATAL_LIMIT_COMPLETION_v1.0.0.1.49.6.md -135f8f284a00f2807cdba5b3d8eead72ff3fa064c5bd2c91b04d97c4bdcb6cf8 project/planning/PHASE_03_SENDING_CONTROLS_COMPLETION_v1.0.0.1.49.8.md -98a667d18711abcc0d2bf75bcf99b5937916893cd3fa3912ebda15b4fe9f023b project/planning/PHASE_04_DYNAMIC_TAGS_COMPLETION_v1.0.0.1.50.md -76811879c3c6c31f423c16a2eebfbde82536251b4277d8d9414f0d01595a1c5a project/planning/PHASE_COMPLETION_LOG.md -e763fbc4118fb65fa013d2c60cc02258dd4f2094aeab9a2366360fb1a2f961a6 project/planning/PRODUCTION_ROADMAP.md -53079436364b7be4ac93de7399e076feb8f20fa426998eb5d4310ad3670cf77a project/README.md -1bdb8c95f6e6ce7a46a355ebaae31596907fba8b52630468127ad58d4a2ef3a1 project/research/FINAL_FORENSIC_VERIFICATION_v1.0.0.1.49.4.md -e822c745f857fa822c500653d99e8e6817fdc32aa73953afe78be8fdcc9b6f63 project/research/FINAL_FORENSIC_VERIFICATION_v1.0.0.1.49.8.md -44fb412bfd3ec7edaafe7993218c4adb9540b8eef0c33d8fe6dc2740598e8c1e project/research/FINAL_FORENSIC_VERIFICATION_v1.0.0.1.49.9.md -72b8878addf2dea8a681707f5a31134e38939e0e663cfc50b4e8e773d8fa4675 project/research/FINAL_FORENSIC_VERIFICATION_v1.0.0.1.50.md -c2b7429757558ae49f7cd7a345a5c59a9e1f1aeb345f50a1b1e6e55f6d359eb8 project/research/ROOT_CAUSE_VERIFICATION_v1.0.0.1.49.4.md -501a978b3760125038a02cb258f8698ba14716869ecf3a06545b0827af17c21c project/research/ROOT_CAUSE_VERIFICATION_v1.0.0.1.49.5.md -9f632bd4ba3561bdf23924f1538f681cc2432e27e0f0a6e36d003af3c0567cab project/research/ROOT_CAUSE_VERIFICATION_v1.0.0.1.49.6.md -f2c3a41ab80aa9993a43425d0ff8199211c88d3be662eb386227d98717dd5975 project/research/ROOT_CAUSE_VERIFICATION_v1.0.0.1.49.9.md -3e2bfb1f038a0e8819f87d2688bfca37660dd8fcd66a02b2a78465326f1d2e33 project/research/UPDATE_IMPLEMENTATION_VERIFICATION_v1.0.0.1.49.4.md -b004dfeb506aeadd1c84e0cb3e9ad1b0af0094b74a6ebcbfcdfcaf2c2e6db9ee project/research/UPDATE_IMPLEMENTATION_VERIFICATION_v1.0.0.1.49.5.md -2966861b1f6c668446fe19c693e083d0d522b37b3b21c68092c58c47c162bd38 project/research/UPDATE_IMPLEMENTATION_VERIFICATION_v1.0.0.1.49.6.md -ca6db73cc7450791d664f37aae73cd10abc4a65eda51390484baa03e7c227794 project/research/UPDATE_IMPLEMENTATION_VERIFICATION_v1.0.0.1.49.8.md -85ea4d7d370ef06ae838992f542547692dc7f78420a62596609457caeac1babf project/research/UPDATE_IMPLEMENTATION_VERIFICATION_v1.0.0.1.50.md -bb46ff475c0f9a4c5111a734c68481c3d2aed879704fb328e7a904f81484169d project/specifications/BASELINE_FREEZE_v1.0.0.1.49.4.md -716ce8cc67f81f71826ff8f0d8b6a6c082171ddfa0f5c527cd58ea1cdd3dfc46 project/specifications/BASELINE_FREEZE_v1.0.0.1.49.5.md -9f4f65686cbcb7d65b9f372118d0608ddcaea2e5a68cc46b555051409aedf259 project/specifications/BASELINE_FREEZE_v1.0.0.1.49.6.md -3279410bf6766cb7d6017db19c01487efa9d7bb067a924f286641f621a863620 project/specifications/BASELINE_FREEZE_v1.0.0.1.49.8.md -fdd8f01269154a5c8406eaf84547b15b6f68cce77ded57791a1b788163a17961 project/specifications/BASELINE_FREEZE_v1.0.0.1.49.9.md -0b60751844702c37502d1613dfbe4fb77d9277c160aa925c695601e7200e5791 project/specifications/BASELINE_FREEZE_v1.0.0.1.50.md -7fabef952a3a212371fc11f5c23e69443b2afd1ad989877eb1ef2294021499a9 PROJECT_STRUCTURE.md -b199f258d84ff18d6cd3fe57fd0e438d80976ecf97f7b76dcf1b0d87c299d40d providers/plugins/odoo/adapter.py -621891bb309284798896bdb5e7dc1e14725a63570b9a0388509f7774b0de765a providers/plugins/odoo/CHANGELOG.md -828d81d277bd9961212802fc8163205ad216b9dca532ced0a3b9af7ae42aec72 providers/plugins/odoo/provider.json -db3c8f7c92cadaad63c50ae93a74f81e8f9833d50dc14c6e6082457fc52e2f4e providers/plugins/odoo/README.md -0283231ed1baa4c4207459ba965d7509d065120fdf1764255c7a456f5b114dd2 providers/plugins/odoo/SHA256SUMS.txt -f23b7a8362bcdba8f0b4de25767cec37df924241498ece5f42c76d6b452190ed pyproject.toml -412ea8cd19495303d359308faa64eba33af4d4f761a08aa527b98760df257f81 README.md -5cc08e19febd6fb0c0dff4c79c9fba7eefca93b359ee120c64004ba165f6fac7 requirements.txt -6e1b2a48f73e53f9f208b112226a907ad7c90b9831a1aa7259523d4e366698f4 ROADMAP.md -16751063ae5453937f368f6d87ca5ed35f0bc7b9841d0b3455a8963bcf2cae39 scripts/provider/build_ivx.py -48a6cc1e536bd7713d279accc711e522243decee5a0286477f01ad7288d85e1a scripts/test/p14_distribution_audit.py -62ec93cca1cb830691d95801ef43a4575edd95c3b328a39253a5d218f4179f50 scripts/test/p14_wheel_audit.py -9bd1e1ad1d270b2f31ac9052c63251fa23b928b9abc40de2f0fd6e57f2797b14 scripts/test/p14_windows_smoke.py -3e3731e7c9eb682be88aa273ff865ef3d6f059120014b09eda6bdb8e8ed504aa src/app.py -81698d6e46abe64ebf9654643da342be7e8e38460439dd07104bb3e2cae6fb73 src/core/dynamic_tags.py -370f7ff67c496b48c495cc902eb94d0eb6ab2da9b0fd67ec47c864f6b49c9153 src/core/provider_manager/ivx.py -72a6ecc6fd6d01f88eaf6b932485c7779b5634525373150438024926bf2dee9c src/core/provider_runtime/external.py -6a8415b7167cb799f84cea914db1759a6a4f9cd77969d65868ae98f3787cbbeb src/core/provider_runtime/preflight.py -a8fcdd34a3a8e518712e71986e3d50171085d82b703cf00ca57a11fbb2e46cb1 src/core/provider_runtime/runtime.py -6034cc7a2c62be17307c80ac7de6e662f10685057fcb99127bcf9c649e5c4d1d src/core/settings/__init__.py -fdc7d58d4c42b64fb18fadc79a0a1d3cdf9985fafc52494a856a2388858a17d7 src/core/settings/manager.py -9f6626b4e89e90f2fde80a30f37040cf1cdd7ca5061a43f60361f3ad4b7ebccf src/core/state/app_state.py -c7d852a3091249e04a5b678a5a2505043cdd4c4aabdebd02612f63ad09da0a61 src/core/storage/domain_store.py -76514c9c420d9745b2cd03cf11327b0ffa0dbcd7864254e7cead2dc91fa64e95 src/core/storage/schema.py -ca7e56f781b206c8665580293d79a632509ad9f65f483b5cd36900bbf579b4b5 src/customers/importers/email_importer.py -7f89ec7bdb01f80310fd9a2c10d27623ec6156bff428e219120f7b051bdef718 src/customers/models/customer_list.py -930cc0551daca7a1cf9696b632072c136ac71e3fdb6916da385e8d0db4a73b69 src/tasks/models/__init__.py -7380753bdc2ef9ac4189718ca2eadfcb35d2296a966adc302807ddf52fbbeba6 src/tasks/models/task.py -3aea10979c4cfba911770a993d9ea080b6d44588bf773100f161f0d5494bfc61 src/ui/main_window.py -39be32cd808e6be6fe0fd4452438d65d4f5fd7a61b7c24122ab05c5080678d55 src/ui/pages/providers_page.py -cca1547f7d7030dea32b1dd8512d850cccc6cfea391b467711a36b4f605f9354 src/ui/pages/settings_page.py -309622714349fe10ff752a50e1e8c61b0cf065c231e7c222e02524d6f47a5b9b src/ui/styles.py -5727e79c966802040eb11ca4d022b62b8d4480b0a5be56b95fa23a54b2e8e537 tests/test_odoo_provider_bundle.py -4f769466b7f160a9b86e76c23129ed5c1df670db41fd9404115408d0e82a7135 tests/test_p08_reliability.py -100a92901e90fe0cf72808fe77e07c1dd82b36a32d2911848984c31a84470937 tests/test_p09_scheduling.py -6127008a7af8a2db591ab9783c0d326ed99d4159db199cbbfccd4d29441be424 tests/test_p10_delivery_ledger.py -e2bef092e5d3d68266e09ca24e5edbb195e2482026be0fcfec3a6a2ab7f81a35 tests/test_p13_external_adapters.py -f869a31a2dc6ddb33ef38f5f6934e95eb6466bb1c68042d99bbe75fbf9f583ed tests/test_p14_distribution_pipeline.py -9b1b3f273fbf9de37763ffa3d5d294b7b61f82ab2308c1eb8d83e05b2678feb4 tests/test_phase4_dynamic_tags.py -18a4aa91b63220b54739d84a565513caa5625e01e73b107ea25620255ed46c73 tests/test_provider_ivx.py -7aa8772216be00294fb4fe99a925400786505b691ab886a0bebefabf319cabc5 tests/test_repository_contracts.py -64e85360fbeff2edbf41d526eb03bbc8f99ef534bc2b8d201a5b90475ed19e50 tests/test_settings.py -07447337e926d8d57e0ee7ea883ded19ed6b3fcc18d2481611f41280a2551d36 tests/test_state.py -3b5b3aa3e4fe0ab8613adfed8285cac5eed472b0d3841e0914c871e942c779f2 tests/test_storage.py -14fe206db88271ead6c0063eb6ac938635211b42150a66fae7945572e85c5810 tests/test_ui_contracts.py -eed2e1aaa93284f985c217c8b5930647fd4058d3e66dc18403f8aa886116651b tests/test_ui_runtime_interactions.py -de5c230886e0dfd295c896f5c0335a617db2cab14a3556dc954eeb7c280ab637 VERSIONING.md -76ce6ab8b90daafff37412cc51f75c0355726eeef4ba54f1338a70264a59c7e6 vibproject.ygit -1757cbe52830c1000941ef305b2a724f52b78d6a8510db6ebd1a66618329d7d4 docs/release-notes/1.0.0.1.50.1.md -943dbb1eca1f281c27401003fe1928fe30544b9f8a454eaccfddf173ddfd0192 PATCH_MANIFEST_v1.0.0.1.50.1.md -aa65ea8be1700a4aabac0532017985b00adceefae83ede2840e1e25b1ef5cb23 project/research/ROOT_CAUSE_VERIFICATION_v1.0.0.1.50.1.md -8d80948a643cfa0a3b85d913460c6555f80f2fbfeb74c84a4b5c284ed276fe62 project/research/UPDATE_IMPLEMENTATION_VERIFICATION_v1.0.0.1.50.1.md -8acf49df653a58dccdaf639cf657e8314d2141f99d4bf88cac786d1daadc04de project/research/FINAL_FORENSIC_VERIFICATION_v1.0.0.1.50.1.md -a31609386d1266caf36b5faa41c3e5ae5ce41055e40348c2e70683b55c041884 project/specifications/BASELINE_FREEZE_v1.0.0.1.50.1.md diff --git a/scripts/test/audit.py b/scripts/test/audit.py index 85ea624..222b98c 100644 --- a/scripts/test/audit.py +++ b/scripts/test/audit.py @@ -34,6 +34,7 @@ def _purge_repository_bytecode() -> int: # source files on Windows, causing stale bytecode to override source truth. _purged_bytecode_dirs = _purge_repository_bytecode() +from scripts.test.repository_hygiene import RepositoryHygieneError, assert_repository_source_hygiene from scripts.test.repository_syntax import RepositorySyntaxError, assert_repository_python_syntax @@ -47,6 +48,13 @@ def _purge_repository_bytecode() -> int: raise SystemExit(str(exc)) from exc print(f"PASS ({checked} repository Python files)") +print("\n== Repository source hygiene ==") +try: + source_candidates = assert_repository_source_hygiene(ROOT) +except RepositoryHygieneError as exc: + raise SystemExit(str(exc)) from exc +print(f"PASS ({source_candidates} public source candidate files)") + print("\n== Invio unit tests ==") subprocess.run([sys.executable, "-m", "unittest", "discover", "-s", "tests", "-v"], cwd=ROOT, check=True) diff --git a/scripts/test/repository_hygiene.py b/scripts/test/repository_hygiene.py new file mode 100644 index 0000000..6352a1b --- /dev/null +++ b/scripts/test/repository_hygiene.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +import subprocess +from pathlib import Path + + +class RepositoryHygieneError(RuntimeError): + """Raised when repository source boundaries are not clean or reproducible.""" + + +_ROOT_TRANSIENT_CHECKSUM = "SHA256SUMS.txt" +_FORBIDDEN_PREFIXES = ( + "project/", + "build/", + "dist/", + "data/runtime/", + "data/exports/", +) +_FORBIDDEN_COMPONENTS = {"__pycache__", ".pytest_cache"} +_FORBIDDEN_SUFFIXES = (".pyc", ".pyo") + + +def _run_git(root: Path, args: list[str]) -> bytes: + root = Path(root).resolve() + try: + completed = subprocess.run( + ["git", "-C", str(root), *args], + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + except (OSError, subprocess.CalledProcessError) as exc: + detail = "" + if isinstance(exc, subprocess.CalledProcessError) and exc.stderr: + detail = exc.stderr.decode("utf-8", errors="replace").strip() + suffix = f": {detail}" if detail else "" + raise RepositoryHygieneError(f"Git repository hygiene command failed{suffix}") from exc + return completed.stdout + + +def _decode_nul_paths(raw: bytes) -> tuple[str, ...]: + paths = [part.decode("utf-8", errors="strict") for part in raw.split(b"\0") if part] + return tuple(sorted(paths)) + + +def repository_candidate_files(root: Path) -> tuple[str, ...]: + """Return the prospective public worktree candidate set before ``git add``. + + Tracked files and untracked/unignored files are included. Pending deletions are + omitted because the file is absent from the prospective source worktree. + Ignored private/runtime/generated material stays outside this candidate set. + """ + root = Path(root).resolve() + raw = _run_git( + root, + ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], + ) + paths: list[str] = [] + for relative in _decode_nul_paths(raw): + if (root / relative).is_file(): + paths.append(relative) + return tuple(paths) + + +def committed_source_files(root: Path, ref: str = "HEAD") -> tuple[str, ...]: + """Return the exact file inventory stored in one committed Git tree.""" + if not ref or ref.startswith("-"): + raise RepositoryHygieneError("Git source ref must be a non-option ref name.") + raw = _run_git(root, ["ls-tree", "-r", "--name-only", "-z", ref, "--"]) + return _decode_nul_paths(raw) + + +def _forbidden_reason(relative: str) -> str | None: + normalized = relative.replace("\\", "/") + if normalized == _ROOT_TRANSIENT_CHECKSUM: + return "repository-root transient SHA256SUMS.txt is not a canonical source artifact" + if normalized.startswith(_FORBIDDEN_PREFIXES): + return "private, generated, build, or runtime path is inside the public source candidate set" + parts = tuple(part for part in normalized.split("/") if part) + if any(part in _FORBIDDEN_COMPONENTS for part in parts): + return "generated cache path is inside the public source candidate set" + if normalized.endswith(_FORBIDDEN_SUFFIXES): + return "generated Python bytecode is inside the public source candidate set" + if normalized.startswith("providers/registry/") and normalized != "providers/registry/.gitkeep": + return "provider registry runtime state is inside the public source candidate set" + return None + + +def source_hygiene_failures(root: Path) -> tuple[str, ...]: + """Return deterministic violations in the prospective public repository source.""" + failures: list[str] = [] + for relative in repository_candidate_files(root): + reason = _forbidden_reason(relative) + if reason: + failures.append(f"{relative}: {reason}") + return tuple(failures) + + +def assert_repository_source_hygiene(root: Path) -> int: + """Fail closed on source-boundary violations and return candidate file count.""" + files = repository_candidate_files(root) + failures = source_hygiene_failures(root) + if failures: + formatted = "\n".join(f"- {failure}" for failure in failures) + raise RepositoryHygieneError(f"Repository source hygiene audit failed:\n{formatted}") + return len(files) + + +def create_source_archive(root: Path, destination: Path, ref: str = "HEAD") -> Path: + """Export one exact committed Git tree as a ZIP without workspace contamination.""" + if not ref or ref.startswith("-"): + raise RepositoryHygieneError("Git source ref must be a non-option ref name.") + root = Path(root).resolve() + destination = Path(destination).resolve() + destination.parent.mkdir(parents=True, exist_ok=True) + try: + subprocess.run( + [ + "git", + "-C", + str(root), + "archive", + "--format=zip", + f"--output={destination}", + ref, + ], + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + except (OSError, subprocess.CalledProcessError) as exc: + destination.unlink(missing_ok=True) + detail = "" + if isinstance(exc, subprocess.CalledProcessError) and exc.stderr: + detail = exc.stderr.decode("utf-8", errors="replace").strip() + suffix = f": {detail}" if detail else "" + raise RepositoryHygieneError(f"Unable to export canonical Git source archive{suffix}") from exc + return destination diff --git a/tests/test_repository_hygiene.py b/tests/test_repository_hygiene.py new file mode 100644 index 0000000..9abf814 --- /dev/null +++ b/tests/test_repository_hygiene.py @@ -0,0 +1,153 @@ +from __future__ import annotations + +import hashlib +import subprocess +import tempfile +import unittest +import zipfile +from pathlib import Path + +from scripts.test.repository_hygiene import ( + RepositoryHygieneError, + assert_repository_source_hygiene, + committed_source_files, + create_source_archive, + repository_candidate_files, +) + + +ROOT = Path(__file__).resolve().parents[1] + + +class RepositoryHygieneTests(unittest.TestCase): + def _git(self, root: Path, *args: str) -> str: + completed = subprocess.run( + ["git", "-C", str(root), *args], + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + return completed.stdout.strip() + + def _repo(self) -> tuple[tempfile.TemporaryDirectory[str], Path]: + holder = tempfile.TemporaryDirectory() + root = Path(holder.name) + self._git(root, "init") + self._git(root, "config", "user.name", "Invio Hygiene Test") + self._git(root, "config", "user.email", "hygiene@example.invalid") + (root / ".gitignore").write_text( + "__pycache__/\n*.py[cod]\nbuild/\ndist/\ndata/runtime/\ndata/exports/\n" + "providers/registry/*\n!providers/registry/.gitkeep\n/project/\n", + encoding="utf-8", + ) + (root / "tracked.py").write_text("VALUE = 1\n", encoding="utf-8") + (root / "providers" / "registry").mkdir(parents=True) + (root / "providers" / "registry" / ".gitkeep").write_text("", encoding="utf-8") + self._git(root, "add", ".") + self._git(root, "commit", "-m", "baseline") + return holder, root + + def test_root_transient_sha256sums_is_absent_from_public_source(self): + self.assertFalse((ROOT / "SHA256SUMS.txt").exists()) + self.assertNotIn("SHA256SUMS.txt", repository_candidate_files(ROOT)) + + def test_private_project_boundary_remains_ignored_and_fails_if_force_tracked(self): + holder, root = self._repo() + try: + private = root / "project" / "research" / "secret.md" + private.parent.mkdir(parents=True) + private.write_text("private\n", encoding="utf-8") + self.assertNotIn("project/research/secret.md", repository_candidate_files(root)) + self._git(root, "add", "-f", "project/research/secret.md") + with self.assertRaisesRegex(RepositoryHygieneError, "project/research/secret.md"): + assert_repository_source_hygiene(root) + finally: + holder.cleanup() + + def test_cache_build_and_runtime_boundaries_fail_if_force_tracked(self): + holder, root = self._repo() + try: + forbidden = ( + "__pycache__/tracked.cpython-312.pyc", + "build/output.bin", + "data/runtime/state.json", + "providers/registry/runtime.json", + ) + for relative in forbidden: + path = root / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"generated") + self._git(root, "add", "-f", relative) + with self.assertRaises(RepositoryHygieneError) as caught: + assert_repository_source_hygiene(root) + message = str(caught.exception) + for relative in forbidden: + self.assertIn(relative, message) + finally: + holder.cleanup() + + def test_canonical_source_inventory_comes_from_exact_git_commit_tree(self): + holder, root = self._repo() + try: + first = self._git(root, "rev-parse", "HEAD") + (root / "tracked.py").write_text("VALUE = 2\n", encoding="utf-8") + (root / "new.py").write_text("NEW = True\n", encoding="utf-8") + self.assertIn("new.py", repository_candidate_files(root)) + committed = committed_source_files(root, first) + self.assertIn("tracked.py", committed) + self.assertNotIn("new.py", committed) + finally: + holder.cleanup() + + def test_source_archive_excludes_ignored_private_cache_and_runtime_files(self): + holder, root = self._repo() + try: + ignored = ( + "project/research/private.md", + "__pycache__/tracked.cpython-312.pyc", + "build/output.bin", + "data/runtime/state.json", + "providers/registry/runtime.json", + ) + for relative in ignored: + path = root / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"workspace-only") + archive = Path(holder.name) / "source.zip" + create_source_archive(root, archive, "HEAD") + with zipfile.ZipFile(archive) as bundle: + names = set(bundle.namelist()) + self.assertIn("tracked.py", names) + for relative in ignored: + self.assertNotIn(relative, names) + finally: + holder.cleanup() + + def test_source_archive_is_immutable_against_untracked_workspace_files(self): + holder, root = self._repo() + try: + first = Path(holder.name) / "first.zip" + second = Path(holder.name) / "second.zip" + create_source_archive(root, first, "HEAD") + (root / "untracked.txt").write_text("not committed\n", encoding="utf-8") + create_source_archive(root, second, "HEAD") + first_digest = hashlib.sha256(first.read_bytes()).hexdigest() + second_digest = hashlib.sha256(second.read_bytes()).hexdigest() + self.assertEqual(first_digest, second_digest) + with zipfile.ZipFile(second) as bundle: + self.assertNotIn("untracked.txt", bundle.namelist()) + finally: + holder.cleanup() + + def test_release_checksum_pipeline_is_separate_from_repository_root_hygiene(self): + helper = (ROOT / "scripts" / "build" / "finalize_release_checksums.py").read_text(encoding="utf-8") + workflow = (ROOT / ".github" / "workflows" / "ci.yml").read_text(encoding="utf-8") + self.assertIn('root / "SHA256SUMS.txt"', helper) + self.assertIn("python scripts/build/finalize_release_checksums.py dist\\release", workflow) + self.assertIn("windows-build:\n if: startsWith(github.ref, 'refs/tags/v')", workflow) + self.assertNotIn("SHA256SUMS.txt", repository_candidate_files(ROOT)) + + +if __name__ == "__main__": + unittest.main()