utils/file_length/config.py
Krzysztof kuhy Rudnicki c9bf289510 Add shared 250-line file-length gate
Files that cannot be read in one piece force re-reads and partial edits,
which is the largest avoidable cost in an LLM-assisted workflow. This adds
the enforcement side of a 250-line cap covering code AND prose.

- file_length/config.py: single source of truth for the cap and its
  exemptions (generated, markup, vendored, data-ish txt), shared by both the
  gate and the survey so the two cannot silently disagree.
- file_length/check.py: the check itself. Exits 1 and lists violations;
  adjudication is an exit code, not a judgement call.
- scripts/check_file_length.sh: thin wrapper pre-commit calls with staged
  files.

Verified against six fixtures: over-cap code fails, under-cap passes,
generated l10n (1417 lines) exempt, vendored pdfjs exempt, the 174800-line
wordlist exempt as data, and a 1101-line CLAUDE.md correctly fails since
prose is capped too.

Baseline at time of writing: 657 files over 250 lines across 29 repos.
Each repo carries a refactor_claude_todo.md prompt with its own list.

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

204 lines
5.1 KiB
Python

"""Shared configuration for the 250-line file-length cap.
Single source of truth for the cap and its exemptions, imported by both the
pre-commit gate (`check.py`) and the survey. Duplicating these lists in shell
is what lets a gate and a survey silently disagree.
"""
from __future__ import annotations
import re
from pathlib import Path
#: Hard cap. Files above this fail the gate. Code and prose alike.
MAX_LINES = 250
#: Extensions the cap applies to: real programming languages plus prose.
#: Markup (.html/.css/.scss) is deliberately absent -- templates split into
#: partials, not functions, so a line cap does not express anything useful.
CAPPED_EXTENSIONS = {
# code
".py",
".dart",
".ts",
".tsx",
".js",
".jsx",
".sh",
".bash",
".zsh",
".c",
".h",
".cpp",
".cc",
".hpp",
".go",
".rs",
".java",
".kt",
".kts",
".swift",
".rb",
".lua",
".sql",
".vue",
".cs",
".php",
# prose
".md",
".markdown",
".txt",
".rst",
".tex",
".org",
}
#: Generated files: regenerated by a tool, so splitting them is meaningless
#: (the tool would just rewrite the original next run).
GENERATED_PATTERN = re.compile(
r"\.g\.dart$"
r"|\.freezed\.dart$"
r"|\.gr\.dart$"
r"|\.config\.dart$"
r"|\.mocks\.dart$"
r"|\.min\.(js|css)$"
r"|\.pb\.(go|py)$"
r"|_pb2(_grpc)?\.py$"
r"|\.generated\.ts$"
r"|\.d\.ts$"
r"|/l10n/generated/"
r"|/generated/"
)
#: A marker in the first few lines also marks a file generated. Covers tools
#: that do not use a distinguishing extension.
GENERATED_MARKERS = (
"GENERATED CODE - DO NOT MODIFY",
"@generated",
"DO NOT EDIT",
"Autogenerated",
"AUTOGENERATED",
"generated by",
)
#: Directory names never descended into: dependencies, build output, caches.
EXCLUDED_DIRS = frozenset(
{
".git",
"node_modules",
".dart_tool",
"build",
".venv",
"venv",
"env",
"site-packages",
"target",
"ephemeral",
"__pycache__",
".mypy_cache",
".pytest_cache",
".ruff_cache",
"dist",
".next",
"vendor",
"third_party",
"Pods",
".gradle",
".idea",
".cxx",
"coverage",
"htmlcov",
".tox",
"Library",
"Temp",
"obj",
".terraform",
".fvm",
".angular",
".cache",
".svelte-kit",
"migrations",
".ci-mirror-venv",
".hippo",
".build-deps",
}
)
#: Third-party content living *inside* a repo you own, keyed by repo dir name.
#: Verified by git remote / vendor manifest, not guessed.
VENDORED_SUBPATHS = {
"mcp-servers": ("servers",),
"dufs-cloud": ("app/assets/pdfjs", "web/assets/pdfjs"),
".claude": (
"projects",
"shell-snapshots",
"cache",
"backups",
"todos",
"statsig",
"history",
"chrome",
"downloads",
"plugins",
"ide",
),
}
#: Whole repos under ~ that are third-party clones, not kuhy's work.
#: Verified 2026-08-14 by `git remote get-url origin` + commit authors.
THIRD_PARTY_REPOS = frozenset(
{
"StringTheory", # github.com/AnthonySf/TheStringTheory
"reaper-mcp", # github.com/TwelveTake-Studios/reaper-mcp
"freesound-mcp", # github.com/timjrobinson/FreesoundMCPServer
"llama_cpp_dart_local", # generated FFI bindings
"aseprite-mcp",
"aur",
"build-your-own-x",
"build_your_x",
"byox-ladder",
"warriorjs",
"untools",
"awesome-mcp-explorer",
"runnerup",
"depotdownloader",
"actions-runner",
}
)
#: A `.txt` whose mean line is shorter than this is data (a wordlist, a
#: coordinate dump), not prose, so the cap does not apply.
DATA_TXT_MAX_MEAN_LINE = 25
def is_generated(path: Path) -> bool:
"""True if the file is tool-generated and must not be split by hand."""
if GENERATED_PATTERN.search(path.as_posix()):
return True
try:
with path.open("r", encoding="utf-8", errors="replace") as handle:
head = "".join(next(handle, "") for _ in range(5))
except OSError:
return False
return any(marker in head for marker in GENERATED_MARKERS)
def is_data_text(path: Path, lines: int, size: int) -> bool:
"""True for wordlist-style .txt: many lines, very short ones."""
if path.suffix.lower() != ".txt" or lines <= 0:
return False
return (size / lines) < DATA_TXT_MAX_MEAN_LINE
def is_vendored(path: Path) -> bool:
"""True if the path sits under a known vendored subtree or excluded dir."""
parts = path.parts
if any(part in EXCLUDED_DIRS for part in parts):
return True
posix = path.as_posix()
for repo, subs in VENDORED_SUBPATHS.items():
for sub in subs:
if f"/{repo}/{sub}/" in posix or posix.startswith(f"{repo}/{sub}/"):
return True
return any(f"/{repo}/" in posix for repo in THIRD_PARTY_REPOS)