#!/usr/bin/env python3 """compose-lock.py contract check (flake check `installer-compose-lock`). Usage: check-compose-lock.py Exercises the offline lock-composer the installer runs on the target with no network: given nomarchy's own flake.lock and the baked locked/original node metadata, it must emit a well-formed downstream lock where root ─▶ nomarchy (locked to the baked rev+narHash) └─▶ every upstream node, verbatim, one level down and where `follows` paths (list-valued inputs, absolute from the old root) are rebased under the new `nomarchy` node. Also pins the fail-closed guards: a locked node without a narHash, and an upstream lock that already carries a `nomarchy` node, must both abort. """ import json import subprocess import sys import tempfile from pathlib import Path tool = sys.argv[1] def run(src_lock, locked, original): """Run compose-lock on a fixture; return (returncode, stderr, out_json|None).""" with tempfile.TemporaryDirectory() as d: src = Path(d) / "upstream.lock" out = Path(d) / "out.lock" src.write_text(json.dumps(src_lock)) p = subprocess.run( [sys.executable, tool, str(src), str(out), json.dumps(locked), json.dumps(original)], capture_output=True, text=True, ) got = json.loads(out.read_text()) if p.returncode == 0 and out.exists() else None return p.returncode, p.stderr, got def check(cond, msg): if not cond: sys.exit(f"compose-lock contract: {msg}") # A representative upstream lock: a plain input, and one that `follows` # it (list-valued input — the case the rebasing logic exists for). upstream = { "nodes": { "root": {"inputs": {"nixpkgs": "nixpkgs", "home-manager": "home-manager"}}, "nixpkgs": {"locked": {"type": "github", "narHash": "sha256-NP"}}, "home-manager": { "inputs": {"nixpkgs": ["nixpkgs"]}, "locked": {"type": "github", "narHash": "sha256-HM"}, }, }, "root": "root", "version": 7, } locked = {"type": "path", "path": "/nix/store/x", "narHash": "sha256-SELF", "lastModified": 1} original = {"type": "git", "url": "https://example.invalid/nomarchy.git", "ref": "v1"} rc, err, got = run(upstream, locked, original) check(rc == 0, f"valid inputs were rejected (rc={rc}): {err}") check(got is not None, "no output lock written on the happy path") check(got["version"] == 7 and got["root"] == "root", "output is not a v7 lock rooted at 'root'") check(got["nodes"]["root"] == {"inputs": {"nomarchy": "nomarchy"}}, f"root node must point only at nomarchy, got {got['nodes']['root']}") nomarchy = got["nodes"].get("nomarchy") check(nomarchy is not None, "no 'nomarchy' node in output") check(nomarchy["locked"] == locked, "nomarchy node lost its baked locked metadata") check(nomarchy["original"] == original, "nomarchy node lost its baked original metadata") check(nomarchy["inputs"] == upstream["nodes"]["root"]["inputs"], "nomarchy node did not inherit the upstream root's input set") check(got["nodes"]["nixpkgs"] == upstream["nodes"]["nixpkgs"], "a plain upstream node was mutated") check(got["nodes"]["home-manager"]["inputs"]["nixpkgs"] == ["nomarchy", "nixpkgs"], "a follows path was not rebased under the nomarchy node " f"(got {got['nodes']['home-manager']['inputs']['nixpkgs']})") # Fail-closed: no narHash in the baked locked metadata. rc, err, _ = run(upstream, {"type": "path", "path": "/x"}, original) check(rc != 0, "a locked node without a narHash was accepted") check("narHash" in err, f"missing-narHash abort did not mention narHash: {err}") # Fail-closed: an upstream lock that already names a nomarchy input. poisoned = json.loads(json.dumps(upstream)) poisoned["nodes"]["nomarchy"] = {"locked": {"narHash": "sha256-X"}} rc, err, _ = run(poisoned, locked, original) check(rc != 0, "an upstream lock already carrying a 'nomarchy' node was accepted") print("compose-lock contract: happy-path shape, follows rebasing, and both " "fail-closed guards hold")