ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
check_function_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: NASA Power-of-10 Rule 4 -- a function fits in one display page (60 lines).
5
6The project's ``.clang-tidy`` configures ``readability-function-size``, but
7clang-tidy only sees files present in ``compile_commands.json``. The host
8unit-test build drops every ARM-cross-compiled translation unit, so ~90% of
9``port/``, much of ``libs/ra8_hal/``, and every example ``main.c`` were exempt.
10This checker walks source text directly so the rule reaches all of it.
11
12Scope is derived, not listed (#359)
13-----------------------------------
14The scope was a hand-written ``SCAN_ROOTS`` tuple that omitted ``scripts/``,
15and the parser understood only C -- so Rule 4 had never applied to a single
16Python or shell function. Scope now comes from :mod:`lint_targets`
17(``git ls-files`` + per-file language detection), and the measurement is
18delegated to one parser per language:
19
20 :mod:`funcsize_c` C / C++ -- textual brace tracking
21 :mod:`funcsize_py` Python -- a real ``ast`` parse
22 :mod:`funcsize_sh` shell -- textual, both ``{}`` and subshell ``()`` bodies
23
24Is 60 the right cap for Python and shell?
25-----------------------------------------
26Yes, and the number is deliberately NOT re-derived per language. Rule 4's
27rationale is that a function fits on one screen so a reviewer holds all of it
28at once. That is a claim about *display lines*, and a screen is the same
29height whatever the file extension. Picking a different number per language
30would say the rationale is about something else.
31
32The tempting alternative -- lean on ruff's ``PLR0915`` (statement count) and
33skip a line cap for Python -- was measured and rejected. ``PLR0915`` at its
34configured 50 reports **zero** findings on this tree, while 41 Python
35functions exceed 60 lines. The two do not measure the same thing: a
36multi-line call, dict literal or ``with`` header is one statement spanning
37many lines, so a statement cap is far weaker than a line cap and cannot stand
38in for it. ``PLR0915`` stays at 50 as a complementary bound on dense
39one-liner-heavy functions; this gate owns the display-page rule. (``PLR0912``
40branches=12 reports zero and is likewise complementary. ``C90`` mccabe is a
41different rule again -- 52 findings, a restructuring campaign rather than a
42size cap -- and remains unselected.)
43
44What is counted is normalised so all three languages measure the same span:
45the function body, with its documented contract excluded. In C the Doxygen
46block sits above the signature and is already outside the span. In Python the
47docstring is the body's first statement, so it is subtracted -- otherwise this
48gate would penalise documentation in exactly the repository that mandates it.
49Blank lines and comments are inside the span in C, so they stay inside it
50everywhere.
51
52Run::
53
54 check_function_size.py # scan the whole tree
55 check_function_size.py path/to/file.c ... # scan listed files
56 check_function_size.py --selftest # prove it fires and stays quiet
57
58Exit 0 if every function is at or below the threshold, exit 1 otherwise.
59"""
60
61from __future__ import annotations
62
63import sys
64import tempfile
65from pathlib import Path
66
67sys.path.insert(0, str(Path(__file__).resolve().parent))
68import funcsize_c
69import funcsize_py
70import funcsize_sh
71from lint_targets import REPO_ROOT, files_for, language_of
72
73# NASA Power-of-10 Rule 4: ~60 source lines per function. Matches the value in
74# ``.clang-tidy`` (readability-function-size.LineThreshold).
75THRESHOLD_LINES = 60
76
77# Maximum signature length printed in the diagnostic table before truncation.
78SIG_DISPLAY_MAX = 70
79
80# Languages this gate can measure, and who measures them. A language absent
81# here is simply not checked -- CMake, YAML, Make and linker scripts have no
82# function construct this rule is about.
83PARSERS = {
84 "c": funcsize_c.scan,
85 "python": funcsize_py.scan,
86 "shell": funcsize_sh.scan,
87}
88
89# Per-app boot boilerplate is copied verbatim into every app and is dominated
90# by vector-table initialisers; flagging it would bury real findings.
91BOOT_BOILERPLATE = frozenset(
92 {"vector_table.c", "system_init.c", "secure_exception.c", "trustzone_init.c"}
93)
94
95EXCLUDE_FRAGMENTS = ("_unsupported/",)
96
97
98def _rel(path: Path) -> str:
99 if path.is_absolute() and path.is_relative_to(REPO_ROOT):
100 return str(path.relative_to(REPO_ROOT))
101 return str(path)
102
103
104def _skipped(rel: str) -> bool:
105 return Path(rel).name in BOOT_BOILERPLATE or any(
106 frag in f"/{rel}" for frag in EXCLUDE_FRAGMENTS
107 )
108
109
110def scan_paths(paths: list[Path]) -> list[tuple[str, int, int, str]]:
111 """Return (file, start_line, length, signature) for every over-cap function."""
112 findings: list[tuple[str, int, int, str]] = []
113 for path in paths:
114 rel = _rel(path)
115 lang = language_of(rel)
116 parser = PARSERS.get(lang)
117 if parser is None or _skipped(rel):
118 continue
119 for start, length, signature in parser(path, THRESHOLD_LINES):
120 findings.append((rel, start, length, signature))
121 findings.sort(key=lambda f: (-f[2], f[0]))
122 return findings
123
124
125def _targets_from_args(args: list[str]) -> list[Path]:
126 out: list[Path] = []
127 for raw in args:
128 path = Path(raw)
129 if not path.is_absolute():
130 path = REPO_ROOT / path
131 if path.is_dir():
132 out.extend(p for p in path.rglob("*") if p.is_file())
133 else:
134 out.append(path)
135 return out
136
137
138def _all_targets() -> list[Path]:
139 grouped = files_for(tuple(PARSERS))
140 return [REPO_ROOT / rel for paths in grouped.values() for rel in paths]
141
142
143# ---------------------------------------------------------------------------
144# Selftest -- asserts BOTH directions, per language, before the real scan.
145# ---------------------------------------------------------------------------
146
147_LONG = THRESHOLD_LINES + 5
148
149_C_OVER = "void f(void)\n{\n" + " x();\n" * _LONG + "}\n"
150_C_UNDER = "void f(void)\n{\n" + " x();\n" * 5 + "}\n"
151_C_STRING_BRACE = 'void f(void)\n{\n const char* s = "{";\n' + " x();\n" * 5 + "}\n"
152# An empty one-line body inside a long enclosing scope. Assuming the opening
153# line contributes depth 1 made the tracker swallow the whole class.
154_CXX_EMPTY_BODY = (
155 "class C {\npublic:\n C(int a)\n : a_(a)\n {}\n" + " void g();\n" * _LONG + "};\n"
156)
157
158_PY_OVER = "def f():\n" + " x = 1\n" * _LONG
159_PY_UNDER = "def f():\n" + " x = 1\n" * 5
160_PY_DOCSTRING = 'def f():\n """\n' + " doc\n" * _LONG + ' """\n' + " x = 1\n" * 5
161_PY_NESTED = "def outer():\n def inner():\n" + " x = 1\n" * _LONG + " return inner\n"
162
163_SH_OVER = "f() {\n" + " x\n" * _LONG + "}\n"
164_SH_UNDER = "f() {\n" + " x\n" * 5 + "}\n"
165_SH_SUBSHELL_OVER = "f() (\n set -e\n" + " x\n" * _LONG + ")\n"
166_SH_SUBSHELL_UNDER = "f() (\n set -e\n" + " x\n" * 5 + ")\n"
167# A short function whose heredoc is full of stray closing braces. If the
168# heredoc body were counted, depth would hit zero early and the function
169# would measure a handful of lines -- or run away past its real end.
170_SH_HEREDOC = "f() {\n cat <<EOF\n" + " }\n" * 5 + "EOF\n" + " x\n" * 5 + "}\n"
171_SH_HEREDOC_LONG = "f() {\n cat <<EOF\n" + " }\n" * _LONG + "EOF\n" + " x\n" * 5 + "}\n"
172
173# (filename, body, expected finding count, what the case proves)
174_CASES: tuple[tuple[str, str, int, str], ...] = (
175 ("over.c", _C_OVER, 1, "C: a long function fires"),
176 ("under.c", _C_UNDER, 0, "C: a short function is clean"),
177 ("brace.c", _C_STRING_BRACE, 0, "C: a brace in a string literal is not a scope"),
178 ("empty.cpp", _CXX_EMPTY_BODY, 0, "C++: a one-line {} body does not swallow its class"),
179 ("over.py", _PY_OVER, 1, "Python: a long function fires"),
180 ("under.py", _PY_UNDER, 0, "Python: a short function is clean"),
181 ("doc.py", _PY_DOCSTRING, 0, "Python: a long docstring does not make a function long"),
182 ("nested.py", _PY_NESTED, 2, "Python: a nested function and its parent both count"),
183 ("over.sh", _SH_OVER, 1, "shell: a long brace-body function fires"),
184 ("under.sh", _SH_UNDER, 0, "shell: a short brace-body function is clean"),
185 ("sub_over.sh", _SH_SUBSHELL_OVER, 1, "shell: a long subshell body fires"),
186 ("sub_under.sh", _SH_SUBSHELL_UNDER, 0, "shell: a short subshell body is clean"),
187 ("heredoc.sh", _SH_HEREDOC, 0, "shell: braces inside a heredoc close no scope"),
188 (
189 "heredoc_long.sh",
190 _SH_HEREDOC_LONG,
191 1,
192 "shell: a long function is still caught through a heredoc",
193 ),
194 ("over.md", _PY_OVER, 0, "a non-code file is out of scope"),
195)
196
197
198def _selftest() -> int:
199 """Assert every parser fires and stays quiet, and that the scope is live.
200
201 Three textual parsers drive this gate. A parser that stops recognising a
202 construct takes that construct's offenders with it and the gate reports a
203 clean tree -- so each language is asserted in both directions here, and
204 the gate body runs this before the real scan.
205 """
206 failures: list[str] = []
207 with tempfile.TemporaryDirectory() as tmp:
208 root = Path(tmp)
209 for name, body, expected, description in _CASES:
210 path = root / name
211 path.write_text(body)
212 got = len(scan_paths([path]))
213 path.unlink()
214 if got != expected:
215 failures.append(f" FAIL {description}: expected {expected}, got {got}")
216
217 grouped = files_for(tuple(PARSERS))
218 for lang, paths in grouped.items():
219 if not paths:
220 failures.append(f" FAIL live scope: language {lang!r} resolved to zero files")
221
222 if failures:
223 print("check_function_size.py: --selftest FAILED", file=sys.stderr)
224 print("\n".join(failures), file=sys.stderr)
225 return 1
226 print(
227 f"check_function_size.py: --selftest OK ({len(_CASES)} cases across "
228 f"{len(PARSERS)} parser(s), both directions; live scope "
229 f"{sum(len(p) for p in grouped.values())} file(s))"
230 )
231 return 0
232
233
234def _report(findings: list[tuple[str, int, int, str]]) -> None:
235 print(
236 f"check_function_size.py: {len(findings)} function(s) exceed the "
237 f"{THRESHOLD_LINES}-line cap (NASA P10 Rule 4):\n",
238 file=sys.stderr,
239 )
240 print(" lines location", file=sys.stderr)
241 for rel, start, length, signature in findings:
242 shown = signature
243 if len(shown) > SIG_DISPLAY_MAX:
244 shown = shown[: SIG_DISPLAY_MAX - 3] + "..."
245 print(f" {length:5d} {rel}:{start} {shown}", file=sys.stderr)
246 print(
247 "\nExtract a helper, or split the function along its responsibilities.",
248 file=sys.stderr,
249 )
250
251
252def main(argv: list[str]) -> int:
253 """Fail any function over the Rule 4 cap, or with ``--selftest`` prove the gate fires.
254
255 Walks source text rather than ``compile_commands.json``, which is the
256 whole reason this exists alongside clang-tidy's identical rule: the host
257 unit-test build emits no entry for an ARM-cross-compiled TU, so the
258 clang-tidy version silently exempted most of port/ and every example main.
259
260 An empty target set exits 2, not 0 -- an empty scan means the derived
261 enumeration broke, and a size gate reporting success over zero files is
262 the failure this pair of checkers was rewritten to stop having.
263
264 Returns 0 when every function is within the cap, 1 when one or more exceed
265 it, and 2 when there was nothing to scan.
266 """
267 args = argv[1:]
268 if "--selftest" in args:
269 return _selftest()
270
271 targets = _targets_from_args(args) if args else _all_targets()
272 if not targets:
273 print("check_function_size.py: FATAL -- no files to scan", file=sys.stderr)
274 return 2
275
276 findings = scan_paths(targets)
277 if not findings:
278 print(
279 f"check_function_size.py: {len(targets)} file(s) scanned, "
280 f"no functions over {THRESHOLD_LINES} lines."
281 )
282 return 0
283 _report(findings)
284 return 1
285
286
287if __name__ == "__main__":
288 sys.exit(main(sys.argv))
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298