#!/bin/bash # test/lint.sh # # Fast, offline integrity checks for the dotfiles repo. # Does NOT require Docker or any packages to be installed beyond: # - shellcheck (silently skipped if unavailable) # - bash (for `bash -n` syntax checks) # # Run with: make test-lint set -uo pipefail REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$REPO_ROOT" || exit 1 PASS=0 FAIL=0 say_fail() { printf 'FAIL: %s\n' "$*"; FAIL=$((FAIL+1)); } say_pass() { printf 'ok: %s\n' "$*"; PASS=$((PASS+1)); } # --------------------------------------------------------------------------- # 1. Collect scripts to check. # Only repo-owned scripts; skip vendored tpm/ plugin shell scripts. # Preferred source is git ls-files (the repo checkout); fall back to a # filesystem scan when .git is not present (e.g. inside the docker dev # image, where the repo is COPYied without .git). Note: git ls-files can # also silently return nothing when the mount has dubious ownership, so the # fallback must stay robust by excluding vendored plugin trees ('*/plugins/'). # --------------------------------------------------------------------------- # Exclusions: never lint tpm or general vendored plugin trees, whose third-party # scripts may legitimately fail `bash -n` and are not written/maintained here. EXCLUDE_VENDOR='(/tpm/|/plugins/|/plugins$)' mapfile -t SCRIPTS < <(git ls-files '*.sh' 2>/dev/null | grep -vE "$EXCLUDE_VENDOR" | while IFS= read -r script; do [[ -f "$script" ]] && printf '%s\n' "$script" done) if [[ ${#SCRIPTS[@]} -eq 0 ]]; then mapfile -t SCRIPTS < <(find . -name '*.sh' \ -not -path './.git/*' -not -path '*/.git/*' \ -not -path '*/tpm/*' -not -path '*/plugins/*' \ | sort) fi if [[ ${#SCRIPTS[@]} -eq 0 ]]; then say_fail "no *.sh files found" exit 1 fi echo "== Checking $((${#SCRIPTS[@]})) tracked shell scripts ==" # --------------------------------------------------------------------------- # 2. Syntax check (bash -n) - always runs, no external deps. # --------------------------------------------------------------------------- for s in "${SCRIPTS[@]}"; do if bash -n "$s" 2>/dev/null; then : else say_fail "bash -n $s" fi done [[ $FAIL -eq 0 ]] && say_pass "bash -n syntax check passed" # --------------------------------------------------------------------------- # 3. shellcheck (optional dependency). # --------------------------------------------------------------------------- if command -v shellcheck >/dev/null 2>&1; then echo "== Running shellcheck ==" if shellcheck --version >/dev/null 2>&1; then if shellcheck -S warning "${SCRIPTS[@]}" ; then say_pass "shellcheck (warning severity)" else say_fail "shellcheck reported issues (warning severity)" fi else say_fail "shellcheck binary present but not functional" fi else echo "== shellcheck not installed; skipping (install with: pacman -S shellcheck) ==" fi # --------------------------------------------------------------------------- # 4. packages.txt parsing sanity (mirrors setup/lib/common.sh read_packages). # --------------------------------------------------------------------------- echo "== Checking packages.txt markers ==" for pkgdir in term basic utils hypr kreation; do # Setup scripts + package lists live in setup/ (outside the stow # packages), sharing one parser in setup/lib/common.sh. PKG_FILE="setup/$pkgdir/packages.txt" SETUP_FILE="setup/$pkgdir/setup.sh" if ! [[ -f "$PKG_FILE" ]]; then say_fail "$PKG_FILE missing" continue fi if grep -q '^@pacman' "$PKG_FILE"; then say_pass "$PKG_FILE declares @pacman section" else say_fail "$PKG_FILE missing @pacman marker" fi # every @aur/@npm marker must be matched by a real consumer: either the # thin setup.sh itself or the shared setup/lib/common.sh it sources. for marker in '@aur' '@npm'; do if grep -q "^$marker" "$PKG_FILE"; then if grep -q "$marker" "$SETUP_FILE" || grep -q "$marker" "setup/lib/common.sh"; then say_pass "$PKG_FILE $marker handled by $SETUP_FILE (via common.sh)" else say_fail "$PKG_FILE uses $marker but neither $SETUP_FILE nor setup/lib/common.sh handles it" fi else say_pass "$PKG_FILE has no $marker section" fi done done # --------------------------------------------------------------------------- # 5. Keep Caelestia on the upstream AUR Quickshell runtime. # --------------------------------------------------------------------------- echo "== Checking Quickshell package selection ==" PACKAGE_FILES=() for pkgdir in term basic utils hypr kreation; do PACKAGE_FILES+=("setup/$pkgdir/packages.txt") done has_package() { local target="$1" file shift for file in "$@"; do if awk -v target="$target" ' { sub(/#.*/, "") gsub(/^[[:space:]]+|[[:space:]]+$/, "") sub(/^[^/]+\//, "") } $0 == target { found = 1 } END { exit(found ? 0 : 1) } ' "$file"; then return 0 fi done return 1 } has_aur_package() { local target="$1" file shift for file in "$@"; do if awk -v target="$target" ' { sub(/#.*/, "") gsub(/^[[:space:]]+|[[:space:]]+$/, "") } $0 == "@aur" { in_aur = 1; next } /^@/ { in_aur = 0; next } in_aur && $0 == target { found = 1 } END { exit(found ? 0 : 1) } ' "$file"; then return 0 fi done return 1 } if has_package quickshell "${PACKAGE_FILES[@]}" && has_package quickshell-git "${PACKAGE_FILES[@]}"; then say_fail "package lists declare both quickshell and quickshell-git" else say_pass "no mutually exclusive quickshell packages declared" fi if has_package noctalia-qs "${PACKAGE_FILES[@]}"; then say_fail "package lists must not install noctalia-qs for Caelestia" else say_pass "incompatible noctalia-qs provider is not declared" fi if has_aur_package aur/quickshell-git "${PACKAGE_FILES[@]}"; then say_pass "quickshell-git is explicitly selected from the AUR" else say_fail "quickshell-git must use the explicit aur/quickshell-git target" fi if awk ' /^[[:space:]]*yay[[:space:]]+-S/ && /--aur/ && /--needed/ { found = 1 } END { exit(found ? 0 : 1) } ' setup/lib/common.sh; then say_pass "AUR package installation uses yay --aur mode" else say_fail "AUR package installation does not use yay --aur mode" fi # --------------------------------------------------------------------------- # 6. Makefile <-> setup script name consistency. # Catches typos such as `setup/basic/setuo.sh` # (missing '/') in Makefile targets. # --------------------------------------------------------------------------- echo "== Checking Makefile references setup scripts that exist ==" # Pull out every `setup/...sh` token mentioned in the Makefile. while IFS= read -r tok; do [[ -z "$tok" ]] && continue if [[ -e "$REPO_ROOT/$tok" ]]; then say_pass "Makefile references existing script: $tok" else say_fail "Makefile references missing script: $tok" fi done < <(grep -P '^\t' Makefile | grep -oE 'setup/[_A-Za-z0-9./-]+\.sh' | sort -u) # --------------------------------------------------------------------------- # 7. Makefile <-> stow package name consistency. # Catches bugs such as `stow nvim` when the actual package dir is `term/`. # --------------------------------------------------------------------------- echo "== Checking Makefile stow targets reference real package dirs ==" # A 'package' is any tracked top-level directory (a .config/ subtree is stowable). have_pkg() { local name="$1" if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then git ls-files 2>/dev/null | awk -F/ 'NF>1 {print $1}' | sort -u | grep -qx "$name" else # git-less checkout (e.g. docker dev image): fall back to filesystem. # Only verify the dir exists; "is it stowable" is validated by stow # itself during the smoke test. [[ -d "$name" ]] fi } while IFS= read -r pkg; do [[ -z "$pkg" ]] && continue case "$pkg" in \#*|"") continue ;; esac if have_pkg "$pkg" && [[ -d "$REPO_ROOT/$pkg" ]]; then say_pass "Makefile stows existing package: $pkg" else say_fail "Makefile stows non-existent package: $pkg" fi done < <(grep -P '^\t' Makefile | grep -E '\bstow\b' | awk '{print $NF}' | sort -u) # --------------------------------------------------------------------------- # 8. Central theme drift guard: no hardcoded theme colors outside # basic/.config/themes (plus declared mirrors). See # basic/.config/themes/scripts/sync-theme.sh. # --------------------------------------------------------------------------- echo "== Checking central theme has no hardcoded copies ==" if [[ -f "basic/.config/themes/scripts/sync-theme.sh" ]]; then if bash basic/.config/themes/scripts/sync-theme.sh; then say_pass "central theme drift guard" else say_fail "central theme drift guard" fi else say_fail "basic/.config/themes/scripts/sync-theme.sh missing" fi # --------------------------------------------------------------------------- # Summary # --------------------------------------------------------------------------- echo echo "==============================================" echo "lint.sh: $PASS passed, $FAIL failed" echo "==============================================" [[ $FAIL -eq 0 ]]