From b42737f834391a2f82e571546b0641a3bac5595a Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sat, 12 Sep 2026 17:10:20 +0200 Subject: [PATCH] Gate home-relative paths against the ~ layout Co-Authored-By: Claude Opus 5 (1M context) --- .pre-commit-config.yaml | 12 ++ home_paths/__init__.py | 0 home_paths/check.py | 225 +++++++++++++++++++++++++++++++++ home_paths/patterns.py | 70 ++++++++++ home_paths/tests/__init__.py | 0 home_paths/tests/test_check.py | 180 ++++++++++++++++++++++++++ scripts/check_home_paths.sh | 42 ++++++ 7 files changed, 529 insertions(+) create mode 100644 home_paths/__init__.py create mode 100644 home_paths/check.py create mode 100644 home_paths/patterns.py create mode 100644 home_paths/tests/__init__.py create mode 100644 home_paths/tests/test_check.py create mode 100755 scripts/check_home_paths.sh diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6de0185..f3ad392 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -31,6 +31,18 @@ repos: # rule that a TODO carries "REMOVE ME AFTER FINISH" and nothing else may. # Shared gate in ~/src/utils owns the namespaces and the exemption list, so # one repo's idea of a valid name cannot drift from every other repo's. + # Since the 2026-09-11 reorganisation ~ holds eleven visible entries and + # home-tidy sweeps anything else to inbox/ within the hour. The migration's + # rewriter matched *literal* /home/kuhy/ strings, so paths assembled + # at run time survived it -- the todo wrapper exported to a dead ~/todo for + # a day while its MCP read ~/src/todo. This gate matches the shape, so it + # stays correct as repos come and go. + - id: home-path-layout + name: home-relative paths stay inside the ~ layout + entry: bash scripts/check_home_paths.sh + language: system + pass_filenames: true + - id: md-naming name: markdown naming convention entry: scripts/check_md_naming.sh diff --git a/home_paths/__init__.py b/home_paths/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/home_paths/check.py b/home_paths/check.py new file mode 100644 index 0000000..3746558 --- /dev/null +++ b/home_paths/check.py @@ -0,0 +1,225 @@ +#!/usr/bin/env python3 +"""Fail when code builds a ``~/`` path that ``~`` may not contain. + +Since the 2026-09-11 reorganisation, ``~`` holds exactly eleven visible +entries (``src``, ``vendor``, ``services``, ``data``, ``media``, ``games``, +``sdk``, ``archive``, ``inbox``, ``Downloads``, ``Documents``). Anything else +is swept to ``inbox/`` by home-tidy. So a program that assembles +``/todo`` is writing to a path that either does not exist or is about +to be moved out from under it -- no allowlist of *old* names required, which +is what makes this gate durable as repos come and go. + +Exit codes: 0 clean, 1 findings, 2 bad usage. + +Usage: + check.py [ ...] # pre-commit passes changed files + check.py --all [] # whole tree, default cwd +""" + +from __future__ import annotations + +import sys +from dataclasses import dataclass +from pathlib import Path + +from home_paths.patterns import first_segments + +# The eleven names ~ may hold, kept in step with system-maintenance's +# home-tidy.toml [root] allow. Dotfiles are never home-tidy's business, and +# a bare `~` with no segment resolves to home itself. +ALLOWED = frozenset( + { + "src", + "vendor", + "services", + "data", + "media", + "games", + "sdk", + "archive", + "inbox", + "Downloads", + "Documents", + } +) + +# Segments that are not really directories under kuhy's ~: filesystem roots, +# and the macOS/Windows home layouts that cross-platform code legitimately +# builds. Flagging `~/Library` in a mac branch is noise, not a finding. +IGNORED_SEGMENTS = frozenset( + { + "root", + "usr", + "etc", + "opt", + "tmp", + "var", + "home", + "Library", + "AppData", + "Applications", + "Developer", + } +) + +# Third-party trees. Their layout is not ours to police, and `~/Unity` in a +# vendored Unity MCP server is correct for the machine it was written for. +THIRD_PARTY_PARTS = frozenset({"mcp-servers", "vendor", "node_modules", "third_party", "vendored"}) + +# Test corpora spell pre-move paths on purpose -- system-maintenance's own +# manifest already protects its fixtures under `refs.skip_paths`, for the +# same reason: rewriting them turned six assertions into tautologies once. +TEST_PARTS = frozenset({"tests", "test", "testdata", "fixtures"}) + +# Leading comment markers. The migration's literal rewriter already handled +# prose; what survived it was *code*, so a path named in a comment is at +# worst doc rot and would otherwise swamp the gate (it was 60% of the first +# run's output, every one of them a sentence rather than a path). +COMMENT_PREFIXES = ("#", "//", "*", "--", "///", "/*") + +CODE_SUFFIXES = frozenset( + { + ".py", + ".dart", + ".sh", + ".bash", + ".zsh", + ".ts", + ".tsx", + ".js", + ".jsx", + ".go", + ".rs", + ".toml", + ".yaml", + ".yml", + } +) + +PRUNE_DIRS = frozenset( + { + ".git", + "node_modules", + "__pycache__", + ".venv", + "venv", + ".ci-mirror-venv", + "build", + ".dart_tool", + "dist", + ".mypy_cache", + ".pytest_cache", + "target", + } +) + +# A line carrying this marker is a deliberate reference to the old layout +# (a migration note, this checker's own tests) and is not a finding. +ALLOW_MARKER = "home-paths: allow" + + +@dataclass(frozen=True) +class Finding: + """One home-relative path whose first segment cannot be right.""" + + path: Path + line_no: int + segment: str + pattern: str + text: str + + def render(self, root: Path | None = None) -> str: + """One grep-style line naming the file, the segment and the fix.""" + where = self.path + if root is not None: + try: + where = self.path.relative_to(root) + except ValueError: + pass + return ( + f"{where}:{self.line_no}: builds ~/{self.segment} — " + f"~ holds only {', '.join(sorted(ALLOWED))}. " + f"Did you mean ~/src/{self.segment}? [{self.pattern}]\n" + f" {self.text.strip()}" + ) + + +def scan_text(path: Path, text: str) -> list[Finding]: + """Every finding in ``text``, attributed to ``path``.""" + out: list[Finding] = [] + for line_no, line in enumerate(text.splitlines(), start=1): + if ALLOW_MARKER in line or line.lstrip().startswith(COMMENT_PREFIXES): + continue + seen: set[str] = set() + for pattern, segment in first_segments(line): + if segment in ALLOWED or segment in IGNORED_SEGMENTS: + continue + # Several spellings overlap -- `${HOME}/x` is both an `interp` + # and an `fstring` -- and one path deserves one finding. + if segment in seen: + continue + seen.add(segment) + # `~/x`, `~/y` -- a one-character segment is a documentation + # placeholder (docstrings explaining the path grammar), never a + # real directory in this layout. + if len(segment) == 1: + continue + out.append(Finding(path, line_no, segment, pattern, line)) + return out + + +def scan_file(path: Path) -> list[Finding]: + """Findings in one file; unreadable or binary files yield none.""" + if path.suffix not in CODE_SUFFIXES or not path.is_file(): + return [] + if any(part in THIRD_PARTY_PARTS or part in TEST_PARTS for part in path.parts): + return [] + if path.name.startswith("test_") or path.stem.endswith("_test"): + return [] + try: + text = path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + return [] + return scan_text(path, text) + + +def walk(root: Path) -> list[Path]: + """Every candidate file under ``root``, pruned of caches and build output.""" + out: list[Path] = [] + for path in sorted(root.rglob("*")): + if any(part in PRUNE_DIRS or part in THIRD_PARTY_PARTS for part in path.parts): + continue + if any(part in TEST_PARTS for part in path.parts): + continue + if path.name.startswith("test_") or path.stem.endswith("_test"): + continue + if path.suffix in CODE_SUFFIXES and path.is_file(): + out.append(path) + return out + + +def main(argv: list[str]) -> int: + """CLI: exit 1 on findings, 2 on bad usage.""" + if not argv: + print(__doc__, file=sys.stderr) + return 2 + if argv[0] == "--all": + root = Path(argv[1]).resolve() if len(argv) > 1 else Path.cwd() + targets, base = walk(root), root + else: + targets, base = [Path(a) for a in argv], None + + findings = [f for target in targets for f in scan_file(target)] + for finding in findings: + print(finding.render(base)) + if findings: + print( + f"\n{len(findings)} home-relative path(s) point outside the ~ layout.", + file=sys.stderr, + ) + return 1 + return 0 + + +if __name__ == "__main__": # pragma: no cover - CLI entry + sys.exit(main(sys.argv[1:])) diff --git a/home_paths/patterns.py b/home_paths/patterns.py new file mode 100644 index 0000000..59c4357 --- /dev/null +++ b/home_paths/patterns.py @@ -0,0 +1,70 @@ +"""Recognising a home-relative path, however it is spelled. + +The 2026-09-11 ``~`` reorganisation rewrote references by matching *literal* +``/home/kuhy/`` strings. Every path assembled at run time from +segments was invisible to it, which is how the todo desktop wrapper went on +exporting to a dead ``~/todo`` for a day while its MCP read ``~/src/todo``. + +So this module does not look for the old paths. It looks for the *shape* -- +"home, then a first segment" -- and lets :mod:`check` judge the segment +against the eleven names ``~`` is allowed to contain. A construction that +resolves to any other first segment cannot be correct, whether or not it +happens to name a directory that moved. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass + +# Identifiers that plausibly hold the home directory. Deliberately narrow: +# `root`, `base` and `dir` hold anything at all and would swamp the gate. +HOME_VARS = r"(?:home|homeDir|home_dir|HOME|userHome|user_home|homedir)" + +# A first path segment: visible (a leading dot is a dotfile, never our +# business) and not a variable/placeholder, which we cannot resolve. +# Must not end on a dot: `~/utils.` in English prose is a sentence, not a path. +SEGMENT = r"(?P[A-Za-z0-9][\w+-]*(?:\.[\w+-]+)*)" + + +@dataclass(frozen=True) +class Pattern: + """One way of spelling ``/``.""" + + name: str + regex: re.Pattern[str] + + +def _p(name: str, pattern: str) -> Pattern: + return Pattern(name, re.compile(pattern)) + + +PATTERNS: tuple[Pattern, ...] = ( + # Python: Path.home() / "seg" and Path.home().joinpath("seg") + _p("py-pathlib", rf"Path\.home\(\)\s*(?:/|\.joinpath\()\s*[\"']{SEGMENT}"), + # Python: os.path.join(os.environ["HOME"], "seg") / expanduser("~/seg") + _p( + "py-environ", + rf"os\.environ(?:\.get)?[\[(]\s*[\"']HOME[\"']\s*[\])](?:\s*,\s*[\"']|\s*\+\s*[\"']/?){SEGMENT}", + ), + _p("py-expanduser", rf"expanduser\(\s*[\"']~/{SEGMENT}"), + # Dart/JS/Go: join(home, 'seg', ...) -- p.join, path.join, filepath.Join + _p( + "join-call", + rf"\b(?:[pP]\.)?(?:path\.|filepath\.)?[jJ]oin\(\s*{HOME_VARS}\s*,\s*[\"']{SEGMENT}", + ), + # JS/TS: path.join(os.homedir(), 'seg') + _p("js-homedir", rf"homedir\(\)\s*,\s*[\"']{SEGMENT}"), + # Interpolation: "$home/seg", "${home}/seg", f"{home}/seg" + _p("interp", rf"[\"'`]?\$\{{?{HOME_VARS}\}}?/{SEGMENT}"), + _p("fstring", rf"\{{\s*{HOME_VARS}\s*\}}/{SEGMENT}"), +) + + +def first_segments(line: str) -> list[tuple[str, str]]: + """Every ``(pattern_name, first_segment)`` this line constructs.""" + out: list[tuple[str, str]] = [] + for pattern in PATTERNS: + for match in pattern.regex.finditer(line): + out.append((pattern.name, match.group("seg"))) + return out diff --git a/home_paths/tests/__init__.py b/home_paths/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/home_paths/tests/test_check.py b/home_paths/tests/test_check.py new file mode 100644 index 0000000..aeb3ecb --- /dev/null +++ b/home_paths/tests/test_check.py @@ -0,0 +1,180 @@ +"""The ~-layout gate: what it must catch, and what it must not. + +The "must not" half carries the weight. A gate that fires on prose is a gate +that gets disabled: the first run of this checker returned 189 findings +across ~/src, and every single one outside `todo_desktop.dart` was either a +comment, a macOS branch, vendored third-party code, or a deliberate test +fixture. Each exclusion below is one of those, pinned so it cannot creep +back in. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from home_paths.check import ALLOW_MARKER, main, scan_file, scan_text + + +def _segments(text: str, name: str = "sample.py") -> list[str]: + return [f.segment for f in scan_text(Path(name), text)] + + +# --- the bug this gate exists for ------------------------------------------- + + +def test_catches_the_todo_desktop_regression() -> None: + """The exact line that served a stale backlog for a day.""" + line = "backlogPath: _argValue(args, '--backlog-path') ?? p.join(home, 'todo', 'BACKLOG.md')," + assert _segments(line, "todo_desktop.dart") == ["todo"] + + +def test_passes_the_fixed_form() -> None: + assert _segments("p.join(home, 'src', 'todo', 'BACKLOG.md')", "x.dart") == [] + + +@pytest.mark.parametrize( + ("line", "segment"), + [ + ('Path.home() / "kuhylog"', "kuhylog"), + ('Path.home().joinpath("kuhylog")', "kuhylog"), + ("os.path.join(os.environ['HOME'], 'utils')", "utils"), + ('expanduser("~/screen-locker")', "screen-locker"), + ('filepath.Join(home, "octoforge")', "octoforge"), + ("path.join(os.homedir(), 'signal-bot')", "signal-bot"), + ('final dir = Directory("$home/todo");', "todo"), + ('BACKUP="${HOME}/phone_backups"', "phone_backups"), + ('f"{home}/scanlation-eval"', "scanlation-eval"), + ], +) +def test_every_construction_spelling(line: str, segment: str) -> None: + """The rewriter's blind spot was *how* the path was spelled, not which.""" + assert _segments(line) == [segment] + + +@pytest.mark.parametrize( + "bucket", ["src", "vendor", "data", "sdk", "media", "Downloads", "Documents"] +) +def test_allowed_buckets_pass(bucket: str) -> None: + assert _segments(f'Path.home() / "{bucket}" / "thing"') == [] + + +def test_dotfiles_are_not_our_business() -> None: + assert _segments('Path.home() / ".config" / "todo"') == [] + assert _segments("p.join(home, '.local', 'share', 'todo')", "a.dart") == [] + + +# --- the noise it must not make --------------------------------------------- + + +def test_comments_are_not_findings() -> None: + """60% of the first run. `~/utils.` in English is a sentence.""" + assert ( + _segments("# Thin delegate to the shared gate in $HOME/utils, which owns it") + == [] + ) + assert _segments("// see ${HOME}/todo for the note format", "a.dart") == [] + + +def test_placeholder_segments_are_not_findings() -> None: + """Docstrings explaining the path grammar say ``$HOME/x``.""" + assert ( + _segments('"""One regex covers ``$HOME/x``, ``${HOME}/x`` and more."""') == [] + ) + + +def test_other_os_home_layouts_are_not_findings() -> None: + assert _segments('Path.home() / "Library" / "Logs"') == [] + assert _segments('Path.home() / "AppData" / "Roaming"') == [] + + +def test_third_party_and_test_trees_are_skipped(tmp_path: Path) -> None: + bad = "p.join(home, 'todo', 'x')" + for rel in ( + "mcp-servers/a.py", + "vendor/b.py", + "repo/tests/c.py", + "repo/test_d.py", + "repo/e_test.py", + ): + target = tmp_path / rel + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(bad) + assert scan_file(target) == [], rel + plain = tmp_path / "repo" / "real.py" + plain.write_text(bad) + assert [f.segment for f in scan_file(plain)] == ["todo"] + + +def test_allow_marker_is_an_escape_hatch() -> None: + assert _segments(f"x = home + '/todo' # {ALLOW_MARKER}: migration note") == [] + + +def test_non_code_and_unreadable_files_yield_nothing(tmp_path: Path) -> None: + (tmp_path / "notes.md").write_text("p.join(home, 'todo')") + assert scan_file(tmp_path / "notes.md") == [] + assert scan_file(tmp_path / "missing.py") == [] + binary = tmp_path / "blob.py" + binary.write_bytes(b"\xff\xfe p.join(home, 'todo')") + assert scan_file(binary) == [] + + +# --- CLI --------------------------------------------------------------------- + + +def test_cli_exit_codes(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + clean = tmp_path / "ok.py" + clean.write_text('Path.home() / "src" / "todo"') + dirty = tmp_path / "bad.py" + dirty.write_text('Path.home() / "todo"') + + assert main([]) == 2 + assert main([str(clean)]) == 0 + assert main([str(dirty)]) == 1 + out = capsys.readouterr().out + assert "builds ~/todo" in out and "Did you mean ~/src/todo?" in out + + assert main(["--all", str(tmp_path)]) == 1 + clean_dir = tmp_path / "clean" + clean_dir.mkdir() + (clean_dir / "f.py").write_text("x = 1") + assert main(["--all", str(clean_dir)]) == 0 + + +def test_render_falls_back_to_absolute_path_outside_root(tmp_path: Path) -> None: + dirty = tmp_path / "bad.py" + dirty.write_text('Path.home() / "todo"') + (finding,) = scan_file(dirty) + assert finding.render(Path("/nowhere")).startswith(str(dirty)) + assert finding.render(tmp_path).startswith("bad.py:") + + +def test_walk_prunes_caches_third_party_and_tests(tmp_path: Path) -> None: + """`--all` must not descend into build output or vendored trees. + + Without this the gate reports on code nobody here owns, which is how the + first run produced 18 findings about macOS `~/Library` inside a vendored + Unity MCP server. + """ + bad = 'Path.home() / "todo"' + for rel in ( + "build/out.py", + "node_modules/pkg/index.js", + ".venv/lib/thing.py", + "vendor/clone/x.py", + "pkg/tests/t.py", + "pkg/test_helper.py", + "pkg/thing_test.py", + "notes.md", + ): + target = tmp_path / rel + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(bad) + real = tmp_path / "pkg" / "real.py" + real.write_text(bad) + + assert main(["--all", str(tmp_path)]) == 1 + from home_paths.check import walk + + assert [p.name for p in walk(tmp_path)] == ["real.py"] diff --git a/scripts/check_home_paths.sh b/scripts/check_home_paths.sh new file mode 100755 index 0000000..62f0f9c --- /dev/null +++ b/scripts/check_home_paths.sh @@ -0,0 +1,42 @@ +#!/bin/bash + +# ============================================================================ +# Fail if code builds a ~/ path that ~ is not allowed to contain. +# +# The 2026-09-11 reorganisation left ~ with eleven visible entries. Its +# reference rewriter matched *literal* /home/kuhy/ strings and a few +# uppercase forms, so lowercase segment-joined paths survived untouched -- +# the todo desktop wrapper kept exporting to a dead ~/todo for a day while +# its MCP read ~/src/todo, and every backlog read in between came back +# stale. This gate matches the shape, not a list of old names, so it stays +# correct as repos come and go. +# +# The real check lives in Python (home_paths/check.py) so the gate and any +# survey share one rule set -- see rules/shell.instructions.md on never +# embedding another language's logic inline. +# +# Usage: +# scripts/check_home_paths.sh [ ...] # pre-commit passes these +# scripts/check_home_paths.sh --all [] # whole tree, default cwd +# ============================================================================ + +set -euo pipefail + +readonly UTILS_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +readonly CHECKER="$UTILS_ROOT/home_paths/check.py" + +main() { + if [[ $# -eq 0 ]]; then + echo "Usage: $(basename "$0") ... | --all []" >&2 + exit 1 + fi + if [[ ! -f "$CHECKER" ]]; then + echo "Error: checker not found at $CHECKER" >&2 + exit 1 + fi + + PYTHONPATH="$UTILS_ROOT${PYTHONPATH:+:$PYTHONPATH}" \ + python3 "$CHECKER" "$@" +} + +main "$@"