utils/file_length/check.py
Krzysztof kuhy Rudnicki ca81e091a1 Skip git-ignored files in the length gate, and vendored skill bundles
Three exemptions the first cut missed, all of them files no commit could
ever fix:

- Git-ignored files (todo's exported BACKLOG.md, logs, scratch dumps).
  These are rewritten wholesale by whatever produces them, so splitting one
  is meaningless, and pre-commit never sees them anyway. Applied to
  explicitly-named paths as well as --all, so the two modes cannot disagree
  about the same file.
- Vendored agent-skill bundles (.agents/skills, .claude/skills). A SKILL.md
  is a fixed-name file with frontmatter; splitting one breaks the format.
  Matched by directory name wherever it appears, since these get dropped
  into any repo.
- .utils, where CI checks out this repo. Without it a repo's lint job fails
  on utils' own long files, which that repo cannot fix.

Baseline accordingly drops from 657 to 597. Re-verified all eight fixtures:
over-cap fails, under-cap passes, generated/vendored/wordlist/git-ignored
exempt, prose still capped, and the gate still passes on its own source.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C7yZz45ezmzCB89Cv5gAtZ
2026-08-14 15:58:46 +02:00

165 lines
5.4 KiB
Python

"""Gate: fail if any given file exceeds the 250-line cap.
Invoked by `scripts/check_file_length.sh`, which pre-commit calls with the
staged files. Exits 1 and lists every violation; exits 0 silently otherwise.
The check is deliberately deterministic code rather than a judgement call --
adjudication belongs in an exit code, not in a model.
"""
from __future__ import annotations
import argparse
import os
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))
from file_length.config import ( # noqa: E402
CAPPED_EXTENSIONS,
EXCLUDED_DIRS,
MAX_LINES,
is_data_text,
is_generated,
is_vendored,
)
def count_lines(path: Path) -> int | None:
"""Number of lines, or None if the file is binary or unreadable."""
try:
with path.open("rb") as handle:
first = handle.read(8192)
if b"\x00" in first:
return None
count = first.count(b"\n")
while chunk := handle.read(1 << 20):
count += chunk.count(b"\n")
if first and not first.endswith(b"\n") and count == 0:
count = 1
return count
except OSError:
return None
def exempt_reason(path: Path, lines: int) -> str | None:
"""Why this file is not subject to the cap, or None if it is subject."""
if path.suffix.lower() not in CAPPED_EXTENSIONS:
return "not a capped extension"
if is_vendored(path):
return "vendored / third-party"
if is_generated(path):
return "generated"
try:
size = path.stat().st_size
except OSError:
return "unreadable"
if is_data_text(path, lines, size):
return "data-ish text (wordlist)"
return None
def git_ignored(paths: list[Path], root: Path) -> set[Path]:
"""The subset of `paths` that git ignores, empty if root is not a repo.
Git-ignored files are build output or generated data the repo does not
own -- an app's own export (todo's BACKLOG.md), a log, a scratch dump.
They are rewritten wholesale by whatever produces them, so splitting one
is meaningless: the next run restores the original. Pre-commit never sees
them either (they cannot be staged), so skipping them makes --all agree
with the hook instead of failing on files no commit could ever fix.
"""
if not paths:
return set()
try:
result = subprocess.run(
["git", "-C", str(root), "check-ignore", "--stdin"],
input="\n".join(str(p) for p in paths),
capture_output=True,
text=True,
check=False,
)
except OSError: # git not installed
return set()
# 0 = some ignored, 1 = none ignored, 128 = not a repo.
if result.returncode not in (0, 1):
return set()
return {Path(line) for line in result.stdout.splitlines() if line}
def iter_all(root: Path):
"""Every file under root, skipping excluded and git-ignored files."""
found: list[Path] = []
for dirpath, dirnames, filenames in os.walk(root, followlinks=False):
dirnames[:] = [d for d in dirnames if d not in EXCLUDED_DIRS]
for name in filenames:
candidate = Path(dirpath) / name
if not candidate.is_symlink():
found.append(candidate)
ignored = git_ignored(found, root)
return [path for path in found if path not in ignored]
def main() -> int:
parser = argparse.ArgumentParser(
description=f"Fail if any file exceeds {MAX_LINES} lines."
)
parser.add_argument("paths", nargs="*", type=Path)
parser.add_argument(
"--all",
action="store_true",
help="check the whole tree under the current directory",
)
parser.add_argument(
"--explain", action="store_true", help="also report why files were skipped"
)
args = parser.parse_args()
if args.all:
targets = list(iter_all(Path.cwd()))
elif args.paths:
# Explicitly-named paths get the same git-ignore skip as --all, so
# naming a file directly cannot report a violation the hook never
# would. Without this the two modes disagree on exactly the files
# no commit could ever fix (todo's exported BACKLOG.md, logs).
existing = [p for p in args.paths if p.is_file()]
ignored = git_ignored(existing, Path.cwd())
targets = [p for p in existing if p not in ignored]
else:
parser.error("give file paths or --all")
violations: list[tuple[Path, int]] = []
for path in targets:
if not path.is_file():
continue
lines = count_lines(path)
if lines is None:
continue
reason = exempt_reason(path, lines)
if reason is not None:
if args.explain:
print(f" skip {path}: {reason}")
continue
if lines > MAX_LINES:
violations.append((path, lines))
if not violations:
return 0
print(
f"File-length gate FAILED: {len(violations)} file(s) over {MAX_LINES} lines\n",
file=sys.stderr,
)
for path, lines in sorted(violations, key=lambda item: -item[1]):
over = lines - MAX_LINES
print(f" {path}: {lines} lines (over by {over})", file=sys.stderr)
print("\nSplit them, or see refactor_claude_todo.md in this repo.", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())