diff --git a/.github/branch-protection/develop.json b/.github/branch-protection/develop.json index 4412a3f..a603ec4 100644 --- a/.github/branch-protection/develop.json +++ b/.github/branch-protection/develop.json @@ -8,6 +8,7 @@ "Coverage", "Architecture (import-linter)", "Pre-commit", + "File length", "Frontend Build", "Frontend Quality", "Branch-protection contexts sync", diff --git a/.github/branch-protection/main.json b/.github/branch-protection/main.json index d20d7b6..d476437 100644 --- a/.github/branch-protection/main.json +++ b/.github/branch-protection/main.json @@ -8,6 +8,7 @@ "Coverage", "Architecture (import-linter)", "Pre-commit", + "File length", "Frontend Build", "Frontend Quality", "Branch-protection contexts sync", diff --git a/.github/scripts/check_file_length.py b/.github/scripts/check_file_length.py new file mode 100644 index 0000000..b9e4483 --- /dev/null +++ b/.github/scripts/check_file_length.py @@ -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()) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index baad7ec..99b29e5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 1d4574a..1789d98 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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] @@ -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"]