Files
Panama/tests/quickshell/declared-assets-contract
T
Gabriel Brown 36fdd4e076 Give the VPN a toggle, an indicator, and a way back
Turning on a WireGuard profile whose server was unreachable used to cost the
whole network stack, and the only way out was nmcli typed into a terminal.
Quickshell's Networking module has no VPN surface, so this arrives as the one
sanctioned nmcli exception: a helper that lists, raises and lowers profiles,
a service that watches NetworkManager for changes made anywhere, a quick
settings tile (left-click toggles the most recently used profile, right-click
picks among them), and a bar glyph while a tunnel is up.

The safety property is in the helper, where it cannot be skipped: activation
waits a bounded 25 seconds, and a failure is rolled back down and reported
instead of leaving a black-hole default route. The contract pins exactly that,
against a stateful stub NetworkManager.

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

224 lines
11 KiB
Bash
Executable File

#!/usr/bin/env bash
# Every asset Panama's desktop names must be something Panama installs.
#
# This is the sibling of declared-dependencies-contract, and it exists because
# that contract has a structural blind spot. It checks commands that scripts
# invoke -- so it reports PASS on a machine where the shell's icon font, the
# pointer theme, the wallpaper and four GUI applications are all missing, because
# none of those are a command in a script.
#
# That was not hypothetical. At the time this was written:
#
# * change-settings set monospace-font-name to a Nerd Font that no package list
# installed, and Fonts.qml documents that a mono family without Nerd glyphs
# replaces every icon in the shell with tofu.
# * oreo_blue_cursors was named in six files and installed by none.
# * Wallpaper.qml declared a `shippedPath` that the repository did not ship,
# and panama-lock fell back to the same non-existent file.
# * gnome-control-center backed fifteen rows of Panama's own Settings and was
# declared nowhere, as were gnome-calendar, podman and pipewire-utils.
#
# Every one of those presents as a feature that quietly is not there, which is
# the same failure mode -- and the same argument -- as the dependency contract.
set -uo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
findings=()
note() { findings+=("$1"); }
declared="$(cat "$repo_dir"/setup/packages/* 2>/dev/null \
| sed 's/#.*//' | tr -d ' ' | grep -v '^$' | sort -u)"
[[ -n "$declared" ]] || { printf 'declared assets contract: no package lists found\n' >&2; exit 1; }
is_declared() { grep -qix -- "$1" <<<"$declared"; }
# ── 1. Fonts ─────────────────────────────────────────────────────────────────
#
# A family name is not a package name. Nerd Font packages follow one rule --
# "VictorMono Nerd Font" is victormono-nerd-fonts -- and everything else needs
# naming, so an unmapped family is reported as a real omission rather than
# silently skipped.
font_package() {
local family="$1"
# Style and width suffixes are not part of the family that ships.
family="${family% Mono}"
family="${family% Propo}"
family="$(sed -E 's/ (Light|Regular|Medium|SemiBold|Bold|Thin|Black|Italic)$//' <<<"$family")"
if [[ "$family" == *" Nerd Font" ]]; then
printf '%s-nerd-fonts' "$(tr '[:upper:]' '[:lower:]' <<<"${family% Nerd Font}" | tr -d ' ')"
return
fi
case "$family" in
"Adwaita Sans"|"Adwaita Mono") printf 'adwaita-sans-fonts' ;;
Cantarell) printf 'abattis-cantarell-fonts' ;;
*) printf '?%s' "$family" ;;
esac
}
# Sources are named individually rather than crawled, because a font family is a
# bare string: crawling for one finds every English phrase in the repository.
fonts="$(
grep -hoE "font-name '[^']+'" "$repo_dir"/setup/scripts/* 2>/dev/null \
| sed -E "s/.*'(.*)'/\1/"
grep -hoE '^gtk-font-name=.*' "$repo_dir"/config/dot/gtk-""*/settings.ini.template 2>/dev/null \
| sed 's/^gtk-font-name=//'
grep -hoE '^font-family = ".*"' "$repo_dir"/config/dot/ghostty/config 2>/dev/null \
| sed -E 's/^font-family = "(.*)"/\1/'
grep -hoE '^\s*font_family = .*' "$repo_dir"/config/dot/hypr/hyprlock.conf.template 2>/dev/null \
| sed -E 's/^\s*font_family = //'
grep -hoE 'key: "(interfaceFont|iconFont|monospaceFont|documentFont|applicationFont)", type: "string", def: "[^"]+"' \
"$repo_dir"/config/dot/quickshell/config/PreferenceSchema.qml 2>/dev/null \
| sed -E 's/.*def: "([^"]+)".*/\1/'
)"
while IFS= read -r family; do
[[ -n "$family" ]] || continue
# Trailing point size, as gsettings and GTK both write it.
family="$(sed -E 's/ [0-9]+$//' <<<"$family")"
package="$(font_package "$family")"
if [[ "$package" == \?* ]]; then
note "font family '${package#?}' is named in the desktop but maps to no known package"
elif ! is_declared "$package"; then
note "font '$family' needs package '$package', which no package list declares"
fi
done < <(sort -u <<<"$fonts")
# ── 2. Cursor themes ─────────────────────────────────────────────────────────
#
# Satisfied either by a package or by a theme vendored into the repository --
# oreo_blue_cursors is packaged by no Fedora repository, so it ships here.
cursors="$(
grep -hoE "cursor-theme '[^']+'" "$repo_dir"/setup/scripts/* 2>/dev/null \
| sed -E "s/.*'(.*)'/\1/"
grep -hoE '^gtk-cursor-theme-name=.*' "$repo_dir"/config/dot/gtk-""*/settings.ini.template 2>/dev/null \
| sed 's/^gtk-cursor-theme-name=//'
grep -hoE '^export XCURSOR_THEME=.*' "$repo_dir"/config/dot/uwsm/env 2>/dev/null \
| sed 's/^export XCURSOR_THEME=//'
grep -hoE 'key: "cursorTheme", type: "string", def: "[^"]+"' \
"$repo_dir"/config/dot/quickshell/config/PreferenceSchema.qml 2>/dev/null \
| sed -E 's/.*def: "([^"]+)".*/\1/'
)"
while IFS= read -r theme; do
[[ -n "$theme" ]] || continue
[[ -d "$repo_dir/config/local/share/icons/$theme/cursors" ]] && continue
is_declared "${theme%%_*}-cursor-theme" && continue
is_declared "$theme" && continue
note "cursor theme '$theme' is named in the desktop but is neither vendored nor packaged"
done < <(sort -u <<<"$cursors")
# ── 3. The shipped wallpaper ─────────────────────────────────────────────────
#
# Wallpaper.qml calls one path `shippedPath` and panama-lock falls back to it.
# A fallback that does not exist is not a fallback.
while IFS= read -r image; do
[[ -n "$image" ]] || continue
[[ -f "$repo_dir/config/wallpapers/$image" ]] \
|| note "wallpaper '$image' is referenced as shipped, but config/wallpapers/$image does not exist"
done < <(grep -rhoE 'Pictures/Wallpapers/[A-Za-z0-9._-]+\.(jpg|jpeg|png|webp)' \
"$repo_dir"/config/dot/quickshell/services "$repo_dir"/config/dot/quickshell/scripts \
"$repo_dir"/config/dot/hypr 2>/dev/null | sed 's|.*/||' | sort -u)
# ── 3b. Shipping is not installing ───────────────────────────────────────────
#
# A vendored asset that link-dotfiles never puts into place is exactly as absent
# as one that was never vendored -- and it would satisfy the two checks above,
# which only prove the repository contains the file. Both directories must
# actually be consumed by the installer.
for vendored in config/local/share/icons config/wallpapers; do
[[ -e "$repo_dir/$vendored" ]] || continue
grep -qF "$vendored" "$repo_dir/setup/scripts/link-dotfiles" 2>/dev/null \
|| note "$vendored is shipped but link-dotfiles never installs it, so a fresh machine still does without"
done
# ── 4. Commands the shell launches ───────────────────────────────────────────
#
# The dependency contract reads scripts. These are launched from QML, so it
# never sees them -- which is how the application backing fifteen Settings rows
# went undeclared.
qml_package() {
case "$1" in
hyprctl) printf 'hyprland' ;;
nmcli) printf 'NetworkManager' ;;
wl-copy|wl-paste) printf 'wl-clipboard' ;;
xdg-open) printf 'xdg-utils' ;;
pw-dump|pw-play) printf 'pipewire-utils' ;;
*) printf '%s' "$1" ;;
esac
}
# Provided by the base system or the shell itself; nothing installs these
# separately, and listing them would be noise.
QML_BASELINE='^(sh|bash|rm|test|systemd-inhibit|loginctl|timedatectl|gsettings|gapplication|systemctl|busctl)$'
while IFS= read -r command_name; do
[[ -n "$command_name" ]] || continue
[[ "$command_name" =~ $QML_BASELINE ]] && continue
package="$(qml_package "$command_name")"
is_declared "$package" \
|| note "the shell launches '$command_name' from QML, but '$package' is undeclared"
done < <(grep -rhoE '(command: |execDetached\(|command = )\["[a-z][a-z0-9._-]*"' \
"$repo_dir"/config/dot/quickshell --include='*.qml' 2>/dev/null \
| sed -E 's/.*\["([^"]*)"/\1/' | sort -u)
# ── 5. Directories nothing puts into service ─────────────────────────────────
#
# A config directory nobody links is a directory nobody has. This catches the
# case where a new dot/ directory is added and link-dotfiles' `dirs` array is not
# updated, which fails silently -- the config is simply never in place.
#
# Linking is not the only way to be used: ohmyposh is loaded straight out of the
# repository by config/bash/shell, which needs no symlink at all. So the shell
# configuration counts as a consumer too, and only a directory nothing at all
# refers to is reported.
consumers=("$repo_dir/setup/scripts/link-dotfiles" "$repo_dir"/config/bash/*)
for dot_dir in "$repo_dir"/config/dot/*/; do
name="$(basename "$dot_dir")"
grep -qE "(^|[^A-Za-z0-9_-])$name([^A-Za-z0-9_-]|$)" "${consumers[@]}" 2>/dev/null \
|| note "config/dot/$name is referenced by neither link-dotfiles nor the shell configuration, so nothing puts it into service"
done
# ── 6. Names in the array with nothing behind them ───────────────────────────
#
# The inverse of the check above, and the one that bites when a directory is
# removed rather than added: an entry left in `dirs` makes link-dotfiles point
# ~/.config/<name> at a path that does not exist, and a dangling symlink is
# worse than no symlink, because everything that looks there finds something.
#
# Missed the first time this contract was written. Deleting config/dot/forge in
# the GNOME excision was caught by nothing until the array entry was removed too,
# which was luck rather than a test.
mapfile -t linked_dirs < <(sed -n '/^dirs=(/,/)/p' "$repo_dir/setup/scripts/link-dotfiles" \
| grep -oE '"[^"]+"' | tr -d '"')
(( ${#linked_dirs[@]} > 0 )) \
|| note "link-dotfiles' dirs array could not be read, so nothing checked what it links"
for name in "${linked_dirs[@]}"; do
[[ -d "$repo_dir/config/dot/$name" ]] \
|| note "link-dotfiles links $name, but config/dot/$name does not exist -- that is a dangling symlink in ~/.config"
done
# ── Report ───────────────────────────────────────────────────────────────────
# One asset named by three different files is one finding, not three.
if (( ${#findings[@]} > 0 )); then
mapfile -t findings < <(printf '%s\n' "${findings[@]}" | sort -u)
printf 'declared assets contract: %d finding(s)\n' "${#findings[@]}" >&2
printf ' - %s\n' "${findings[@]}" >&2
exit 1
fi
printf 'declared assets contract: PASS\n'