4"""Gate: NASA Power-of-10 Rule 4 -- a function fits in one display page (60 lines).
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.
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:
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
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.
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.)
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
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
58Exit 0 if every function is at or below the threshold, exit 1 otherwise.
61from __future__
import annotations
65from pathlib
import Path
67sys.path.insert(0, str(Path(__file__).resolve().parent))
71from lint_targets
import REPO_ROOT, files_for, language_of
85 "python": funcsize_py.scan,
86 "shell": funcsize_sh.scan,
91BOOT_BOILERPLATE = frozenset(
92 {
"vector_table.c",
"system_init.c",
"secure_exception.c",
"trustzone_init.c"}
95EXCLUDE_FRAGMENTS = (
"_unsupported/",)
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))
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
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]] = []
115 lang = language_of(rel)
116 parser = PARSERS.get(lang)
117 if parser
is None or _skipped(rel):
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]))
125def _targets_from_args(args: list[str]) -> list[Path]:
129 if not path.is_absolute():
130 path = REPO_ROOT / path
132 out.extend(p
for p
in path.rglob(
"*")
if p.is_file())
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]
147_LONG = THRESHOLD_LINES + 5
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"
155 "class C {\npublic:\n C(int a)\n : a_(a)\n {}\n" +
" void g();\n" * _LONG +
"};\n"
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"
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"
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"
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"),
192 "shell: a long function is still caught through a heredoc",
194 (
"over.md", _PY_OVER, 0,
"a non-code file is out of scope"),
198def _selftest() -> int:
199 """Assert every parser fires and stays quiet, and that the scope is live.
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.
206 failures: list[str] = []
207 with tempfile.TemporaryDirectory()
as tmp:
209 for name, body, expected, description
in _CASES:
211 path.write_text(body)
212 got = len(scan_paths([path]))
215 failures.append(f
" FAIL {description}: expected {expected}, got {got}")
217 grouped = files_for(tuple(PARSERS))
218 for lang, paths
in grouped.items():
220 failures.append(f
" FAIL live scope: language {lang!r} resolved to zero files")
223 print(
"check_function_size.py: --selftest FAILED", file=sys.stderr)
224 print(
"\n".join(failures), file=sys.stderr)
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))"
234def _report(findings: list[tuple[str, int, int, str]]) ->
None:
236 f
"check_function_size.py: {len(findings)} function(s) exceed the "
237 f
"{THRESHOLD_LINES}-line cap (NASA P10 Rule 4):\n",
240 print(
" lines location", file=sys.stderr)
241 for rel, start, length, signature
in findings:
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)
247 "\nExtract a helper, or split the function along its responsibilities.",
252def main(argv: list[str]) -> int:
253 """Fail any function over the Rule 4 cap, or with ``--selftest`` prove the gate fires.
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.
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.
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.
268 if "--selftest" in args:
271 targets = _targets_from_args(args)
if args
else _all_targets()
273 print(
"check_function_size.py: FATAL -- no files to scan", file=sys.stderr)
276 findings = scan_paths(targets)
279 f
"check_function_size.py: {len(targets)} file(s) scanned, "
280 f
"no functions over {THRESHOLD_LINES} lines."
287if __name__ ==
"__main__":
288 sys.exit(
main(sys.argv))
void main(void)
The application entry point Reset_Handler hands control to.