feat(nix): check the home-manager module's composition (task 0047) #56

Merged
alexion merged 1 commits from task-0047-flake-check-hm-module-composition into main 2026-07-20 20:45:41 -04:00
4 changed files with 217 additions and 19 deletions

View File

@@ -26,11 +26,24 @@ This adds a module check alongside the existing package check in the flake's `ch
## Acceptance criteria ## Acceptance criteria
- [ ] The flake has a home-manager input whose nixpkgs follows the flake's nixpkgs; `flake.lock` is updated. - [x] The flake has a home-manager input whose nixpkgs follows the flake's nixpkgs; `flake.lock` is updated.
- [ ] A new check under the flake's `checks` output evaluates the real module through home-manager's standalone configuration entry point and builds the home files derivation — no Claude Code binary or running agent required. - [x] A new check under the flake's `checks` output evaluates the real module through home-manager's standalone configuration entry point and builds the home files derivation — no Claude Code binary or running agent required.
- [ ] The attribute-set-skills configuration asserts the module's Skill and the operator's skill both land, each at its own name. - [x] The attribute-set-skills configuration asserts the module's Skill and the operator's skill both land, each at its own name.
- [ ] The whole-directory-skills (path form) configuration asserts the module's Skill lands alongside the operator's directory contents; a non-recursive path-form install would fail this as a build-time collision. - [x] The whole-directory-skills (path form) configuration asserts the module's Skill lands alongside the operator's directory contents; a non-recursive path-form install would fail this as a build-time collision.
- [ ] The Claude-Code-disabled configuration asserts no gitea-axi Skill entry is written. - [x] The Claude-Code-disabled configuration asserts no gitea-axi Skill entry is written.
- [ ] A configuration with an operator's own SessionStart hook asserts the module's hook merges into that list rather than replacing it. - [x] A configuration with an operator's own SessionStart hook asserts the module's hook merges into that list rather than replacing it.
- [ ] The check asserts on the generation's file tree only, not on option values or store paths. - [x] The check asserts on the generation's file tree only, not on option values or store paths.
- [ ] `nix flake check` runs the new check across the flake's systems and passes. - [x] `nix flake check` runs the new check across the flake's systems and passes.
## Implementation Notes
The check lives in its own file, `checks/home-manager-module.nix`, imported from the flake's `checks` output beside the existing package check; the `forAllSystems` callback now destructures `{ pkgs, system }` because the check needs `pkgs` to build fixtures and the derivation.
`home-manager` is added as a flake input with `inputs.nixpkgs.follows = "nixpkgs"`; it is a check-only development input with no bearing on the package or the module a consumer imports, and the comment in `flake.nix` says so.
Each of the four configurations builds `config.home-files` — the home-manager file-linkage layer — and the final `runCommandLocal` asserts on that tree with `test`/`grep` only: skill files present or absent by path, and the two SessionStart commands present as quoted JSON string values in `settings.json`.
The hook-merge assertion reads generated file content because that is the only place a list merge is observable; the spec's Testing Decisions name "the hook merges into the operator's own hooks" as a required assertion, so this stays within "assert on the file tree, not on option values or store paths".
On the cross-system criterion: `nix flake check` builds the check for the host system and passes, and omits the incompatible systems (`aarch64-*`) with a warning — identical per-system semantics to the pre-existing package check, which also builds only natively.
The check is import-from-derivation-bearing (the Claude Code module reads the fixture skill directories at evaluation time), so evaluating a foreign system's check forces a cross-platform fixture build rather than skipping cleanly; this is not exercised by the default `nix flake check` and does not affect the native run.
Verified by `nix flake check` (passes, all outputs) and by inspecting each configuration's built `home-files` tree directly: the disabled-Claude-Code generation contains no `.claude` directory at all, and the merged-hook `settings.json` contains both hooks in the `SessionStart` array — confirming the assertions are not vacuous.

View File

