#!/usr/bin/env bash # Every asset Panama's desktop names must be something Panama installs. # # This is the sibling of declared-dependencies-contract.sh, 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' ;; 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 # ── 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'