4"""Gate: source files shall not exceed a maintainability line cap (1000).
6The per-function NASA Power-of-10 Rule 4 cap (``check_function_size.py``,
760 lines/function) keeps individual routines small but says nothing about the
8size of the *file* they live in: a translation unit can stay Rule-4-clean while
9growing into a multi-thousand-line god-file, or the same body can be
10copy-pasted across siblings until each is enormous.
12Scope is derived, not listed (#359)
13-----------------------------------
14This gate used to carry a hand-written ``SCAN_ROOTS`` tuple that omitted
15``scripts/`` and a ``SOURCE_SUFFIXES`` tuple covering only C and C++. The
16documented 1000-line cap had therefore never applied to a single Python or
17shell file in the repository's history -- and it did not *fail* to apply, it
18reported success over a shrinking slice of the tree. A stale hardcoded list
19looks exactly like a clean one.
21The scope now comes from :mod:`lint_targets`, which derives it from
22``git ls-files`` plus per-file language detection (suffix, well-known basename,
23or ``#!`` shebang). A new top-level directory is covered the day it is added,
24and an extensionless executable -- the 670-line ``scripts/git/pre-commit``, for
25instance -- cannot escape by having no suffix.
27Every language ``lint_targets`` calls code is in scope: C/C++, Python, shell,
28CMake, YAML, Make and linker scripts. Markdown is deliberately NOT: prose is
29not code, a long reference document is not a maintainability defect, and the
30three docs over the cap (ROADMAP, HARDWARE_BRINGUP, JOF) are reference material
31that would be worse split up.
35A generated file may waive the cap with a standalone comment in its head that
36names a tracked generator and a recipe/reason. A hand-authored exception uses
37the equally strict ``FILE-SIZE-OK: <reason>`` standalone-comment grammar.
41 check_file_size.py # scan the whole tree
42 check_file_size.py path/to/file.c ... # scan listed files
43 check_file_size.py --selftest # prove it fires and stays quiet
45Exit 0 if every file is at or below the cap, exit 1 (with a table) otherwise.
48from __future__
import annotations
52from pathlib
import Path
54sys.path.insert(0, str(Path(__file__).resolve().parent))
55from lint_targets
import REPO_ROOT, files_for, language_of
56from suppression_generated_markers
import path_has_effective_waiver
62def _is_waived(path: Path, tracked_paths: frozenset[str]) -> bool:
63 """Return whether the shared canonical head-waiver grammar accepts `path`."""
64 return path_has_effective_waiver(path, REPO_ROOT, tracked_paths)
67def _count_lines(path: Path) -> int:
68 """Return the physical line count of `path`, or -1 if unreadable."""
70 text = path.read_text(encoding=
"utf-8", errors=
"replace")
73 return len(text.splitlines())
76def _targets_from_args(args: list[str]) -> list[Path]:
77 """Resolve explicitly named files / directories to a scan list."""
81 if not path.is_absolute():
82 path = REPO_ROOT / path
85 p
for p
in path.rglob(
"*")
if p.is_file()
and language_of(_rel(p))
is not None
87 elif language_of(_rel(path))
is not None:
92def _rel(path: Path) -> str:
93 if path.is_absolute()
and path.is_relative_to(REPO_ROOT):
94 return str(path.relative_to(REPO_ROOT))
98def _all_targets() -> list[Path]:
99 """Every first-party code file, derived from git + language detection."""
100 grouped = files_for()
101 return [REPO_ROOT / rel
for paths
in grouped.values()
for rel
in paths]
104def over_cap(targets: list[Path]) -> list[tuple[int, str]]:
105 """The (line count, path) of every unwaived file above the cap."""
107 grouped = files_for()
108 tracked_paths = frozenset(rel
for paths
in grouped.values()
for rel
in paths)
113 if language_of(_rel(path))
is None or _is_waived(path, tracked_paths):
115 count = _count_lines(path)
116 if count > THRESHOLD_LINES:
117 over.append((count, _rel(path)))
118 over.sort(reverse=
True)
128_OVER = THRESHOLD_LINES + 1
131 (
"over.c",
"x\n" * _OVER, 1,
"a C file over the cap fires"),
132 (
"under.c",
"x\n" * THRESHOLD_LINES, 0,
"a C file exactly at the cap is clean"),
133 (
"over.py",
"x\n" * _OVER, 1,
"a Python file over the cap fires"),
134 (
"over.sh",
"x\n" * _OVER, 1,
"a shell file over the cap fires"),
135 (
"over.cmake",
"x\n" * _OVER, 1,
"a CMake file over the cap fires"),
136 (
"over.md",
"x\n" * _OVER, 0,
"Markdown is prose and out of scope"),
137 (
"over.json",
"x\n" * _OVER, 0,
"a data file is out of scope"),
140 "# @generated by tools/generator.py -- fixture recipe\n" +
"x\n" * _OVER,
142 "a canonical generated marker waives when its generator resolves",
146 "# @generated by tools/generator.py -- fixture recipe\n" +
"\n" * _OVER,
148 "a marker followed only by blank lines does not waive",
152 "# @generated by tools/generator.py -- fixture recipe\n" +
"# fixture\n" * _OVER,
154 "a marker followed only by comments does not waive",
158 "# FILE-SIZE-OK: justified\n" +
"x\n" * _OVER,
160 "the FILE-SIZE-OK marker waives",
164 "# marker far below the head\n" * 40
165 +
"# @generated by tools/generator.py -- fixture recipe\n"
168 "a marker below the head does not waive",
172 'text = "@generated by tools/generator.py -- string data"\n' +
"x\n" * _OVER,
174 "generated prose/string data does not waive",
178 "# @generated\n" +
"x\n" * _OVER,
180 "a generator-less marker does not waive",
185def _selftest_cap(cases: tuple) -> list[str]:
186 """Run the threshold / waiver / scope fixtures and return any failures."""
187 failures: list[str] = []
188 with tempfile.TemporaryDirectory()
as tmp:
190 for name, body, expected, description
in cases:
192 path.write_text(body)
196 if "tools/generator.py" in body:
197 tracked = REPO_ROOT /
"scripts" /
"gen" /
"rabook_parity_gen.py"
198 rewritten = body.replace(
"tools/generator.py", str(tracked.relative_to(REPO_ROOT)))
199 path.write_text(rewritten)
200 got = len(over_cap([path]))
203 failures.append(f
" FAIL {description}: expected {expected}, got {got}")
208 hook = root /
"pre-commit-like"
209 hook.write_text(
"#!/usr/bin/env bash\n" +
"x\n" * (THRESHOLD_LINES + 1))
210 if len(over_cap([hook])) != 1:
211 failures.append(
" FAIL an extensionless shebang script is in scope")
215def _selftest_scope() -> tuple[list[str], dict[str, list[str]]]:
216 """Assert the live scope still resolves files for every known language.
218 Zero files for a language is always a broken enumeration, never a clean
219 tree -- the failure mode this checker was rewritten to end.
221 grouped = files_for()
223 f
" FAIL live scope: language {lang!r} resolved to zero files"
224 for lang, paths
in grouped.items()
227 return failures, grouped
230def _selftest() -> int:
231 """Prove the cap fires, the waivers work, and the scope reaches every language."""
232 failures = _selftest_cap(_SELFTEST_CASES)
233 scope_failures, grouped = _selftest_scope()
234 failures.extend(scope_failures)
237 print(
"check_file_size.py: --selftest FAILED", file=sys.stderr)
238 print(
"\n".join(failures), file=sys.stderr)
241 f
"check_file_size.py: --selftest OK ({len(_SELFTEST_CASES) + 1} cases both "
242 f
"directions; live scope covers {len(grouped)} language(s), "
243 f
"{sum(len(p) for p in grouped.values())} file(s))"
248def main(argv: list[str]) -> int:
249 """Fail any file over the line cap, or with ``--selftest`` prove the gate still fires.
251 Note the THREE distinct exit codes. An empty target set exits 2, not 0:
252 this gate derives its scope from ``git ls-files`` (#359) and so an empty
253 scan means the enumeration broke, which must never be reported as a clean
254 tree -- that is precisely how this checker spent its early life passing
255 over a scope that omitted every Python and shell file.
257 Returns 0 when every scanned file is within the cap, 1 when one or more
258 exceed it, and 2 when there was nothing to scan at all.
261 if "--selftest" in args:
264 targets = _targets_from_args(args)
if args
else _all_targets()
266 print(
"check_file_size.py: FATAL -- no files to scan", file=sys.stderr)
269 over = over_cap(targets)
272 f
"check_file_size.py: {len(targets)} file(s) scanned, "
273 f
"none over {THRESHOLD_LINES} lines."
278 f
"check_file_size.py: {len(over)} file(s) exceed the {THRESHOLD_LINES}-line cap:\n",
281 print(
" lines file", file=sys.stderr)
282 for count, path
in over:
283 print(f
" {count:5d} {path}", file=sys.stderr)
285 "\nSplit the file along its responsibilities, or extract a shared "
286 "helper if the bulk is duplicated. Generated files may carry a "
287 "@generated marker; a justified hand-authored file may use FILE-SIZE-OK.",
293if __name__ ==
"__main__":
294 sys.exit(
main(sys.argv))
void main(void)
The application entry point Reset_Handler hands control to.