From c9bf2895104b0bc55573fa6421644544c276d16a Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Fri, 14 Aug 2026 14:24:57 +0200 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_01C7yZz45ezmzCB89Cv5gAtZ --- file_length/__init__.py | 5 + file_length/check.py | 128 ++++++++++++++++++++++ file_length/config.py | 203 +++++++++++++++++++++++++++++++++++ refactor_claude_todo.md | 96 +++++++++++++++++ scripts/check_file_length.sh | 38 +++++++ 5 files changed, 470 insertions(+) create mode 100644 file_length/__init__.py create mode 100644 file_length/check.py create mode 100644 file_length/config.py create mode 100644 refactor_claude_todo.md create mode 100755 scripts/check_file_length.sh diff --git a/file_length/__init__.py b/file_length/__init__.py new file mode 100644 index 0000000..8945ab8 --- /dev/null +++ b/file_length/__init__.py @@ -0,0 +1,5 @@ +"""Shared 250-line file-length cap: config, gate, and survey.""" + +from file_length.config import MAX_LINES + +__all__ = ["MAX_LINES"] diff --git a/file_length/check.py b/file_length/check.py new file mode 100644 index 0000000..1184282 --- /dev/null +++ b/file_length/check.py @@ -0,0 +1,128 @@ +"""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 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 iter_all(root: Path): + """Every file under root, skipping excluded directories.""" + 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(): + yield candidate + + +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: + targets = args.paths + 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()) diff --git a/file_length/config.py b/file_length/config.py new file mode 100644 index 0000000..ba8153b --- /dev/null +++ b/file_length/config.py @@ -0,0 +1,203 @@ +"""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) diff --git a/refactor_claude_todo.md b/refactor_claude_todo.md new file mode 100644 index 0000000..dc16423 --- /dev/null +++ b/refactor_claude_todo.md @@ -0,0 +1,96 @@ +# Refactor TODO — enforce the 250-line file cap + +> **This file is a ready-to-use prompt.** Paste it to Claude, or open this repo +> and say "do refactor_claude_todo". It is self-contained: everything needed to +> execute is below. Generated 2026-08-14 from a measured survey of every repo. + +## Goal + +Every file in this repo must be **at most 250 lines** — source, tests, and +prose (`.md`/`.txt`/`.rst`/`.tex`) alike — and must **stay** that way forever, +enforced by a gate that fails the commit, not by a note anyone can ignore. + +Why: a file that cannot be read in one piece forces re-reads and partial edits, +which is the single largest avoidable cost in an LLM-assisted workflow. Aim by +churn, not size alone — refactoring pays where code is read and changed often +(Fowler, *refactoring economic benefit*). + +## Scope in this repo + +- **39 files** currently exceed 250 lines (of 139 eligible files). +- **14,348 lines** sit in violation; longest file is **525 lines**. + +Exempt (do NOT split these): +- generated files — `*.g.dart`, `*.freezed.dart`, `*.gr.dart`, `**/l10n/generated/**`, + anything with a `GENERATED` header +- markup — `.html`, `.css`, `.scss` +- data files — `.json`, `.yaml`, `.csv`, wordlists and other data-ish `.txt` + (mean line length under 25 chars) + +## Violations, highest ROI first + +ROI = lines x commits in the last year. Work top-down; a long file nobody edits +has near-zero payoff and should not be first. + +| lines | commits/yr | kind | file | +|------:|-----------:|:-----|:-----| +| 515 | 6 | code | `gatelock/gatelock/_window.py` | +| 477 | 3 | code | `gatelock/gatelock/tests/test_recovery.py` | +| 259 | 5 | code | `crdt_sync_dart/lib/src/sync.dart` | +| 406 | 3 | code | `crdt-sync/crdt_sync/_firebase_auth.py` | +| 393 | 3 | code | `crdt-sync/crdt_sync/tests/test_firebase_auth.py` | +| 363 | 3 | code | `crdt-sync/crdt_sync/_sync.py` | +| 525 | 2 | code | `crdt_sync_dart/test/firebase_auth_rest_test.dart` | +| 483 | 2 | code | `gatelock/gatelock/_scrollable.py` | +| 321 | 3 | code | `gatelock/gatelock/_recovery.py` | +| 457 | 2 | code | `sync_settings_ui/test/sync_settings_screen_test.dart` | +| 452 | 2 | code | `crdt_sync_dart/test/mirror_store_test.dart` | +| 451 | 2 | code | `crdt_sync_dart/test/sync_revisions_test.dart` | +| 439 | 2 | code | `aur-publish/publish.sh` | +| 291 | 3 | code | `crdt_sync_dart/test/github_client_test.dart` | +| 418 | 2 | code | `gatelock/gatelock/tests/test_outputs.py` | + +_(24 further files over 250 lines not listed — re-run the survey for the full set.)_ + +## How to split + +- **Python** — extract cohesive helpers into sibling modules; keep the public + API and imports stable. +- **Shell** — split into `lib/*.sh` sourced by a thin entry script. Keep + `set -euo pipefail` in each. +- **Dart / TypeScript** — extract widgets/components into their own files. +- **Tests** — split by test-group into sibling files + (`foo.test.ts` -> `foo.parsing.test.ts`, `foo.render.test.ts`). Coverage must + not drop. +- **Docs** — split into topic files under `docs/` with an index. For an + oversized `CLAUDE.md`, move detail into referenced docs so the + always-loaded part shrinks. + +**Do not** game the cap: no one-lining, no deleting tests, no moving code into +an exempt extension, no `# noqa`-style suppressions. + +## Make it permanent (required — this is the point) + +A refactor without a gate silently regrows. Before this task is done: + +1. Wire the shared gate `~/utils/scripts/check_file_length.sh` into this repo's + `.pre-commit-config.yaml` as a local hook. If the repo has no pre-commit + config, add a minimal one. +2. The hook checks **files in the commit** (not the whole tree), so unrelated + commits never break, and it **fails** — exit 1, not a warning. +3. No baseline file and no allowlist. Those are suppressions. +4. If this repo has CI (`.github/workflows`), add the same check there so it + also fails on push. + +## Done condition + +- `bash ~/utils/scripts/check_file_length.sh --all` from this repo root exits 0. +- The repo's own test suite and coverage bar are still green. +- `pre-commit run --files ` passes. +- A deliberately over-250-line test file, staged, makes `git commit` **fail**. +- For a deployed daemon/app: the entry point still actually runs. + +## Verify + +Run the repo's own suite, then run the program itself and confirm the +behaviour is unchanged. Testing is the last step, never the first. diff --git a/scripts/check_file_length.sh b/scripts/check_file_length.sh new file mode 100755 index 0000000..d678687 --- /dev/null +++ b/scripts/check_file_length.sh @@ -0,0 +1,38 @@ +#!/bin/bash + +# ============================================================================ +# Fail if any file exceeds the shared 250-line cap. +# +# A file that cannot be read in one piece forces re-reads and partial edits, +# which is the largest avoidable cost in an LLM-assisted workflow. The cap +# applies to code AND prose; generated files, markup and data are exempt. +# +# The real check lives in Python (file_length/check.py) so that the gate and +# the survey share one exemption list -- see rules/shell.instructions.md on +# never embedding another language's logic inline. +# +# Usage: +# scripts/check_file_length.sh [ ...] # pre-commit passes these +# scripts/check_file_length.sh --all # whole tree, from cwd +# ============================================================================ + +set -euo pipefail + +readonly UTILS_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +readonly CHECKER="$UTILS_ROOT/file_length/check.py" + +main() { + if [[ $# -eq 0 ]]; then + echo "Usage: $(basename "$0") ... | --all" >&2 + exit 1 + fi + if [[ ! -f "$CHECKER" ]]; then + echo "Error: checker not found at $CHECKER" >&2 + exit 1 + fi + + PYTHONPATH="$UTILS_ROOT${PYTHONPATH:+:$PYTHONPATH}" \ + python3 "$CHECKER" "$@" +} + +main "$@"