From 879265b966b92d450a7881c5c7d23473feaa1887 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sat, 12 Sep 2026 18:53:19 +0200 Subject: [PATCH] file_length: repo-local .file-length-exempt list, fail-closed, package tested to 100% A fork of a third-party project can carry data that is code only by extension (TachiyomiSY's 6000-line exh/eh/tags/*.kt string tables). The reason belongs in that repo next to the data, so the gate now reads a .file-length-exempt of ' # ' lines from its cwd. A glob without a reason is a suppression and exits 2 instead of passing. The package had no tests at all; it now has a suite at 100% line+branch, wired into shared-gates-tests.yml with --cov-fail-under=100. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Yc4TiPjhbQLfH35QBPfrbP --- .github/workflows/shared-gates-tests.yml | 10 ++ file_length/check.py | 15 ++- file_length/repo_exempt.py | 81 ++++++++++++ file_length/tests/__init__.py | 0 file_length/tests/conftest.py | 40 ++++++ file_length/tests/test_check.py | 151 +++++++++++++++++++++++ file_length/tests/test_exemptions.py | 63 ++++++++++ file_length/tests/test_repo_exempt.py | 66 ++++++++++ 8 files changed, 425 insertions(+), 1 deletion(-) create mode 100644 file_length/repo_exempt.py create mode 100644 file_length/tests/__init__.py create mode 100644 file_length/tests/conftest.py create mode 100644 file_length/tests/test_check.py create mode 100644 file_length/tests/test_exemptions.py create mode 100644 file_length/tests/test_repo_exempt.py diff --git a/.github/workflows/shared-gates-tests.yml b/.github/workflows/shared-gates-tests.yml index 578eb8f..f68330d 100644 --- a/.github/workflows/shared-gates-tests.yml +++ b/.github/workflows/shared-gates-tests.yml @@ -38,3 +38,13 @@ jobs: - name: md_naming run: PYTHONPATH=. python -m pytest md_naming/tests -q + + # Whole package at 100% branch: the gate is enforced in every repo and + # now reads a per-repo `.file-length-exempt`, whose fail-closed rule is + # exactly the kind of branch a partial suite would leave green by accident. + - name: file_length — 100% branch coverage on the gate itself + run: | + PYTHONPATH=. python -m pytest file_length/tests -q \ + --cov=file_length --cov-branch \ + --cov-report=term-missing \ + --cov-fail-under=100 diff --git a/file_length/check.py b/file_length/check.py index 2cc077a..38a96af 100644 --- a/file_length/check.py +++ b/file_length/check.py @@ -11,9 +11,9 @@ from __future__ import annotations import argparse import os -from pathlib import Path import subprocess import sys +from pathlib import Path if __package__ in (None, ""): # invoked as a plain script sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) @@ -27,6 +27,7 @@ from file_length.config import ( is_session_artifact, is_vendored, ) +from file_length.repo_exempt import ExemptFileError, load, repo_exempt_reason def count_lines(path: Path) -> int | None: @@ -68,6 +69,9 @@ def exempt_reason(path: Path, lines: int) -> str | None: return "not a capped extension" if is_vendored(absolutize(path)): return "vendored / third-party" + repo_reason = repo_exempt_reason(absolutize(path), Path.cwd()) + if repo_reason is not None: + return repo_reason if is_session_artifact(absolutize(path)): return "frozen session artifact" if is_generated(path): @@ -137,6 +141,15 @@ def main() -> int: ) args = parser.parse_args() + # Read the repo's own exemption list once, up front: a malformed entry is + # a suppression the gate refuses to guess about, so it is exit 2, not a + # silent pass on the file it would have covered. + try: + load(Path.cwd()) + except ExemptFileError as exc: + print(f"File-length gate ERROR: {exc}", file=sys.stderr) + return 2 + if args.all: targets = list(iter_all(Path.cwd())) elif args.paths: diff --git a/file_length/repo_exempt.py b/file_length/repo_exempt.py new file mode 100644 index 0000000..d6195a5 --- /dev/null +++ b/file_length/repo_exempt.py @@ -0,0 +1,81 @@ +"""Repo-local exemptions from the 250-line cap: a `.file-length-exempt` file. + +The shared tables in :mod:`file_length._tables` hold exemptions that are true +everywhere (generated, vendored, wordlists). A fork of a third-party project +can also carry data that is code only by extension -- TachiyomiSY's +``exh/eh/tags/*.kt`` are 6000-line string tables that upstream regenerates -- +and that knowledge belongs next to the data, in the repo, not in a shared +list every other repo loads. + +Format, one entry per line, glob relative to the repo root:: + + app/src/main/java/exh/eh/tags/*.kt # tag data tables mirrored from upstream + +The reason is mandatory. An entry without one is a suppression, so the gate +fails closed on it (exit 2) instead of honouring it silently. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from functools import cache +from pathlib import Path, PurePosixPath + +#: Basename of the per-repo exemption list, looked up in the gate's cwd. +EXEMPT_FILE = ".file-length-exempt" + + +class ExemptFileError(ValueError): + """`.file-length-exempt` is malformed; the gate must not guess.""" + + +@dataclass(frozen=True) +class Exemption: + """One glob plus the human reason it is exempt.""" + + pattern: str + reason: str + + +def parse(text: str) -> list[Exemption]: + """Parse the file body; every non-comment entry must carry a reason.""" + entries: list[Exemption] = [] + for number, raw in enumerate(text.splitlines(), start=1): + line = raw.strip() + if not line or line.startswith("#"): + continue + pattern, sep, reason = line.partition("#") + pattern, reason = pattern.strip(), reason.strip() + if not sep or not reason or not pattern: + msg = f"{EXEMPT_FILE}:{number}: every entry is ' # '" + raise ExemptFileError(msg) + entries.append(Exemption(pattern, reason)) + return entries + + +@cache +def load(root: Path) -> tuple[Exemption, ...]: + """The exemptions for the repo at `root`, empty when it has no file.""" + path = root / EXEMPT_FILE + if not path.is_file(): + return () + return tuple(parse(path.read_text(encoding="utf-8"))) + + +def repo_exempt_reason(path: Path, root: Path) -> str | None: + """The repo-local reason `path` is exempt, or None if no entry matches. + + `path` must be absolute (see `check.absolutize`); anything outside `root` + is not the repo's to exempt and gets None. + """ + entries = load(root) + if not entries: + return None + try: + relative = PurePosixPath(path.relative_to(root).as_posix()) + except ValueError: + return None + for entry in entries: + if relative.full_match(entry.pattern): + return f"repo exemption: {entry.reason}" + return None diff --git a/file_length/tests/__init__.py b/file_length/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/file_length/tests/conftest.py b/file_length/tests/conftest.py new file mode 100644 index 0000000..75720de --- /dev/null +++ b/file_length/tests/conftest.py @@ -0,0 +1,40 @@ +"""Shared fixtures for the file_length tests.""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + + +@pytest.fixture(autouse=True) +def _fresh_exempt_cache() -> None: + """`load` is memoised per root; tests rewrite the file, so drop it.""" + from file_length import repo_exempt + + repo_exempt.load.cache_clear() + + +@pytest.fixture +def repo(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """A throwaway git repo that is also the cwd, as the gate expects.""" + subprocess.run(["git", "init", "-q", str(tmp_path)], check=True) + monkeypatch.chdir(tmp_path) + return tmp_path + + +def write(root: Path, relative: str, body: str) -> Path: + """Create `relative` under `root`, parents included, and return it.""" + path = root / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(body, encoding="utf-8") + return path + + +def lines(count: int) -> str: + """`count` numbered lines, newline-terminated.""" + return "".join(f"line {i}\n" for i in range(count)) diff --git a/file_length/tests/test_check.py b/file_length/tests/test_check.py new file mode 100644 index 0000000..97c7d0e --- /dev/null +++ b/file_length/tests/test_check.py @@ -0,0 +1,151 @@ +"""The gate entry point: counting, skipping, git-ignore, and exit codes.""" + +from __future__ import annotations + +import runpy +import subprocess +import sys +from pathlib import Path + +import pytest + +from file_length import check +from file_length.repo_exempt import EXEMPT_FILE +from file_length.tests.conftest import lines, write + + +def run_main(monkeypatch: pytest.MonkeyPatch, *argv: str) -> int: + monkeypatch.setattr(sys, "argv", ["check.py", *argv]) + return check.main() + + +def test_count_lines_variants(tmp_path: Path) -> None: + assert check.count_lines(write(tmp_path, "a.py", lines(3))) == 3 + assert check.count_lines(write(tmp_path, "b.py", "no newline")) == 1 + assert check.count_lines(write(tmp_path, "c.py", "")) == 0 + binary = tmp_path / "d.py" + binary.write_bytes(b"ab\x00cd\n") + assert check.count_lines(binary) is None + assert check.count_lines(tmp_path / "missing.py") is None + + +def test_count_lines_spans_chunks(tmp_path: Path) -> None: + big = tmp_path / "big.py" + big.write_bytes(b"x\n" * (1 << 20)) + assert check.count_lines(big) == 1 << 20 + + +def test_absolutize(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.chdir(tmp_path) + assert check.absolutize(Path("a.py")) == tmp_path / "a.py" + assert check.absolutize(tmp_path / "b.py") == tmp_path / "b.py" + + +def test_exempt_reasons(repo: Path, monkeypatch: pytest.MonkeyPatch) -> None: + assert check.exempt_reason(Path("x.html"), 1) == "not a capped extension" + assert check.exempt_reason(Path("node_modules/x.js"), 1) == "vendored / third-party" + write(repo, EXEMPT_FILE, "tags/*.kt # tag tables\n") + assert check.exempt_reason(Path("tags/A.kt"), 1) == "repo exemption: tag tables" + art = Path("docs/superpowers/plans/p.md") + assert check.exempt_reason(art, 1) == "frozen session artifact" + assert check.exempt_reason(Path("m.g.dart"), 1) == "generated" + words = write(repo, "w.txt", "a\n" * 10) + assert check.exempt_reason(words, 10) == "data-ish text (wordlist)" + code = write(repo, "c.py", lines(3)) + assert check.exempt_reason(code, 3) is None + + def boom(_self: Path) -> None: + raise OSError("gone") + + monkeypatch.setattr(Path, "stat", boom) + assert check.exempt_reason(Path("c.py"), 3) == "unreadable" + + +def test_git_ignored(repo: Path, monkeypatch: pytest.MonkeyPatch) -> None: + write(repo, ".gitignore", "*.log\n") + kept = write(repo, "a.py", "x\n") + dropped = write(repo, "b.log", "x\n") + assert check.git_ignored([], repo) == set() + assert check.git_ignored([kept, dropped], repo) == {dropped} + assert check.git_ignored([kept], repo.parent / "not-a-repo-xyz") == set() + + def no_git(*_a: object, **_k: object) -> None: + raise OSError("no git") + + monkeypatch.setattr(subprocess, "run", no_git) + assert check.git_ignored([kept], repo) == set() + + +def test_iter_all_skips_excluded_ignored_and_symlinks(repo: Path) -> None: + write(repo, ".gitignore", "out/\n") + keep = write(repo, "src/a.py", "x\n") + write(repo, "node_modules/b.py", "x\n") + write(repo, "out/c.py", "x\n") + (repo / "link.py").symlink_to(keep) + found = set(check.iter_all(repo)) + assert keep in found + assert not any(p.name in {"b.py", "c.py", "link.py"} for p in found) + + +def test_main_requires_paths_or_all( + repo: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + with pytest.raises(SystemExit) as exc: + run_main(monkeypatch) + assert exc.value.code == 2 + + +def test_main_all_reports_violations_sorted( + repo: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + write(repo, "small.py", lines(10)) + write(repo, "mid.py", lines(260)) + write(repo, "huge.py", lines(400)) + write(repo, "skip.g.dart", lines(400)) + assert run_main(monkeypatch, "--all", "--explain") == 1 + out, err = capsys.readouterr() + assert "skip" in out and "generated" in out + assert err.index("huge.py") < err.index("mid.py") + assert "small.py" not in err + + +def test_main_paths_honour_gitignore_and_missing( + repo: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + write(repo, ".gitignore", "*.log\n") + write(repo, "a.log", lines(300)) + write(repo, "b.py", lines(300)) + assert run_main(monkeypatch, "a.log", "missing.py") == 0 + assert run_main(monkeypatch, "b.py") == 1 + + +def test_main_skips_binary_and_honours_repo_exemption( + repo: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + (repo / "blob.py").write_bytes(b"\x00" * 10) + write(repo, "tags/Big.kt", lines(6000)) + write(repo, EXEMPT_FILE, "tags/*.kt # tag data tables\n") + assert run_main(monkeypatch, "--all") == 0 + + +def test_main_fails_closed_on_malformed_exempt_file( + repo: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + write(repo, EXEMPT_FILE, "tags/*.kt\n") + assert run_main(monkeypatch, "--all") == 2 + assert "File-length gate ERROR" in capsys.readouterr().err + + +def test_main_tolerates_a_file_vanishing_mid_run( + repo: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(check, "iter_all", lambda _root: [repo / "gone.py"]) + assert run_main(monkeypatch, "--all") == 0 + + +def test_runs_as_a_plain_script(repo: Path, monkeypatch: pytest.MonkeyPatch) -> None: + write(repo, "ok.py", lines(3)) + monkeypatch.setattr(sys, "argv", ["check.py", "--all"]) + with pytest.raises(SystemExit) as exc: + runpy.run_path(str(Path(check.__file__)), run_name="__main__") + assert exc.value.code == 0 diff --git a/file_length/tests/test_exemptions.py b/file_length/tests/test_exemptions.py new file mode 100644 index 0000000..6e11d4d --- /dev/null +++ b/file_length/tests/test_exemptions.py @@ -0,0 +1,63 @@ +"""The shared exemption predicates in file_length.exemptions.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from file_length.config import ( + DATA_TXT_MAX_MEAN_LINE, + is_data_text, + is_generated, + is_session_artifact, + is_vendored, +) +from file_length.tests.conftest import write + + +def test_generated_by_extension_pattern(tmp_path: Path) -> None: + assert is_generated(tmp_path / "model.g.dart") + assert is_generated(tmp_path / "lib" / "generated" / "x.py") + + +def test_generated_by_header_marker(tmp_path: Path) -> None: + marked = write(tmp_path, "a.py", "# line\n# @generated by tool\nx = 1\n") + plain = write(tmp_path, "b.py", "x = 1\n") + assert is_generated(marked) + assert not is_generated(plain) + + +def test_generated_unreadable_is_false(tmp_path: Path) -> None: + assert not is_generated(tmp_path / "missing.py") + + +def test_data_text_only_for_short_line_txt(tmp_path: Path) -> None: + short = int(DATA_TXT_MAX_MEAN_LINE) - 1 + assert is_data_text(tmp_path / "words.txt", lines=10, size=short * 10) + assert not is_data_text(tmp_path / "prose.txt", lines=10, size=80 * 10) + assert not is_data_text(tmp_path / "words.py", lines=10, size=10) + assert not is_data_text(tmp_path / "empty.txt", lines=0, size=0) + + +@pytest.mark.parametrize( + "path", + [ + "/home/u/repo/node_modules/pkg/index.js", + "/home/u/repo/.claude/skills/x/SKILL.md", + "/home/u/mcp-servers/servers/foo/main.py", + "/home/u/aseprite-mcp/src/x.py", + ], +) +def test_vendored_paths(path: str) -> None: + assert is_vendored(Path(path)) + + +def test_own_code_is_not_vendored() -> None: + assert not is_vendored(Path("/home/u/src/todo/lib/main.dart")) + assert not is_vendored(Path("/home/u/mcp-servers/session-autopsy/x.py")) + + +def test_session_artifact() -> None: + assert is_session_artifact(Path("/home/u/r/docs/superpowers/plans/a.md")) + assert not is_session_artifact(Path("/home/u/r/docs/README.md")) diff --git a/file_length/tests/test_repo_exempt.py b/file_length/tests/test_repo_exempt.py new file mode 100644 index 0000000..f36a229 --- /dev/null +++ b/file_length/tests/test_repo_exempt.py @@ -0,0 +1,66 @@ +"""`.file-length-exempt`: parsing, matching, and the fail-closed rule.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from file_length.repo_exempt import ( + EXEMPT_FILE, + ExemptFileError, + Exemption, + load, + parse, + repo_exempt_reason, +) +from file_length.tests.conftest import write + + +def test_parse_skips_blank_and_comment_lines() -> None: + text = "\n# a comment\n \napp/**/*.kt # data tables\n" + assert parse(text) == [Exemption("app/**/*.kt", "data tables")] + + +@pytest.mark.parametrize("line", ["app/x.kt", "app/x.kt #", "app/x.kt # ", "# r"]) +def test_parse_rejects_entries_without_a_reason(line: str) -> None: + if line.startswith("#"): + assert parse(line) == [] # a pure comment is not an entry + return + with pytest.raises(ExemptFileError, match=f"{EXEMPT_FILE}:1"): + parse(line) + + +def test_parse_reports_the_offending_line_number() -> None: + with pytest.raises(ExemptFileError, match=f"{EXEMPT_FILE}:3"): + parse("a/*.kt # ok\n\nb/*.kt\n") + + +def test_load_without_file_is_empty(tmp_path: Path) -> None: + assert load(tmp_path) == () + + +def test_load_reads_and_memoises(tmp_path: Path) -> None: + write(tmp_path, EXEMPT_FILE, "a/*.kt # one\n") + first = load(tmp_path) + write(tmp_path, EXEMPT_FILE, "b/*.kt # two\n") + assert load(tmp_path) is first + assert first == (Exemption("a/*.kt", "one"),) + + +def test_reason_none_when_repo_has_no_entries(tmp_path: Path) -> None: + assert repo_exempt_reason(tmp_path / "a" / "x.kt", tmp_path) is None + + +def test_reason_matches_glob_and_double_star(tmp_path: Path) -> None: + write(tmp_path, EXEMPT_FILE, "app/**/tags/*.kt # tag data tables\n") + hit = tmp_path / "app" / "src" / "exh" / "tags" / "Group.kt" + miss = tmp_path / "app" / "src" / "exh" / "Other.kt" + assert repo_exempt_reason(hit, tmp_path) == "repo exemption: tag data tables" + assert repo_exempt_reason(miss, tmp_path) is None + + +def test_reason_none_for_paths_outside_root(tmp_path: Path) -> None: + write(tmp_path, EXEMPT_FILE, "**/*.kt # everything\n") + outside = tmp_path.parent / "elsewhere.kt" + assert repo_exempt_reason(outside, tmp_path) is None