feat(nix): check the home-manager module's composition (task 0047)
All checks were successful
CI / test (22) (pull_request) Successful in 49s
CI / test (true, 24) (pull_request) Successful in 1m5s
CI / flake (pull_request) Successful in 2s
CI / test (22) (push) Successful in 49s
CI / test (true, 24) (push) Successful in 1m3s
CI / flake (push) Successful in 3s

Add a `home-manager` flake input, its nixpkgs following this flake's, and a
`home-manager-module` check that evaluates the real module through
home-manager's standalone entry point and builds the home files derivation
under four operator configurations, asserting on the tree each produces:
the Skill coexisting with an operator's own skills in both the attribute-set
and whole-directory forms, the sibling-enable gate leaving no Skill when
Claude Code is off, and the hook merging into an operator's own SessionStart
list. This is the first automated proof of the module's composition (ADR 0021),
replacing verification by maintainer rebuild.
This commit was merged in pull request #56.
This commit is contained in:
2026-07-20 20:10:15 -04:00
parent 534097121d
commit 1468003f5b
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
- [ ] 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.
- [ ] 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.
- [ ] 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.
- [ ] 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] The flake has a home-manager input whose nixpkgs follows the flake's nixpkgs; `flake.lock` is updated.
- [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.
- [x] The attribute-set-skills configuration asserts the module's Skill and the operator's skill both land, each at its own name.
- [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.
- [x] The Claude-Code-disabled configuration asserts no gitea-axi Skill entry is written.
- [x] A configuration with an operator's own SessionStart hook asserts the module's hook merges into that list rather than replacing it.
- [x] The check asserts on the generation's file tree only, not on option values or store paths.
- [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": {
"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": {
"locked": {
"lastModified": 1784356753,
@@ -18,6 +38,7 @@
},
"root": {
"inputs": {
"home-manager": "home-manager",
"nixpkgs": "nixpkgs"
}
}

View File

@@ -6,8 +6,18 @@
# only — never the deployed artifact.
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 =
{ self, nixpkgs }:
{ self, nixpkgs, home-manager }:
let
# x86_64-darwin is deliberately absent: nixpkgs 26.11 dropped it, and
# `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
# both its verification phases — the fast tier in `checkPhase`, the
# installed-binary tier in `installCheckPhase`.
# Two checks. `gitea-axi` is an alias for the package, so `nix flake check`
# builds it and thereby runs both its verification phases — the fast tier
# in `checkPhase`, the installed-binary tier in `installCheckPhase`.
#
# No granular per-stage checks: the one stage that would 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 ({ system, ... }: { inherit (self.packages.${system}) gitea-axi; });
# `home-manager-module` evaluates the home-manager module through real
# home-manager and builds the home files derivation it produces under
# several operator configurations, proving the module's composition (ADR
# 0021). It reuses the package check's store path rather than rebuilding.
#
# No granular per-stage checks for the package: the one stage that would
# 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;
};
}
);
};
}