diff --git a/file_length/check.py b/file_length/check.py index 514333c..ea11177 100644 --- a/file_length/check.py +++ b/file_length/check.py @@ -11,19 +11,20 @@ from __future__ import annotations import argparse import os +from pathlib import Path 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 +from file_length.config import ( CAPPED_EXTENSIONS, EXCLUDED_DIRS, MAX_LINES, is_data_text, is_generated, + is_session_artifact, is_vendored, ) @@ -45,12 +46,30 @@ def count_lines(path: Path) -> int | None: 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(path): + if is_vendored(absolutize(path)): return "vendored / third-party" + if is_session_artifact(absolutize(path)): + return "frozen session artifact" if is_generated(path): return "generated" try: diff --git a/file_length/config.py b/file_length/config.py index ce2a0c3..183ee3b 100644 --- a/file_length/config.py +++ b/file_length/config.py @@ -7,8 +7,8 @@ is what lets a gate and a survey silently disagree. from __future__ import annotations -import re from pathlib import Path +import re #: Hard cap. Files above this fail the gate. Code and prose alike. MAX_LINES = 250 @@ -158,6 +158,18 @@ VENDORED_SUBPATHS = { #: repo that wants them, not just the one they were first seen in. VENDORED_ANYWHERE = ("/.agents/skills/", "/.claude/skills/") +#: Frozen records of past agent sessions: an implementation plan or an +#: append-only session log describes what was done at one moment. Splitting one +#: rewrites a historical record for no reading benefit -- nobody navigates them, +#: they are read whole or not at all. Same category as generated files. +#: +#: Deliberately NOT the whole of docs/superpowers/: `specs/` and `contracts/` +#: stay capped, because those are living documents that get re-read and edited. +SESSION_ARTIFACTS_ANYWHERE = ( + "/docs/superpowers/plans/", + "/docs/superpowers/sessions/", +) + #: 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( @@ -205,19 +217,35 @@ def is_data_text(path: Path, lines: int, size: int) -> bool: def is_vendored(path: Path) -> bool: - """True if the path sits under a known vendored subtree or excluded dir.""" + """True if the path sits under a known vendored subtree or excluded dir. + + Callers must pass an absolute path (see `check.absolutize`). Every rule + below needs the repo-name context that a repo-relative path has already + thrown away: '/.claude/skills/' cannot match 'skills/x.md', so a relative + path would quietly skip the exemption instead of applying it. + """ parts = path.parts if any(part in EXCLUDED_DIRS for part in parts): return True - # Pre-commit passes paths relative to the repo root ('.agents/skills/x'), - # while --all walks absolute ones ('/home/kuhy/todo/.agents/skills/x'). - # Compare against a leading-slash form so one rule covers both modes. posix = path.as_posix() - anchored = posix if posix.startswith("/") else f"/{posix}" - if any(marker in anchored for marker in VENDORED_ANYWHERE): + if any(marker in posix for marker in VENDORED_ANYWHERE): return True for repo, subs in VENDORED_SUBPATHS.items(): for sub in subs: - if f"/{repo}/{sub}/" in posix or posix.startswith(f"{repo}/{sub}/"): + if f"/{repo}/{sub}/" in posix: return True return any(f"/{repo}/" in posix for repo in THIRD_PARTY_REPOS) + + +def is_session_artifact(path: Path) -> bool: + """True for a frozen agent-session record (a plan or a session log). + + Kept separate from `is_vendored` so the gate reports why a file is exempt: + these are kuhy's own files, not third-party code. + + Callers must pass an absolute path (see `check.absolutize`), for the same + reason `is_vendored` does -- a repo-relative path has already dropped the + leading directory these markers match on. + """ + posix = path.as_posix() + return any(marker in posix for marker in SESSION_ARTIFACTS_ANYWHERE)