"""Lint guard: a project file MUST NOT contain its own relative path from the repo root as a string literal anywhere in its content. Background ========== Hard-coded self-path references rot the moment the file is renamed or moved: a header comment like ``# utils/foo/bar.py`` at the top of a module, a docstring referring to ``utils/foo/bar.py``, or a docs entry linking ``utils/foo/bar.py`` from inside ``utils/foo/bar.py`` itself all silently desync from reality after the next ``git mv``. Cross-file references survive moves (the link target updates with the renamed file on the other side), but self-references survive nothing. Detection ========= For every file the project tracks (via :func:`utils.cache.files.iter_project_files`), the lint computes the file's POSIX-style relative path from ``PROJECT_ROOT`` and reports any line whose text contains that path as a substring. Scope and exemptions ==================== * Files at the repo root (relative path without any ``/`` separator) are exempt: a bare filename like ``Makefile`` or ``pyproject.toml`` appears in too many cross-references to flag meaningfully, and it has no directory part to drift away from. * The lint test itself is exempt — its docstring legitimately spells out the rule and the example shapes that fire. * Symlinks point elsewhere by definition; their text is the link target, not file-local content. * Per-line opt-out via ``# nocheck: self-path-reference`` (case- insensitive) on the offending line. Reserve the marker for files whose self-reference is load-bearing (e.g. a static site generator injects its own source path into the rendered output) and document the reason next to the marker. Caching ======= File reads route through :func:`utils.cache.files.read_text`, so the sweep adds no fresh disk I/O when other lints have already touched the same files in this ``make test`` run. """ from __future__ import annotations import unittest from pathlib import Path from utils.annotations.suppress import line_has_rule from utils.cache.files import PROJECT_ROOT, iter_project_files, read_text SUPPRESS_RULE = "self-path-reference" # docstring; flagging it would force a useless ``nocheck`` on every _EXEMPT_PATHS: frozenset[str] = frozenset( { "tests/lint/repository/test_no_self_path_reference.py", } ) def _scan_file(abs_path: Path, rel_str: str) -> list[str]: """Return ``rel_str:lineno: ...`` failure lines for *abs_path*. Empty list means the file is clean. Reads route through the project-wide cached text reader. """ try: text = read_text(str(abs_path)) except (OSError, UnicodeDecodeError): return [] failures: list[str] = [] for lineno, line in enumerate(text.splitlines(), start=1): if rel_str not in line: continue if line_has_rule(line, SUPPRESS_RULE): continue failures.append(f"{rel_str}:{lineno}: contains its own path as a string") return failures class TestNoSelfPathReference(unittest.TestCase): def test_no_self_path_reference(self) -> None: project_root = Path(str(PROJECT_ROOT)) offenders: list[str] = [] for path_str in iter_project_files(): abs_path = Path(path_str) if abs_path.is_symlink(): continue try: rel = abs_path.relative_to(project_root) except ValueError: continue rel_str = rel.as_posix() if "/" not in rel_str: continue if rel_str in _EXEMPT_PATHS: continue if rel.parts and rel.parts[0].endswith(".egg-info"): continue offenders.extend(_scan_file(abs_path, rel_str)) if offenders: self.fail( f"{len(offenders)} self-path reference(s) found " "(file contains its own relative path as a string; " "remove the literal or mark with " "'# nocheck: self-path-reference'):\n" + "\n".join(f" - {o}" for o in offenders) ) if __name__ == "__main__": # pragma: no cover unittest.main()