@@ -0,0 +1,139 @@
# The first automated proof of the home-manager module's composition (ADR 0021).
#
# It evaluates the *real* module through home-manager's standalone configuration
# entry point and builds the resulting home files derivation — the file-linkage
# layer that actually decides whether two declarations collide — under several
# operator configurations, then asserts on the tree each one produces. It never
# reads module internals: not option values, not store paths, not the shape of
# the file mechanism, only which files a generation contains. Building the home
# files derivation needs neither the Claude Code binary nor a running agent.
#
# The configurations cover the composition risks the reshape introduced: the
# Skill coexisting with an operator's own skills in both the attribute-set and
# whole-directory forms, the explicit sibling-enable gate that keeps the Skill
# off a host without Claude Code, and the hook merging into an operator's own
# SessionStart list rather than replacing it.
{
pkgs,
home-manager,
module,
package,
}:
let
inherit (pkgs) lib;
# A skill the operator declares as their own, as one attribute-set entry. Its
# SKILL.md is what the attribute-set assertion looks for beside the module's.
operatorAttrSkill = pkgs.runCommandLocal "operator-attr-skill" { } ''
mkdir -p "$out"
printf '%s\n' "the operator's own attribute-set skill" > "$out/SKILL.md"
'';
# A whole directory of skills, one folder per skill, for the path form of the
# operator's own `programs.claude-code.skills`. The Claude Code module installs
# this recursively; the module's own nested Skill entry has to drop in beside
# its contents rather than collide with a single link over the directory.
operatorSkillsDir = pkgs.runCommandLocal "operator-skills-dir" { } ''
mkdir -p "$out/operator-dir-skill"
printf '%s\n' "the operator's own whole-directory skill" \
> "$out/operator-dir-skill/SKILL.md"
'';
# A distinctive command so the merged-hook assertion can tell the operator's
# own SessionStart hook apart from gitea-axi's in the generated settings.json.
operatorHook = {
matcher = "";
hooks = [
{
type = "command";
command = "operator-own-session-hook";
}
];
};
# Evaluate the real module through home-manager's standalone entry point and
# return the home files derivation — the tree home-manager would link into
# $HOME. `programs.gitea-axi.enable` is on in every configuration; the package
# is the flake's own build, so the check reuses the store path the package
# check already produces rather than building a second time.
homeFiles =
operatorConfig:
(home-manager.lib.homeManagerConfiguration {
inherit pkgs;
modules = [
module
{
home.username = "operator";
home.homeDirectory = "/home/operator";
home.stateVersion = "24.11";
programs.gitea-axi.enable = true;
programs.gitea-axi.package = package;
}
operatorConfig
];
}).config.home-files;
# An operator on Claude Code who declares their own skill as an attribute-set
# entry: the module's Skill and theirs must both land, each at its own name.
attrSetSkills = homeFiles {
programs.claude-code.enable = true;
# A store path (not a bare derivation): the skills value type takes a path,
# and a derivation would be read as the attribute-set branch of the option.
programs.claude-code.skills.operator-attr-skill = "${operatorAttrSkill}";
};
# An operator who declares their own skills as a whole directory (path form):
# the module's Skill lands beside the directory's contents. A regression to a
# non-recursive path-form install would fail this build as a file collision.
wholeDirSkills = homeFiles {
programs.claude-code.enable = true;
programs.claude-code.skills = "${operatorSkillsDir}";
};
# Claude Code disabled: the explicit sibling-enable gate must leave no
# gitea-axi Skill entry in the generation.
claudeCodeOff = homeFiles {
programs.claude-code.enable = false;
};
# An operator with their own SessionStart hook: the module's hook must merge
# into that list rather than replace it.
mergedHook = homeFiles {
programs.claude-code.enable = true;
programs.claude-code.settings.hooks.SessionStart = [ operatorHook ];
};
in
pkgs.runCommandLocal "gitea-axi-home-manager-module-check"
{
# Forcing each derivation as a build input is what actually builds the home
# files tree under every configuration; the whole-directory build would fail
# here, before any assertion runs, on a non-recursive-install regression.
inherit
attrSetSkills
wholeDirSkills
claudeCodeOff
mergedHook
;
}
''
echo "attribute-set skills: module's Skill and operator's both land"
test -f "$attrSetSkills/.claude/skills/gitea-axi/SKILL.md"
test -f "$attrSetSkills/.claude/skills/operator-attr-skill/SKILL.md"
echo "whole-directory skills: module's Skill lands beside the directory"
test -f "$wholeDirSkills/.claude/skills/gitea-axi/SKILL.md"
test -f "$wholeDirSkills/.claude/skills/operator-dir-skill/SKILL.md"
echo "Claude Code disabled: no gitea-axi Skill entry is written"
test ! -e "$claudeCodeOff/.claude/skills/gitea-axi"
echo "operator's own SessionStart hook: the module's hook merges in"
# Match the commands as quoted JSON string values, not by their position or
# the emitter's colon spacing: both must be present for a merge (rather than
# a replacement) of the two SessionStart hooks.
grep -q '"operator-own-session-hook"' "$mergedHook/.claude/settings.json"
grep -q '"gitea-axi"' "$mergedHook/.claude/settings.json"
touch "$out"
''

