Skip to content

Commit ec96aec

Browse files
authored
chore: file/function length caps — ruff PLR0915/PLR0912 + check_file_length.py (#124) (#69)
1 parent 1d3ef1a commit ec96aec

5 files changed

Lines changed: 144 additions & 0 deletions

File tree

.github/branch-protection/develop.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
"Coverage",
99
"Architecture (import-linter)",
1010
"Pre-commit",
11+
"File length",
1112
"Frontend Build",
1213
"Frontend Quality",
1314
"Branch-protection contexts sync",

.github/branch-protection/main.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
"Coverage",
99
"Architecture (import-linter)",
1010
"Pre-commit",
11+
"File length",
1112
"Frontend Build",
1213
"Frontend Quality",
1314
"Branch-protection contexts sync",
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
#!/usr/bin/env python3
2+
"""Enforce the per-file line-count cap from `CLAUDE.md`.
3+
4+
`CLAUDE.md` *Code standards*: *"No file over 300 lines, no function
5+
over ~50 lines."* The function-length half is enforced by ruff's
6+
`PLR0915` / `PLR0912` rules (`pyproject.toml [tool.ruff.lint].select`).
7+
This script enforces the file-length half.
8+
9+
Behaviour:
10+
11+
- Walks `src/`, `tests/`, `eval/`, `.github/scripts/` for `*.py` files.
12+
- For each file, counts lines (newline-terminated and final-line-without-
13+
newline both count as one line).
14+
- Fails when any file exceeds `THRESHOLD = 300`.
15+
16+
There is **no exemption mechanism**. Per `feedback_no_noqa`, an
17+
allowlist that records "current offenders with a tracker ticket" is a
18+
non-blocking deferral by another name — the team gets used to seeing
19+
the offenders listed, the tracker ticket sits open, and the rule never
20+
fully bites. Pre-existing offenders were refactored in #144 before this
21+
gate landed (six files / two functions split into helpers).
22+
23+
If a file legitimately should not be capped (generated code, vendored
24+
sources), put it in a directory this script does not walk — and document
25+
the exemption as a structural decision in `docs/DEVELOPMENT.md`, not as
26+
an inline allowlist entry.
27+
28+
Exit codes:
29+
0 — every walked file is at or under `THRESHOLD`
30+
1 — at least one file exceeds the cap
31+
2 — script-level error (no walk-target directories at all)
32+
33+
Usage (from repo root):
34+
35+
python .github/scripts/check_file_length.py
36+
"""
37+
38+
from __future__ import annotations
39+
40+
import sys
41+
from pathlib import Path
42+
43+
THRESHOLD = 300
44+
45+
# Directories walked. Each is project-owned Python code subject to the
46+
# `CLAUDE.md` cap. Adding a new walk root requires a code change here
47+
# (and a comment naming the rationale) — there is deliberately no
48+
# environment-variable / CLI-flag override.
49+
ROOTS: tuple[str, ...] = (
50+
"src",
51+
"tests",
52+
"eval",
53+
".github/scripts",
54+
)
55+
56+
57+
def count_lines(path: Path) -> int:
58+
"""Count newline-terminated lines plus a final un-terminated line, if any."""
59+
text = path.read_text(encoding="utf-8")
60+
if not text:
61+
return 0
62+
# `splitlines()` discards a trailing empty token, mirroring `wc -l + 1`
63+
# for files without a trailing newline.
64+
return len(text.splitlines())
65+
66+
67+
def _normalised(path: Path) -> str:
68+
"""Return the path with forward slashes (Windows / POSIX parity)."""
69+
return path.as_posix()
70+
71+
72+
def main() -> int:
73+
walked: list[Path] = []
74+
failures: list[str] = []
75+
76+
for root_name in ROOTS:
77+
root = Path(root_name)
78+
if not root.is_dir():
79+
# Directory may not exist yet (e.g. eval/ in a new fork). Skip cleanly.
80+
continue
81+
for path in sorted(root.rglob("*.py")):
82+
walked.append(path)
83+
lines = count_lines(path)
84+
if lines > THRESHOLD:
85+
failures.append(
86+
f"::error file={_normalised(path)}::{lines} lines > "
87+
f"{THRESHOLD} (per `CLAUDE.md`). Split the file or "
88+
"refactor — there is no exemption mechanism, see the "
89+
"module docstring."
90+
)
91+
92+
if not walked:
93+
print(f"::error::no walk targets exist; checked {ROOTS!r}")
94+
return 2
95+
96+
if failures:
97+
for line in failures:
98+
print(line)
99+
print(
100+
f"\n{len(failures)} file(s) exceed the cap. Refactor in this PR — "
101+
"splitting into single-responsibility modules or extracting helpers "
102+
"is the same shape #144 used for the original offenders."
103+
)
104+
return 1
105+
106+
print(
107+
f"File-length audit OK — {len(walked)} file(s) checked across "
108+
f"{len(ROOTS)} root(s), threshold {THRESHOLD}."
109+
)
110+
return 0
111+
112+
113+
if __name__ == "__main__":
114+
sys.exit(main())

.github/workflows/ci.yml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,20 @@ jobs:
9191
- run: uv sync --frozen --extra dev
9292
- run: uv run pre-commit run --all-files --show-diff-on-failure
9393

94+
file-length:
95+
name: File length
96+
runs-on: ubuntu-latest
97+
# CLAUDE.md: "no file over 300 lines, no function over ~50 lines". Ruff
98+
# PLR0915 / PLR0912 enforce the function-half (run by `Lint & Format`);
99+
# this job enforces the file-half. No exemption mechanism — pre-existing
100+
# offenders should be split before this job lands, not allowlisted.
101+
steps:
102+
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
103+
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
104+
with:
105+
python-version: "3.14"
106+
- run: python .github/scripts/check_file_length.py
107+
94108
frontend-build:
95109
name: Frontend Build
96110
runs-on: ubuntu-latest

pyproject.toml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,14 @@ select = [
8282
"TCH", # flake8-type-checking
8383
"S", # flake8-bandit (security checks — SQL injection, hardcoded crypto, etc.)
8484
"RUF", # ruff-specific rules
85+
# PLR0915 (too-many-statements) + PLR0912 (too-many-branches) enforce the
86+
# function-complexity half of CLAUDE.md *Code standards*. Limits pinned
87+
# explicitly in [tool.ruff.lint.pylint] below to defend against an
88+
# upstream ruff default change silently widening the cap. The file-half
89+
# of the rule is enforced by .github/scripts/check_file_length.py
90+
# (300-line cap, no exemption mechanism).
91+
"PLR0915",
92+
"PLR0912",
8593
]
8694

8795
[tool.ruff.lint.per-file-ignores]
@@ -90,6 +98,12 @@ select = [
9098
"tests/**" = ["S101"] # pytest asserts are idiomatic (rewritten for rich errors)
9199
".claude/hooks/**" = ["S603", "S607"] # hook scripts intentionally run git/uv/npx from PATH
92100

101+
[tool.ruff.lint.pylint]
102+
# Pinned explicitly — ruff's defaults can drift between versions. Matches
103+
# CLAUDE.md "no function over ~50 lines".
104+
max-statements = 50
105+
max-branches = 12
106+
93107
[tool.ruff.lint.isort]
94108
known-first-party = ["src"]
95109

0 commit comments

Comments
 (0)