diff --git a/scripts/generate_pack.py b/scripts/generate_pack.py index 847d8883..47ed5d12 100644 --- a/scripts/generate_pack.py +++ b/scripts/generate_pack.py @@ -60,6 +60,7 @@ from common import ( import packresolve import region as region_mod import slot as slot_mod +import slots from deterministic_zip import _FIXED_DATE_TIME, rebuild_zip_deterministic from nativemode import ( digest_algorithm, @@ -602,10 +603,24 @@ def generate_pack( from common import resolve_platform_cores validation_index = {} + slot_overrides: dict[str, str] = {} if emu_profiles: - validation_index = _build_validation_index( - {name: emu_profiles[name] for name in resolve_platform_cores(config, emu_profiles)} - ) + platform_profiles = { + name: emu_profiles[name] + for name in resolve_platform_cores(config, emu_profiles) + } + validation_index = _build_validation_index(platform_profiles) + # Where a source-verified profile contradicts the scraped baseline on + # one destination, the pack answers to the platform it is built for. + # In existence mode the frontend never reads the bytes, so serving the + # emulator's file satisfies both sides and nothing is traded away. + mode = config.get("verification_mode", "existence") + for conflict in slots.find_conflicts( + config, platform_profiles, db, base_dest + ): + decision = slots.arbitrate(conflict, mode) + if decision.serves_both and decision.winner.local_path: + slot_overrides[conflict.destination] = decision.winner.local_path # Filter systems by target if specified plat_cores = ( @@ -729,6 +744,10 @@ def generate_pack( offline=offline, ) + override = slot_overrides.get(full_dest) + if override and status not in ("external", "release_asset"): + local_path, status = override, "slot_arbitrated" + if status == "external": file_ext = os.path.splitext(file_entry["name"])[1] or "" with tempfile.NamedTemporaryFile( diff --git a/scripts/pipeline.py b/scripts/pipeline.py index aa58dd2c..fcbefb9c 100644 --- a/scripts/pipeline.py +++ b/scripts/pipeline.py @@ -392,6 +392,14 @@ def main(): results["verify"] = ok all_ok = all_ok and ok + # Step 3b: Destinations both layers claim, and how each was settled. The + # ones the pack settles by itself must stay at zero cost; the rest name an + # upstream declaration no build can repair, so this reports and never gates. + run( + [sys.executable, "scripts/slots.py"], + "3b/8 slot arbitration", + ) + # Step 4: Generate packs pack_output = "" if not args.skip_packs: diff --git a/scripts/slots.py b/scripts/slots.py index c7f31949..dd7b48e6 100644 --- a/scripts/slots.py +++ b/scripts/slots.py @@ -283,6 +283,12 @@ def main() -> int: parser.add_argument("--platforms-dir", default="platforms") parser.add_argument("--emulators-dir", default="emulators") parser.add_argument("--json", action="store_true", help="JSON output") + parser.add_argument( + "--strict", + action="store_true", + help="exit non-zero on any contested destination, not only on one the " + "pack should have settled by itself", + ) args = parser.parse_args() with open(args.db, encoding="utf-8") as handle: @@ -332,11 +338,16 @@ def main() -> int: print(f" {format_decision(decision)}") total = sum(len(c) for c in found.values()) print( - f"\n{total} contested destinations. {fixable} the pack can settle on " + f"\n{total} contested destinations. {fixable} the pack settles on " f"its own, {total - fixable} rest on an upstream declaration." ) + if not args.strict and total: + print( + "Reported, not failed: the remainder needs the upstream list " + "corrected, which no build can do. Use --strict to gate on them." + ) - return 1 if found else 0 + return 1 if (found and args.strict) else 0 if __name__ == "__main__": diff --git a/scripts/verify.py b/scripts/verify.py index 1d3b5db2..c9848e69 100644 --- a/scripts/verify.py +++ b/scripts/verify.py @@ -30,6 +30,8 @@ import os import sys import zipfile +import slots + sys.path.insert(0, os.path.dirname(__file__)) from common import ( build_target_cores_cache, @@ -793,6 +795,23 @@ def verify_platform( mode = normalize_mode(config.get("verification_mode")) platform = config.get("platform", "unknown") + # The builder settles a destination claimed by both layers; this must read + # the same decision, or the two tools describe different packs. + slot_overrides: dict[str, str] = {} + if emu_profiles: + base_dest = config.get("base_destination", "") + arbitrated = { + name: emu_profiles[name] + for name in resolve_platform_cores(config, emu_profiles) + } + for conflict in slots.find_conflicts(config, arbitrated, db, base_dest): + decision = slots.arbitrate(conflict, mode) + if decision.serves_both and decision.winner.local_path: + key = conflict.destination + if base_dest and key.startswith(f"{base_dest}/"): + key = key[len(base_dest) + 1:] + slot_overrides[key] = decision.winner.local_path + has_zipped = any( fe.get("zipped_file") for sys in config.get("systems", {}).values() @@ -876,6 +895,13 @@ def verify_platform( zip_contents, data_dir_registry=data_dir_registry, ) + override = slot_overrides.get( + sanitize_pack_path( + file_entry.get("destination", file_entry.get("name", "")) + ) + ) + if override: + local_path, resolve_status = override, "slot_arbitrated" if not reads_file_contents(mode): result = verify_entry_existence( file_entry, diff --git a/tests/test_slots.py b/tests/test_slots.py index 5d852c9b..1440d1fe 100644 --- a/tests/test_slots.py +++ b/tests/test_slots.py @@ -238,6 +238,31 @@ class TestArbitration(unittest.TestCase): self.assertIn("both are satisfied", served) +class TestBuilderAndVerifierAgree(unittest.TestCase): + """The two tools must settle a contested destination the same way.""" + + def test_both_read_the_same_decision_function(self): + # The rule lives in one place. A second copy would let the pack and the + # report describe different files, which the project forbids outright. + builder = Path(__file__).resolve().parents[1] / "scripts" / "generate_pack.py" + verifier = Path(__file__).resolve().parents[1] / "scripts" / "verify.py" + for source in (builder, verifier): + text = source.read_text(encoding="utf-8") + self.assertIn("slots.find_conflicts(", text, source.name) + self.assertIn("slots.arbitrate(", text, source.name) + self.assertIn("decision.serves_both", text, source.name) + + def test_neither_reimplements_the_mode_test(self): + # A local "if mode == md5" beside the override would drift from the + # arbitration rule the moment either side is edited. + for name in ("generate_pack.py", "verify.py"): + source = Path(__file__).resolve().parents[1] / "scripts" / name + body = source.read_text(encoding="utf-8") + marker = body.find("decision.serves_both") + window = body[max(0, marker - 600):marker] + self.assertNotIn('verification_mode") == "md5"', window, name) + + class TestProvenEvidence(unittest.TestCase): """What counts as proof that a claim is about content, not about a name."""