Files
dotfiles/scripts/dotfiles-check.py

527 lines
22 KiB
Python
Executable File

#!/usr/bin/env python3
"""Change-aware, non-activating validation for this Nix dotfiles repository."""
from __future__ import annotations
import argparse
from collections import defaultdict, deque
from contextlib import contextmanager
import io
import json
import os
from pathlib import Path, PurePosixPath
import shutil
import subprocess
import sys
import tarfile
import tempfile
import tomllib
from typing import Any, Iterator, Sequence
GLOBAL_FILES = {"flake.nix", "flake.lock", "hosts/default.nix"}
GLOBAL_PREFIXES = ("flake/", "libs/", "overlays/")
FULL_EVAL_PREFIXES = GLOBAL_PREFIXES + ("scripts/", "shells/", "tests/")
FULL_BUILD_PREFIXES = GLOBAL_PREFIXES + ("scripts/", "shells/", "tests/")
DOC_SUFFIXES = (".md", ".png", ".jpg", ".jpeg", ".webp")
class ValidationError(RuntimeError):
pass
def command_text(command: Sequence[str]) -> str:
return " ".join(json.dumps(part) if any(c.isspace() for c in part) else part for part in command)
def run(
command: Sequence[str],
*,
cwd: Path,
capture: bool = False,
check: bool = True,
input_text: str | None = None,
) -> subprocess.CompletedProcess[str]:
print(f"+ {command_text(command)}", file=sys.stderr)
return subprocess.run(
list(command),
cwd=cwd,
check=check,
text=True,
input=input_text,
stdout=subprocess.PIPE if capture else None,
stderr=subprocess.PIPE if capture else None,
)
def git(root: Path, *args: str, check: bool = True) -> str:
return run(["git", *args], cwd=root, capture=True, check=check).stdout
def repo_root() -> Path:
return Path(run(["git", "rev-parse", "--show-toplevel"], cwd=Path.cwd(), capture=True).stdout.strip()).resolve()
def normalize(root: Path, raw: str) -> str:
candidate = Path(raw)
absolute = candidate.resolve() if candidate.is_absolute() else (root / candidate).resolve()
try:
return absolute.relative_to(root).as_posix()
except ValueError as error:
raise ValidationError(f"path escapes repository root: {raw}") from error
def nonempty_lines(value: str) -> list[str]:
return [line for line in value.splitlines() if line]
def tracked_paths(root: Path) -> set[str]:
return set(nonempty_lines(git(root, "ls-files")))
def untracked_paths(root: Path) -> set[str]:
return set(nonempty_lines(git(root, "ls-files", "--others", "--exclude-standard")))
def expand_paths(root: Path, raw_paths: Sequence[str], available: set[str]) -> list[str]:
result: set[str] = set()
for raw in raw_paths:
relative = normalize(root, raw)
target = root / relative
if target.is_dir():
prefix = f"{relative.rstrip('/')}/" if relative else ""
result.update(path for path in available if path.startswith(prefix))
else:
result.add(relative)
return sorted(result)
def collect_paths(root: Path, args: argparse.Namespace) -> tuple[list[str], set[str]]:
tracked = tracked_paths(root)
untracked = untracked_paths(root)
if args.paths:
paths = expand_paths(root, args.paths, tracked | untracked)
elif args.all_files:
paths = sorted(tracked)
elif args.base:
paths = nonempty_lines(
git(root, "diff", "--name-only", "--no-renames", "--diff-filter=ACMRTUXBD", f"{args.base}...HEAD", "--")
)
else:
paths = nonempty_lines(git(root, "diff", "--name-only", "--no-renames", "--diff-filter=ACMRTUXBD", "HEAD", "--"))
paths += sorted(untracked)
return sorted(set(paths)), untracked
def remove_path(path: Path) -> None:
if path.is_symlink() or path.is_file():
path.unlink()
elif path.is_dir():
shutil.rmtree(path)
@contextmanager
def flake_reference(root: Path, changed: Sequence[str]) -> Iterator[str]:
"""Create a clean HEAD snapshot and overlay only task-owned worktree paths."""
with tempfile.TemporaryDirectory(prefix="dotfiles-flake-") as temporary:
source = Path(temporary) / "source"
source.mkdir()
archive = subprocess.run(
["git", "archive", "--format=tar", "HEAD"],
cwd=root,
check=True,
stdout=subprocess.PIPE,
).stdout
with tarfile.open(fileobj=io.BytesIO(archive), mode="r:") as tar:
tar.extractall(source, filter="data")
for relative in changed:
src, dst = root / relative, source / relative
remove_path(dst)
if src.is_symlink():
dst.parent.mkdir(parents=True, exist_ok=True)
dst.symlink_to(os.readlink(src))
elif src.is_file():
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dst)
yield f"path:{source}"
def load_metadata(root: Path, reference: str) -> dict[str, Any]:
result = run(
["nix", "eval", "--json", "--show-trace", f"{reference}#lib.validationMetadata"],
cwd=root,
capture=True,
)
metadata = json.loads(result.stdout)
if metadata.get("schemaVersion") != 1:
raise ValidationError("unsupported validation metadata schema")
return metadata
def reverse_dependencies(units: dict[str, Any]) -> dict[str, set[str]]:
result: dict[str, set[str]] = defaultdict(set)
for unit_id, unit in units.items():
for dependency in unit.get("includes", []):
result[dependency].add(unit_id)
return result
def users_of(unit_id: str, reverse: dict[str, set[str]]) -> set[str]:
result, queue = {unit_id}, deque([unit_id])
while queue:
for dependent in reverse.get(queue.popleft(), set()):
if dependent not in result:
result.add(dependent)
queue.append(dependent)
return result
def owner_for(path: str, units: dict[str, Any]) -> str | None:
matches = []
for unit_id, unit in units.items():
directory = unit["directory"].rstrip("/")
if path == directory or path.startswith(f"{directory}/"):
matches.append((len(directory), unit_id))
return max(matches)[1] if matches else None
def path_classes(path: str, unit: dict[str, Any]) -> set[str]:
directory = unit["directory"].rstrip("/")
relative = path[len(directory) :].lstrip("/")
if relative == "nixos.nix" or relative == "home/nixos.nix":
return {"nixos"}
if relative == "darwin.nix" or relative == "home/darwin.nix":
return {"darwin"}
if relative == "meta.nix":
return {"nixos", "darwin"}
if relative in {"common.nix", "home.nix", "home/common.nix"}:
return {"nixos", "darwin"}
fragments = set(unit.get("fragments", []))
classes: set[str] = set()
if fragments & {"common", "nixos", "home", "homeCommon", "homeNixos"}:
classes.add("nixos")
if fragments & {"common", "darwin", "home", "homeCommon", "homeDarwin"}:
classes.add("darwin")
return classes or {"nixos", "darwin"}
def is_docs_only(path: str) -> bool:
return path.endswith(DOC_SUFFIXES)
def plan(metadata: dict[str, Any], paths: Sequence[str], *, all_hosts: bool = False) -> dict[str, Any]:
hosts: dict[str, Any] = metadata["hosts"]
units: dict[str, Any] = metadata["units"]
reverse = reverse_dependencies(units)
affected_units: set[str] = set()
unit_classes: dict[str, set[str]] = defaultdict(set)
affected_hosts = {"nixos": set(), "darwin": set()}
global_change = all_hosts
requires_full_eval = False
requires_full_build = False
requires_nix = False
for path in paths:
if path in GLOBAL_FILES or path.startswith(GLOBAL_PREFIXES):
global_change = True
requires_nix = True
if path.startswith(FULL_EVAL_PREFIXES) or path in GLOBAL_FILES:
requires_full_eval = True
if path.startswith(FULL_BUILD_PREFIXES) or path in {"flake.nix", "flake.lock"}:
requires_full_build = True
if path.endswith(".nix") or path == "flake.lock":
requires_nix = True
if path == "scripts/dotfiles-check.py":
requires_nix = requires_full_eval = requires_full_build = True
if path.startswith("hosts/") and path != "hosts/default.nix":
parts = PurePosixPath(path).parts
if len(parts) > 1 and parts[1] in hosts:
affected_hosts[hosts[parts[1]]["kind"]].add(parts[1])
requires_nix = True
owner = owner_for(path, units)
if owner:
classes = path_classes(path, units[owner])
for unit_id in users_of(owner, reverse):
affected_units.add(unit_id)
unit_classes[unit_id].update(classes)
requires_nix = requires_nix or not is_docs_only(path)
elif path.startswith("modules/") and not is_docs_only(path):
global_change = True
requires_nix = True
if global_change:
for name, host in hosts.items():
affected_hosts[host["kind"]].add(name)
else:
for name, host in hosts.items():
selected = set(host["selectedUnits"])
if any(unit_id in selected and host["kind"] in unit_classes[unit_id] for unit_id in affected_units):
affected_hosts[host["kind"]].add(name)
synthetic = {"nixos": set(), "darwin": set()}
for unit_id in affected_units:
for kind in unit_classes[unit_id]:
if not any(unit_id in hosts[name]["selectedUnits"] for name in affected_hosts[kind]):
synthetic[kind].add(unit_id)
systems = sorted({host["system"] for host in hosts.values()})
native_systems = {system: False for system in systems}
if requires_full_build:
native_systems = {system: requires_nix for system in systems}
else:
for kind, names in affected_hosts.items():
for name in names:
native_systems[hosts[name]["system"]] = True
for kind, unit_ids in synthetic.items():
if unit_ids:
candidates = sorted(host["system"] for host in hosts.values() if host["kind"] == kind)
if candidates:
native_systems[candidates[0]] = True
return {
"schemaVersion": 1,
"paths": sorted(paths),
"affectedUnits": sorted(affected_units),
"affectedHosts": {kind: sorted(names) for kind, names in affected_hosts.items()},
"syntheticUnits": {kind: sorted(names) for kind, names in synthetic.items()},
"requiresNixValidation": requires_nix,
"requiresFullEvaluation": requires_full_eval,
"requiresFullNativeBuild": requires_full_build,
"nativeBuildSystems": native_systems,
}
def render_plan(value: dict[str, Any], reference: str) -> None:
print(f"flake: {reference}")
print("paths:", ", ".join(value["paths"]) or "(none)")
print("units:", ", ".join(value["affectedUnits"]) or "(none)")
for kind in ("nixos", "darwin"):
print(f"{kind} hosts:", ", ".join(value["affectedHosts"][kind]) or "(none)")
print(f"{kind} synthetic:", ", ".join(value["syntheticUnits"][kind]) or "(none)")
print("full evaluation:", value["requiresFullEvaluation"])
print("full native build:", value["requiresFullNativeBuild"])
def validate_skill(path: Path) -> None:
text = path.read_text()
if not text.startswith("---\n"):
raise ValidationError(f"missing Skill frontmatter: {path}")
try:
frontmatter = text.split("---\n", 2)[1]
name = next(line.split(":", 1)[1].strip() for line in frontmatter.splitlines() if line.startswith("name:"))
except (IndexError, StopIteration) as error:
raise ValidationError(f"invalid Skill frontmatter: {path}") from error
if name != path.parent.name:
raise ValidationError(f"Skill name {name!r} does not match directory {path.parent.name!r}")
def static_checks(root: Path, paths: Sequence[str], untracked: set[str]) -> None:
for relative in paths:
path = root / relative
if not path.is_file():
continue
if relative.endswith(".nix"):
run(["nix-instantiate", "--parse", str(path)], cwd=root, capture=True)
elif relative.endswith(".json"):
json.loads(path.read_text())
elif relative.endswith(".toml"):
tomllib.loads(path.read_text())
elif relative.endswith(".py"):
compile(path.read_text(), relative, "exec")
if path.name == "SKILL.md":
validate_skill(path)
if relative in untracked:
for number, line in enumerate(path.read_text(errors="replace").splitlines(), 1):
if line.rstrip() != line:
raise ValidationError(f"trailing whitespace: {relative}:{number}")
diff_paths = [path for path in paths if path not in untracked]
if diff_paths:
run(["git", "diff", "--check", "HEAD", "--", *diff_paths], cwd=root)
def run_fast(root: Path, reference: str, paths: Sequence[str], untracked: set[str], *, all_files: bool) -> None:
static_checks(root, paths, untracked)
command = ["nix", "develop", f"{reference}#validation", "--command", "pre-commit", "run"]
command += ["--all-files"] if all_files else (["--files", *paths] if paths else [])
if paths or all_files:
run(command, cwd=root)
def selection_module_expr(unit_id: str) -> str:
quoted = json.dumps(unit_id)
return f"(flake.lib.registry.mkSelectionModule [ {quoted} ])"
def synthetic_expr(reference: str, host_name: str, host: dict[str, Any], unit_id: str, *, drv_path: bool) -> str:
selection = selection_module_expr(unit_id)
modules = [selection]
if host.get("homeManager", True):
user = json.dumps(host["user"])
modules.append(f"{{ home-manager.users.{user}.imports = [ {selection} ]; }}")
base = f"flake.{('darwinConfigurations' if host['kind'] == 'darwin' else 'nixosConfigurations')}.{json.dumps(host_name)}"
output = "extended.system" if host["kind"] == "darwin" else "extended.config.system.build.toplevel"
if drv_path:
output += ".drvPath"
return f'''let
flake = builtins.getFlake {json.dumps(reference)};
base = {base};
extended = base.extendModules {{ modules = [ {' '.join(modules)} ]; }};
in {output}'''
def representative_host(metadata: dict[str, Any], kind: str, current_system: str | None = None) -> tuple[str, dict[str, Any]] | None:
candidates = [(name, host) for name, host in metadata["hosts"].items() if host["kind"] == kind]
if current_system:
candidates = [item for item in candidates if item[1]["system"] == current_system]
return sorted(candidates)[0] if candidates else None
def host_drv_attr(reference: str, host: dict[str, Any]) -> str:
return f"{reference}#{host['buildAttr']}.drvPath"
def run_eval(
root: Path,
reference: str,
metadata: dict[str, Any],
value: dict[str, Any],
*,
all_systems: bool,
) -> None:
if all_systems or value["requiresFullEvaluation"]:
run(["nix", "flake", "check", reference, "--no-build", "--all-systems", "--keep-going", "--show-trace"], cwd=root)
return
for kind in ("nixos", "darwin"):
for name in value["affectedHosts"][kind]:
run(["nix", "eval", "--raw", "--show-trace", host_drv_attr(reference, metadata["hosts"][name])], cwd=root)
representative = representative_host(metadata, kind)
if representative:
name, host = representative
for unit_id in value["syntheticUnits"][kind]:
run(["nix", "eval", "--raw", "--impure", "--show-trace", "--expr", synthetic_expr(reference, name, host, unit_id, drv_path=True)], cwd=root)
def current_system(root: Path) -> str:
return run(["nix", "eval", "--raw", "--impure", "--expr", "builtins.currentSystem"], cwd=root, capture=True).stdout.strip()
def run_full_native(root: Path, reference: str, system: str) -> None:
run(
["nix", "run", f"{reference}#nix-fast-build", "--", "--flake", f"{reference}#checks.{system}", "--skip-cached", "--no-nom", "--no-link"],
cwd=root,
)
def run_build(root: Path, reference: str, metadata: dict[str, Any], value: dict[str, Any]) -> None:
system = current_system(root)
if value["requiresFullNativeBuild"]:
run_full_native(root, reference, system)
return
installables: list[str] = []
for kind in ("nixos", "darwin"):
for name in value["affectedHosts"][kind]:
host = metadata["hosts"][name]
if host["system"] == system:
installables.append(f"{reference}#{host['buildAttr']}")
else:
print(f"skip incompatible build: {name} ({host['system']})", file=sys.stderr)
for check in ("registry", "validation-tool"):
installables.append(f"{reference}#checks.{system}.{check}")
if installables:
run(["nix", "build", "--no-link", "--keep-going", "--print-build-logs", *sorted(set(installables))], cwd=root)
for kind in ("nixos", "darwin"):
representative = representative_host(metadata, kind, system)
if representative:
name, host = representative
for unit_id in value["syntheticUnits"][kind]:
run(["nix", "build", "--no-link", "--impure", "--expr", synthetic_expr(reference, name, host, unit_id, drv_path=False)], cwd=root)
def self_test() -> None:
metadata = {
"schemaVersion": 1,
"units": {
"applications.foo": {"directory": "modules/applications/foo", "includes": [], "fragments": ["home", "homeNixos"]},
"applications.dormant": {"directory": "modules/applications/dormant", "includes": [], "fragments": ["home"]},
"profiles.workload.dev": {"directory": "modules/profiles/workload/dev", "includes": ["applications.foo"], "fragments": ["meta"]},
},
"hosts": {
"linux": {"kind": "nixos", "system": "x86_64-linux", "user": "test", "homeManager": True, "selectedUnits": ["profiles.workload.dev"], "buildAttr": "nixosConfigurations.linux.config.system.build.toplevel"},
"mac": {"kind": "darwin", "system": "aarch64-darwin", "user": "test", "homeManager": True, "selectedUnits": ["profiles.workload.dev"], "buildAttr": "darwinConfigurations.mac.system"},
},
}
nixos = plan(metadata, ["modules/applications/foo/home/nixos.nix"])
assert nixos["affectedHosts"] == {"nixos": ["linux"], "darwin": []}
assert nixos["affectedUnits"] == ["applications.foo", "profiles.workload.dev"]
common = plan(metadata, ["modules/applications/foo/home.nix"])
assert common["affectedHosts"] == {"nixos": ["linux"], "darwin": ["mac"]}
dormant = plan(metadata, ["modules/applications/dormant/home.nix"])
assert dormant["syntheticUnits"] == {"nixos": ["applications.dormant"], "darwin": ["applications.dormant"]}
global_value = plan(metadata, ["flake.nix"])
assert global_value["requiresFullEvaluation"] and global_value["requiresFullNativeBuild"]
docs = plan(metadata, ["modules/profiles/README.md"])
assert not docs["requiresNixValidation"]
print("dotfiles-check self-test passed")
def parser() -> argparse.ArgumentParser:
result = argparse.ArgumentParser(description=__doc__)
result.add_argument("command", choices=("plan", "fast", "eval", "build", "all", "full", "self-test"))
result.add_argument("--paths", nargs="+", help="Explicit task-owned repository paths")
result.add_argument("--base", help="Compare BASE...HEAD")
result.add_argument("--all-files", action="store_true", help="Check every tracked file")
result.add_argument("--all-hosts", action="store_true", help="Validate every registered host")
result.add_argument("--all-systems", action="store_true", help="Evaluate every flake system")
result.add_argument("--json", action="store_true", help="Emit the plan as JSON")
return result
def main() -> int:
args = parser().parse_args()
if args.command == "self-test":
self_test()
return 0
selectors = sum(bool(value) for value in (args.paths, args.base, args.all_files))
if selectors > 1:
raise ValidationError("use only one of --paths, --base, or --all-files")
if args.command == "full":
if selectors or args.all_hosts or args.all_systems:
raise ValidationError("full is exhaustive and accepts no scope flags")
args.all_files = args.all_hosts = args.all_systems = True
root = repo_root()
paths, untracked = collect_paths(root, args)
with flake_reference(root, paths) as reference:
metadata = load_metadata(root, reference)
value = plan(metadata, paths, all_hosts=args.all_hosts)
if args.command == "plan":
print(json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True)) if args.json else render_plan(value, reference)
return 0
if args.command in {"fast", "all", "full"}:
run_fast(root, reference, paths, untracked, all_files=args.all_files)
if args.command in {"eval", "all", "full"} and value["requiresNixValidation"]:
run_eval(root, reference, metadata, value, all_systems=args.all_systems or args.command in {"all", "full"})
if args.command in {"build", "all"} and value["requiresNixValidation"]:
run_build(root, reference, metadata, value)
if args.command == "full":
run_full_native(root, reference, current_system(root))
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except ValidationError as error:
print(f"error: {error}", file=sys.stderr)
raise SystemExit(2) from error
except subprocess.CalledProcessError as error:
if error.stdout:
print(error.stdout, file=sys.stderr, end="")
if error.stderr:
print(error.stderr, file=sys.stderr, end="")
raise SystemExit(error.returncode) from error