test(checks): pure-contract guards batch (#49)
All checks were successful
Check / eval (push) Successful in 3m6s

Four new no-VM checks.* + CI py_compile expansion:
- hardware-db-modules: installer DB module names ∈ pinned
  nixos-hardware.nixosModules (a lock bump can rename a module and break
  profiled installs on just the matching DMI, invisible to any VM).
- installer-compose-lock: offline lock-composer contract on fixtures.
- installer-disko: pure Nix assert — swapSize "0"/"0G" → no @swap, "2G"
  → sized, withLuks wraps root (permanently guards the #46 install fix).
- windowrule-syntax: builds the generated hyprland.conf and fails on the
  pre-0.53 grammar / windowrulev2 keyword (guards ed7fd93).
CI py_compile now covers all tracked *.py; docs/TESTING.md §1 synced.

Implemented by a worktree agent; diff reviewed. Verified V0 (flake check)
+ V1 (each checks.x86_64-linux.* built on main, exit 0); agent's negative
tests confirm each guard fails on a regression.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Bernardo Magri
2026-07-09 19:10:45 +01:00
parent 97bf26a23f
commit e01303851d
8 changed files with 335 additions and 18 deletions

98
tools/check-compose-lock.py Executable file
View File

@@ -0,0 +1,98 @@
#!/usr/bin/env python3
"""compose-lock.py contract check (flake check `installer-compose-lock`).
Usage: check-compose-lock.py <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")

53
tools/check-hardware-db.py Executable file
View File

@@ -0,0 +1,53 @@
#!/usr/bin/env python3
"""Installer hardware-DB ↔ nixos-hardware drift check (flake check
`hardware-db-modules`).
Usage: check-hardware-db.py <hardware-db.sh> <nixos-hardware-modules.txt>
Every nixos-hardware module name the installer references — the third
`|`-field of each HARDWARE_DB table row, plus the literal `common-*`
modules the CPU/GPU/chassis autodetect emits — must exist in the pinned
`nixos-hardware.nixosModules`. A lock bump can rename a module upstream
(the X1 Carbon → `x1-Nth-gen` churn is the canonical example) and silently
break profiled installs on exactly the machines whose DMI matches — a
class of bug that never shows up in a VM. `nixos-hardware-modules.txt` is
generated at eval time in flake.nix from `attrNames
nixos-hardware.nixosModules`, so the "what actually exists" side can't go
stale by itself.
"""
import re
import sys
db_file, modules_file = sys.argv[1], sys.argv[2]
available = {l.strip() for l in open(modules_file) if l.strip()}
db = open(db_file).read()
referenced = {} # module name -> short reason (first seen)
# HARDWARE_DB rows: "sys_vendor_regex | product_regex | module". The first
# two fields are bash regexes (may contain anything but a literal quote);
# the module name is a plain nixos-hardware attr (lower-case, dashes).
for vendor, product, module in re.findall(
r'"([^"|]*)\|([^"|]*)\|([a-z0-9][a-z0-9-]*)"', db
):
referenced.setdefault(module, f"HARDWARE_DB row ({vendor.strip()} {product.strip()})")
# Literal `common-*` (and any other bare-literal) modules emitted by the
# autodetect via `echo "MODULE <name>"`. The `$mod` variable form is the
# HARDWARE_DB table above and is skipped here.
for module in re.findall(r'echo "MODULE ([a-z0-9][a-z0-9-]*)"', db):
referenced.setdefault(module, "autodetect MODULE line")
if not referenced:
sys.exit("hardware-db: parsed no module references — parser or file changed")
missing = sorted(m for m in referenced if m not in available)
if missing:
print(f"hardware-db: {len(missing)} module(s) not in nixos-hardware.nixosModules")
for m in missing:
print(f" {m} — referenced by {referenced[m]}")
sys.exit(1)
print(f"hardware-db: all {len(referenced)} referenced modules exist in nixos-hardware")

View File

@@ -0,0 +1,73 @@
#!/usr/bin/env python3
"""Hyprland windowrule dead-syntax guard (flake check `windowrule-syntax`).
Usage: check-windowrule-syntax.py <hyprland.conf>
Hyprland 0.53 rewrote window rules (hyprlang `handleWindowrule`): the
`windowrulev2` keyword is a hard error, and the old rule-first order
(`windowrule = float, class:^…$`) no longer parses. Both surface as a red
config-error banner on every session start and silently drop the rules
(fixed in ed7fd93). The current, correct grammar is
`windowrule = <effect> <value>, match:<prop> ^…$` — e.g.
`windowrule = float 1, match:class ^…$`.
This guards the GENERATED hyprland.conf against a regression to the dead
syntax, catching three markers:
1. the `windowrulev2` keyword anywhere;
2. rule-first order — an effect keyword directly followed by a comma
(`windowrule = float, …` / `center, …` / `size, …`), i.e. no value;
3. a bare `<prop>:` matcher (`class:`, `title:`, …) instead of the
`match:<prop>` form — this also catches an effect that DOES carry a
value but kept the old matcher (`size 60% 65%, class:^…$`).
The current correct config must PASS; any of the above must FAIL.
"""
import re
import sys
conf = sys.argv[1]
lines = open(conf).read().splitlines()
# Effect keywords that in the dead grammar sat directly before the comma.
EFFECTS = (
"float", "center", "tile", "pin", "fullscreen", "maximize",
"size", "move", "opacity", "workspace", "monitor", "fullscreenstate",
"group", "stayfocused", "pseudo", "immediate",
)
rule_first = re.compile(
r"windowrule\s*=\s*(?:" + "|".join(EFFECTS) + r")\s*,"
)
# A window-property matcher written the old (colon-suffixed) way. The new
# grammar spells these `match:class …` — "class" is then followed by a
# space, never a colon — so a bare `class:`/`title:` only ever appears in
# the dead form.
bare_matcher = re.compile(
r"\b(?:initialclass|initialtitle|class|title|tag|xwayland|floating)\s*:",
re.IGNORECASE,
)
failures = []
for n, line in enumerate(lines, 1):
stripped = line.strip()
if stripped.startswith("#"):
continue
if "windowrulev2" in line:
failures.append((n, "dead `windowrulev2` keyword", stripped))
continue
if not re.match(r"\s*windowrule\s*=", line):
continue
if rule_first.search(line):
failures.append((n, "rule-first order (effect directly before comma)", stripped))
if bare_matcher.search(line):
failures.append((n, "bare `<prop>:` matcher — expected `match:<prop>`", stripped))
if failures:
print(f"windowrule-syntax: {len(failures)} dead-syntax rule(s) in {conf}")
for n, why, text in failures:
print(f" line {n}: {why}\n {text}")
sys.exit(1)
rules = sum(1 for l in lines if re.match(r"\s*windowrule\s*=", l))
print(f"windowrule-syntax: {rules} windowrule line(s), all post-0.53 grammar")