Files
Panama/tests/quickshell/declared-dependencies-contract
T
Gabriel Brown 12f6b2a310 Let the contracts assert properties, not the machine they were written on
The first run of the suite on a laptop found five contracts asserting the
desktop instead of the code. settings-system pinned DP-2 at 4500x3000 in
XRGB2101010; it now asks Hyprland what is actually primary. ssh-keys hardcoded
id_ed25519; it now uses whichever key exists. switcher's live half stepped a
session with one window, which step() deliberately refuses. displays raced the
service's revert readback -- the compositor looks restored while verification
still holds busy, so an immediate apply was refused with its error already
cleared; the harness now exposes settled and the contract waits for it.

declared-dependencies gets an OPTIONAL list for docker: the aliases serve
machines that run Docker deliberately, Panama's runtime is rootless podman,
and a missing docker fails by naming the command, which is loud enough.

Claude-Session: https://claude.ai/code/session_01Epx9ZC1gwm81K3jm9x9CKh
2026-08-23 10:32:17 -04:00

235 lines
12 KiB
Bash
Executable File

#!/usr/bin/env bash
# Every external command Panama's own scripts invoke must be installed by
# Panama's own package lists.
#
# This exists because the lists had drifted badly. jq is used by thirty-one call
# sites across the helpers and the contracts; kitty has a full shipped config
# and a dock pin; tmux and btop have shipped themes that the color scheme
# switches. None of the four were declared. So a fresh machine that followed
# this repository's own install instructions would not have them.
#
# The failure is quiet by design, which is what makes it worth a test: the
# helpers are written to report "not installed" rather than crash, so a missing
# dependency presents as a feature that silently is not there.
#
# Commands from coreutils and the shell itself are not checked -- nothing
# installs those separately, and listing them would be noise.
set -uo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
fail() {
printf 'declared dependencies contract: %s\n' "$1" >&2
exit 1
}
# Shell syntax and builtins. These are not commands anyone installs, and the
# first version of this contract reported `then`, `esac` and `done` as missing
# packages, which buried the four real findings in a hundred lines of noise.
SHELL_WORDS='^(if|then|else|elif|fi|for|while|until|do|done|case|esac|in|function|select|time|coproc|break|continue|return|exit|local|readonly|declare|export|unset|shift|eval|exec|source|trap|set|shopt|alias|unalias|builtin|command|enable|help|let|read|mapfile|printf|echo|test|true|false|wait|jobs|bg|fg|kill|pwd|cd|dirs|pushd|popd|umask|type|hash|getopts|split|sync)$'
# Provided by any Fedora install: coreutils, util-linux, the shell, and the
# systemd/session tooling. Nothing here is a choice Panama makes.
#
# authselect is on the list for the same reason: it manages Fedora's PAM and
# nsswitch profiles and arrives with fprintd-pam, realmd and nss-mdns, so the
# fingerprint aliases in config/bash can rely on it without declaring it.
BASELINE='^(sh|bash|cat|cut|sed|awk|gawk|grep|egrep|head|tail|sort|uniq|tr|wc|find|xargs|basename|dirname|mkdir|rm|cp|mv|ln|chmod|chown|stat|df|du|date|sleep|env|id|tee|touch|mktemp|readlink|realpath|seq|comm|join|paste|od|file|nl|fold|column|tput|timeout|flock|install|sha256sum|md5sum|base64|nproc|uptime|free|uname|hostname|whoami|ps|pgrep|pkill|kill|killall|lsblk|mount|umount|sudo|su|rpm|dnf|flatpak|git|python3|ss|ip|lsof|authselect|setsid|nohup)$'
SESSION='^(systemctl|busctl|journalctl|loginctl|hostnamectl|localectl|systemd-inhibit|systemd-run|udevadm|gsettings|dconf|dbus-send|dbus-monitor|hyprctl|qs|quickshell|gnf|panama|wl-copy|wl-paste)$'
# Installed by install-packages itself rather than by a package list. Two
# reasons, both deliberate: bun and claude have no RPM or flatpak at all, and
# node, npm and pnpm come from nvm on purpose -- a dnf nodejs earlier on PATH
# would win every per-project `nvm use`, which is the whole point of having nvm.
# Anything added here needs a matching install block and a stated reason.
SELF_INSTALLED='^(bun|claude|node|npm|pnpm)$'
# Tools an alias may lean on without Panama installing them anywhere. The
# docker aliases serve the machines that run Docker by deliberate choice;
# Panama's container runtime is rootless podman (development-packages), and
# declaring docker in a list would put a second container daemon on every
# fresh machine to keep five aliases company. Where docker is absent the
# aliases fail by naming the missing command, which is the honest outcome.
# Anything added here needs that same property: absence must be loud.
OPTIONAL='^(docker)$'
# jq programs are quoted arguments, but the scanner is line-based and cannot
# tell a filter from a command. `not` is a jq builtin appearing inside one.
JQ_BUILTINS='^(not|empty|error|env|input|inputs)$'
declared="$(cat "$repo_dir"/setup/packages/* 2>/dev/null | sed 's/#.*//' | tr -d ' ' | grep -v '^$' | sort -u)"
[[ -n "$declared" ]] || fail 'no package lists found'
# A package is not always named after its command. Only the genuine mismatches
# are mapped, so an unmapped command is a real omission rather than a lookup
# failure.
package_for() {
case "$1" in
zbarimg) printf 'zbar' ;;
fc-list|fc-match) printf 'fontconfig' ;;
lspci) printf 'pciutils' ;;
getenforce) printf 'libselinux-utils' ;;
nmcli) printf 'NetworkManager' ;;
wpctl) printf 'wireplumber' ;;
nvim) printf 'neovim' ;;
fwupdmgr) printf 'fwupd' ;;
dnf4) printf 'python3-dnf' ;;
notify-send) printf 'libnotify' ;;
wl-copy|wl-paste) printf 'wl-clipboard' ;;
ssh-keygen) printf 'openssh' ;;
ssh|ssh-add) printf 'openssh-clients' ;;
# The Wayland build is the one that can inject into this session; the
# x11 one cannot. desktop-packages declares it under that name.
espanso) printf 'espanso-wayland' ;;
rg) printf 'ripgrep' ;;
xdg-mime|xdg-settings|xdg-open) printf 'xdg-utils' ;;
update-desktop-database|desktop-file-validate) printf 'desktop-file-utils' ;;
python3) printf 'python3' ;;
*) printf '%s' "$1" ;;
esac
}
missing=()
unguarded=()
checked=0
while read -r script; do
[[ -n "$script" ]] || continue
head -1 "$script" | grep -qE 'bash|/sh' || continue
# Commands appearing at the start of a statement or after a pipe. Crude, but
# it is looking for undeclared dependencies, not building a call graph.
#
# No minimum length. An earlier version required three characters, which
# quietly excluded the most-used dependency in the repository -- jq, at
# thirty-one call sites -- along with rg, ss and ip. A dependency checker
# with a blind spot for short names is worse than none, because it reports
# PASS.
while read -r cmd; do
[[ -n "$cmd" ]] || continue
[[ "$cmd" =~ $SHELL_WORDS ]] && continue
[[ "$cmd" =~ $BASELINE ]] && continue
[[ "$cmd" =~ $SESSION ]] && continue
[[ "$cmd" =~ $SELF_INSTALLED ]] && continue
[[ "$cmd" =~ $OPTIONAL ]] && continue
[[ "$cmd" =~ $JQ_BUILTINS ]] && continue
pkg="$(package_for "$cmd")"
grep -qx "$pkg" <<<"$declared" && continue
# Only report a command that actually exists on this machine. An
# invented name in a comment or a heredoc is a false positive; a real
# binary that nothing declares is the thing being looked for.
command -v "$cmd" >/dev/null 2>&1 || continue
missing+=("$cmd (from $(basename "$script"), package: $pkg)")
done < <({
# Heredoc bodies are not shell. A python or sql block embedded in a
# script is scanned as though every line began a command, and its
# keywords collide with real binaries often enough to matter: `import`
# is ImageMagick, `time` is a package, `select` is shell syntax. The
# scanner cannot parse those languages and should not try, so the
# bodies are dropped before anything else looks at them.
scanned="$(awk '
# Whole-line comments. These files explain themselves at length,
# and prose containing "; cancel it" or "| list" reads as a
# statement to a line-based scanner. Dropped first so a comment
# mentioning <<EOF cannot open a heredoc either.
!inbody && /^[[:space:]]*#/ { next }
# <<MARKER, <<-MARKER, <<"MARKER", <<'"'"'MARKER'"'"' -- with or
# without a command in front of it.
!inbody && match($0, /<<-?[[:space:]]*["'"'"']?[A-Za-z_][A-Za-z0-9_]*["'"'"']?/) {
marker = substr($0, RSTART, RLENGTH)
gsub(/^<<-?[[:space:]]*["'"'"']?|["'"'"']?$/, "", marker)
inbody = 1
print
next
}
inbody {
line = $0
gsub(/^[[:space:]]+|[[:space:]]+$/, "", line)
if (line == marker) inbody = 0
next
}
{ print }
' "$script")"
# Statement-initial or after a pipe.
#
# A word followed by `=` is a variable assignment and a word followed
# by `)` is a case label; neither is a command, and both collide with
# real binaries -- `count`, `host` and `cancel` are all installed on a
# normal Fedora machine, so `command -v` below cannot filter them out.
# Requiring whitespace or end-of-line after the word excludes both.
grep -oE '(^|[|;&]|\$\()[[:space:]]*[a-z][a-z0-9_-]+([[:space:]]|$)' <<<"$scanned" \
| grep -oE '[a-z][a-z0-9_-]+'
# Behind a wrapper. ddcutil is always invoked as `timeout 10 ddcutil`,
# so it never appears statement-initial and was missed entirely.
grep -oE '\b(timeout[[:space:]]+[0-9.]+|sudo|nohup|env)[[:space:]]+[a-z][a-z0-9_-]+' <<<"$scanned" \
| grep -oE '[a-z][a-z0-9_-]+$'
# `command -v X` is how these helpers probe for a tool before using it,
# which makes it the clearest possible statement of a dependency.
grep -oE 'command -v[[:space:]]+[a-z][a-z0-9_-]+' "$script" \
| grep -oE '[a-z][a-z0-9_-]+$'
} | sort -u)
checked=$((checked + 1))
done < <(find "$repo_dir/config/dot/quickshell/scripts" \
"$repo_dir/config/local/share/vicinae/scripts" \
"$repo_dir/setup/scripts" "$repo_dir/bin" \
"$repo_dir/config/bash" \
-type f 2>/dev/null)
# ── Sourced paths ────────────────────────────────────────────────────────────
#
# config/bash is scanned above because it is where dependencies hide: the shell
# configuration names nvm, oh-my-posh, zoxide, eza and fzf, and it was excluded
# for long enough that `nvm` reached this repository's own shell config without
# ever being installed by it.
#
# Sourcing is the other half. A shell configuration that sources a path nothing
# guarantees exists produces an error on every single shell start, on exactly
# the machines least able to explain it -- new ones. That happened twice here:
# `$HOME/.cargo/env`, which rustup writes only after rustup-init has run, and
# `/etc/profile.d/nvm.sh`, which belongs to a package nothing installed.
#
# So a source of anything outside this repository has to be guarded. Whether the
# owning package is declared is not enough: the file still does not exist until
# that package is installed, and a shell can be opened before then.
while read -r file; do
[[ -f "$file" ]] || continue
while IFS= read -r line; do
# Only unconditional ones. A guard anywhere on the line is the fix.
[[ "$line" =~ \[\[?[[:space:]]*-[fesr] ]] && continue
path="$(sed -E 's/^[[:space:]]*(source|\.)[[:space:]]+//; s/[[:space:]].*//' <<<"$line")"
[[ -n "$path" ]] || continue
# Only literal paths. `. "$rc"` inside a loop that already tested the
# variable is a different shape, guarded structurally rather than on the
# same line, and reporting it would bury the real findings.
[[ "$path" =~ ^[\"\']?(/|~|\$HOME|\$\{HOME) ]] || continue
# Paths inside the repository ship with it and are always present.
[[ "$path" == *PANAMA* ]] && continue
unguarded+=("$(basename "$file"): $path")
done < <(grep -nE '^[[:space:]]*(source|\.)[[:space:]]+[^[:space:]]' "$file" | sed 's/^[0-9]*://')
done < <(find "$repo_dir/config/bash" -type f 2>/dev/null)
if (( ${#unguarded[@]} > 0 )); then
printf 'declared dependencies contract: sourced without checking it exists:\n' >&2
printf ' %s\n' "${unguarded[@]}" | sort -u >&2
fail 'guard each with a -f test, or every shell on a fresh machine starts with an error'
fi
if (( ${#missing[@]} > 0 )); then
printf 'declared dependencies contract: commands used but never installed:\n' >&2
printf ' %s\n' "${missing[@]}" | sort -u >&2
fail 'add each to a list in setup/packages/, or the feature silently will not exist on a fresh machine'
fi
printf 'declared dependencies contract: PASS (%d scripts)\n' "$checked"