Gate home-relative paths against the ~ layout

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Krzysztof Rudnicki 2026-09-12 17:10:20 +02:00
parent 001670fcbc
commit b42737f834
7 changed files with 529 additions and 0 deletions

View File

@ -31,6 +31,18 @@ repos:
# rule that a TODO carries "REMOVE ME AFTER FINISH" and nothing else may. # 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 # 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. # 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/<name> 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 - id: md-naming
name: markdown naming convention name: markdown naming convention
entry: scripts/check_md_naming.sh entry: scripts/check_md_naming.sh

0
home_paths/__init__.py Normal file
View File

225
home_paths/check.py Normal file
View File

@ -0,0 +1,225 @@
#!/usr/bin/env python3
"""Fail when code builds a ``~/<name>`` 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
``<home>/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 <file> [<file> ...] # pre-commit passes changed files
check.py --all [<root>] # 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:]))

70
home_paths/patterns.py Normal file
View File

@ -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/<name>`` 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<seg>[A-Za-z0-9][\w+-]*(?:\.[\w+-]+)*)"
@dataclass(frozen=True)
class Pattern:
"""One way of spelling ``<home>/<segment>``."""
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

View File

View File

@ -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"]

42
scripts/check_home_paths.sh Executable file
View File

@ -0,0 +1,42 @@
#!/bin/bash
# ============================================================================
# Fail if code builds a ~/<name> path that ~ is not allowed to contain.
#
# The 2026-09-11 reorganisation left ~ with eleven visible entries. Its
# reference rewriter matched *literal* /home/kuhy/<name> 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 <file> [<file> ...] # pre-commit passes these
# scripts/check_home_paths.sh --all [<root>] # 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") <file>... | --all [<root>]" >&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 "$@"