Files
Panama/tests/quickshell/declared-dependencies-contract
T
Gabriel Brown 333ab9f6b7 Press the browser key twice, get one browser
The application keys ran their command unconditionally, so pressing the
browser key twice gave you two browsers. That is the single most common
"Linux feels wrong" moment and it is a twenty-line fix: match the
window class, focus it if it is there, launch if it is not.

Two details the obvious version gets wrong. Patterns are anchored,
because an unanchored "mail" matches gmail-notifier and the mail key
would raise somebody's notifier. And class alone is not enough here:
the terminal and the editor are both kitty, so the editor bind narrows
by title or it raises whatever terminal happened to be open. The
contract's fixture contains both traps.

Found and fixed a bug this shipped with earlier today. Hyprland 0.56
dispatches through Lua, so `hyprctl dispatch focuswindow address:0x...`
is parsed as Lua source and fails with a syntax error -- which is what
the launcher's Switch Window command had been doing since it landed.
Its own contract stubbed hyprctl and recorded the arguments, so the
call looked correct and never ran. Both now use the selector form
hl.focus actually accepts, and the contract asserts the shape rather
than only that something was dispatched.
2026-08-22 06:02:47 -04:00

225 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)$'
# 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" =~ $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"