21
flake.lock generated
View File

@@ -1,5 +1,25 @@
{ {
"nodes": { "nodes": {
"home-manager": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1784588016,
"narHash": "sha256-ouZe80aWEhMLVMkqICFDN+JUw+0FJtCr/bh+hHtRtMg=",
"owner": "nix-community",
"repo": "home-manager",
"rev": "deeb6b7eb7e0c44ae1819c051ce175bd92a85100",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "home-manager",
"type": "github"
}
},
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1784356753, "lastModified": 1784356753,
@@ -18,6 +38,7 @@
}, },
"root": { "root": {
"inputs": { "inputs": {
"home-manager": "home-manager",
"nixpkgs": "nixpkgs" "nixpkgs": "nixpkgs"
} }
} }

View File

@@ -6,8 +6,18 @@
# only — never the deployed artifact. # only — never the deployed artifact.
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
# Present only so `nix flake check` can evaluate the home-manager module
# against real home-manager (the `home-manager-module` check). Its nixpkgs
# follows this flake's, so the module is checked against the same
# nixpkgs-and-home-manager pairing a consumer following this flake would get.
# It has no bearing on the package or the module a consumer imports — the
# module is a bare function that takes the importing configuration's own
# home-manager and pkgs.
inputs.home-manager.url = "github:nix-community/home-manager";
inputs.home-manager.inputs.nixpkgs.follows = "nixpkgs";
outputs = outputs =
{ self, nixpkgs }: { self, nixpkgs, home-manager }:
let let
# x86_64-darwin is deliberately absent: nixpkgs 26.11 dropped it, and # x86_64-darwin is deliberately absent: nixpkgs 26.11 dropped it, and
# `legacyPackages.x86_64-darwin` now throws rather than merely failing to # `legacyPackages.x86_64-darwin` now throws rather than merely failing to
@@ -79,16 +89,31 @@
} }
); );
# An alias for the package, so `nix flake check` builds it and thereby runs # Two checks. `gitea-axi` is an alias for the package, so `nix flake check`
# both its verification phases — the fast tier in `checkPhase`, the # builds it and thereby runs both its verification phases — the fast tier
# installed-binary tier in `installCheckPhase`. # in `checkPhase`, the installed-binary tier in `installCheckPhase`.
# #
# No granular per-stage checks: the one stage that would add coverage the # `home-manager-module` evaluates the home-manager module through real
# package build does not already have is the full typecheck, which spans # home-manager and builds the home files derivation it produces under
# `test/` and `bench/` and would therefore drag the benchmark harness into # several operator configurations, proving the module's composition (ADR
# the derivation's inputs — undoing the source filtering that keeps # 0021). It reuses the package check's store path rather than rebuilding.
# benchmark churn from forcing a rebuild. That typecheck stays in #
# continuous integration, where it already runs. # No granular per-stage checks for the package: the one stage that would
checks = forAllSystems ({ system, ... }: { inherit (self.packages.${system}) gitea-axi; }); # add coverage the package build does not already have is the full
# typecheck, which spans `test/` and `bench/` and would therefore drag the
# benchmark harness into the derivation's inputs — undoing the source
# filtering that keeps benchmark churn from forcing a rebuild. That
# typecheck stays in continuous integration, where it already runs.
checks = forAllSystems (
{ pkgs, system }: {
inherit (self.packages.${system}) gitea-axi;
home-manager-module = import ./checks/home-manager-module.nix {
inherit pkgs home-manager;
module = self.homeModules.gitea-axi;
package = self.packages.${system}.gitea-axi;
};
}
);
}; };
} }