file_length: repo-local .file-length-exempt list, fail-closed, package tested to 100%
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run

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 '<glob>  # <reason>' 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Yc4TiPjhbQLfH35QBPfrbP
This commit is contained in:
Krzysztof Rudnicki 2026-09-12 18:53:19 +02:00
parent db69c7251b
commit 879265b966
8 changed files with 425 additions and 1 deletions

View File

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

View File

@ -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:

View File

@ -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 '<glob> # <reason>'"
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

View File

View File

@ -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))

View File

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

View File

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

View File

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