aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-08-17 11:28:55 -0700
committerCraig Jennings <c@cjennings.net>2026-08-17 11:28:55 -0700
commit4dfecc5f705c9df0d1f8e8245276146f826e16cc (patch)
treee439583e4ad1602e887d70ea7e90ab0d728a1e2b
parent1d486a7b43e3fdd2e194b579285d979dc9beb788 (diff)
downloadarchsetup-4dfecc5f705c9df0d1f8e8245276146f826e16cc.tar.gz
archsetup-4dfecc5f705c9df0d1f8e8245276146f826e16cc.zip
feat(scripts): add post-rebuild-check for the gaps a reinstall leaves
A rebuilt machine looks finished and isn't. Five gaps surfaced on velox within two days of its reinstall. Three looked fine on inspection: a stowed unit file, an enabled-looking timer, a present git clone. The script runs those five checks and prints a line for each whether or not it finds anything. Every probe fails closed. A check that cannot run reports a finding rather than a pass, which matters more here than anywhere: a silent no-op in the checker is the exact failure it exists to catch. `systemctl --user` exits 1 with empty output when there is no user bus, so reading that as "no failed units" would call a machine healthy at the moment nothing was checked. Calls are bounded for the same reason. A check that hangs reports nothing at all, and the machine most in need of checking is the one it hangs on. I suppressed three classes of finding, each because the live run produced them and reality disagreed. A timer-activated service is supposed to sit linked and not enabled. One expected tooling file is seed-only, so most projects legitimately never have one. Vendored package trees ship their own example files. Left in, those were 19 of the first run's 27 findings, and a check nobody reads is a check that isn't run. The post-install checklist points at it, and 58 tests cover it.
-rw-r--r--docs/post-install-checklist.org26
-rwxr-xr-xscripts/post-rebuild-check407
-rw-r--r--tests/post-rebuild-check/test_post_rebuild_check.py684
3 files changed, 1117 insertions, 0 deletions
diff --git a/docs/post-install-checklist.org b/docs/post-install-checklist.org
index 97fc0d5..f0545a7 100644
--- a/docs/post-install-checklist.org
+++ b/docs/post-install-checklist.org
@@ -18,6 +18,32 @@ bluetooth pairing landed below.
* Checklist
+** Run the post-rebuild check first
+
+Before working through the manual steps below, run:
+
+#+begin_src sh
+~/code/archsetup/scripts/post-rebuild-check
+#+end_src
+
+It runs the five checks a rebuilt machine actually needs — failed units,
+user units that are present but never enabled, =*.example= configs whose
+real sibling is missing, gitignore-mode projects missing the working state
+their own =.gitignore= names, and the signal-cli registration. Each prints
+a line whether or not it finds anything; exit 1 means something needs
+attention.
+
+These are the gaps velox hit within two days of its 2026-08-13 reinstall,
+and three of the five looked fine on casual inspection: a stowed unit file,
+an enabled-looking timer, a present git clone. Run it again a day or two
+after the install, once timers have had a chance to fail.
+
+It normally finishes in a second or two. On a machine whose user systemd is
+wedged it takes a couple of minutes instead, because every =systemctl= call
+is bounded at five seconds and check 2 makes one per unit. That is the slow
+case working as intended: it reports what it could not read rather than
+hanging. Set =PRC_SYSTEMCTL_TIMEOUT= lower to cut the wait.
+
** Pair bluetooth peripherals
Pairing is inherently interactive (scan, pick the device, confirm), so it
diff --git a/scripts/post-rebuild-check b/scripts/post-rebuild-check
new file mode 100755
index 0000000..8807f85
--- /dev/null
+++ b/scripts/post-rebuild-check
@@ -0,0 +1,407 @@
+#!/bin/sh
+# SPDX-License-Identifier: GPL-3.0-or-later
+# post-rebuild-check - the five checks a rebuilt machine actually needs.
+#
+# A rebuilt machine looks finished and isn't. Five gaps surfaced on velox
+# within two days of the 2026-08-13 reinstall, and three of them LOOKED
+# fine: a stowed unit file, an enabled timer, a present git clone. Each
+# check below is cheap and turns a silent no-op into a visible line:
+#
+# 1. failed systemd units, user and system scope (calendar-sync failed
+# every 15 minutes for two days with nobody watching)
+# 2. user unit files present but not enabled (roam-sync and
+# signal-receive came back linked and inert -- a unit file being
+# present is not the same as running)
+# 3. tracked *.example files whose real sibling is missing (three
+# *.local.el were gone on velox; the .example survives in git,
+# the real file never does)
+# 4. gitignore-mode projects missing tooling paths their own .gitignore
+# names (a reinstall drops every such project's untracked working
+# state -- 374 files in .emacs.d's case -- and nothing carries it)
+# 5. signal-cli holds no registered account (velox lost its
+# registration, and because agent-text relays into this machine,
+# that silently broke paging for the WHOLE fleet)
+#
+# The .gitignore rule in check 4 is what scopes it: a tooling path is only
+# expected where the project's own .gitignore names it, so a project that
+# never had a todo.org never flags. The ignore file is the project's own
+# record of what it is supposed to hold untracked.
+#
+# EVERY PROBE FAILS CLOSED. A check that cannot run reports a finding, never
+# a pass. This matters more here than anywhere else in the script: the whole
+# point is catching silent no-ops, so a silent no-op in the checker would be
+# the worst possible defect. `systemctl --user` exits 1 with empty output
+# when there is no user bus -- over ssh, from cron, under sudo, on a TTY
+# before the graphical session starts -- and reading that as "no failed
+# units" would report a machine as healthy exactly when nothing was checked.
+#
+# Exit 0 when every check is clean, 1 when any check found something,
+# 2 on usage error.
+#
+# Test seams (env; for each, set-but-empty means "the probe ran and found
+# nothing", unset means "run the real probe"):
+# PRC_FAILED_UNITS newline list of "scope:unit" (scope user|system)
+# PRC_UNIT_STATES newline list of "unit-file state" replacing the
+# user-unit-dir enumeration + is-enabled calls
+# PRC_LOCAL_SCAN_ROOTS NEWLINE-separated roots for the *.example scan
+# (default: ~/.emacs.d ~/.dotfiles)
+# PRC_PROJECT_ROOTS NEWLINE-separated project dirs for check 4
+# (default: ~/code/* ~/projects/* ~/.emacs.d
+# ~/.dotfiles)
+# PRC_SIGNAL_ACCOUNTS signal-cli listAccounts output; "" = no account,
+# the special value MISSING = binary absent
+# PRC_SYSTEMCTL path to the systemctl binary (a fake, under test)
+# PRC_SYSTEMCTL_TIMEOUT seconds to allow each systemctl call (default 5)
+#
+# Roots are newline-separated, not space-separated, because a POSIX
+# `for root in $var` splits on spaces and turns one real directory into
+# several imaginary missing ones.
+
+usage() {
+ cat <<'EOF'
+post-rebuild-check - verify a rebuilt machine is actually finished
+
+Runs the five checks that caught velox's 2026-08 reinstall gaps: failed
+units, present-but-inert user units, orphaned *.example configs, missing
+per-project tooling state, and the signal-cli registration.
+
+Usage: post-rebuild-check [--help]
+
+Exit 0 when every check is clean, 1 when any check found something.
+Every probe fails closed: a check that cannot run is a finding, not a pass.
+EOF
+}
+
+case "${1:-}" in
+ --help|-h) usage; exit 0 ;;
+ "") ;;
+ *) echo "post-rebuild-check: unknown argument: $1" >&2; usage >&2; exit 2 ;;
+esac
+
+# Own the internal flags rather than inheriting them, so a caller's unrelated
+# variable of the same name cannot manufacture or mask a finding.
+TOTAL_FINDINGS=0
+CHECK_FINDINGS=0
+FINDING_LINES=""
+signal_missing=""
+
+# Every systemctl call is bounded. A wedged user manager spins and answers
+# nothing -- seen live on velox 2026-08-17, where `is-enabled`, `cat`, and
+# `list-unit-files` all hung while `list-units` still returned. Unbounded, this
+# script would hang on the first unit and never reach the remaining checks,
+# which is a worse failure than reporting nothing: a check that hangs is its
+# own outage, and the machine most in need of checking is the one it hangs on.
+# A timeout yields empty output and a non-zero status, and both are already
+# handled as findings, so bounding the call is all that is needed to fail closed.
+SCTL_TIMEOUT=${PRC_SYSTEMCTL_TIMEOUT:-5}
+SYSTEMCTL=${PRC_SYSTEMCTL:-systemctl}
+
+sctl() {
+ if command -v timeout >/dev/null 2>&1; then
+ timeout "$SCTL_TIMEOUT" "$SYSTEMCTL" "$@"
+ else
+ # Say so rather than dropping the bound silently: without timeout a
+ # wedged manager hangs this run indefinitely, and the whole point of
+ # the bound is that a check which hangs reports nothing at all.
+ [ -n "${sctl_unbounded_warned:-}" ] || {
+ echo "post-rebuild-check: timeout(1) not found — systemctl calls are UNBOUNDED and may hang" >&2
+ sctl_unbounded_warned=1
+ }
+ "$SYSTEMCTL" "$@"
+ fi
+}
+
+WORK=${TMPDIR:-/tmp}/.post-rebuild-check.$$
+if ! mkdir "$WORK" 2>/dev/null; then
+ # Every check stages its input through a file in here. Without it each
+ # loop would read nothing and every check would come back clean, which is
+ # the one failure this script must never produce.
+ echo "post-rebuild-check: cannot create a work directory under ${TMPDIR:-/tmp}" >&2
+ echo " nothing was checked; this is not a pass" >&2
+ exit 1
+fi
+trap 'rm -rf "$WORK"' EXIT HUP INT TERM
+
+STAGE="$WORK/stage"
+
+finding() {
+ CHECK_FINDINGS=$((CHECK_FINDINGS + 1))
+ TOTAL_FINDINGS=$((TOTAL_FINDINGS + 1))
+ FINDING_LINES="${FINDING_LINES} DEVIATION: $1
+"
+}
+
+# Print the check's one visible line, then its findings. The visible line
+# is the point: a silent no-op is exactly what let the gaps sit unseen.
+report() {
+ if [ "$CHECK_FINDINGS" -eq 0 ]; then
+ echo "$1 — ok"
+ else
+ echo "$1 — $CHECK_FINDINGS finding(s)"
+ printf '%s' "$FINDING_LINES"
+ fi
+ CHECK_FINDINGS=0
+ FINDING_LINES=""
+}
+
+# Stage a value into $STAGE for the read loops. A failed write is fatal for
+# the same reason a missing work directory is.
+stage() {
+ if ! printf '%s\n' "$1" > "$STAGE" 2>/dev/null; then
+ echo "post-rebuild-check: cannot write $STAGE" >&2
+ echo " nothing was checked; this is not a pass" >&2
+ exit 1
+ fi
+}
+
+# --- 1. failed units ------------------------------------------------------
+
+if [ -n "${PRC_FAILED_UNITS+set}" ]; then
+ failed=$PRC_FAILED_UNITS
+else
+ failed=""
+ if user_out=$(sctl --user list-units --state=failed --no-legend --plain 2>/dev/null); then
+ failed=$(printf '%s' "$user_out" | awk 'NF {print "user:"$1}')
+ else
+ finding "could not query user units (no user bus?) — nothing was checked in this scope"
+ fi
+ if sys_out=$(sctl list-units --state=failed --no-legend --plain 2>/dev/null); then
+ failed="$failed
+$(printf '%s' "$sys_out" | awk 'NF {print "system:"$1}')"
+ else
+ finding "could not query system units — nothing was checked in this scope"
+ fi
+fi
+stage "$failed"
+while IFS= read -r line; do
+ [ -n "$line" ] || continue
+ scope=${line%%:*}
+ unit=${line#*:}
+ finding "$scope unit failed: $unit"
+done < "$STAGE"
+report "check 1/5: failed units"
+
+# --- 2. user unit files present but not enabled ---------------------------
+
+if [ -n "${PRC_UNIT_STATES+set}" ]; then
+ states=$PRC_UNIT_STATES
+else
+ states=""
+ unit_dir="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
+ if [ ! -d "$unit_dir" ]; then
+ finding "no user unit directory at $unit_dir — nothing was checked"
+ else
+ for f in "$unit_dir"/*.timer "$unit_dir"/*.service; do
+ # -L as well as -e: a stow symlink whose target moved in the
+ # rebuild is exactly the "looked fine" case this check is for,
+ # and -e is false for a broken link.
+ [ -e "$f" ] || [ -L "$f" ] || continue
+ name=$(basename "$f")
+ # A link with nothing behind it is its own finding, decided on the
+ # filesystem rather than from systemd. `is-enabled` calls a
+ # dangling link "not-found" -- the same answer it gives for a unit
+ # that was never installed -- so routing this through the state
+ # table below would drop it silently.
+ if [ -L "$f" ] && [ ! -e "$f" ]; then
+ finding "stowed unit file points at a missing target: $name"
+ continue
+ fi
+ # is-enabled exits non-zero AND prints a state for disabled and
+ # linked, so the exit code cannot distinguish "this unit is
+ # disabled" from "the query failed". The output can: a real answer
+ # is always a word. Empty means no answer, which is a finding
+ # rather than a silent skip -- with no user bus (ssh, cron, sudo,
+ # a TTY before the graphical session) every unit answers empty,
+ # and treating that as unknown-so-ignore would pass the machine
+ # while reading nothing at all.
+ #
+ # No separate bus probe: `is-system-running` and
+ # `show-environment` both block here, and a check that can hang is
+ # its own outage.
+ state=$(sctl --user is-enabled "$name" 2>/dev/null)
+ if [ -z "$state" ]; then
+ finding "could not read the enablement state of $name — it was not checked"
+ continue
+ fi
+ states="${states}${name} ${state}
+"
+ done
+ fi
+fi
+stage "$states"
+# A second copy for the sibling-timer lookup below, so the awk that reads it
+# is never the same open file as the loop reading it.
+cp "$STAGE" "$WORK/states" 2>/dev/null || {
+ echo "post-rebuild-check: cannot write $WORK/states" >&2
+ echo " nothing was checked; this is not a pass" >&2; exit 1; }
+while read -r name state; do
+ [ -n "$name" ] || continue
+ case "$state" in
+ disabled|linked) ;;
+ *) continue ;;
+ esac
+ # A timer-activated service is SUPPOSED to sit linked-and-not-enabled:
+ # the timer owns activation, and enabling the service as well would run
+ # it at boot on top of its schedule. So a service is suppressed only when
+ # its sibling timer can actually start it (enabled), or when the timer is
+ # itself inert and therefore the finding already -- reporting both would
+ # name one gap twice. A masked, static, or not-found timer starts
+ # nothing, so the service beneath it is as dead as one with no timer.
+ case "$name" in
+ *.service)
+ timer="${name%.service}.timer"
+ tstate=$(awk -v t="$timer" '$1 == t {print $2; exit}' "$WORK/states")
+ # enabled-runtime (enabled until reboot) and generated (something
+ # produced and installed it) are live activation paths, so the
+ # service under one is being started and is not a finding.
+ # disabled and linked suppress for a different reason: the timer
+ # is then the finding itself, reported in its own right.
+ #
+ # "indirect" deliberately does NOT suppress. It means the unit
+ # file itself is not enabled, only that some Also= relative might
+ # be, so nothing here is known to start the service. The
+ # fail-closed rule says the uncertain case flags.
+ case "$tstate" in
+ enabled|enabled-runtime|generated) continue ;;
+ disabled|linked) continue ;;
+ esac
+ ;;
+ esac
+ finding "unit file present but not enabled: $name ($state)"
+done < "$STAGE"
+report "check 2/5: unit files"
+
+# --- 3. *.example files whose real sibling is missing ---------------------
+
+if [ -n "${PRC_LOCAL_SCAN_ROOTS+set}" ]; then
+ scan_roots=$PRC_LOCAL_SCAN_ROOTS
+else
+ scan_roots="$HOME/.emacs.d
+$HOME/.dotfiles"
+fi
+printf '%s\n' "$scan_roots" > "$WORK/roots" 2>/dev/null || {
+ echo "post-rebuild-check: cannot write $WORK/roots" >&2; exit 1; }
+while IFS= read -r root; do
+ [ -n "$root" ] || continue
+ if [ ! -d "$root" ]; then
+ finding "scan root missing: $root"
+ continue
+ fi
+ # Vendored package trees ship their own .example docs; those belong to
+ # the package, not to this machine, so they are noise in front of the
+ # real findings this check exists for.
+ #
+ # -prune, not -not -path: the latter filters find's OUTPUT while still
+ # descending, so an unreadable directory inside a tree we deliberately
+ # ignore would set find's exit status and be reported as an unscanned
+ # part of the root. Pruning means those trees are never entered, so the
+ # exit status only reflects places this check actually wanted to read.
+ #
+ # That status matters: find exits non-zero when it cannot descend
+ # somewhere, having printed only what it could reach. Discarding it would
+ # hide every orphan under an unreadable directory behind a clean "ok",
+ # which is the defect this script exists to catch.
+ if ! find "$root" \
+ \( -name .git -o -name elpa -o -name straight \
+ -o -name node_modules -o -name .venv \) -prune \
+ -o -name '*.example' -print > "$WORK/examples" 2>/dev/null; then
+ finding "could not fully scan $root — part of it was not checked"
+ fi
+ while IFS= read -r ex; do
+ [ -n "$ex" ] || continue
+ # -e, so a sibling that exists only as a dangling symlink counts as
+ # missing. It is not a config the machine can read.
+ [ -e "${ex%.example}" ] || finding "example without its real file: $ex"
+ done < "$WORK/examples"
+done < "$WORK/roots"
+report "check 3/5: local files"
+
+# --- 4. gitignore-mode projects missing their tooling ---------------------
+
+if [ -n "${PRC_PROJECT_ROOTS+set}" ]; then
+ projects=$PRC_PROJECT_ROOTS
+else
+ projects=$(ls -d "$HOME"/code/*/ "$HOME"/projects/*/ 2>/dev/null; \
+ printf '%s\n%s\n' "$HOME/.emacs.d" "$HOME/.dotfiles")
+fi
+printf '%s\n' "$projects" > "$WORK/projects" 2>/dev/null || {
+ echo "post-rebuild-check: cannot write $WORK/projects" >&2; exit 1; }
+# CLAUDE.md is deliberately absent from this set. It is seed-only --
+# install-lang writes it once and the project owns it afterward -- so most
+# projects legitimately never have one, and ratio shows the identical
+# absences in the identical projects. That match is what proves it is the
+# steady state rather than reinstall drift, and flagging it would put nine
+# standing findings in front of every real one.
+#
+# The list is fed to the inner loop straight from a heredoc rather than
+# staged through a file. It is a constant, so a file bought nothing and cost
+# a fifth unguarded write: had it failed (a full tmpfs, say) the inner loop
+# would read nothing and every project would pass silently, which is the one
+# outcome this script must never produce. The heredoc is the inner loop's own
+# stdin and leaves the outer loop's redirect alone.
+while IFS= read -r proj; do
+ [ -n "$proj" ] || continue
+ proj=${proj%/}
+ # -e not -d: in a worktree or submodule .git is a file naming the real
+ # gitdir, and a -d test would skip those projects silently.
+ [ -e "$proj/.git" ] || continue
+ [ -f "$proj/.gitignore" ] || continue
+ while read -r disk pattern; do
+ # Both the anchored (/.ai/) and unanchored (.ai/) ignore styles exist
+ # across the fleet; the sweep-gitignore audit hit exactly that split.
+ #
+ # grep exits 1 for no-match and 2 for an error, so the two are told
+ # apart rather than both read as "the ignore file does not name this".
+ # An unreadable .gitignore would otherwise pass the whole project.
+ grep -Eq "^/?${pattern}/?\$" "$proj/.gitignore" 2>/dev/null
+ case $? in
+ 0) [ -e "$proj/$disk" ] \
+ || finding "$proj: .gitignore names $disk but it is missing on disk" ;;
+ 1) ;;
+ *) finding "$proj: could not read .gitignore — the project was not checked"
+ break ;;
+ esac
+ done <<'EOF'
+.ai \.ai
+.claude \.claude
+todo.org todo\.org
+inbox inbox
+EOF
+done < "$WORK/projects"
+report "check 4/5: project tooling"
+
+# --- 5. signal-cli registration -------------------------------------------
+
+if [ -n "${PRC_SIGNAL_ACCOUNTS+set}" ]; then
+ accounts=$PRC_SIGNAL_ACCOUNTS
+ if [ "$accounts" = "MISSING" ]; then
+ accounts=""
+ signal_missing=1
+ fi
+else
+ if command -v signal-cli >/dev/null 2>&1; then
+ if ! accounts=$(signal-cli listAccounts 2>/dev/null); then
+ accounts=""
+ finding "signal-cli listAccounts failed — the registration was not checked"
+ signal_missing=skip
+ fi
+ else
+ accounts=""
+ signal_missing=1
+ fi
+fi
+if [ "$signal_missing" = 1 ]; then
+ finding "signal-cli is not installed — paging relies on it fleet-wide"
+elif [ -z "$signal_missing" ] && [ -z "$accounts" ]; then
+ finding "no signal account registered — agent-text relays into this machine, so paging breaks for the whole fleet"
+fi
+report "check 5/5: signal registration"
+
+# --- summary --------------------------------------------------------------
+
+if [ "$TOTAL_FINDINGS" -eq 0 ]; then
+ echo "all checks clean"
+ exit 0
+fi
+echo "$TOTAL_FINDINGS finding(s) across 5 checks"
+exit 1
diff --git a/tests/post-rebuild-check/test_post_rebuild_check.py b/tests/post-rebuild-check/test_post_rebuild_check.py
new file mode 100644
index 0000000..4894451
--- /dev/null
+++ b/tests/post-rebuild-check/test_post_rebuild_check.py
@@ -0,0 +1,684 @@
+"""Tests for the post-rebuild-check script.
+
+A rebuilt machine looks finished and isn't: on velox 2026-08-13 five gaps
+surfaced within two days, three of which LOOKED fine (a stowed unit file, an
+enabled timer, a present git clone). The script runs the five checks from the
+post-rebuild task and turns each silent no-op into a visible line:
+
+ 1. failed systemd units (user and system scope)
+ 2. user unit files present but not enabled (linked-and-inert timers)
+ 3. tracked *.example files whose real sibling is missing
+ 4. gitignore-mode projects missing tooling paths their own .gitignore names
+ 5. signal-cli holds no registered account
+
+Exit 0 with every check clean, 1 when any check found something.
+
+Test seams (env vars the production script honors; for each, SET-BUT-EMPTY
+means "the real probe ran and found nothing", UNSET means "run the real
+probe"):
+ PRC_FAILED_UNITS newline list of "scope:unit" (scope user|system)
+ PRC_UNIT_STATES newline list of "unit-file state" for the user unit dir
+ PRC_LOCAL_SCAN_ROOTS newline-separated roots to scan for *.example orphans
+ PRC_PROJECT_ROOTS newline-separated project dirs for the tooling check
+ PRC_SIGNAL_ACCOUNTS signal-cli listAccounts output ("" = no accounts);
+ the special value MISSING means the binary is absent
+
+Run from repo root:
+ python3 -m unittest tests.post-rebuild-check.test_post_rebuild_check
+"""
+
+import os
+import subprocess
+import tempfile
+import time
+import unittest
+
+
+REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
+CHECK = os.path.join(REPO_ROOT, "scripts", "post-rebuild-check")
+
+
+def run_check(failed_units="", unit_states="", local_roots="",
+ project_roots="", signal_accounts="+15045551234"):
+ """Run the script with every probe stubbed; defaults are all-clean.
+
+ Roots are newline-separated. Empty means "the seam is set and names no
+ roots" -- the script tests with ${VAR+set}, so an empty value is still
+ set and never falls through to the real probe.
+ """
+ env = dict(os.environ)
+ env["PRC_FAILED_UNITS"] = failed_units
+ env["PRC_UNIT_STATES"] = unit_states
+ env["PRC_LOCAL_SCAN_ROOTS"] = local_roots
+ env["PRC_PROJECT_ROOTS"] = project_roots
+ env["PRC_SIGNAL_ACCOUNTS"] = signal_accounts
+ return subprocess.run(
+ ["sh", CHECK], capture_output=True, text=True, timeout=30, env=env,
+ )
+
+
+class AllClean(unittest.TestCase):
+ # --- Normal cases ---------------------------------------------------
+
+ def test_all_clean_exits_zero(self):
+ r = run_check()
+ self.assertEqual(r.returncode, 0, r.stdout + r.stderr)
+
+ def test_all_clean_prints_one_line_per_check(self):
+ # The visible line per check is the point of the script: a silent
+ # no-op is exactly what let the velox gaps sit unseen for two days.
+ r = run_check()
+ for label in ("failed units", "unit files", "local files",
+ "project tooling", "signal"):
+ self.assertIn(label, r.stdout.lower())
+
+ def test_all_clean_summary_says_clean(self):
+ r = run_check()
+ self.assertIn("all checks clean", r.stdout.lower())
+
+ def test_all_clean_no_deviation_lines(self):
+ r = run_check()
+ self.assertNotIn("DEVIATION", r.stdout)
+
+
+class FailedUnits(unittest.TestCase):
+ # --- Normal cases ---------------------------------------------------
+
+ def test_failed_user_unit_flags(self):
+ r = run_check(failed_units="user:calendar-sync.service")
+ self.assertEqual(r.returncode, 1)
+ self.assertIn("calendar-sync.service", r.stdout)
+ self.assertIn("DEVIATION", r.stdout)
+
+ def test_failed_system_unit_flags(self):
+ r = run_check(failed_units="system:tlp.service")
+ self.assertEqual(r.returncode, 1)
+ self.assertIn("tlp.service", r.stdout)
+
+ def test_multiple_failed_units_each_reported(self):
+ r = run_check(
+ failed_units="user:calendar-sync.service\nsystem:tlp.service")
+ self.assertIn("calendar-sync.service", r.stdout)
+ self.assertIn("tlp.service", r.stdout)
+
+ # --- Boundary cases -------------------------------------------------
+
+ def test_blank_lines_in_seam_ignored(self):
+ r = run_check(failed_units="\n\nuser:a.service\n\n")
+ self.assertEqual(r.returncode, 1)
+ self.assertIn("a.service", r.stdout)
+
+
+class UnitFilesNotEnabled(unittest.TestCase):
+ # --- Normal cases ---------------------------------------------------
+
+ def test_disabled_timer_flags(self):
+ r = run_check(unit_states="roam-sync.timer disabled")
+ self.assertEqual(r.returncode, 1)
+ self.assertIn("roam-sync.timer", r.stdout)
+
+ def test_linked_timer_flags(self):
+ # The exact velox case: a unit symlinked into the user dir by hand,
+ # never enabled — present, inert, and it LOOKS installed.
+ r = run_check(unit_states="signal-receive.timer linked")
+ self.assertEqual(r.returncode, 1)
+ self.assertIn("signal-receive.timer", r.stdout)
+
+ def test_enabled_timer_passes(self):
+ r = run_check(unit_states="roam-sync.timer enabled")
+ self.assertEqual(r.returncode, 0, r.stdout)
+
+ def test_static_service_passes(self):
+ # A service with no [Install] section is pulled in by its timer;
+ # "static" is its healthy state, not a gap.
+ r = run_check(unit_states="roam-sync.service static")
+ self.assertEqual(r.returncode, 0, r.stdout)
+
+ def test_disabled_service_flags(self):
+ r = run_check(unit_states="obsbot-wb-guard.service disabled")
+ self.assertEqual(r.returncode, 1)
+ self.assertIn("obsbot-wb-guard.service", r.stdout)
+
+ # --- Boundary cases -------------------------------------------------
+
+ def test_mixed_states_only_inert_reported(self):
+ r = run_check(unit_states="a.timer enabled\nb.timer disabled\n"
+ "c.service static\nd.service linked")
+ self.assertEqual(r.returncode, 1)
+ self.assertNotIn("a.timer", r.stdout)
+ self.assertIn("b.timer", r.stdout)
+ self.assertNotIn("c.service", r.stdout)
+ self.assertIn("d.service", r.stdout)
+
+ def test_masked_unit_passes(self):
+ # Masking is a deliberate act (ppd on laptops), not rebuild rot.
+ r = run_check(unit_states="power-profiles-daemon.service masked")
+ self.assertEqual(r.returncode, 0, r.stdout)
+
+ def test_service_whose_timer_is_enabled_passes(self):
+ # A timer-activated service is SUPPOSED to sit linked-not-enabled:
+ # the timer owns activation, and enabling the service too would run
+ # it at boot as well. Six of velox's units are this shape, and
+ # flagging them is the noise that gets a check ignored.
+ r = run_check(unit_states="roam-sync.service linked\n"
+ "roam-sync.timer enabled")
+ self.assertEqual(r.returncode, 0, r.stdout)
+
+ def test_service_whose_timer_is_inert_flags_the_timer_only(self):
+ # When the timer itself never got enabled, the timer is the finding.
+ # Naming the service too would double-count one gap.
+ r = run_check(unit_states="obs-record-watchdog.service linked\n"
+ "obs-record-watchdog.timer linked")
+ self.assertEqual(r.returncode, 1)
+ self.assertIn("obs-record-watchdog.timer", r.stdout)
+ self.assertNotIn("obs-record-watchdog.service", r.stdout)
+
+ def test_service_without_a_timer_still_flags(self):
+ # Nothing else can start it, so linked-not-enabled means dead.
+ r = run_check(unit_states="emacs.service linked")
+ self.assertEqual(r.returncode, 1)
+ self.assertIn("emacs.service", r.stdout)
+
+ def test_a_runtime_enabled_timer_suppresses_its_service(self):
+ # enabled-runtime is a live activation path (enabled until reboot)
+ # and generated means something produced and installed it, so the
+ # service beneath either is being started and is not a finding.
+ for state in ("enabled-runtime", "generated"):
+ with self.subTest(timer=state):
+ r = run_check(unit_states=f"foo.service linked\n"
+ f"foo.timer {state}")
+ self.assertEqual(r.returncode, 0,
+ f"a {state} timer failed to suppress")
+
+ def test_an_indirect_timer_does_not_suppress_its_service(self):
+ # "indirect" means the unit file itself is NOT enabled -- only that
+ # some Also= relative might be. Under this script's own fail-closed
+ # rule the uncertain case flags, so suppressing here would be the
+ # masked blind spot again in a narrower form.
+ r = run_check(unit_states="foo.service linked\nfoo.timer indirect")
+ self.assertEqual(r.returncode, 1,
+ "an indirect timer suppressed a service nothing starts")
+ self.assertIn("foo.service", r.stdout)
+
+ def test_a_service_whose_timer_cannot_start_it_still_flags(self):
+ # Suppression is earned by a timer that can actually run the service.
+ # A masked, static, or absent timer starts nothing, so the service is
+ # as dead as one with no timer at all -- and suppressing on the mere
+ # presence of a timer line hides exactly that.
+ for state in ("masked", "static", "not-found"):
+ with self.subTest(timer=state):
+ r = run_check(unit_states=f"foo.service linked\n"
+ f"foo.timer {state}")
+ self.assertEqual(r.returncode, 1,
+ f"a {state} timer suppressed a dead service")
+ self.assertIn("foo.service", r.stdout)
+
+
+class LocalExampleOrphans(unittest.TestCase):
+ # --- Normal cases ---------------------------------------------------
+
+ def test_example_without_sibling_flags(self):
+ with tempfile.TemporaryDirectory() as root:
+ open(os.path.join(root, "auth.local.el.example"), "w").close()
+ r = run_check(local_roots=root)
+ self.assertEqual(r.returncode, 1)
+ self.assertIn("auth.local.el.example", r.stdout)
+
+ def test_a_real_file_that_is_a_dangling_symlink_still_flags(self):
+ # A sibling that exists only as a broken link is not a config the
+ # machine can read, so it is the same gap as an absent one.
+ with tempfile.TemporaryDirectory() as root:
+ open(os.path.join(root, "auth.local.el.example"), "w").close()
+ os.symlink("/nonexistent/stow/target",
+ os.path.join(root, "auth.local.el"))
+ r = run_check(local_roots=root)
+ self.assertEqual(r.returncode, 1,
+ "a dangling sibling counted as present")
+ self.assertIn("auth.local.el.example", r.stdout)
+
+ def test_example_with_sibling_passes(self):
+ with tempfile.TemporaryDirectory() as root:
+ open(os.path.join(root, "auth.local.el.example"), "w").close()
+ open(os.path.join(root, "auth.local.el"), "w").close()
+ r = run_check(local_roots=root)
+ self.assertEqual(r.returncode, 0, r.stdout)
+
+ def test_nested_example_found(self):
+ with tempfile.TemporaryDirectory() as root:
+ sub = os.path.join(root, "modules")
+ os.makedirs(sub)
+ open(os.path.join(sub, "mail.local.el.example"), "w").close()
+ r = run_check(local_roots=root)
+ self.assertEqual(r.returncode, 1)
+ self.assertIn("mail.local.el.example", r.stdout)
+
+ # --- Boundary cases -------------------------------------------------
+
+ def test_two_roots_both_scanned(self):
+ with tempfile.TemporaryDirectory() as a, \
+ tempfile.TemporaryDirectory() as b:
+ open(os.path.join(a, "one.example"), "w").close()
+ open(os.path.join(b, "two.example"), "w").close()
+ r = run_check(local_roots=a + "\n" + b)
+ self.assertIn("one.example", r.stdout)
+ self.assertIn("two.example", r.stdout)
+
+ def test_vendored_package_dirs_not_scanned(self):
+ # elpa/ and friends hold third-party packages that ship their own
+ # .example docs. Those are the package's business, not this machine's,
+ # and one of them (dirvish's) was the only finding check 3 produced on
+ # velox — a standing false positive in front of any real one.
+ with tempfile.TemporaryDirectory() as root:
+ for vendor in ("elpa", "node_modules", ".venv", "straight"):
+ d = os.path.join(root, vendor, "pkg-1.0", "docs")
+ os.makedirs(d)
+ open(os.path.join(d, "config.example"), "w").close()
+ r = run_check(local_roots=root)
+ self.assertEqual(r.returncode, 0, r.stdout)
+
+ def test_an_unreadable_vendored_dir_is_not_a_finding(self):
+ # The vendored trees are excluded by design, so failing to descend
+ # into one is not a gap in what this check covers. Filtering find's
+ # output without pruning its descent turns a package directory
+ # nobody wanted read into a standing "could not fully scan".
+ with tempfile.TemporaryDirectory() as root:
+ locked = os.path.join(root, "elpa", "pkg-1.0")
+ os.makedirs(locked)
+ os.chmod(locked, 0o000)
+ try:
+ r = run_check(local_roots=root)
+ finally:
+ os.chmod(locked, 0o755)
+ self.assertEqual(r.returncode, 0, r.stdout)
+
+ def test_git_dir_not_scanned(self):
+ # .git holds hooks' sample files; those are git's, not the tree's.
+ with tempfile.TemporaryDirectory() as root:
+ g = os.path.join(root, ".git", "hooks")
+ os.makedirs(g)
+ open(os.path.join(g, "pre-commit.example"), "w").close()
+ r = run_check(local_roots=root)
+ self.assertEqual(r.returncode, 0, r.stdout)
+
+ # --- Error cases ----------------------------------------------------
+
+ def test_an_unreadable_subdirectory_is_a_finding_not_a_pass(self):
+ # find exits non-zero when it cannot descend somewhere, and prints
+ # what it did reach. Discarding that status hides every orphan under
+ # the unreadable directory behind a clean "ok" -- the same defect
+ # class as a probe that cannot run reading as a pass.
+ with tempfile.TemporaryDirectory() as root:
+ locked = os.path.join(root, "locked")
+ os.makedirs(locked)
+ open(os.path.join(locked, "auth.local.el.example"), "w").close()
+ os.chmod(locked, 0o000)
+ try:
+ r = run_check(local_roots=root)
+ finally:
+ os.chmod(locked, 0o755)
+ self.assertEqual(r.returncode, 1,
+ "an unreadable directory read as nothing to check")
+ self.assertIn("could not", r.stdout.lower())
+
+ def test_missing_root_is_its_own_finding(self):
+ # A scan root that's gone is a rebuild gap too, not a pass.
+ r = run_check(local_roots="/nonexistent/scan-root")
+ self.assertEqual(r.returncode, 1)
+ self.assertIn("/nonexistent/scan-root", r.stdout)
+
+ def test_a_path_with_spaces_is_one_root_not_three(self):
+ # Roots arrive newline-separated for this reason: splitting on spaces
+ # turns one real directory into several imaginary missing ones.
+ with tempfile.TemporaryDirectory() as base:
+ root = os.path.join(base, "a dir with spaces")
+ os.makedirs(root)
+ open(os.path.join(root, "orphan.example"), "w").close()
+ r = run_check(local_roots=root)
+ self.assertEqual(r.stdout.count("DEVIATION"), 1, r.stdout)
+ self.assertIn("orphan.example", r.stdout)
+
+
+class ProjectTooling(unittest.TestCase):
+ def project(self, root, gitignore_lines, present=()):
+ os.makedirs(os.path.join(root, ".git"))
+ with open(os.path.join(root, ".gitignore"), "w") as f:
+ f.write("\n".join(gitignore_lines) + "\n")
+ for p in present:
+ path = os.path.join(root, p)
+ if p.endswith("/"):
+ os.makedirs(path, exist_ok=True)
+ else:
+ open(path, "w").close()
+
+ # --- Normal cases ---------------------------------------------------
+
+ def test_ignored_but_absent_tooling_flags(self):
+ with tempfile.TemporaryDirectory() as root:
+ self.project(root, [".ai/", ".claude/", "todo.org"])
+ r = run_check(project_roots=root)
+ self.assertEqual(r.returncode, 1)
+ for missing in (".ai", ".claude", "todo.org"):
+ self.assertIn(missing, r.stdout)
+
+ def test_ignored_and_present_tooling_passes(self):
+ with tempfile.TemporaryDirectory() as root:
+ self.project(root, [".ai/", "CLAUDE.md"],
+ present=(".ai/", "CLAUDE.md"))
+ r = run_check(project_roots=root)
+ self.assertEqual(r.returncode, 0, r.stdout)
+
+ def test_claude_md_absence_never_flags(self):
+ # CLAUDE.md is seed-only: install-lang writes it once and the project
+ # owns it afterward, so most projects legitimately never have one.
+ # Ratio shows the identical absences in the identical projects, which
+ # is what proves it is the steady state and not reinstall drift.
+ with tempfile.TemporaryDirectory() as root:
+ self.project(root, [".ai/", "CLAUDE.md"], present=(".ai/",))
+ r = run_check(project_roots=root)
+ self.assertEqual(r.returncode, 0, r.stdout)
+
+ def test_missing_ai_dir_still_flags(self):
+ # The one that carries real working state — 374 files in .emacs.d's
+ # case — and that nothing restores: not git, not stow, not bootstrap.
+ with tempfile.TemporaryDirectory() as root:
+ self.project(root, [".ai/", "CLAUDE.md"])
+ r = run_check(project_roots=root)
+ self.assertEqual(r.returncode, 1)
+ self.assertIn(".ai", r.stdout)
+ self.assertNotIn("CLAUDE.md", r.stdout)
+
+ def test_unignored_tooling_never_expected(self):
+ # A project that never gitignored todo.org never had one to lose;
+ # the project's own .gitignore is the record of what it should hold.
+ with tempfile.TemporaryDirectory() as root:
+ self.project(root, [".ai/"], present=(".ai/",))
+ r = run_check(project_roots=root)
+ self.assertEqual(r.returncode, 0, r.stdout)
+
+ # --- Boundary cases -------------------------------------------------
+
+ def test_anchored_ignore_style_recognized(self):
+ # Both /.ai/ (anchored) and .ai/ (unanchored) styles exist across
+ # the fleet; the sweep-gitignore audit hit exactly this split.
+ with tempfile.TemporaryDirectory() as root:
+ self.project(root, ["/.ai/", "/CLAUDE.md"])
+ r = run_check(project_roots=root)
+ self.assertEqual(r.returncode, 1)
+ self.assertIn(".ai", r.stdout)
+
+ def test_a_worktree_or_submodule_is_still_a_project(self):
+ # In a worktree or submodule, .git is a file pointing at the real
+ # gitdir rather than a directory, so a -d test skips the project
+ # silently.
+ with tempfile.TemporaryDirectory() as root:
+ with open(os.path.join(root, ".git"), "w") as f:
+ f.write("gitdir: /somewhere/else\n")
+ with open(os.path.join(root, ".gitignore"), "w") as f:
+ f.write(".ai/\n")
+ r = run_check(project_roots=root)
+ self.assertEqual(r.returncode, 1)
+ self.assertIn(".ai", r.stdout)
+
+ def test_an_unreadable_gitignore_is_a_finding_not_a_pass(self):
+ # grep exits 2 on error and 1 on no-match, so treating both as
+ # "nothing named" lets an unreadable ignore file pass the project
+ # silently.
+ with tempfile.TemporaryDirectory() as root:
+ self.project(root, [".ai/"])
+ os.chmod(os.path.join(root, ".gitignore"), 0o000)
+ try:
+ r = run_check(project_roots=root)
+ finally:
+ os.chmod(os.path.join(root, ".gitignore"), 0o644)
+ self.assertEqual(r.returncode, 1,
+ "an unreadable .gitignore read as nothing to check")
+ self.assertIn("could not", r.stdout.lower())
+
+ def test_non_git_dir_skipped(self):
+ with tempfile.TemporaryDirectory() as root:
+ with open(os.path.join(root, ".gitignore"), "w") as f:
+ f.write(".ai/\n")
+ r = run_check(project_roots=root)
+ self.assertEqual(r.returncode, 0, r.stdout)
+
+ def test_project_without_gitignore_skipped(self):
+ with tempfile.TemporaryDirectory() as root:
+ os.makedirs(os.path.join(root, ".git"))
+ r = run_check(project_roots=root)
+ self.assertEqual(r.returncode, 0, r.stdout)
+
+ def test_unrelated_ignore_lines_no_flags(self):
+ with tempfile.TemporaryDirectory() as root:
+ self.project(root, ["*.pyc", "node_modules/", "dist/"])
+ r = run_check(project_roots=root)
+ self.assertEqual(r.returncode, 0, r.stdout)
+
+
+class SignalAccount(unittest.TestCase):
+ # --- Normal cases ---------------------------------------------------
+
+ def test_registered_account_passes(self):
+ r = run_check(signal_accounts="Number: +15045173983 ...")
+ self.assertEqual(r.returncode, 0, r.stdout)
+
+ def test_no_account_flags(self):
+ # The velox case: a wiped registration silently breaks paging for
+ # the whole fleet, because agent-text relays into this machine.
+ r = run_check(signal_accounts="")
+ self.assertEqual(r.returncode, 1)
+ self.assertIn("signal", r.stdout.lower())
+
+ # --- Error cases ----------------------------------------------------
+
+ def test_missing_binary_flags(self):
+ r = run_check(signal_accounts="MISSING")
+ self.assertEqual(r.returncode, 1)
+ self.assertIn("signal-cli", r.stdout)
+
+ def test_a_stray_signal_missing_in_the_environment_is_ignored(self):
+ # The script's own internal flag must not be settable from outside,
+ # or a caller's unrelated variable turns a registered account into a
+ # "signal-cli is not installed" finding.
+ env = dict(os.environ)
+ env.update({"PRC_FAILED_UNITS": "", "PRC_UNIT_STATES": "",
+ "PRC_LOCAL_SCAN_ROOTS": "", "PRC_PROJECT_ROOTS": "",
+ "PRC_SIGNAL_ACCOUNTS": "+15045551234",
+ "signal_missing": "1"})
+ r = subprocess.run(["sh", CHECK], capture_output=True, text=True,
+ timeout=30, env=env)
+ self.assertEqual(r.returncode, 0, r.stdout)
+
+
+class ProbeFailure(unittest.TestCase):
+ """A probe that could not run must never read as a clean check.
+
+ This is the defect the whole script exists to catch, so it would be the
+ worst possible place to have it. `systemctl --user` exits 1 with empty
+ output when there is no user bus -- over ssh, from cron, under sudo, or on
+ a TTY before the graphical session starts. Reading that as "no failed
+ units" reports a machine as healthy precisely when nothing can be checked.
+ """
+
+ def unset(self, *names):
+ """Run with the named seams unset, so the real probes execute."""
+ env = dict(os.environ)
+ env.update({"PRC_FAILED_UNITS": "", "PRC_UNIT_STATES": "",
+ "PRC_LOCAL_SCAN_ROOTS": "", "PRC_PROJECT_ROOTS": "",
+ "PRC_SIGNAL_ACCOUNTS": "+15045551234"})
+ for n in names:
+ env.pop(n, None)
+ env["XDG_RUNTIME_DIR"] = "/nonexistent-runtime-dir"
+ return subprocess.run(["sh", CHECK], capture_output=True, text=True,
+ timeout=30, env=env)
+
+ # --- Error cases ----------------------------------------------------
+
+ def test_unreachable_user_bus_is_a_finding_not_a_pass(self):
+ r = self.unset("PRC_FAILED_UNITS")
+ self.assertEqual(r.returncode, 1,
+ "a failed probe reported the machine as clean")
+ self.assertIn("could not", r.stdout.lower())
+
+ def test_unreachable_user_bus_fails_the_unit_state_check_too(self):
+ r = self.unset("PRC_UNIT_STATES")
+ self.assertEqual(r.returncode, 1,
+ "a failed probe reported the machine as clean")
+
+ def test_an_unusable_tmpdir_is_a_finding_not_a_pass(self):
+ # Every check stages its input through a temp file. If that write
+ # fails, each loop reads nothing and every check comes back clean --
+ # with real findings passed in.
+ env = dict(os.environ)
+ env.update({"PRC_FAILED_UNITS": "user:calendar-sync.service",
+ "PRC_UNIT_STATES": "", "PRC_LOCAL_SCAN_ROOTS": "",
+ "PRC_PROJECT_ROOTS": "",
+ "PRC_SIGNAL_ACCOUNTS": "+15045551234",
+ "TMPDIR": "/nonexistent-tmp-dir"})
+ r = subprocess.run(["sh", CHECK], capture_output=True, text=True,
+ timeout=30, env=env)
+ self.assertEqual(r.returncode, 1,
+ "an unwritable TMPDIR swallowed a real finding")
+
+
+class RealUnitDirEnumeration(unittest.TestCase):
+ """Check 2's unseamed path, where the unit files are read off disk.
+
+ The PRC_UNIT_STATES seam skips this enumeration entirely, so a defect in
+ it survives every seamed test. That is where the dangling-stow-link case
+ lives, and a dangling stow link is precisely the requirement's headline
+ example of a unit file that LOOKED fine.
+ """
+
+ def run_real(self, config_home):
+ env = dict(os.environ)
+ env.update({"PRC_FAILED_UNITS": "", "PRC_LOCAL_SCAN_ROOTS": "",
+ "PRC_PROJECT_ROOTS": "",
+ "PRC_SIGNAL_ACCOUNTS": "+15045551234",
+ "XDG_CONFIG_HOME": config_home})
+ env.pop("PRC_UNIT_STATES", None)
+ return subprocess.run(["sh", CHECK], capture_output=True, text=True,
+ timeout=30, env=env)
+
+ # --- Boundary cases -------------------------------------------------
+
+ def test_a_dangling_stow_link_is_enumerated_not_skipped(self):
+ with tempfile.TemporaryDirectory() as home:
+ unit_dir = os.path.join(home, "systemd", "user")
+ os.makedirs(unit_dir)
+ os.symlink("/nonexistent/stow/roam-sync.timer",
+ os.path.join(unit_dir, "roam-sync.timer"))
+ r = self.run_real(home)
+ self.assertEqual(r.returncode, 1,
+ "a dangling stow link read as nothing to check")
+ self.assertIn("roam-sync.timer", r.stdout)
+ self.assertIn("missing target", r.stdout)
+
+ # --- Error cases ----------------------------------------------------
+
+ def test_a_missing_unit_directory_is_a_finding(self):
+ with tempfile.TemporaryDirectory() as home:
+ r = self.run_real(home)
+ self.assertEqual(r.returncode, 1)
+ self.assertIn("no user unit directory", r.stdout)
+
+
+class WedgedSystemctl(unittest.TestCase):
+ """A systemd manager that never answers must not hang the check.
+
+ Seen live on velox 2026-08-17: the user manager spun at 96% CPU with
+ `is-enabled`, `cat`, and `list-unit-files` all hanging while `list-units`
+ still returned. Unbounded, the check stops at the first unit and never
+ runs checks 3 through 5, so the machine most in need of checking is the
+ one it reports nothing about.
+ """
+
+ def run_with_fake(self, script_body, timeout_s="1"):
+ """Run against a fake systemctl, with check 2's unit dir empty.
+
+ Pointing XDG_CONFIG_HOME at an empty directory keeps check 2 from
+ making one call per real unit, so the test measures the bound rather
+ than the size of this machine's unit directory.
+ """
+ with tempfile.TemporaryDirectory() as d:
+ fake = os.path.join(d, "systemctl")
+ with open(fake, "w") as f:
+ f.write(script_body)
+ os.chmod(fake, 0o755)
+ env = dict(os.environ)
+ env.update({"PRC_LOCAL_SCAN_ROOTS": "", "PRC_PROJECT_ROOTS": "",
+ "PRC_SIGNAL_ACCOUNTS": "+15045551234",
+ "PRC_SYSTEMCTL": fake,
+ "PRC_SYSTEMCTL_TIMEOUT": timeout_s,
+ "XDG_CONFIG_HOME": d})
+ env.pop("PRC_FAILED_UNITS", None)
+ env.pop("PRC_UNIT_STATES", None)
+ start = time.monotonic()
+ r = subprocess.run(["sh", CHECK], capture_output=True,
+ text=True, timeout=60, env=env)
+ return r, time.monotonic() - start
+
+ # --- Error cases ----------------------------------------------------
+
+ def test_a_hanging_systemctl_is_bounded_and_reported(self):
+ r, _ = self.run_with_fake("#!/bin/sh\nsleep 300\n")
+ self.assertEqual(r.returncode, 1)
+ self.assertIn("could not query user units", r.stdout)
+ # The run must reach the end rather than stopping at the first call.
+ self.assertIn("check 5/5", r.stdout)
+
+ def test_a_hanging_systemctl_does_not_stall_the_whole_run(self):
+ # The fake sleeps 8s against a 1s bound, so a bounded run lands near
+ # 2s (two calls) and an unbounded one near 16s. Deliberately short
+ # enough that losing the bound fails this assertion in seconds rather
+ # than hitting the subprocess ceiling a minute later -- a regression
+ # nobody waits out is a regression nobody catches.
+ _, elapsed = self.run_with_fake("#!/bin/sh\nsleep 8\n")
+ self.assertLess(elapsed, 6,
+ "the run was not bounded by PRC_SYSTEMCTL_TIMEOUT")
+
+
+class Reporting(unittest.TestCase):
+ # --- Normal cases ---------------------------------------------------
+
+ def test_findings_counted_in_summary(self):
+ # Assert the count in the summary line specifically. A bare
+ # assertIn("2") passes on the always-present "check 2/5" text, so it
+ # stays green even when the counter is arithmetically wrong.
+ r = run_check(failed_units="user:a.service\nuser:b.service",
+ unit_states="c.timer disabled")
+ self.assertEqual(r.returncode, 1)
+ summary = r.stdout.strip().splitlines()[-1]
+ self.assertEqual(summary, "3 finding(s) across 5 checks")
+
+ def test_the_summary_count_tracks_every_check(self):
+ # One finding from each of the five, so a counter that drops or
+ # double-counts any single check shows up here.
+ with tempfile.TemporaryDirectory() as scan, \
+ tempfile.TemporaryDirectory() as proj:
+ open(os.path.join(scan, "orphan.example"), "w").close()
+ os.makedirs(os.path.join(proj, ".git"))
+ with open(os.path.join(proj, ".gitignore"), "w") as f:
+ f.write(".ai/\n")
+ r = run_check(failed_units="user:a.service",
+ unit_states="b.timer disabled",
+ local_roots=scan, project_roots=proj,
+ signal_accounts="")
+ summary = r.stdout.strip().splitlines()[-1]
+ self.assertEqual(summary, "5 finding(s) across 5 checks")
+
+ def test_help_exits_zero(self):
+ r = subprocess.run(["sh", CHECK, "--help"],
+ capture_output=True, text=True, timeout=10)
+ self.assertEqual(r.returncode, 0)
+ self.assertIn("post-rebuild-check", r.stdout)
+
+ # --- Error cases ----------------------------------------------------
+
+ def test_unknown_flag_errors(self):
+ r = subprocess.run(["sh", CHECK, "--bogus"],
+ capture_output=True, text=True, timeout=10)
+ self.assertNotEqual(r.returncode, 0)
+
+
+if __name__ == "__main__":
+ unittest.main()