mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 21:27:14 +01:00
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
A fork of a third-party project can carry data that is code only by extension (TachiyomiSY's 6000-line exh/eh/tags/*.kt string tables). The reason belongs in that repo next to the data, so the gate now reads a .file-length-exempt of '<glob> # <reason>' lines from its cwd. A glob without a reason is a suppression and exits 2 instead of passing. The package had no tests at all; it now has a suite at 100% line+branch, wired into shared-gates-tests.yml with --cov-fail-under=100. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Yc4TiPjhbQLfH35QBPfrbP
197 lines
6.8 KiB
Python
197 lines
6.8 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 (
|
|
CAPPED_EXTENSIONS,
|
|
EXCLUDED_DIRS,
|
|
MAX_LINES,
|
|
is_data_text,
|
|
is_generated,
|
|
is_session_artifact,
|
|
is_vendored,
|
|
)
|
|
from file_length.repo_exempt import ExemptFileError, load, repo_exempt_reason
|
|
|
|
|
|
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 absolutize(path: Path) -> Path:
|
|
"""`path` anchored at the cwd, without resolving symlinks.
|
|
|
|
The exemption rules are written against full paths ('/.claude/skills/'),
|
|
but pre-commit passes paths relative to the repo root ('skills/x.md') while
|
|
--all walks absolute ones. Matching a relative path against those rules
|
|
silently drops the repo-name context, so the same file passed the gate one
|
|
way and failed it the other.
|
|
|
|
Deliberately NOT `Path.resolve()`: build and asset dirs here are symlinks
|
|
out to ../testsAndMisc_builds/, and resolving would move those paths
|
|
outside the repo and change which exemptions match.
|
|
"""
|
|
return path if path.is_absolute() else Path.cwd() / path
|
|
|
|
|
|
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(absolutize(path)):
|
|
return "vendored / third-party"
|
|
repo_reason = repo_exempt_reason(absolutize(path), Path.cwd())
|
|
if repo_reason is not None:
|
|
return repo_reason
|
|
if is_session_artifact(absolutize(path)):
|
|
return "frozen session artifact"
|
|
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()
|
|
|
|
# Read the repo's own exemption list once, up front: a malformed entry is
|
|
# a suppression the gate refuses to guess about, so it is exit 2, not a
|
|
# silent pass on the file it would have covered.
|
|
try:
|
|
load(Path.cwd())
|
|
except ExemptFileError as exc:
|
|
print(f"File-length gate ERROR: {exc}", file=sys.stderr)
|
|
return 2
|
|
|
|
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 TODO-file-length-250.md in this repo.", file=sys.stderr)
|
|
return 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|