From 483d343f48bc66bd2220c5797ccc920ef594cc6b Mon Sep 17 00:00:00 2001 From: moons14 Date: Thu, 6 Aug 2026 20:16:17 +0900 Subject: [PATCH] flake: add coding-agent validation workflow --- .codex/config.toml | 13 + .github/actions/check-nixos/action.yaml | 28 +- .github/actions/setup-nix/action.yaml | 1 + .github/workflows/nixos.yaml | 169 +++++- .github/workflows/update-flake.yaml | 73 ++- .gitignore | 8 +- .vscode/settings.json | 28 + README.md | 5 +- docs/coding-agents.md | 86 +++ flake/default.nix | 1 + flake/git-hooks.nix | 2 + flake/registry.nix | 59 +- flake/validation.nix | 48 ++ modules/applications/vscode/home/common.nix | 2 +- .../profiles/workload/development/home.nix | 5 +- opencode.json | 23 + scripts/dotfiles-check.py | 526 ++++++++++++++++++ shells/dotnix.nix | 9 + skills/add-application-or-service/SKILL.md | 35 +- .../references/review-checklist.md | 61 +- skills/debug-nix-failure/SKILL.md | 63 +++ skills/test-nixos-service/SKILL.md | 64 +++ skills/update-flake-input/SKILL.md | 58 ++ skills/validate-nix-change/SKILL.md | 128 +++++ 24 files changed, 1399 insertions(+), 96 deletions(-) create mode 100644 .codex/config.toml create mode 100644 .vscode/settings.json create mode 100644 docs/coding-agents.md create mode 100644 flake/validation.nix create mode 100644 opencode.json create mode 100755 scripts/dotfiles-check.py create mode 100644 skills/debug-nix-failure/SKILL.md create mode 100644 skills/test-nixos-service/SKILL.md create mode 100644 skills/update-flake-input/SKILL.md create mode 100644 skills/validate-nix-change/SKILL.md diff --git a/.codex/config.toml b/.codex/config.toml new file mode 100644 index 0000000..d6efd65 --- /dev/null +++ b/.codex/config.toml @@ -0,0 +1,13 @@ +[mcp_servers.nixos] +command = "mcp-nixos" +startup_timeout_sec = 30 +tool_timeout_sec = 60 +required = false + +[mcp_servers.github] +url = "https://api.githubcopilot.com/mcp/" +bearer_token_env_var = "GITHUB_PERSONAL_ACCESS_TOKEN" +http_headers = { X-MCP-Readonly = "true", X-MCP-Toolsets = "repos,pull_requests,actions" } +startup_timeout_sec = 30 +tool_timeout_sec = 60 +required = false diff --git a/.github/actions/check-nixos/action.yaml b/.github/actions/check-nixos/action.yaml index cae4036..6174950 100644 --- a/.github/actions/check-nixos/action.yaml +++ b/.github/actions/check-nixos/action.yaml @@ -1,27 +1,15 @@ # Reference: https://github.com/ryoppippi/dotfiles/blob/main/.github/workflows/nix-build.yaml -name: Check NixOS configurations -description: Build every NixOS configuration and the Registry tests +name: Build Linux checks +description: Build every x86_64-linux flake check in parallel runs: using: composite steps: - - name: Build every NixOS configuration + - name: Build all Linux checks shell: bash run: | set -euo pipefail - - mapfile -t hosts < <( - nix eval --raw .#nixosConfigurations \ - --apply 'configs: builtins.concatStringsSep "\n" (builtins.attrNames configs)' - ) - - installables=(.#checks.x86_64-linux.registry) - for host in "${hosts[@]}"; do - installables+=(".#nixosConfigurations.${host}.config.system.build.toplevel") - done - - nix build \ - --keep-going \ - --no-link \ - --print-build-logs \ - --show-trace \ - "${installables[@]}" + nix run .#nix-fast-build -- \ + --flake .#checks.x86_64-linux \ + --skip-cached \ + --no-nom \ + --no-link diff --git a/.github/actions/setup-nix/action.yaml b/.github/actions/setup-nix/action.yaml index eae5b0b..91414b8 100644 --- a/.github/actions/setup-nix/action.yaml +++ b/.github/actions/setup-nix/action.yaml @@ -20,3 +20,4 @@ runs: primary-key: nix-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('flake.lock') }} restore-prefixes-first-match: nix-${{ runner.os }}-${{ runner.arch }}- gc-max-store-size-linux: 4G + gc-max-store-size-macos: 4G diff --git a/.github/workflows/nixos.yaml b/.github/workflows/nixos.yaml index 734dfa2..ac0bb6d 100644 --- a/.github/workflows/nixos.yaml +++ b/.github/workflows/nixos.yaml @@ -1,36 +1,50 @@ # Reference: https://github.com/ryoppippi/dotfiles/blob/main/.github/workflows/nix-build.yaml -name: "CI: NixOS" +name: "CI: Nix" on: push: branches: - main paths: + - .gitignore + - AGENTS.md + - README.md + - "docs/**" - flake.nix - flake.lock + - ".codex/**" + - ".github/**" + - ".vscode/**" - "flake/**" - "hosts/**" - "libs/**" - "modules/**" + - "opencode.json" - "overlays/**" + - "scripts/**" - "shells/**" + - "skills/**" - "tests/**" - - ".github/actions/check-nixos/**" - - ".github/actions/setup-nix/**" - - ".github/workflows/nixos.yaml" pull_request: paths: + - .gitignore + - AGENTS.md + - README.md + - "docs/**" - flake.nix - flake.lock + - ".codex/**" + - ".github/**" + - ".vscode/**" - "flake/**" - "hosts/**" - "libs/**" - "modules/**" + - "opencode.json" - "overlays/**" + - "scripts/**" - "shells/**" + - "skills/**" - "tests/**" - - ".github/actions/check-nixos/**" - - ".github/actions/setup-nix/**" - - ".github/workflows/nixos.yaml" workflow_dispatch: concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -38,14 +52,149 @@ concurrency: permissions: contents: read jobs: - check: - name: Check all NixOS configurations + validate: + name: Plan, lint, and evaluate runs-on: ubuntu-latest timeout-minutes: 120 + outputs: + build_linux: ${{ steps.plan.outputs.build_linux }} + build_darwin: ${{ steps.plan.outputs.build_darwin }} + nix_validation: ${{ steps.plan.outputs.nix_validation }} steps: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + fetch-depth: 0 - name: Setup Nix uses: ./.github/actions/setup-nix - - name: Check NixOS configurations + - name: Plan validation + id: plan + env: + PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} + PUSH_BASE_SHA: ${{ github.event.before }} + shell: bash + run: | + set -euo pipefail + + case "${{ github.event_name }}" in + pull_request) + plan="$(nix run .#check -- plan --base "$PR_BASE_SHA" --json)" + ;; + push) + plan="$(nix run .#check -- plan --base "$PUSH_BASE_SHA" --json)" + ;; + *) + plan="$(nix run .#check -- plan --all-files --all-hosts --json)" + ;; + esac + + printf '%s\n' "$plan" + nix_validation="$(jq -r '.requiresNixValidation' <<<"$plan")" + + if [[ "${{ github.event_name }}" == "pull_request" ]]; then + build_linux="$(jq -r '.nativeBuildSystems["x86_64-linux"] // false' <<<"$plan")" + build_darwin="$(jq -r '.nativeBuildSystems["aarch64-darwin"] // false' <<<"$plan")" + else + build_linux="$nix_validation" + build_darwin="$nix_validation" + fi + + { + echo "nix_validation=$nix_validation" + echo "build_linux=$build_linux" + echo "build_darwin=$build_darwin" + } >> "$GITHUB_OUTPUT" + - name: Run fast checks + env: + PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} + PUSH_BASE_SHA: ${{ github.event.before }} + shell: bash + run: | + set -euo pipefail + case "${{ github.event_name }}" in + pull_request) + nix run .#check -- fast --base "$PR_BASE_SHA" + ;; + push) + nix run .#check -- fast --base "$PUSH_BASE_SHA" + ;; + *) + nix run .#check -- fast --all-files + ;; + esac + - name: Evaluate configurations + if: steps.plan.outputs.nix_validation == 'true' || github.event_name == 'workflow_dispatch' + env: + PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} + PUSH_BASE_SHA: ${{ github.event.before }} + shell: bash + run: | + set -euo pipefail + case "${{ github.event_name }}" in + pull_request) + nix run .#check -- eval --base "$PR_BASE_SHA" + ;; + push) + nix run .#check -- eval --base "$PUSH_BASE_SHA" --all-systems + ;; + *) + nix run .#check -- eval --all-hosts --all-systems + ;; + esac + + build-linux: + name: Build Linux checks + needs: validate + if: needs.validate.outputs.build_linux == 'true' + runs-on: ubuntu-latest + timeout-minutes: 180 + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + fetch-depth: 0 + - name: Setup Nix + uses: ./.github/actions/setup-nix + - name: Build affected Linux checks + if: github.event_name == 'pull_request' + env: + PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} + shell: bash + run: | + set -euo pipefail + nix run .#check -- build --base "$PR_BASE_SHA" + - name: Build all Linux checks + if: github.event_name != 'pull_request' uses: ./.github/actions/check-nixos + + build-darwin: + name: Build Darwin checks + needs: validate + if: needs.validate.outputs.build_darwin == 'true' + runs-on: macos-15 + timeout-minutes: 180 + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + fetch-depth: 0 + - name: Setup Nix + uses: ./.github/actions/setup-nix + - name: Build affected Darwin checks + if: github.event_name == 'pull_request' + env: + PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} + shell: bash + run: | + set -euo pipefail + nix run .#check -- build --base "$PR_BASE_SHA" + - name: Build all Darwin checks + if: github.event_name != 'pull_request' + shell: bash + run: | + set -euo pipefail + nix run .#nix-fast-build -- \ + --flake .#checks.aarch64-darwin \ + --skip-cached \ + --no-nom \ + --no-link diff --git a/.github/workflows/update-flake.yaml b/.github/workflows/update-flake.yaml index 1c8a76b..7bfdbe4 100644 --- a/.github/workflows/update-flake.yaml +++ b/.github/workflows/update-flake.yaml @@ -12,9 +12,11 @@ permissions: pull-requests: write jobs: update: - name: Update and validate flake inputs + name: Update and check Linux runs-on: ubuntu-latest - timeout-minutes: 120 + timeout-minutes: 180 + outputs: + changed: ${{ steps.update.outputs.changed }} steps: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 @@ -22,18 +24,78 @@ jobs: uses: ./.github/actions/setup-nix - name: Update flake inputs id: update + shell: bash run: | + set -euo pipefail nix flake update if git diff --quiet -- flake.lock; then echo 'changed=false' >> "$GITHUB_OUTPUT" else echo 'changed=true' >> "$GITHUB_OUTPUT" fi - - name: Check updated NixOS configurations + - name: Evaluate every flake system + if: steps.update.outputs.changed == 'true' + shell: bash + run: | + set -euo pipefail + nix flake check \ + --no-build \ + --all-systems \ + --keep-going \ + --show-trace + - name: Build Linux checks if: steps.update.outputs.changed == 'true' uses: ./.github/actions/check-nixos - - name: Create update pull request + - name: Upload updated lock file if: steps.update.outputs.changed == 'true' + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: flake-lock + path: flake.lock + if-no-files-found: error + + check-darwin: + name: Check Darwin + needs: update + if: needs.update.outputs.changed == 'true' + runs-on: macos-15 + timeout-minutes: 180 + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - name: Download updated lock file + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: flake-lock + path: . + - name: Setup Nix + uses: ./.github/actions/setup-nix + - name: Build Darwin checks + shell: bash + run: | + set -euo pipefail + nix run .#nix-fast-build -- \ + --flake .#checks.aarch64-darwin \ + --skip-cached \ + --no-nom \ + --no-link + + pull-request: + name: Create update pull request + needs: + - update + - check-darwin + if: needs.update.outputs.changed == 'true' + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - name: Download validated lock file + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: flake-lock + path: . + - name: Create update pull request uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 with: token: ${{ secrets.GITHUB_TOKEN }} @@ -45,4 +107,5 @@ jobs: body: | Automated update of `flake.lock`. - The updated inputs passed the Registry tests and a build of every NixOS configuration. + The updated inputs passed all-system evaluation and native builds of + the Linux and Darwin check sets. diff --git a/.gitignore b/.gitignore index e539c3d..e9f83fd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ /* +**/__pycache__/ +*.py[cod] !.gitignore !README.md @@ -6,8 +8,10 @@ !AGENTS.md !/docs/ -!.github/ +!/.codex/ +!/.github/ !.gitea/ +!/.vscode/ !.envrc @@ -15,7 +19,9 @@ !/flake.nix !/flake.lock +!/opencode.json +!/scripts/ !/shells/ !/flake/ !/overlays/ diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..b82504e --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,28 @@ +{ + "nix.enableLanguageServer": true, + "nix.serverPath": "nixd", + "nix.serverSettings": { + "nixd": { + "formatting": { + "command": ["nixfmt"] + }, + "nixpkgs": { + "expr": "import (builtins.getFlake (builtins.toString ./.)).inputs.nixpkgs { }" + }, + "options": { + "nixos": { + "expr": "(builtins.getFlake (builtins.toString ./.)).nixosConfigurations.galleria.options" + }, + "home-manager-nixos": { + "expr": "(builtins.getFlake (builtins.toString ./.)).nixosConfigurations.galleria.options.home-manager.users.type.getSubOptions []" + }, + "darwin": { + "expr": "(builtins.getFlake (builtins.toString ./.)).darwinConfigurations.m2.options" + }, + "home-manager-darwin": { + "expr": "(builtins.getFlake (builtins.toString ./.)).darwinConfigurations.m2.options.home-manager.users.type.getSubOptions []" + } + } + } + } +} diff --git a/README.md b/README.md index 6ed21e3..3764c2a 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,6 @@ # moons14 dotfiles -My NixOS + Home Manager configurations build with flake. +My NixOS, nix-darwin, and Home Manager configurations built with flakes. + +See [Coding-agent validation](docs/coding-agents.md) for the non-activating, +change-aware validation workflow used by Codex, OpenCode, and CI. diff --git a/docs/coding-agents.md b/docs/coding-agents.md new file mode 100644 index 0000000..ce61b21 --- /dev/null +++ b/docs/coding-agents.md @@ -0,0 +1,86 @@ +# Coding-agent workflow + +This repository exposes deterministic validation and narrowly scoped research +tools for Codex, OpenCode, and editor agents. Live system activation is outside +this workflow. + +## Validation commands + +Use task-owned paths during the edit loop: + +```console +nix run .#check -- plan --paths modules/applications/example/home.nix --json +nix run .#check -- fast --paths modules/applications/example/home.nix +nix run .#check -- eval --paths modules/applications/example/home.nix +nix run .#check -- build --paths modules/applications/example/home.nix +nix run .#check -- all --paths modules/applications/example/home.nix +``` + +For a committed pull-request range, replace `--paths ...` with +`--base `. `full` is reserved for CI, scheduled maintenance, or an +explicit repository-wide audit: + +```console +nix run .#check -- full +``` + +The stages have distinct meanings: + +- `plan` maps changed paths to Registry units, reverse `meta.includes` + dependencies, real hosts, and compatible build systems. +- `fast` parses changed Nix files, validates JSON, TOML, Python, and Agent Skill + frontmatter, checks whitespace, and runs configured hooks only for the + selected files. +- `eval` instantiates affected NixOS and nix-darwin configurations. Flake-wide, + validation-tool, shell, overlay, and test changes additionally evaluate every + flake system. +- `build` realizes affected configurations supported by the current platform + with no result link. Incompatible targets remain evaluation-only until a + matching runner handles them. +- `all` performs task-scoped file checks, all-system evaluation, and compatible + targeted builds. +- `full` runs all-file hooks, all-system evaluation, and every check for the + current platform through `nix-fast-build`. + +The validation app constructs a filtered temporary `path:` flake from the +committed `HEAD` tree and overlays only task-owned changed paths. This isolates +unrelated worktree changes, makes new untracked Registry fragments visible to +Nix without staging them, and avoids copying ignored state such as `.direnv`. + +None of these commands runs `nh os switch`, `nixos-rebuild switch`, +`darwin-rebuild switch`, `home-manager switch`, or another activation command. +The user performs activation separately. + +## Agent Skills + +Read `AGENTS.md` first. Use the repository skills as follows: + +- `validate-nix-change` controls validation scope and evidence. +- `debug-nix-failure` classifies parse, evaluation, build, test, activation-log, + and runtime failures before proposing a correction. +- `test-nixos-service` adds a `pkgs.testers.runNixOSTest` check when a build + cannot prove service behavior. +- `update-flake-input` limits lock-file updates and validates them without + activating a host. +- `add-application-or-service` preserves Registry ownership and delegates + validation to `validate-nix-change`. + +## MCP and language-server setup + +The development workload installs `mcp-nixos`, `nixd`, `nix-fast-build`, and +`nix-tree`. + +Project-local Codex and OpenCode configuration exposes: + +- `mcp-nixos` for current NixOS, Home Manager, nix-darwin, package, and Nix + documentation queries; +- GitHub's remote MCP endpoint for Codex and OpenCode in read-only mode, + restricted to repository, pull-request, and Actions toolsets. + +Set `GITHUB_PERSONAL_ACCESS_TOKEN` in the launching environment when GitHub MCP +access is needed. Do not commit the token or put it in a Nix expression because +that would expose it through source control or the Nix store. + +The workspace VS Code settings use `nixd` and expose option sets for the +`galleria` NixOS configuration, its integrated Home Manager configuration, the +`m2` nix-darwin configuration, and its integrated Home Manager configuration. diff --git a/flake/default.nix b/flake/default.nix index d14de03..5886b50 100644 --- a/flake/default.nix +++ b/flake/default.nix @@ -3,5 +3,6 @@ ./formatter.nix ./git-hooks.nix ./registry.nix + ./validation.nix ]; } diff --git a/flake/git-hooks.nix b/flake/git-hooks.nix index 31ffd2c..a5d3d7e 100644 --- a/flake/git-hooks.nix +++ b/flake/git-hooks.nix @@ -34,7 +34,9 @@ ]; }; + actionlint.enable = true; deadnix.enable = true; + ruff.enable = true; statix.enable = true; shellcheck.enable = true; }; diff --git a/flake/registry.nix b/flake/registry.nix index bf73d9c..35f43a3 100644 --- a/flake/registry.nix +++ b/flake/registry.nix @@ -25,23 +25,63 @@ let else { }; configurations = dotfilesLib.hosts.mkConfigurations hostSpecs; + + validationMetadata = { + schemaVersion = 1; + + hosts = lib.mapAttrs ( + name: spec: + let + isDarwin = lib.hasSuffix "-darwin" spec.system; + in + { + inherit (spec) system user; + kind = if isDarwin then "darwin" else "nixos"; + homeManager = spec.homeManager or true; + selectedUnits = dotfilesLib.hosts.selectedUnits spec; + buildAttr = + if isDarwin then + "darwinConfigurations.${name}.system" + else + "nixosConfigurations.${name}.config.system.build.toplevel"; + } + ) hostSpecs; + + units = lib.mapAttrs ( + _id: unit: { + inherit (unit) id relativePath; + directory = "modules/${lib.concatStringsSep "/" unit.relativePath}"; + includes = unit.meta.includes; + fragments = builtins.attrNames (lib.filterAttrs (_: value: value != null) unit.fragments); + } + ) dotfilesLib.registry.units; + }; in { flake = { inherit (configurations) darwinConfigurations nixosConfigurations; - lib = dotfilesLib; + lib = dotfilesLib // { inherit validationMetadata; }; }; perSystem = { pkgs, system, ... }: let - nixosChecks = - lib.mapAttrs' (name: nixos: lib.nameValuePair "nixos-${name}" nixos.config.system.build.toplevel) - ( - lib.filterAttrs ( - _: nixos: nixos.pkgs.stdenv.hostPlatform.system == system - ) configurations.nixosConfigurations - ); + nixosChecks = lib.mapAttrs' ( + name: nixos: + lib.nameValuePair "nixos-${name}" nixos.config.system.build.toplevel + ) ( + lib.filterAttrs ( + name: _: hostSpecs.${name}.system == system + ) configurations.nixosConfigurations + ); + darwinChecks = lib.mapAttrs' ( + name: darwin: + lib.nameValuePair "darwin-${name}" darwin.system + ) ( + lib.filterAttrs ( + name: _: hostSpecs.${name}.system == system + ) configurations.darwinConfigurations + ); in { checks = { @@ -49,6 +89,7 @@ in inherit inputs lib pkgs; }; } - // nixosChecks; + // nixosChecks + // darwinChecks; }; } diff --git a/flake/validation.nix b/flake/validation.nix new file mode 100644 index 0000000..6acd2bd --- /dev/null +++ b/flake/validation.nix @@ -0,0 +1,48 @@ +_: { + perSystem = + { pkgs, config, ... }: + let + script = pkgs.writeText "dotfiles-check.py" (builtins.readFile ../scripts/dotfiles-check.py); + dotfilesCheck = pkgs.writeShellApplication { + name = "dotfiles-check"; + runtimeInputs = [ + pkgs.git + pkgs.nix + pkgs.python3 + ]; + text = '' + exec python3 ${script} "$@" + ''; + }; + in + { + apps = { + check = { + type = "app"; + program = "${dotfilesCheck}/bin/dotfiles-check"; + }; + nix-fast-build = { + type = "app"; + program = "${pkgs.nix-fast-build}/bin/nix-fast-build"; + }; + }; + + packages = { + dotfiles-check = dotfilesCheck; + inherit (pkgs) nix-fast-build; + }; + + devShells.validation = config.pre-commit.devShell; + + checks = { + validation-tool = pkgs.runCommand "dotfiles-check-self-test" { + nativeBuildInputs = [ dotfilesCheck ]; + } '' + dotfiles-check self-test + touch "$out" + ''; + + dotnix-shell = config.devShells.dotnix; + }; + }; +} diff --git a/modules/applications/vscode/home/common.nix b/modules/applications/vscode/home/common.nix index 7412c35..4d583bf 100644 --- a/modules/applications/vscode/home/common.nix +++ b/modules/applications/vscode/home/common.nix @@ -118,7 +118,7 @@ let ]; userSettings = { "nix.enableLanguageServer" = true; - "nix.serverPath" = "nil"; + "nix.serverPath" = "nixd"; "nix.serverSettings" = { nixd = { formatting.command = [ "nixfmt" ]; diff --git a/modules/profiles/workload/development/home.nix b/modules/profiles/workload/development/home.nix index 92038ee..937a58f 100644 --- a/modules/profiles/workload/development/home.nix +++ b/modules/profiles/workload/development/home.nix @@ -3,7 +3,10 @@ home.packages = with pkgs; [ bind bun - nil + mcp-nixos + nix-fast-build + nix-tree + nixd python312 uv ]; diff --git a/opencode.json b/opencode.json new file mode 100644 index 0000000..fd0d141 --- /dev/null +++ b/opencode.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "nixos": { + "type": "local", + "command": ["mcp-nixos"], + "enabled": true, + "timeout": 30000 + }, + "github": { + "type": "remote", + "url": "https://api.githubcopilot.com/mcp/", + "enabled": true, + "oauth": false, + "headers": { + "Authorization": "Bearer {env:GITHUB_PERSONAL_ACCESS_TOKEN}", + "X-MCP-Readonly": "true", + "X-MCP-Toolsets": "repos,pull_requests,actions" + }, + "timeout": 30000 + } + } +} diff --git a/scripts/dotfiles-check.py b/scripts/dotfiles-check.py new file mode 100755 index 0000000..40ff218 --- /dev/null +++ b/scripts/dotfiles-check.py @@ -0,0 +1,526 @@ +#!/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 diff --git a/shells/dotnix.nix b/shells/dotnix.nix index bef8bb2..1f46ce7 100644 --- a/shells/dotnix.nix +++ b/shells/dotnix.nix @@ -3,15 +3,22 @@ _: { { pkgs, config, + lib, ... }: { devShells.dotnix = pkgs.mkShell { packages = [ config.treefmt.build.wrapper + config.packages.dotfiles-check + pkgs.actionlint pkgs.git pkgs.gitleaks + pkgs.mcp-nixos + pkgs.nix-fast-build + pkgs.nix-tree + pkgs.nixd pkgs.pre-commit # sops-nix / age @@ -22,6 +29,8 @@ _: { # YubiKey for sops editing pkgs.age-plugin-yubikey pkgs.yubikey-manager + ] + ++ lib.optionals pkgs.stdenv.isLinux [ pkgs.pcsc-tools ]; diff --git a/skills/add-application-or-service/SKILL.md b/skills/add-application-or-service/SKILL.md index 2c04310..9c218c9 100644 --- a/skills/add-application-or-service/SKILL.md +++ b/skills/add-application-or-service/SKILL.md @@ -95,26 +95,33 @@ points cannot express the requirement. ### 4. Prove the change -Run the validation matrix in -[references/review-checklist.md](references/review-checklist.md). At minimum: +Invoke the `validate-nix-change` skill and use the task-owned files as its +explicit path set. At minimum: -1. Format the task-owned files with the repository formatter and run - `git diff --check`. -2. Inspect the complete task diff for accidental files, duplication, leaked +1. Inspect `nix run .#check -- plan --paths ... --json` and confirm + the reported units, host classes, and real hosts are correct. +2. Run `nix run .#check -- fast --paths ...` during the edit loop. +3. Inspect the complete task diff for accidental files, duplication, leaked secrets, forced values, direct enable assignments, and unrelated rewrites. -3. Run `nix flake check`. -4. Run `pre-commit run --all-files`. -5. Evaluate every affected real host without switching it. For a - cross-platform unit or profile, evaluate both NixOS and nix-darwin even if - only one class changed. Build an affected configuration with `--no-link` - when the current platform can build it. -6. Verify selection as well as syntax: confirm that the expected package, +4. Run `nix run .#check -- all --paths ...` after the structure is + complete. This evaluates every flake system and builds affected targets for + the current platform without activation. +5. Verify selection as well as syntax: confirm that the expected package, program, service, group, cask, or external module appears in the resulting configuration. +6. Add a `pkgs.testers.runNixOSTest` check through the `test-nixos-service` + skill when service startup or another runtime contract cannot be proved by + evaluation and a system build. + +Use `nix run .#check -- full` only for CI, scheduled maintenance, or an explicit +repository-wide audit. These commands never activate the live system. Do not +run `nh os switch`, `nixos-rebuild switch`, `darwin-rebuild switch`, +`home-manager switch`, or an equivalent activation command as validation. If a command is unavailable, blocked by the environment, or fails for a -pre-existing reason, diagnose it and report the exact gap. Never silently skip -a required check or weaken the implementation to make a check pass. +pre-existing reason, invoke the `debug-nix-failure` skill, diagnose it, and +report the exact gap. Never silently skip a required check or weaken the +implementation to make a check pass. ### 5. Audit before completion diff --git a/skills/add-application-or-service/references/review-checklist.md b/skills/add-application-or-service/references/review-checklist.md index e82cfae..cdff028 100644 --- a/skills/add-application-or-service/references/review-checklist.md +++ b/skills/add-application-or-service/references/review-checklist.md @@ -72,56 +72,47 @@ user-owned mutable state unless the requested policy explicitly owns it. ## Validation matrix -Run checks from the repository root and keep the exact results for the handoff. -Do not switch or activate a live system merely to validate a change. +Use the `validate-nix-change` skill and run checks from the repository root. +Keep exact results for the handoff. The validation app never switches or +activates a live system. ### Always -1. Format task-owned files. If the worktree contains unrelated user changes, - pass only task-owned paths to the configured formatter when supported. -2. Run `git diff --check`. -3. Review `git status --short`, `git diff --stat`, and the complete `git diff`. -4. Run `nix flake check`. -5. Run `pre-commit run --all-files`. +1. Run `nix run .#check -- plan --paths ... --json` and inspect the + affected units and hosts. +2. Run `nix run .#check -- fast --paths ...` during implementation. +3. Review `git status --short`, `git diff --stat`, and the complete task diff. +4. Run `nix run .#check -- all --paths ...` before handoff. It runs + all-system evaluation and compatible targeted builds without activation. +5. Reserve `nix run .#check -- full` for CI, scheduled maintenance, or an + explicit repository-wide audit. + +Do not run `nh os switch`, `nixos-rebuild switch`, `darwin-rebuild switch`, +`home-manager switch`, or an equivalent activation command. ### NixOS or Home Manager on NixOS -- Evaluate each affected host's system toplevel derivation. -- Build at least one affected NixOS configuration with `--no-link` when the - current system supports it. +- Confirm that the validation plan includes each affected real NixOS host. +- Build affected NixOS configurations with `--no-link` through the validation + app when the current system supports them. - Inspect the resulting option that proves selection: for example `environment.systemPackages`, the user's `home.packages`, `systemd.services`, `users.users..extraGroups`, or the upstream `programs`/`services` option. -Typical build shape: - -```sh -nix build .#nixosConfigurations..config.system.build.toplevel --no-link -``` - ### nix-darwin or Home Manager on Darwin -- Evaluate every affected Darwin host even when running on Linux. +- Confirm that every affected Darwin host is evaluated even when running on + Linux. - Inspect `homebrew.casks` or `homebrew.brews` for Homebrew-backed additions. - Evaluate the relevant Home Manager program or package option. -- Build a Darwin configuration only on a compatible Darwin builder; otherwise - report that build as an explicit runtime-validation gap. - -Typical evaluation shapes: - -```sh -nix eval --raw .#darwinConfigurations..system.drvPath -nix eval --json .#darwinConfigurations..config.homebrew.casks -``` - -Confirm the exact output attribute against the current flake before using a -command; do not paste these shapes blindly. +- Build a Darwin configuration only on a compatible Darwin runner or builder; + otherwise report the build as an explicit platform gap. ### Profiles and cross-platform changes -- Determine transitive selection through `meta.includes`, not only direct - mentions. +- Confirm transitive selection through `meta.includes`, not only direct + mentions. The validation plan computes reverse dependency closure. - Evaluate every real host selecting the changed profile. - Evaluate both host classes for a cross-platform profile, even if only one current fragment changed. @@ -134,6 +125,8 @@ command; do not paste these shapes blindly. ### Runtime-dependent behavior Evaluation and builds cannot prove GUI appearance, credentials, network access, -hardware behavior, or successful daemon interaction. State the precise manual -post-activation check needed for those behaviors. Never describe evaluation as +hardware behavior, successful daemon interaction, or reboot state. For +reusable NixOS behavior, use the `test-nixos-service` skill and add a +`pkgs.testers.runNixOSTest` check. State the precise manual post-activation check +needed for physical hardware or external systems. Never describe evaluation as a runtime test. diff --git a/skills/debug-nix-failure/SKILL.md b/skills/debug-nix-failure/SKILL.md new file mode 100644 index 0000000..bc97417 --- /dev/null +++ b/skills/debug-nix-failure/SKILL.md @@ -0,0 +1,63 @@ +--- +name: debug-nix-failure +description: Diagnose failures from parsing, Nix module evaluation, derivation builds, flake checks, NixOS tests, or Home Manager activation logs without changing the live system. Use when `nix run .#check`, `nix flake check`, `nix build`, CI, or a user-provided activation log fails. +--- + +# Debug a Nix Failure + +Classify the failure before changing code. Preserve the original command, +complete error, first causal frame, and affected attribute. Never run a live +switch or activation to reproduce a validation failure. + +## Identify the failing layer + +- **Parse or format:** syntax location, malformed string, unmatched delimiter, + or formatter-owned rewrite. +- **Static analysis:** dead binding, suspicious expression, ShellCheck finding, + secret scan, or workflow lint. +- **Module evaluation:** missing option, wrong type, assertion, infinite + recursion, conflicting definitions, Registry selection, or unsupported host + class. +- **Derivation instantiation/build:** missing dependency, hash mismatch, patch + failure, compiler/test failure, sandbox violation, or unsupported platform. +- **NixOS test:** failed unit, timeout, command assertion, network readiness, or + reboot state. +- **Activation/runtime:** filesystem conflict, activation script, systemd unit, + hardware, credential, or external-service behavior. Diagnose only from logs + supplied by the user unless they explicitly request a non-switch inspection + command. + +## Reproduce the narrowest failing operation + +Start with the stage and paths reported by the validation app: + +```sh +nix run .#check -- plan --paths ... --json +nix run .#check -- fast --paths ... +nix run .#check -- eval --paths ... +nix run .#check -- build --paths ... +``` + +For a single attribute, use `nix eval --show-trace` on its `drvPath` before a +build. For a failed derivation, retain `--print-build-logs` and inspect +`nix log ` when the summary omits the causal lines. + +## Read traces selectively + +1. Find the first repository-owned frame or option path. +2. Separate the immediate failure from wrapper frames in `modules.nix`, + `lib.evalModules`, or flake-parts. +3. Inspect the option declaration and every definition contributing to it. +4. Confirm package and option names against locked inputs, not memory. +5. Check whether the failure reproduces on the base revision before calling it + task-owned. + +Do not respond to a type or ownership error with import-order changes, +`lib.mkForce`, global arguments, or an overlay unless repository evidence shows +that those mechanisms are the correct owner. + +## Finish with a bounded diagnosis + +Report the failing layer, root cause, minimal correction, rerun command, and any +remaining platform or runtime gap. Include enough of the error to identify it, +but do not paste large unrelated logs. diff --git a/skills/test-nixos-service/SKILL.md b/skills/test-nixos-service/SKILL.md new file mode 100644 index 0000000..c4a002f --- /dev/null +++ b/skills/test-nixos-service/SKILL.md @@ -0,0 +1,64 @@ +--- +name: test-nixos-service +description: Add or extend a non-activating NixOS VM or container test for service startup, sockets, timers, permissions, firewall behavior, reboot state, and inter-service dependencies. Use when evaluation and a system build cannot prove the requested runtime behavior. +--- + +# Test NixOS Runtime Behavior + +Prefer `pkgs.testers.runNixOSTest` for reusable NixOS behavior that can be +proved without the user's physical machine. Do not activate the host +configuration and do not substitute a live `nh os switch` for a deterministic +test. + +## Define the observable contract + +List the runtime facts that must hold, such as: + +- a systemd unit reaches `active`; +- a socket or port is listening; +- a timer triggers its service; +- a user can or cannot read a file; +- a group membership grants access; +- a firewall permits one path and blocks another; +- state survives a reboot; +- one service waits for another dependency. + +Exclude behavior that requires physical GPU, fingerprint, audio, display, +Secure Boot, TPM, private credentials, or an external provider unless the test +can model it explicitly. + +## Implement the smallest useful machine + +Create a test under `tests/` and expose it through `checks.`. Import the +owning module or Registry selection instead of copying its implementation into +the test. Use only the packages, users, files, and network peers required by the +contract. + +Typical shape: + +```nix +pkgs.testers.runNixOSTest { + name = "service-name"; + + nodes.machine = { + # Enable the owning unit or import the module under test. + }; + + testScript = '' + machine.start() + machine.wait_for_unit("service-name.service") + machine.succeed("systemctl is-active service-name.service") + ''; +} +``` + +Use `wait_for_unit`, `wait_for_open_port`, `succeed`, `fail`, and explicit +reboots to express outcomes. Avoid arbitrary sleeps when a readiness condition +exists. + +## Validate and report + +Run the targeted test through its flake check, then run the repository +validation app for the task paths. Report the test attribute and assertions +that passed. State clearly which hardware or external behavior remains outside +the VM/container model. diff --git a/skills/update-flake-input/SKILL.md b/skills/update-flake-input/SKILL.md new file mode 100644 index 0000000..3ff5d7b --- /dev/null +++ b/skills/update-flake-input/SKILL.md @@ -0,0 +1,58 @@ +--- +name: update-flake-input +description: Update one or more pinned flake inputs with bounded lock-file changes and non-activating Linux and Darwin validation. Use for dependency refreshes, input-specific updates, automated lock-file pull requests, or diagnosing a regression introduced by flake.lock. +--- + +# Update a Flake Input + +Keep the update scope explicit and treat `flake.lock` as generated dependency +state. Never activate a host as part of this workflow; do not run `nh os switch`, +`nixos-rebuild switch`, `darwin-rebuild switch`, `home-manager switch`, or an +equivalent command. + +## Bound the update + +1. Read `AGENTS.md`, inspect `git status --short`, and preserve unrelated work. +2. Record the input names and the behavior or version change being requested. +3. Prefer an input-specific update: + +```sh +nix flake update +``` + +Use an unrestricted `nix flake update` only when the task explicitly requests a +full refresh. Do not hand-edit lock nodes. + +## Audit the lock diff + +Inspect the complete `flake.lock` diff. Confirm that changed nodes are the +requested inputs or unavoidable followers and that source owners, repositories, +reference types, and hashes remain expected. Investigate unexpected node +replacement, disappearing followers, or a large transitive graph rewrite before +validation. + +## Validate without activation + +Run the common validation workflow against the lock file: + +```sh +nix run .#check -- plan --paths flake.lock --json +nix run .#check -- fast --paths flake.lock +nix run .#check -- eval --paths flake.lock +nix run .#check -- build --paths flake.lock +``` + +A lock-file change requires full evaluation and the complete native check set. +Linux and Darwin builds must run on compatible runners. The scheduled update +workflow uploads the candidate lock file, builds Linux and Darwin checks, and +creates a pull request only after both pass. + +When a failure appears only after the update, invoke `debug-nix-failure`, compare +the failing derivation or option with the base lock, and narrow the responsible +input before adding an override or patch. + +## Report the result + +List requested and transitively changed inputs, validation commands and native +platform results, any package or option migration, and remaining manual runtime +checks. Evaluation or a native build is not activation. diff --git a/skills/validate-nix-change/SKILL.md b/skills/validate-nix-change/SKILL.md new file mode 100644 index 0000000..ddf47f2 --- /dev/null +++ b/skills/validate-nix-change/SKILL.md @@ -0,0 +1,128 @@ +--- +name: validate-nix-change +description: Plan and run efficient, non-activating validation for edits to this NixOS, nix-darwin, and Home Manager flake. Use after changing Nix modules, hosts, profiles, overlays, flake outputs, tests, scripts, CI, or agent configuration; before handing off a task; or when deciding which real hosts must be evaluated or built. +--- + +# Validate a Nix Change + +Use the repository validation app as the source of truth for change impact and +validation commands. It derives affected hosts from Registry ownership, +`meta.includes`, host selections, fragment class, and host-local paths. + +Never activate a live configuration as part of this workflow. Do not run +`nh os switch`, `nixos-rebuild switch`, `darwin-rebuild switch`, +`home-manager switch`, or an equivalent activation command. The user owns live +activation separately. + +## Establish the validation scope + +1. Read `AGENTS.md` and run `git status --short` before editing. +2. Preserve unrelated user changes. Track the paths owned by the current task, + including newly created untracked files. +3. Inspect the plan before expensive checks: + +```sh +nix run .#check -- plan --paths ... --json +``` + +When validating a committed pull-request range, use: + +```sh +nix run .#check -- plan --base --json +``` + +The app automatically uses a `path:` flake reference when task paths are +untracked, so newly created Registry fragments are visible to Nix without +staging them. + +## Run checks in increasing cost order + +### Fast edit loop + +After each coherent edit, parse Nix files, validate project JSON, TOML, and +skill frontmatter, check whitespace, and run the configured hooks only for +task-owned files: + +```sh +nix run .#check -- fast --paths ... +``` + +Do not replace this with `pre-commit run --all-files` during the edit loop. +Unrelated repository files must not become part of the task merely because an +existing check fails elsewhere. + +### Evaluation + +After the implementation is structurally complete, evaluate every affected +NixOS and Darwin derivation plus the supporting checks without realizing or +activating them. Flake-wide paths additionally evaluate every flake system: + +```sh +nix run .#check -- eval --paths ... +``` + +This proves module evaluation, option types, assertions, Registry selection, +and derivation instantiation. It does not prove a successful build or runtime +behavior. + +### Compatible builds + +Build affected configurations for the current platform with no result link: + +```sh +nix run .#check -- build --paths ... +``` + +The app reports incompatible targets as evaluated but skipped for native build. +A Darwin target must be built by a compatible Darwin runner or builder; a Linux +evaluation is not a Darwin build. + +### Final task validation + +Before handoff, run the cumulative task check. It applies file checks only to +task-owned paths, evaluates every flake system, and builds affected native +targets: + +```sh +nix run .#check -- all --paths ... +``` + +Use `--all-hosts` only when a deliberate audit must report every registered +host as affected. Use the exhaustive command for CI, scheduled maintenance, or +an explicit repository-wide audit: + +```sh +nix run .#check -- full +``` + +`full` runs hooks over every tracked file and builds every check for the current +platform through `nix-fast-build`. + +## Add runtime tests when needed + +Evaluation and builds do not prove service startup, socket behavior, firewall +rules, users and groups, permissions, reboot behavior, or network interaction. +For reusable NixOS behavior, invoke the `test-nixos-service` skill and add a +`pkgs.testers.runNixOSTest` check. Hardware, credentials, GUI appearance, and +external services may still require a precisely described manual check after +the user activates the configuration. + +## Diagnose failures by layer + +Invoke the `debug-nix-failure` skill when a stage fails. Fix the first failing +layer before running a more expensive one. Do not hide a pre-existing failure, +weaken an assertion, add `lib.mkForce`, or skip a required host merely to make +the task appear green. + +## Report evidence precisely + +Conclude with: + +- task-owned paths; +- affected units and hosts reported by `plan`; +- each command run and its result; +- which targets were parsed, evaluated, built, or runtime-tested; +- any compatible-platform or manual-runtime gap. + +Never describe evaluation as a build, a build as activation, or a VM test as +proof of hardware-specific behavior.