ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
check_file_size.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# SPDX-License-Identifier: MIT
3# Copyright (c) 2026 Brighton Sikarskie
4"""Gate: source files shall not exceed a maintainability line cap (1000).
5
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.
11
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.
20
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.
26
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.
32
33Waivers
34-------
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.
38
39Run::
40
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
44
45Exit 0 if every file is at or below the cap, exit 1 (with a table) otherwise.
46"""
47
48from __future__ import annotations
49
50import sys
51import tempfile
52from pathlib import Path
53
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
57
58# Maintainability cap: a single source file should stay reviewable.
59THRESHOLD_LINES = 1000
60
61
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)
65
66
67def _count_lines(path: Path) -> int:
68 """Return the physical line count of `path`, or -1 if unreadable."""
69 try:
70 text = path.read_text(encoding="utf-8", errors="replace")
71 except OSError:
72 return -1
73 return len(text.splitlines())
74
75
76def _targets_from_args(args: list[str]) -> list[Path]:
77 """Resolve explicitly named files / directories to a scan list."""
78 out: list[Path] = []
79 for raw in args:
80 path = Path(raw)
81 if not path.is_absolute():
82 path = REPO_ROOT / path
83 if path.is_dir():
84 out.extend(
85 p for p in path.rglob("*") if p.is_file() and language_of(_rel(p)) is not None
86 )
87 elif language_of(_rel(path)) is not None:
88 out.append(path)
89 return out
90
91
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))
95 return str(path)
96
97
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]
102
103
104def over_cap(targets: list[Path]) -> list[tuple[int, str]]:
105 """The (line count, path) of every unwaived file above the cap."""
106 over = []
107 grouped = files_for()
108 tracked_paths = frozenset(rel for paths in grouped.values() for rel in paths)
109 for path in targets:
110 # Language is re-checked here rather than trusted from the caller, so
111 # the rule is the same however the file arrived -- derived scan,
112 # explicit argument, or selftest fixture.
113 if language_of(_rel(path)) is None or _is_waived(path, tracked_paths):
114 continue
115 count = _count_lines(path)
116 if count > THRESHOLD_LINES:
117 over.append((count, _rel(path)))
118 over.sort(reverse=True)
119 return over
120
121
122# ---------------------------------------------------------------------------
123# Selftest -- asserts BOTH directions before the real scan is trusted.
124# ---------------------------------------------------------------------------
125
126
127# (filename, body, expected finding count, what the case proves)
128_OVER = THRESHOLD_LINES + 1
129
130_SELFTEST_CASES = (
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"),
138 (
139 "gen.py",
140 "# @generated by tools/generator.py -- fixture recipe\n" + "x\n" * _OVER,
141 0,
142 "a canonical generated marker waives when its generator resolves",
143 ),
144 (
145 "marker_only.py",
146 "# @generated by tools/generator.py -- fixture recipe\n" + "\n" * _OVER,
147 1,
148 "a marker followed only by blank lines does not waive",
149 ),
150 (
151 "comment_only.py",
152 "# @generated by tools/generator.py -- fixture recipe\n" + "# fixture\n" * _OVER,
153 1,
154 "a marker followed only by comments does not waive",
155 ),
156 (
157 "waived.py",
158 "# FILE-SIZE-OK: justified\n" + "x\n" * _OVER,
159 0,
160 "the FILE-SIZE-OK marker waives",
161 ),
162 (
163 "deep.py",
164 "# marker far below the head\n" * 40
165 + "# @generated by tools/generator.py -- fixture recipe\n"
166 + "x\n" * _OVER,
167 1,
168 "a marker below the head does not waive",
169 ),
170 (
171 "prose.py",
172 'text = "@generated by tools/generator.py -- string data"\n' + "x\n" * _OVER,
173 1,
174 "generated prose/string data does not waive",
175 ),
176 (
177 "bare.py",
178 "# @generated\n" + "x\n" * _OVER,
179 1,
180 "a generator-less marker does not waive",
181 ),
182)
183
184
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:
189 root = Path(tmp)
190 for name, body, expected, description in cases:
191 path = root / name
192 path.write_text(body)
193 # The production helper requires generated provenance to resolve
194 # beneath REPO_ROOT. Use a real tracked generator in these local
195 # fixtures by substituting its path rather than weakening that rule.
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]))
201 path.unlink()
202 if got != expected:
203 failures.append(f" FAIL {description}: expected {expected}, got {got}")
204
205 # A shebang script with no suffix is code. This is the case a
206 # suffix-only scope silently drops -- and scripts/git/pre-commit is
207 # 677 lines of exactly it.
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")
212 return failures
213
214
215def _selftest_scope() -> tuple[list[str], dict[str, list[str]]]:
216 """Assert the live scope still resolves files for every known language.
217
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.
220 """
221 grouped = files_for()
222 failures = [
223 f" FAIL live scope: language {lang!r} resolved to zero files"
224 for lang, paths in grouped.items()
225 if not paths
226 ]
227 return failures, grouped
228
229
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)
235
236 if failures:
237 print("check_file_size.py: --selftest FAILED", file=sys.stderr)
238 print("\n".join(failures), file=sys.stderr)
239 return 1
240 print(
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))"
244 )
245 return 0
246
247
248def main(argv: list[str]) -> int:
249 """Fail any file over the line cap, or with ``--selftest`` prove the gate still fires.
250
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.
256
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.
259 """
260 args = argv[1:]
261 if "--selftest" in args:
262 return _selftest()
263
264 targets = _targets_from_args(args) if args else _all_targets()
265 if not targets:
266 print("check_file_size.py: FATAL -- no files to scan", file=sys.stderr)
267 return 2
268
269 over = over_cap(targets)
270 if not over:
271 print(
272 f"check_file_size.py: {len(targets)} file(s) scanned, "
273 f"none over {THRESHOLD_LINES} lines."
274 )
275 return 0
276
277 print(
278 f"check_file_size.py: {len(over)} file(s) exceed the {THRESHOLD_LINES}-line cap:\n",
279 file=sys.stderr,
280 )
281 print(" lines file", file=sys.stderr)
282 for count, path in over:
283 print(f" {count:5d} {path}", file=sys.stderr)
284 print(
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.",
288 file=sys.stderr,
289 )
290 return 1
291
292
293if __name__ == "__main__":
294 sys.exit(main(sys.argv))
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298