#!/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|ls|rfkill|lsof|authselect|setsid|nohup|grub2-mkconfig)$' 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)$' # Provided by a package swap rather than a list entry. ffmpeg cannot be a list # entry: RPM Fusion's ffmpeg CONFLICTS with the ffmpeg-free that Fedora ships # preinstalled, so `dnf install ffmpeg` fails the whole transaction. The codec # section swaps ffmpeg-free -> ffmpeg with --allowerasing instead. Either way # /usr/bin/ffmpeg exists -- ffmpeg-free provides it too -- so panama-transcode # always has it; it is simply never a name in a package list. SWAPPED='^(ffmpeg)$' # 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' ;; fprintd-list) printf 'fprintd' ;; 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" =~ $SWAPPED ]] && 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 </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"