Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/branch-protection/develop.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"Coverage",
"Architecture (import-linter)",
"Pre-commit",
"File length",
"Frontend Build",
"Frontend Quality",
"Branch-protection contexts sync",
Expand Down
1 change: 1 addition & 0 deletions .github/branch-protection/main.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"Coverage",
"Architecture (import-linter)",
"Pre-commit",
"File length",
"Frontend Build",
"Frontend Quality",
"Branch-protection contexts sync",
Expand Down
114 changes: 114 additions & 0 deletions .github/scripts/check_file_length.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
#!/usr/bin/env python3
"""Enforce the per-file line-count cap from `CLAUDE.md`.

`CLAUDE.md` *Code standards*: *"No file over 300 lines, no function
over ~50 lines."* The function-length half is enforced by ruff's
`PLR0915` / `PLR0912` rules (`pyproject.toml [tool.ruff.lint].select`).
This script enforces the file-length half.

Behaviour:

- Walks `src/`, `tests/`, `eval/`, `.github/scripts/` for `*.py` files.
- For each file, counts lines (newline-terminated and final-line-without-
newline both count as one line).
- Fails when any file exceeds `THRESHOLD = 300`.

There is **no exemption mechanism**. Per `feedback_no_noqa`, an
allowlist that records "current offenders with a tracker ticket" is a
non-blocking deferral by another name — the team gets used to seeing
the offenders listed, the tracker ticket sits open, and the rule never
fully bites. Pre-existing offenders were refactored in #144 before this
gate landed (six files / two functions split into helpers).

If a file legitimately should not be capped (generated code, vendored
sources), put it in a directory this script does not walk — and document
the exemption as a structural decision in `docs/DEVELOPMENT.md`, not as
an inline allowlist entry.

Exit codes:
0 — every walked file is at or under `THRESHOLD`
1 — at least one file exceeds the cap
2 — script-level error (no walk-target directories at all)

Usage (from repo root):

python .github/scripts/check_file_length.py
"""

from __future__ import annotations

import sys
from pathlib import Path

THRESHOLD = 300

# Directories walked. Each is project-owned Python code subject to the
# `CLAUDE.md` cap. Adding a new walk root requires a code change here
# (and a comment naming the rationale) — there is deliberately no
# environment-variable / CLI-flag override.
ROOTS: tuple[str, ...] = (
"src",
"tests",
"eval",
".github/scripts",
)


def count_lines(path: Path) -> int:
"""Count newline-terminated lines plus a final un-terminated line, if any."""
text = path.read_text(encoding="utf-8")
if not text:
return 0
# `splitlines()` discards a trailing empty token, mirroring `wc -l + 1`
# for files without a trailing newline.
return len(text.splitlines())


def _normalised(path: Path) -> str:
"""Return the path with forward slashes (Windows / POSIX parity)."""
return path.as_posix()


def main() -> int:
walked: list[Path] = []
failures: list[str] = []

for root_name in ROOTS:
root = Path(root_name)
if not root.is_dir():
# Directory may not exist yet (e.g. eval/ in a new fork). Skip cleanly.
continue
for path in sorted(root.rglob("*.py")):
walked.append(path)
lines = count_lines(path)
if lines > THRESHOLD:
failures.append(
f"::error file={_normalised(path)}::{lines} lines > "
f"{THRESHOLD} (per `CLAUDE.md`). Split the file or "
"refactor — there is no exemption mechanism, see the "
"module docstring."
)

if not walked:
print(f"::error::no walk targets exist; checked {ROOTS!r}")
return 2

if failures:
for line in failures:
print(line)
print(
f"\n{len(failures)} file(s) exceed the cap. Refactor in this PR — "
"splitting into single-responsibility modules or extracting helpers "
"is the same shape #144 used for the original offenders."
)
return 1

print(
f"File-length audit OK — {len(walked)} file(s) checked across "
f"{len(ROOTS)} root(s), threshold {THRESHOLD}."
)
return 0


if __name__ == "__main__":
sys.exit(main())
14 changes: 14 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,20 @@ jobs:
- run: uv sync --frozen --extra dev
- run: uv run pre-commit run --all-files --show-diff-on-failure

file-length:
name: File length
runs-on: ubuntu-latest
# CLAUDE.md: "no file over 300 lines, no function over ~50 lines". Ruff
# PLR0915 / PLR0912 enforce the function-half (run by `Lint & Format`);
# this job enforces the file-half. No exemption mechanism — pre-existing
# offenders should be split before this job lands, not allowlisted.
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: "3.14"
- run: python .github/scripts/check_file_length.py

frontend-build:
name: Frontend Build
runs-on: ubuntu-latest
Expand Down
14 changes: 14 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,14 @@ select = [
"TCH", # flake8-type-checking
"S", # flake8-bandit (security checks — SQL injection, hardcoded crypto, etc.)
"RUF", # ruff-specific rules
# PLR0915 (too-many-statements) + PLR0912 (too-many-branches) enforce the
# function-complexity half of CLAUDE.md *Code standards*. Limits pinned
# explicitly in [tool.ruff.lint.pylint] below to defend against an
# upstream ruff default change silently widening the cap. The file-half
# of the rule is enforced by .github/scripts/check_file_length.py
# (300-line cap, no exemption mechanism).
"PLR0915",
"PLR0912",
]

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

[tool.ruff.lint.pylint]
# Pinned explicitly — ruff's defaults can drift between versions. Matches
# CLAUDE.md "no function over ~50 lines".
max-statements = 50
max-branches = 12

[tool.ruff.lint.isort]
known-first-party = ["src"]

Expand Down
Loading