diff --git a/.github/workflows/install.yml b/.github/workflows/install.yml index 660d76f..a96340a 100644 --- a/.github/workflows/install.yml +++ b/.github/workflows/install.yml @@ -455,6 +455,12 @@ jobs: # true before install.sh ran, so 15 green cells proved nothing. python3 tests/no-fixture-masking.py + - name: Assert every linked config belongs to a tool + run: | + # The tmux bug was linking config for a tool that was not installed. + # no-fixture-masking.py cannot see that class; this is the other half. + python3 tests/config-needs-tool.py + - name: Assert the gate requires every job run: | # A new job that nobody adds to `needs` above would sit OUTSIDE the gate diff --git a/AGENTS.md b/AGENTS.md index de56b6d..c54bbd8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -58,6 +58,9 @@ fallback and the unapplied-pins path - 28 cells total. list with a per-package retry, keep an optional tool's apt steps non-fatal (`|| { log_warn ...; return; }`), and let the `_install_*` GitHub fallbacks cover whatever apt cannot supply. +- **Do not configure what is not installed** (`tests/config-needs-tool.py` enforces + it: every linked config dir must declare `installer`, `guarded`, or `prerequisite` + with a reason, and an undeclared one fails). - **Do not configure what is not installed.** `install_zsh` and `install_tmux` both return early with an actionable warning when their binary is absent; a host that looks configured while nothing can use it is how the no-sudo tmux gap stayed diff --git a/CHANGELOG.md b/CHANGELOG.md index ab62e9e..6eaf341 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.13.3] - 2026-09-20 + +### Added +- `tests/config-needs-tool.py`, run by the CI gate: every config directory the install links must belong to a tool the repo installs, guards, or declares a prerequisite with a reason. An undeclared config dir fails, so nobody can ship config for an uninstalled tool silently. + ## [1.13.2] - 2026-09-19 ### Fixed diff --git a/VERSION b/VERSION index 61ce01b..01b7568 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.13.2 +1.13.3 diff --git a/tests/config-needs-tool.py b/tests/config-needs-tool.py new file mode 100755 index 0000000..addcffd --- /dev/null +++ b/tests/config-needs-tool.py @@ -0,0 +1,170 @@ +#!/usr/bin/env python3 +"""Every config this repo links must belong to a tool it installs, guards, or declares. + +The no-sudo tmux bug was not "tmux was missing". It was the repo LINKING tmux config +and cloning tmux plugins for a tmux that was not there, so a host looked configured +while nothing could use it. `tests/no-fixture-masking.py` cannot see that class - it +only knows tools that already have an installer - and its own output says so. This +is the check for the other half. + +For each config directory the install links, exactly one must hold: + + installer modules/ defines `_install_`, so the repo provides it + guarded the function that links it returns early unless `has ` + prerequisite declared here WITH a reason, for a tool the repo cannot provide + +The mapping is a TABLE, not a heuristic. A config directory's name does not reliably +name its tool (`ripgrep` -> `rg`), and whether something is a fair prerequisite is a +judgment call this script must not pretend to make. So an unlisted config directory +is a FAILURE that asks the author to declare intent, which is the property worth +having: nobody can add config for an uninstalled tool without saying so. + +Honest limit, printed in the verdict: "guarded" is verified by finding a `has ` +test inside the linking function. A guard written some other way reads as absent, and +a `has ` used for something else inside that function reads as present. +""" +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent + +# config dir -> (tool, policy, target, reason) +# +# `target` names the function to verify, because inferring it was wrong three ways +# on the first run: the nvim installer is `install_neovim` not `_install_nvim`, the +# ripgrep one is `_install_ripgrep` not `_install_rg`, and zsh's guard lives in +# `install_zsh` while the symlink is one call away in `_link_zshrc`. Declaring the +# name is honest; guessing it produced three false findings against correct code. +POLICY = { + "zsh": ("zsh", "guarded", "install_zsh", ""), + "tmux": ("tmux", "guarded", "install_tmux", ""), + "nvim": ("nvim", "installer", "install_neovim", ""), + "ripgrep": ("rg", "installer", "_install_ripgrep", ""), + "yazi": ("yazi", "installer", "_install_yazi", ""), + "git": ("git", "prerequisite", "", + "git is required to obtain this repo at all - get.sh clones it - so a " + "host without git cannot reach the code that would link its config"), + "x11": ("xcape", "prerequisite", "", + "opt-in desktop helper, never run by install.sh"), +} + +SH_FILES = sorted( + list((ROOT / "modules").glob("*.sh")) + [ROOT / "install.sh"] +) + + +def linked_config_dirs(): + """Top-level repo dirs whose files the install symlinks into the home dir.""" + dirs = set() + pat = re.compile(r'symlink\s+"\$\{DOTFILES_DIR\}/([^/"]+)/') + for f in SH_FILES: + for m in pat.finditer(f.read_text(encoding="utf-8")): + dirs.add(m.group(1)) + # nvim is linked from a variable, not a literal symlink call. + for f in SH_FILES: + if 'DOTFILES_DIR}/nvim/.config/nvim' in f.read_text(encoding="utf-8"): + dirs.add("nvim") + return dirs + + +def function_exists(name): + for f in SH_FILES: + if re.search(rf"^{re.escape(name)}\(\)", f.read_text(encoding="utf-8"), re.M): + return True + return False + + +def _functions(text): + """{name: body} for top-level `name() {` ... `}` blocks.""" + out, cur, depth, body = {}, None, 0, [] + for line in text.splitlines(): + if cur is None: + m = re.match(r"^([A-Za-z_][\w]*)\(\)\s*\{", line) + if m: + cur, depth, body = m.group(1), line.count("{") - line.count("}"), [line] + continue + body.append(line) + depth += line.count("{") - line.count("}") + if depth <= 0: + out[cur] = "\n".join(body) + cur = None + return out + + +def link_guarded(cfgdir, tool, guard_fn): + """True when `guard_fn` tests `has ` AND reaches the code that links it. + + One hop is resolved: install_zsh tests `has zsh` then calls _link_zshrc, which + holds the symlink. Deeper chains are not followed - stated as a limit rather + than silently approximated. + """ + pat = re.compile(rf'symlink\s+"\$\{{DOTFILES_DIR\}}/{re.escape(cfgdir)}/') + funcs = {} + for f in SH_FILES: + funcs.update(_functions(f.read_text(encoding="utf-8"))) + body = funcs.get(guard_fn) + if body is None: + return False, f"function {guard_fn}() not found" + if not re.search(rf"\bhas {re.escape(tool)}\b", body): + return False, f"{guard_fn}() does not test `has {tool}`" + if pat.search(body): + return True, "" + for callee, cbody in funcs.items(): + if re.search(rf"^\s*{re.escape(callee)}\s*$", body, re.M) and pat.search(cbody): + return True, "" + return False, f"{guard_fn}() guards, but nothing it calls links {cfgdir}/ config" + + +def main(): + dirs = linked_config_dirs() + print(f"config directories linked by the install: {', '.join(sorted(dirs))}") + + failures = [] + + undeclared = dirs - set(POLICY) + for d in sorted(undeclared): + failures.append( + f"config dir '{d}' is linked but not declared in POLICY - say whether the " + f"repo installs its tool, guards the link, or treats it as a prerequisite" + ) + + for cfgdir in sorted(dirs & set(POLICY)): + tool, policy, target, reason = POLICY[cfgdir] + if policy == "installer": + if not function_exists(target): + failures.append( + f"'{cfgdir}' claims policy 'installer' via {target}(), which does not exist" + ) + elif policy == "guarded": + ok, why = link_guarded(cfgdir, tool, target) + if not ok: + failures.append(f"'{cfgdir}' claims policy 'guarded': {why}") + elif policy == "prerequisite": + if not reason.strip(): + failures.append(f"'{cfgdir}' is declared a prerequisite with no reason") + else: + failures.append(f"'{cfgdir}' has unknown policy '{policy}'") + + print() + for cfgdir in sorted(POLICY): + tool, policy, _t, _r = POLICY[cfgdir] + mark = "linked" if cfgdir in dirs else "not linked" + print(f" {cfgdir:<9} tool={tool:<6} policy={policy:<12} ({mark})") + + print() + if failures: + for f in failures: + print(f" ERROR: {f}") + print() + print(f"RESULT: FAILED ({len(failures)} finding(s))") + return 1 + + print(f"RESULT: PASSED ({len(dirs)} linked config dir(s) checked, none skipped)") + print(" Limit: 'guarded' is verified by finding a `has ` test inside the") + print(" linking function; a guard written another way would read as absent.") + return 0 + + +if __name__ == "__main__": + sys.exit(main())