mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 17:47:24 +01:00
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
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:
parent
db69c7251b
commit
879265b966
10
.github/workflows/shared-gates-tests.yml
vendored
10
.github/workflows/shared-gates-tests.yml
vendored
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
81
file_length/repo_exempt.py
Normal file
81
file_length/repo_exempt.py
Normal 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
|
||||
0
file_length/tests/__init__.py
Normal file
0
file_length/tests/__init__.py
Normal file
40
file_length/tests/conftest.py
Normal file
40
file_length/tests/conftest.py
Normal 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))
|
||||
151
file_length/tests/test_check.py
Normal file
151
file_length/tests/test_check.py
Normal 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
|
||||
63
file_length/tests/test_exemptions.py
Normal file
63
file_length/tests/test_exemptions.py
Normal 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"))
|
||||
66
file_length/tests/test_repo_exempt.py
Normal file
66
file_length/tests/test_repo_exempt.py
Normal 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
|
||||
Loading…
Reference in New Issue
Block a user