diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..8a47c0e
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,9 @@
+# Keep image builds light: the runtime mounts the live repo via -v.
+# NOTE: do NOT exclude test/ - the dev stage COPYs the repo and then runs
+# test/lint.sh from it.
+.git
+**/node_modules
+**/lazy-lock.json
+**/fish_variables
+kreation/.vst3
+term/.config/tmux/plugins
\ No newline at end of file
diff --git a/AGENTS.md b/AGENTS.md
index 8837464..fed0014 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -7,15 +7,15 @@ Only entries that would be missed without explicit help are included.
## 1. Project Scope
- **Dotfiles** for a personal workstation (hyprland + Neovim + VST plugin).
-- No tests, CI or build scripts – the repo is purely configuration data.
-- The only executable content is the `install_*` shell scripts that install system packages.
+- No CI or build scripts – the repo is purely configuration data; the only executable content is the `install_*` scripts and the `test/` harness.
+- **Testing** lives in `test/`. Run `make test-lint` / `make test-stow` (offline, no Docker) or `make test-smoke` (Docker-based, see `test/Dockerfile` and `README.md → Testing`). The harness currently surfaces pre-existing Makefile bugs by design (missing `/` in `install_*` script paths, `stow nvim` with no `nvim/` package).
---
## 2. System Setup
| Task | Command | Why it matters |
|------|---------|----------------|
-| **Install required programs** | `bash term/setup.sh`
`bash .install_skripte/install_basic_pcks.sh`
`bash .install_skripte/install_hypr_pcks.sh`
`bash .install_skripte/install_kreation_pcks.sh` | These scripts install Neovim, hyprland, VST runtime, and other utilities. Edit `term/packages.txt` to add/remove terminal packages. |
+| **Install required programs** | `bash term/setup.sh`
`bash _install_skripte/install_basic_pcks.sh`
`bash _install_skripte/install_hypr_pcks.sh`
`bash _install_skripte/install_kreation_pcks.sh` | These scripts install Neovim, hyprland, VST runtime, and other utilities. Edit `term/packages.txt` to add/remove terminal packages. |
| **Link configuration directories** | `ln -s ~/.dotfiles/hypr/.config/hypr ~/.config/hypr`
`ln -s ~/.dotfiles/term/.config/nvim ~/.config/nvim` | The repository expects the configs to live in `~/.config`. |
| **Add VST plugin** | Copy `kreation/.vst3/MT-PowerDrumKit.vst3` into your DAW’s plugins folder (usually `~/.vst`). | The repo contains a pre‑built VST for use in audio software. |
diff --git a/Makefile b/Makefile
index 4ef81fd..8fa7674 100644
--- a/Makefile
+++ b/Makefile
@@ -19,3 +19,44 @@ kreation: basic
stow kreation
bunters-monster: basic hypr kreation
_install_skripte/buntes-monster-links.sh
+
+# ---------------------------------------------------------------------------
+# Testing (see README.md "Testing" and test/ for details)
+# ---------------------------------------------------------------------------
+.PHONY: test test-lint test-smoke test-stow test-shellcheck test-lint-docker
+
+# Full test suite: fast offline lint first, then the optional Docker smoke.
+test: test-lint test-smoke
+
+# Fast, offline integrity checks. No Docker required.
+# shellcheck is used if installed, otherwise only syntax checks run.
+test-lint:
+ bash test/lint.sh
+
+# Check stow package resolution without creating symlinks.
+test-stow:
+ bash test/probe_stow.sh
+
+# Run shellcheck over all tracked repo scripts (needs shellcheck installed).
+test-shellcheck:
+ shellcheck -S warning $$(git ls-files '*.sh' | grep -v '/tpm/')
+
+# Test shellcheck: build the /dev stage with shellcheck and run lint.sh.
+# docker build context is the repo root; .dockerignore excludes heavy dirs.
+test-lint-docker:
+ docker build --target dev -t dotfiles-lint -f test/Dockerfile .
+ docker run --rm dotfiles-lint
+
+# Docker smoke test: builds the Arch container and runs the dry probes.
+# RUN_SMOKE=1 also installs the real pacman packages inside the container.
+# RUN_SMOKE=1 RUN_FULL=1 additionally attempts AUR/npm + hypr/kreation.
+# SKIP_NET=1 skips anything that contacts the network.
+# Example: make test-smoke RUN_SMOKE=1
+test-smoke:
+ docker build --target test -t dotfiles-test -f test/Dockerfile .
+ docker run --rm \
+ -e RUN_SMOKE="$${RUN_SMOKE:-0}" \
+ -e RUN_FULL="$${RUN_FULL:-0}" \
+ -e SKIP_NET=$${SKIP_NET:-0} \
+ -v "$$PWD":/dotfiles \
+ dotfiles-test
diff --git a/README.md b/README.md
index 67d461d..cb2fc9b 100644
--- a/README.md
+++ b/README.md
@@ -9,7 +9,9 @@ Personal configuration files for an Arch Linux workstation (Hyprland + Neovim +
| `term/` | Fish + kitty + starship + nvim + tmux + yazi + fastfetch + opencode terminal environment |
| `hypr/` | Hyprland compositor config, kitty theme, wpaperd wallpapers |
| `kreation/`| VST plugin (`MT-PowerDrumKit.vst3`) |
+| `local_ai/`| Local AI / MCP tooling config |
| `_install_skripte/` | Miscellaneous install helper scripts |
+| `test/` | Docker test harness (lint + smoke tests) |
Each top-level directory is a **stow package**. `stow` symlinks the files from the repository into your home directory (e.g. `term/.config/...` → `~/.config/...`).
@@ -125,4 +127,56 @@ stow --ignore='\.config/(local|misc)' term
---
+## Testing
+
+The `test/` directory contains a Docker-based test harness that verifies the
+install scripts, the `Makefile`, and the stow packages.
+
+There are **two tiers**:
+
+| Target | What it does | Docker required? |
+|--------|--------------|------------------|
+| `make test-lint` | Fast, offline integrity checks: `bash -n` syntax pass over every script, `shellcheck` (if installed), `packages.txt` marker validation, and Makefile ↔ script/stow-package name consistency. Catches typos like `_install_skripte_install_basic_pcks.sh` (missing `/`). | No |
+| `make test-stow` | `stow --simulate` for every stow-able package; fails if a package the Makefile stows doesn't exist (e.g. `stow nvim` — nvim lives under `term/`). | No |
+| `make test-shellcheck` | Runs shellcheck over all tracked scripts (shellcheck must be installed). | No |
+| `make test-lint-docker` | Builds the `/dev` stage (`archlinux:latest` + shellcheck) and runs `test/lint.sh` inside it. | Yes |
+| `make test-smoke` | Builds the `/test` stage and runs the container's dry probes: `make -n` over every target, `stow --simulate` over every Makefile-stowed package, plus `test/lint.sh`. A network-enabled `RUN_SMOKE=1` variant installs the real pacman packages. | Yes |
+| `make test` | `test-lint` + `test-smoke`. | Partially |
+
+### Docker smoke details
+
+```bash
+make test-smoke # dry probes only (fast, no installs)
+make test-smoke RUN_SMOKE=1 # + real pacman installs of the term packages
+make test-smoke RUN_SMOKE=1 RUN_FULL=1 # + AUR/npm + hypr/kreation/buntes-monster
+make test-smoke SKIP_NET=1 # skip anything that contacts the network
+```
+
+The container:
+
+- uses `archlinux:latest` (the scripts assume Arch + pacman),
+- creates a non-root user (`mathias`, uid 1000) with passwordless sudo so the
+ scripts don't hang on a password prompt,
+- mounts the live repo at `/dotfiles` (`-v "$PWD":/dotfiles`), so the tests
+ always run against your current checkout,
+- stubs host daemon commands (`systemctl`, `updatedb`, `kbuildsycoca6`,
+ `dbus-update-activation-environment`) as no-ops so nothing talks to a real
+ systemd/dbus inside the container.
+
+In the `RUN_SMOKE=1` install path, the harness additionally **verifies the
+installed packages** (`test/verify_packages.sh`):
+- every `@pacman` entry is confirmed via `pacman -Q ` (covers fonts,
+ libs and python modules that ship no binary),
+- a curated mapping probes each package's binary with a real
+ `--version`-style call (e.g. `fish --version`, `nvim --version`,
+ `ssh -V`), failing if the binary is missing or does not report a version.
+
+> **Note for the current repo state:** `make test-lint` and `make test-smoke`
+> currently **fail** because the harness surfaces pre-existing Makefile bugs
+> (missing `/` in the `install_*` script paths and `stow nvim` for a
+> non-existent `nvim/` package). That is the intended behaviour — fix the
+> Makefile and the tests should go green.
+
+---
+
**When in doubt:** run `stow --verbose --simulate ` first — it prints what would happen (linking) without changing anything. Never `rm -rf` the `~/.config` of something you still want to keep; move it to a backup directory instead.
diff --git a/test/Dockerfile b/test/Dockerfile
new file mode 100644
index 0000000..6fd8172
--- /dev/null
+++ b/test/Dockerfile
@@ -0,0 +1,73 @@
+# test/Dockerfile
+#
+# Docker test harness for the dotfiles repo.
+#
+# Two stages:
+# --target dev : fast shellcheck/lint environment (no heavy installs)
+# --target test : smoke-test environment that can run the install scripts
+#
+# Build with (repo root as context; .dockerignore trims heavy/binary dirs):
+# docker build --target test -t dotfiles-test -f test/Dockerfile .
+# Run with:
+# docker run --rm -v "$PWD":/dotfiles dotfiles-test
+#
+# Or simply use the Makefile targets:
+# make test-lint-docker # build dev stage, run shellcheck+lint
+# make test-smoke # dry probes; add RUN_SMOKE=1 for real installs
+#
+# See README.md ("Testing") and test/entrypoint-test.sh for details.
+
+FROM archlinux:latest AS base
+
+# Non-root test user that mirrors the workstation account.
+ARG UID_ARG=1000
+ARG USERNAME=mathias
+
+RUN pacman -Syu --noconfirm \
+ base-devel \
+ git \
+ stow \
+ curl \
+ which \
+ sudo \
+ && pacman -Scc --noconfirm
+
+RUN useradd -m -u "$UID_ARG" -s /bin/bash -G wheel "$USERNAME"
+
+# Passwordless sudo so scripts that call `sudo pacman`, `sudo systemctl`, etc.
+# do not hang on a password prompt inside the container.
+RUN printf '%%wheel ALL=(ALL) NOPASSWD: ALL\n' > /etc/sudoers.d/wheel-nopasswd \
+ && chmod 440 /etc/sudoers.d/wheel-nopasswd
+
+USER "$USERNAME"
+ENV HOME="/home/$USERNAME"
+WORKDIR /dotfiles
+
+# ---------------------------------------------------------------------------
+# dev stage: shellcheck/lint only. Fast, no install heavy-lifting.
+# ---------------------------------------------------------------------------
+FROM base AS dev
+
+USER root
+RUN pacman -S --noconfirm --needed shellcheck
+
+USER "$USERNAME"
+WORKDIR /dotfiles
+
+COPY --chown="$USERNAME" . /dotfiles
+
+CMD ["bash", "test/lint.sh"]
+
+# ---------------------------------------------------------------------------
+# test stage: prepared to actually run the install scripts & probe stow.
+# Uses the mounted repo (see -v $PWD:/dotfiles in the Makefile) so scripts
+# see the live workspace checkout.
+# ---------------------------------------------------------------------------
+FROM base AS test
+
+USER "$USERNAME"
+WORKDIR /dotfiles
+
+# The entrypoint drives the smoke tests. The repo is mounted at /dotfiles
+# via the Makefile's `docker run -v`.
+ENTRYPOINT ["bash", "/dotfiles/test/entrypoint-test.sh"]
diff --git a/test/entrypoint-test.sh b/test/entrypoint-test.sh
new file mode 100755
index 0000000..86d89ac
--- /dev/null
+++ b/test/entrypoint-test.sh
@@ -0,0 +1,252 @@
+#!/bin/bash
+# test/entrypoint-test.sh
+#
+# Runs inside the docker `test` stage. The repo is mounted read-write at
+# /dotfiles (see the Makefile's `docker run -v "$PWD":/dotfiles`).
+#
+# Two tiers:
+# 1. DRY PROBES (always, fast, offline)
+# - `make -n` dry-runs to catch Makefile typos / missing scripts
+# - `stow --simulate` to prove every stow target resolves
+# 2. REAL SMOKE (guarded by RUN_SMOKE=1)
+# - exercises term/setup.sh package parsing and (optionally) installs
+# - verifies stow actually creates symlinks into $HOME
+# Full AUR/npm + kreation/buntes-monster only run with RUN_FULL=1.
+#
+# Env flags (set via `docker run -e`):
+# RUN_SMOKE=1 run the real pacman-install smoke
+# RUN_FULL=1 also attempt AUR/npm + hypr/kreation/buntes-monster
+# SKIP_NET=1 pretend no network; skip anything that needs the mirror
+
+set -uo pipefail
+
+# Resolve the repo root: prefer the container's /dotfiles mount, otherwise
+# fall back to the directory above this script (a local checkout).
+if [[ -d /dotfiles ]]; then
+ cd /dotfiles || { echo "FATAL: repo not mounted at /dotfiles"; exit 1; }
+else
+ cd "$(dirname "${BASH_SOURCE[0]}")/.." || { echo "FATAL: cannot locate repo root"; exit 1; }
+fi
+REPO="$(pwd)"
+
+# Guard: both resolution paths must find a real checkout.
+if [[ ! -f "$REPO/Makefile" || ! -d "$REPO/test" ]]; then
+ echo "FATAL: '$REPO' does not look like the dotfiles repo root." >&2
+ echo " In docker, run with -v \"\$PWD\":/dotfiles ." >&2
+ exit 1
+fi
+
+PASS=0
+FAIL=0
+say_ok() { printf ' ok: %s\n' "$*"; PASS=$((PASS+1)); }
+say_fail(){ printf ' FAIL: %s\n' "$*"; FAIL=$((FAIL+1)); }
+header() { printf '\n==== %s ====\n' "$*"; }
+
+if command -v git >/dev/null 2>&1 && [[ ! -d .git ]]; then
+ # when mounted from a worktree, .git may be a file; fine.
+ :
+fi
+
+echo "entrypoint-test.sh: running as $(id -un) in $(pwd)"
+
+# ---------------------------------------------------------------------------
+# 1. DRY PROBES - always run, cheap, offline.
+# ---------------------------------------------------------------------------
+header "Dry probes"
+
+# 1a. Makefile dry runs for every target.
+# Discover targets dynamically so we never drift from the Makefile.
+targets=$(grep -oE '^[A-Za-z0-9_-]+:' Makefile | tr -d ':' | grep -v '^test' | sort -u)
+for target in $targets; do
+ # `make -n` must not error out from missing scripts / dirs.
+ if ( cd "$REPO" && make -n "$target" >/dev/null 2>err.txt ); then
+ say_ok "make -n $target parses"
+ else
+ say_fail "make -n $target (typo? missing script?)"
+ sed 's/^/ /' err.txt 2>/dev/null
+ fi
+ rm -f err.txt
+done
+
+# 1b. stow --simulate for every package the Makefile stows.
+while IFS= read -r pkg; do
+ [[ -z "$pkg" ]] && continue
+ if stow --simulate --dir="$REPO" --target="$REPO" "$pkg" 2>err.txt; then
+ say_ok "stow --simulate $pkg resolves"
+ else
+ say_fail "stow --simulate $pkg (non-existent package or conflict)"
+ sed 's/^/ /' err.txt 2>/dev/null
+ fi
+ rm -f err.txt
+done < <(grep -P '^\t' Makefile | grep -oE 'stow [A-Za-z0-9_-]+' | awk '{print $2}' | sort -u)
+
+# 1c. run the repo's own lint + probe scripts (they're mounted here).
+if [[ -x test/lint.sh ]] || [[ -f test/lint.sh ]]; then
+ if bash test/lint.sh >/dev/null 2>&1; then
+ say_ok "test/lint.sh"
+ else
+ say_fail "test/lint.sh (see logs above / rerun outside container)"
+ fi
+else
+ say_fail "test/lint.sh not present in mount"
+fi
+
+header "Smoke gates"
+echo " RUN_SMOKE=${RUN_SMOKE:-0} RUN_FULL=${RUN_FULL:-0} SKIP_NET=${SKIP_NET:-0}"
+
+# ---------------------------------------------------------------------------
+# 2. REAL SMOKE (RUN_SMOKE=1)
+# ---------------------------------------------------------------------------
+if [[ "${RUN_SMOKE:-0}" == "1" ]]; then
+ header "Real smoke (RUN_SMOKE=1)"
+
+ # 2a. Allow scripts to use sudo without a password (already NOPASSWD via
+ # the Dockerfile for the wheel group).
+ #
+ # 2b. Override host-daemon commands with no-ops so nothing tries to talk
+ # to systemd/dbus/dbus in the container.
+ mkdir -p "$HOME/bin"
+ cat > "$HOME/bin/systemctl" <<'NOOP'
+#!/bin/bash
+echo "[stub] systemctl $*"
+exit 0
+NOOP
+ cat > "$HOME/bin/dbus-update-activation-environment" <<'NOOP'
+#!/bin/bash
+echo "[stub] dbus-update-activation-environment $*"
+exit 0
+NOOP
+ cat > "$HOME/bin/kbuildsycoca6" <<'NOOP'
+#!/bin/bash
+echo "[stub] kbuildsycoca6 $*"
+exit 0
+NOOP
+ cat > "$HOME/bin/updatedb" <<'NOOP'
+#!/bin/bash
+echo "[stub] updatedb $*"
+exit 0
+NOOP
+ chmod +x "$HOME/bin/"*
+ export PATH="$HOME/bin:$PATH"
+
+ # 2c. term/setup.sh package parsing sanity (mirrors read_packages).
+ if ( cd "$REPO" && bash -n term/setup.sh ); then
+ say_ok "term/setup.sh syntax OK"
+ else
+ say_fail "term/setup.sh syntax error"
+ fi
+ pacman_count=$(awk '
+ /^@pacman/{s="pacman"}
+ /^@aur/{s="aur"}
+ /^@npm/{s="npm"}
+ /^[[:space:]]*#/{next}
+ /^[[:space:]]*$/{next}
+ s=="pacman"{pc++} s=="aur"{ac++} s=="npm"{nc++}
+ END{printf "%d %d %d", pc,ac,nc}
+ ' $REPO/term/packages.txt)
+ read -r PC AC NC <<< "$pacman_count"
+ say_ok "packages.txt -> ${PC} pacman, ${AC} aur, ${NC} npm entries"
+
+ # 2d. If network allowed and not skipped, actually install the pacman
+ # section of term/setup.sh (mirrors `make term` without AUR/npm).
+ if [[ "${SKIP_NET:-0}" != "1" ]]; then
+ header "make term (pacman packages, AUR/npm skipped)"
+ # Patch packages.txt to drop @aur/@npm so we don't hit AUR/npm.
+ cp "$REPO/term/packages.txt" "$HOME/packages.txt.full"
+ awk '
+ /^@aur$/{s="skip"}; /^@npm$/{s="skip"}; /^@pacman$/{s="pacman"}
+ { if (s!="skip" && NF>0 && $1 !~ /^#/) print }
+ ' "$REPO/term/packages.txt" > "$HOME/packages.txt.term-only"
+ # set a scratch HOME for setup.sh so it doesn't touch the real one.
+ mkdir -p "$HOME/term"
+ cp "$REPO/term/setup.sh" "$HOME/term/setup.sh"
+ cp "$HOME/packages.txt.term-only" "$HOME/term/packages.txt"
+ # chsh is stubbed via $HOME/bin so even `chsh` works without a pty.
+ setup_ok=0
+ if ( cd "$HOME/term" && bash setup.sh ) ; then
+ say_ok "term/setup.sh (term-only) completed"
+ setup_ok=1
+ else
+ say_fail "term/setup.sh (term-only) failed (exit $?)"
+ fi
+
+ # 2d2. Verify that every package is really installed and its binary
+ # runs. Only meaningful if the install itself succeeded.
+ if [[ "$setup_ok" == "1" ]]; then
+ if [[ -x test/verify_packages.sh ]]; then
+ header "verify installed packages"
+ if bash test/verify_packages.sh "$HOME/packages.txt.term-only"; then
+ say_ok "verify_packages.sh: all packages verified"
+ else
+ say_fail "verify_packages.sh: one or more packages failed verification (see above)"
+ fi
+ else
+ say_fail "test/verify_packages.sh missing"
+ fi
+ else
+ echo " skipping package verification (install did not complete)"
+ fi
+ else
+ echo " SKIP_NET=1 -> skipping real package install and verification"
+ fi
+
+ # 2e. stow --actual into $HOME to prove real symlinks are created.
+ header "stow real symlinks into \$HOME"
+ for pkg in term hypr kreation local_ai; do
+ if [[ -d "$REPO/$pkg" ]]; then
+ if stow --dir="$REPO" --target="$HOME" "$pkg" 2>err.txt; then
+ say_ok "stow $pkg into \$HOME"
+ else
+ say_fail "stow $pkg into \$HOME"
+ sed 's/^/ /' err.txt 2>/dev/null
+ fi
+ rm -f err.txt
+ else
+ say_fail "package dir missing: $pkg"
+ fi
+ done
+ # sanity: representative symlinks must now exist in $HOME.
+ missing=""
+ for link in .config/fish .config/hypr .vst3/MT-PowerDrumKit.vst3; do
+ [[ -e "$HOME/$link" ]] || missing="$missing $link"
+ done
+ if [[ -z "$missing" ]]; then
+ say_ok "stow created representative \$HOME symlinks"
+ else
+ say_fail "missing expected stow symlinks:$missing"
+ fi
+
+ # 2f. kreation package: verify the VST path is stow-able and present.
+ header "kreation VST"
+ if [[ -f "$REPO/kreation/.vst3/MT-PowerDrumKit.vst3/Contents/x86_64-linux/MT-PowerDrumKit.so" ]]; then
+ say_ok "MT-PowerDrumKit.vst3 binary present"
+ else
+ say_fail "MT-PowerDrumKit.vst3 binary missing"
+ fi
+else
+ echo " RUN_SMOKE not set -> skipping real installs & symlink creation."
+fi
+
+# ---------------------------------------------------------------------------
+# 3. FULL (RUN_FULL=1) - opt-in heavy path (AUR/npm/hypr/kreation)
+# ---------------------------------------------------------------------------
+if [[ "${RUN_FULL:-0}" == "1" ]] && [[ "${SKIP_NET:-0}" != "1" ]]; then
+ header "Full smoke (RUN_FULL=1)"
+ if ( cd "$REPO" && make buntes-monster ); then
+ say_ok "make buntes-monster"
+ else
+ say_fail "make buntes-monster (exit $?)"
+ fi
+else
+ echo " RUN_FULL not set (or SKIP_NET) -> skipping full/hypr/kreation install."
+fi
+
+# ---------------------------------------------------------------------------
+# Summary & exit code
+# ---------------------------------------------------------------------------
+echo
+echo "=============================================="
+echo "entrypoint-test.sh: $PASS passed, $FAIL failed"
+echo "=============================================="
+# Exit non-zero if anything failed.
+[[ $FAIL -eq 0 ]]
diff --git a/test/lint.sh b/test/lint.sh
new file mode 100755
index 0000000..f713d00
--- /dev/null
+++ b/test/lint.sh
@@ -0,0 +1,145 @@
+#!/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="$(pwd)"
+cd "$REPO_ROOT"
+
+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).
+# ---------------------------------------------------------------------------
+mapfile -t SCRIPTS < <(git ls-files '*.sh' 2>/dev/null | grep -v '/tpm/' | grep -vE '/plugins/[^/]+/scripts/')
+if [[ ${#SCRIPTS[@]} -eq 0 ]]; then
+ mapfile -t SCRIPTS < <(find . -name '*.sh' -not -path './.git/*' -not -path '*/tpm/*' -not -path '*/.git/*' | 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 term/setup.sh read_packages).
+# ---------------------------------------------------------------------------
+echo "== Checking term/packages.txt markers =="
+if grep -q '^@pacman' term/packages.txt; then
+ say_pass "packages.txt declares @pacman section"
+else
+ say_fail "packages.txt missing @pacman marker"
+fi
+# every @aur/@npm marker must be matched by a real consumer in setup.sh
+for marker in '@aur' '@npm'; do
+ if grep -q "^$marker" term/packages.txt; then
+ grep -q "$marker" term/setup.sh \
+ && say_pass "packages.txt $marker handled by term/setup.sh" \
+ || say_fail "packages.txt uses $marker but term/setup.sh does not handle it"
+ else
+ say_pass "packages.txt has no $marker section"
+ fi
+done
+
+# ---------------------------------------------------------------------------
+# 5. Makefile <-> install script name consistency.
+# Catches typos such as `_install_skripte_install_basic_pcks.sh`
+# (missing '/') in Makefile targets.
+# ---------------------------------------------------------------------------
+echo "== Checking Makefile references install scripts that exist =="
+# Pull out every `*_install_*_pcks.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 '[_A-Za-z0-9/-]*install_[A-Za-z0-9_-]*\.sh' | sort -u)
+
+# ---------------------------------------------------------------------------
+# 6. 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 -oE 'stow [A-Za-z0-9_-]+' | awk '{print $2}' | sort -u)
+
+# ---------------------------------------------------------------------------
+# Summary
+# ---------------------------------------------------------------------------
+echo
+echo "=============================================="
+echo "lint.sh: $PASS passed, $FAIL failed"
+echo "=============================================="
+[[ $FAIL -eq 0 ]]
diff --git a/test/probe_stow.sh b/test/probe_stow.sh
new file mode 100755
index 0000000..3a8a00f
--- /dev/null
+++ b/test/probe_stow.sh
@@ -0,0 +1,79 @@
+#!/bin/bash
+# test/probe_stow.sh
+#
+# Verifies that each package the Makefile stows actually resolves and that
+# stow can create all symlinks into the target without conflicts.
+#
+# This is a DRY RUN: it uses `stow --simulate` and creates no real symlinks.
+# It catches bugs like `stow nvim` for a non-existent `nvim/` package.
+#
+# Usage:
+# bash test/probe_stow.sh # probe every stow-able package
+# bash test/probe_stow.sh ... # probe specific packages
+
+set -uo pipefail
+
+REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+cd "$REPO_ROOT"
+
+# A stow-able package is any tracked top-level directory that contains a
+# dot-prefixed config subtree (or a dot-prefixed top-level file/dir).
+tracked_basenames() {
+ git ls-files | awk -F/ 'NF>1 {print $1}' | sort -u
+}
+
+is_stowable() {
+ local pkg="$1"
+ [[ -d "$REPO_ROOT/$pkg" ]] || return 1
+ # stow needs at least one dot-prefixed entry at the package root
+ git ls-files "$pkg/" | grep -qE "^$pkg/\." || return 1
+}
+
+# Determine which packages to probe.
+PACKAGES=()
+if [[ $# -eq 0 ]]; then
+ while IFS= read -r b; do
+ [[ -z "$b" ]] && continue
+ is_stowable "$b" && PACKAGES+=("$b")
+ done < <(tracked_basenames)
+else
+ PACKAGES=("$@")
+fi
+
+if [[ ${#PACKAGES[@]} -eq 0 ]]; then
+ echo "probe_stow.sh: no stow-able packages found" >&2
+ exit 1
+fi
+
+echo "== Probing stow packages: ${PACKAGES[*]} =="
+
+FAIL=0
+for pkg in "${PACKAGES[@]}"; do
+ echo "--- stow --simulate: $pkg ---"
+ if stow --simulate --verbose=1 --dir="$REPO_ROOT" --target="$REPO_ROOT" "$pkg" >/dev/null 2>probe_err.txt; then
+ echo "ok: stow --simulate '$pkg' resolved without conflicts"
+ else
+ echo "FAIL: stow --simulate '$pkg'"
+ sed 's/^/ /' probe_err.txt
+ FAIL=$((FAIL+1))
+ fi
+ rm -f probe_err.txt
+done
+
+# Cross-check: does the Makefile stow anything that is NOT in our package set?
+echo "== Checking Makefile stow targets against stow-able packages =="
+make_pkgs="$(grep -P '^\t' Makefile | grep -oE 'stow [A-Za-z0-9_-]+' | awk '{print $2}' | sort -u)"
+for mp in $make_pkgs; do
+ if is_stowable "$mp"; then
+ echo "ok: Makefile 'stow $mp' matches a stow-able package"
+ else
+ echo "FAIL: Makefile 'stow $mp' does not match a stow-able package"
+ FAIL=$((FAIL+1))
+ fi
+done
+
+echo
+echo "=============================================="
+echo "probe_stow.sh: $(( ${#PACKAGES[@]} + $(printf '%s\n' $make_pkgs | grep -c . || true) )) checks, $FAIL failed"
+echo "=============================================="
+[[ $FAIL -eq 0 ]]
diff --git a/test/verify_packages.sh b/test/verify_packages.sh
new file mode 100755
index 0000000..307c3e7
--- /dev/null
+++ b/test/verify_packages.sh
@@ -0,0 +1,121 @@
+#!/bin/bash
+# test/verify_packages.sh
+#
+# Post-install verification of the packages the smoke test installs.
+# Given a package-list file (one package per line, `@section` markers
+# allowed - only the section content is verified), it checks that every
+# listed package is:
+# 1. installed in the pacman DB -> `pacman -Q `
+# 2. its expected binary exists and reports a version (curated mapping)
+#
+# Layer 2 runs a real `--version`-style probe for packages that install a
+# binary, so a broken/renamed executable is caught even if pacman reports
+# the package as installed. Fonts/libs/modules without a binary are verified
+# by Layer 1 only.
+#
+# Usage: bash test/verify_packages.sh
+
+set -uo pipefail
+
+PKGS_FILE="${1:?usage: verify_packages.sh }"
+[[ -f "$PKGS_FILE" ]] || { echo "verify_packages.sh: not a file: $PKGS_FILE" >&2; exit 1; }
+
+PASS=0
+FAIL=0
+say_ok() { printf ' ok: %s\n' "$*"; PASS=$((PASS+1)); }
+say_fail() { printf ' FAIL: %s\n' "$*"; FAIL=$((FAIL+1)); }
+
+# ---------------------------------------------------------------------------
+# Curated mapping: package -> "binary version_flag"
+# - packages not listed here are checked with pacman -Q only
+# - flag missing (empty) => existence check only
+# ---------------------------------------------------------------------------
+declare -A BINS=(
+ [fish]="fish --version"
+ [kitty]="kitty --version"
+ [neovim]="nvim --version"
+ [openssh]="ssh -V"
+ [rclone]="rclone --version"
+ [rsync]="rsync --version"
+ [unison]="unison -version"
+ [stow]="stow --version"
+ [nodejs]="node --version"
+ [npm]="npm --version"
+ [starship]="starship --version"
+ [fastfetch]="fastfetch --version"
+ [tree-sitter-cli]="tree-sitter --version"
+ [python]="python --version"
+ [python-pip]="pip3 --version"
+ [unzip]="unzip --version"
+ [selene]="selene --version"
+ [tmux]="tmux -V"
+ [yazi]="yazi --version"
+ [zoxide]="zoxide --version"
+ [fzf]="fzf --version"
+ [chromium]="chromium --version"
+ [locate]="locate --version"
+)
+
+# python-pip may expose `pip` instead of `pip3` on some setups; treat them
+# as interchangeable so the probe does not spuriously fail.
+probe_pip() {
+ for b in pip3 pip; do
+ if command -v "$b" >/dev/null 2>&1; then
+ out=$("$b" --version 2>&1) && [[ -n "$out" ]] && { say_ok "$b --version ($out)"; return 0; }
+ fi
+ done
+ say_fail "python-pip: neither pip3 nor pip found/probe failed"
+ return 1
+}
+
+echo "== Verifying installed packages from: $PKGS_FILE =="
+
+while IFS= read -r line || [[ -n "$line" ]]; do
+ line="${line%%#*}" # strip trailing comments
+ line="${line// /}" # drop inline whitespace
+ [[ -z "$line" ]] && continue
+ [[ "$line" == @* ]] && continue # skip @pacman/@aur/@npm section markers
+
+ pkg="${line#*/}" # strip repo prefix (extra/kitty -> kitty)
+ if [[ -z "$pkg" ]]; then
+ say_fail "empty package name in '$line'"
+ continue
+ fi
+
+ # Layer 1: installed in the pacman DB?
+ if pacman -Q "$pkg" >/dev/null 2>&1; then
+ say_ok "pacman -Q $pkg (installed)"
+ else
+ say_fail "pacman -Q $pkg (not installed)"
+ continue
+ fi
+
+ # Layer 2: runtime binary probe (curated mapping)?
+ spec="${BINS[$pkg]:-}"
+ [[ -z "$spec" ]] && continue # no binary (font/lib/module) - Layer 1 only
+
+ # python-pip has special multi-candidate handling
+ [[ "$pkg" == "python-pip" ]] && { probe_pip; continue; }
+
+ read -r bin flag <<< "$spec"
+ if command -v "$bin" >/dev/null 2>&1; then
+ if [[ -z "$flag" ]]; then
+ say_ok "command -v $bin (present)"
+ else
+ out=$("$bin" $flag 2>&1) && [[ -n "$out" ]]
+ if [[ $? -eq 0 ]]; then
+ say_ok "$bin $flag ($out)"
+ else
+ say_fail "$bin $flag (binary present but probe failed)"
+ fi
+ fi
+ else
+ say_fail "command -v $bin (expected from package '$pkg')"
+ fi
+done < "$PKGS_FILE"
+
+echo
+echo "----------------------------------------------"
+echo "verify_packages.sh: $PASS passed, $FAIL failed"
+echo "----------------------------------------------"
+[[ $FAIL -eq 0 ]]
\ No newline at end of file