ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
stack_usage_check.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"""Aggregate gcc ``-fstack-usage`` .su files into a project-wide stack report.
5
6Background
7----------
8
9NASA Power-of-10 Rule 3 forbids dynamic memory allocation after init.
10The silent equivalent is unbounded stack growth: a deep call chain or a
11single oversized frame can blow past the linker-reserved stack region
12and corrupt adjacent RAM. IEC 61508 SIL 3 and DO-178C Level B both
13require the project to *demonstrate* a worst-case stack bound, not
14merely assume one.
15
16When compiled with ``-fstack-usage``, gcc emits a ``<file>.su`` file
17next to each ``.o``. Each line has the form::
18
19 path/to/file.c:LINE:COL:function_name<TAB>FRAME_BYTES<TAB>QUALIFIER
20
21where QUALIFIER is one of ``static``, ``dynamic``, ``bounded``, or
22combinations like ``dynamic,bounded``.
23
24What this script does
25---------------------
26
271. Walks every ``examples/**/build*/**/*.su`` file.
282. For each function: records TU, function name, frame size, qualifier.
293. Flags any function with frame > ``--frame-limit`` (default 2048) or
30 with a ``dynamic`` qualifier.
314. Writes ``build/stack_usage.csv`` and a per-app
32 ``build/stack_usage_<app>.txt``.
335. Exits non-zero if a critical-path module
34 (ra8_isr, ra8_check, ra8_err, ra8_mpu, ra8_cgc, ra8_pfs) has any function
35 with frame > 256 bytes or any ``dynamic`` qualifier anywhere.
36
37This script is report-only by default for the rest of the codebase --
38the per-target ``-Wstack-usage=N`` warning (in cmake/ra8_warnings.cmake)
39is the build-time gate; this script is the project-wide aggregator.
40"""
41
42from __future__ import annotations
43
44import argparse
45import csv
46import re
47import sys
48import tempfile
49from collections import defaultdict
50from pathlib import Path
51
52DEFAULT_FRAME_LIMIT = 2048
53CRITICAL_FRAME_LIMIT = 256
54CRITICAL_MODULES = (
55 "ra8_isr",
56 "ra8_check",
57 "ra8_err",
58 "ra8_mpu",
59 "ra8_cgc",
60 "ra8_pfs",
61)
62
63# --- Enumeration floor (issue #386) ------------------------------------------
64#
65# A scan that parsed ZERO functions -- no .su files at all, or .su files that
66# decode to nothing -- used to exit 0, byte-for-byte identical to a genuine
67# clean pass. A build-flag change, a moved build directory, or a compiler swap
68# silently produces zero .su files; the gate then reported success over a stack
69# budget it never measured. On a bare-metal target a blown frame is memory
70# corruption rather than an exception, so this is the highest-consequence
71# instance of the "green while measuring nothing" defect this tree keeps
72# finding, and it left every RA8_MAX_STACK annotation unverified while it
73# appeared enforced.
74#
75# The floor makes a degraded enumeration read as FAILURE, never as an
76# improvement -- the same shape as check_lint_coverage.py's FILE_FLOOR and
77# check_annotations.py's MIN_CALL_RESOLUTION. Below it the gate exits non-zero
78# and says the sweep collapsed.
79#
80# Calibrated against a full cross-build: the `build-cross` gate runs
81# scripts/builders/all_examples.sh (218 apps at time of writing), then the
82# `stack-usage` gate aggregates its .su output. That build parses 90,140
83# first-party-plus-SOUP functions from 7552 .su files. The floor sits far below
84# that (~5.5%) so any legitimate build clears it with an 18x margin, while the
85# collapse modes above -- which drive the count to zero or a handful -- trip it
86# unambiguously. --allow-empty (the pre-commit path) skips the floor, because a
87# single-app local build legitimately parses far fewer than a full sweep.
88DEFAULT_MIN_FUNCTIONS = 5000
89
90# Process exit codes. Distinct so a caller (and a reader of the CI log) can tell
91# "the code has an over-budget frame" from "this gate could not measure its
92# subject", mirroring the convention in check_lint_coverage.py.
93RC_OK = 0
94RC_VIOLATION = 1
95RC_ENUMERATION_BROKE = 2
96
97# --- Strict-mode partitioning -------------------------------------------------
98#
99# In `--strict` mode the soft per-app frame limit is promoted to a hard
100# gate, but only for *first-party* code (everything outside
101# THIRD_PARTY_PATH_FRAGMENTS). Third-party SOUP is exempt because:
102#
103# * It is pre-qualified and lives under one of the two canonical third-party
104# roots -- its justification documents are filed under docs/SOUP/ (see
105# docs/SOUP/README.md).
106# * The largest current offenders (miniz mz_zip_reader_*,
107# tinfl_decompress_mem_to_*, ~10 kB frames) are deflate / zip
108# decoder helpers that are only invoked from the ereader app's
109# dedicated worker thread, which carries a generously-sized stack.
110#
111# The FIRST_PARTY_EXEMPTIONS list is the explicit, documented escape
112# hatch for first-party functions that have a justified large frame.
113# Add a tuple `(tu_substring, function_name, max_bytes, rationale)` per
114# entry; the gate will accept any first-party frame at or below
115# `max_bytes` for that function. Empty list today means *no* first-
116# party function is exempt.
117
118THIRD_PARTY_PATH_FRAGMENTS = (
119 "/libs/third_party/",
120 "/apps/shared_libs/third_party/",
121 # Vendor-supplied port shims live under port/ but mostly call into
122 # third_party/ libraries; they remain first-party and gated.
123)
124
125FIRST_PARTY_EXEMPTIONS = (
126 # ("tu_substring", "function_name", max_bytes, "rationale"),
127 #
128 # Example template -- intentionally empty today (every first-party
129 # function is currently under the 2048-byte default limit, see
130 # `python3 scripts/checks/stack_usage_check.py --strict` against
131 # HEAD as of the commit that added this list):
132 #
133 # (
134 # "apps/shared_libs/epub/src/epub_open.c",
135 # "priv_parse_archive",
136 # 4344,
137 # "ZIP central-directory parser: 4 kB scratch struct on stack "
138 # "to avoid heap; only invoked once at chapter open from the "
139 # "ereader worker thread (8 kB stack budget).",
140 # ),
141)
142
143
144def is_first_party(tu_path: str) -> bool:
145 """Whether a .su translation-unit path is ours rather than vendored SOUP.
146
147 Decided by path fragment against the vendored trees, because the TU path
148 inside a .su is whatever the compiler was given and is not reliably
149 repo-relative -- a prefix test would miss an absolute one.
150 """
151 return not any(frag in tu_path for frag in THIRD_PARTY_PATH_FRAGMENTS)
152
153
154def exemption_for(tu_path: str, func: str) -> int:
155 """Return the exempt max-bytes for a first-party (tu, func) pair, or 0."""
156 for tu_frag, fn_name, max_bytes, _why in FIRST_PARTY_EXEMPTIONS:
157 if tu_frag in tu_path and fn_name == func:
158 return max_bytes
159 return 0
160
161
162_SU_LINE = re.compile(
163 r"^(?P<path>.+?):(?P<line>\d+):(?P<col>\d+):(?P<func>[^\s\t]+)"
164 r"[\t ]+(?P<bytes>\d+)[\t ]+(?P<qual>[A-Za-z,]+)\s*$"
165)
166
167
168class StackEntry:
169 """One function's stack frame, as reported by a single line of a .su file.
170
171 ``tu`` is the path the COMPILER recorded, not where the .su was found. The
172 two diverge for the shared-library archive, and the gates key off ``tu``
173 precisely so that a critical-path frame is caught wherever it was built.
174 """
175
176 __slots__ = ("app", "bytes_", "func", "qualifier", "su_file", "tu")
177
178 def __init__( # noqa: PLR0913 # data class init, all fields are required
179 self,
180 app: str,
181 tu: str,
182 func: str,
183 bytes_: int,
184 qualifier: str,
185 su_file: Path,
186 ) -> None:
187 """Record one parsed .su line; every field is required and none is derived."""
188 self.app = app
189 self.tu = tu
190 self.func = func
191 self.bytes_ = bytes_
192 self.qualifier = qualifier
193 self.su_file = su_file
194
195 @property
196 def is_dynamic(self) -> bool:
197 """Whether gcc reported this frame as dynamically sized.
198
199 The qualifier is a comma-separated set, so it is split and matched as a
200 whole token rather than by substring -- a compound qualifier must be
201 decomposed, not pattern-matched.
202
203 ``dynamic,bounded`` counts as dynamic here even though gcc could bound
204 it. That is the conservative reading and it is deliberate: a bounded
205 dynamic frame still has no single compile-time size to put in a
206 worst-case stack budget, so it is surfaced for a human to judge.
207 """
208 return "dynamic" in self.qualifier.split(",")
209
210 @property
211 def is_critical(self) -> bool:
212 """Whether this frame belongs to a module held to the tighter 256-byte limit.
213
214 Matches the module name in the TU path OR in the function name, since a
215 critical-path function can be defined in a differently-named TU; either
216 hit is enough to pull the frame under the stricter budget.
217 """
218 return any(m in self.tu for m in CRITICAL_MODULES) or any(
219 m in self.func for m in CRITICAL_MODULES
220 )
221
222
223# The CI cross-build fast path (scripts/builders/all_examples.sh) compiles the
224# universal first-party library set (ra8_core / ra8_hal / ra8_net_pal /
225# ra8_usb_pal / board / secure_app) ONCE into a static archive under
226# build/shared_libs/ instead of recompiling it into every app, so those
227# sources' .su files land there rather than under examples/<app>/build/. That
228# archive holds the critical-path modules (ra8_isr / ra8_check / ra8_err /
229# ra8_mpu / ra8_cgc / ra8_pfs), so it MUST be aggregated too -- otherwise the
230# gate would silently lose its whole shared-library surface. See
231# cmake/ra8_shared_libs.cmake.
232SHARED_LIB_BUILD_SUBDIR = "build/shared_libs"
233SHARED_LIB_APP_NAME = "ra8_shared"
234
235
236def find_su_files(repo_root: Path) -> list:
237 """Collect every gcc ``.su`` file the cross-build emits.
238
239 Two roots:
240
241 * ``examples/`` -- each app's per-app CMake output lives in ``<app>/
242 build*/``, nested 2-4 levels below ``examples/`` (``examples/<tier>/
243 .../<app>/``). A ``.su`` is only ever emitted inside such a build tree,
244 so we recurse to any depth rather than assume a fixed layout. This also
245 covers the ineligible apps (TrustZone, non-ek board) that the fast path
246 still compiles from source per-app.
247
248 * ``build/shared_libs/`` -- the prebuilt universal-library archive the
249 fast path compiles once (see ``SHARED_LIB_BUILD_SUBDIR``). Absent on a
250 per-app-only build, so this is additive and never regresses the old
251 examples-only sweep.
252 """
253 found: list = []
254 examples = repo_root / "examples"
255 if examples.is_dir():
256 found.extend(p for p in examples.rglob("*.su") if p.is_file())
257 shared = repo_root / SHARED_LIB_BUILD_SUBDIR
258 if shared.is_dir():
259 found.extend(p for p in shared.rglob("*.su") if p.is_file())
260 return sorted(found)
261
262
263def app_name_for(su_file: Path, repo_root: Path) -> str:
264 """Attribute a .su file to the app whose build tree produced it.
265
266 Used only for GROUPING the per-app reports. The critical-path and
267 first-party gates deliberately key off the TU path recorded inside the .su
268 instead, so a shared-library frame is still judged correctly despite being
269 filed under a synthetic app name.
270
271 Returns "unknown" rather than raising for a .su outside both known roots:
272 an unattributable file should still appear in the aggregate report.
273 """
274 # Archive .su files live under build/shared_libs/, not examples/. Attribute
275 # them to a synthetic app so the per-app report groups them; the
276 # critical-path and first-party gates key off the real TU path (e.g.
277 # libs/ra8_hal/src/ra8_isr.c), which is preserved inside the .su, so those
278 # gates still fire on shared-library frames.
279 try:
280 su_file.relative_to(repo_root / SHARED_LIB_BUILD_SUBDIR)
281 except ValueError:
282 pass
283 else:
284 return SHARED_LIB_APP_NAME
285 try:
286 rel = su_file.relative_to(repo_root / "examples")
287 except ValueError:
288 return "unknown"
289 parts = rel.parts
290 # The app directory is the path component immediately preceding the
291 # first `build*` segment: examples/<tier>/.../<app>/build*/... .
292 for i, part in enumerate(parts):
293 if part.startswith("build"):
294 return parts[i - 1] if i > 0 else "unknown"
295 return parts[0] if parts else "unknown"
296
297
298def parse_su(su_file: Path, app: str) -> list:
299 """Parse one .su file into StackEntry records, skipping anything unrecognised.
300
301 Every failure mode here is a silent skip -- an unreadable file, a line that
302 does not match the .su grammar, a byte count that is not an integer. That
303 tolerance suits a format gcc owns and may extend, but note the consequence:
304 if the .su grammar ever changed wholesale this would return no entries, and
305 ``main`` reports a parse of zero functions as success. The gate would go
306 quiet rather than fail. Nothing currently detects that.
307 """
308 entries = []
309 try:
310 text = su_file.read_text(encoding="utf-8", errors="replace")
311 except OSError:
312 return entries
313 for raw_line in text.splitlines():
314 line = raw_line.strip()
315 if not line:
316 continue
317 match = _SU_LINE.match(line)
318 if not match:
319 continue
320 try:
321 nbytes = int(match.group("bytes"))
322 except ValueError:
323 continue
324 entries.append(
325 StackEntry(
326 app,
327 match.group("path"),
328 match.group("func"),
329 nbytes,
330 match.group("qual"),
331 su_file,
332 )
333 )
334 return entries
335
336
337def write_csv(out_path: Path, entries: list) -> None:
338 """Write the flat machine-readable report of every parsed frame.
339
340 Written with ``newline=""`` as the csv module requires, and in ASCII so a
341 non-ASCII symbol name fails here rather than producing a file the rest of
342 the ASCII-only toolchain cannot read.
343 """
344 out_path.parent.mkdir(parents=True, exist_ok=True)
345 with out_path.open("w", encoding="ascii", newline="") as fh:
346 writer = csv.writer(fh)
347 writer.writerow(["app", "tu", "function", "frame_bytes", "qualifier"])
348 for e in entries:
349 writer.writerow([e.app, e.tu, e.func, e.bytes_, e.qualifier])
350
351
352def write_per_app(out_dir: Path, entries: list) -> None:
353 """Write one human-readable report per app, biggest frame first.
354
355 Sorted descending because these files are read to answer "what should I
356 shrink?", so the answer belongs at the top rather than after a scroll.
357 """
358 by_app = defaultdict(list)
359 for e in entries:
360 by_app[e.app].append(e)
361 out_dir.mkdir(parents=True, exist_ok=True)
362 for app, rows in sorted(by_app.items()):
363 rows.sort(key=lambda r: r.bytes_, reverse=True)
364 path = out_dir / f"stack_usage_{app}.txt"
365 with path.open("w", encoding="ascii") as fh:
366 fh.write(f"# stack usage report for {app}\n")
367 fh.write(f"# {len(rows)} functions analysed\n")
368 fh.write("# columns: bytes qualifier function tu\n\n")
369 for r in rows:
370 fh.write(f"{r.bytes_:>8} {r.qualifier:<16} {r.func} {r.tu}\n")
371
372
373def find_violations(entries: list, frame_limit: int) -> tuple[list, list]:
374 """Partition entries into critical-path and soft breaches.
375
376 The two buckets are mutually exclusive by construction: a critical-path
377 frame is judged ONLY against the 256-byte limit and never also counted as
378 a soft breach, so one oversized function cannot be reported twice.
379
380 Returns ``(critical, soft)``.
381 """
382 critical = []
383 soft = []
384 for e in entries:
385 if e.is_critical:
386 if e.bytes_ > CRITICAL_FRAME_LIMIT or e.is_dynamic:
387 critical.append(e)
388 elif e.bytes_ > frame_limit or e.is_dynamic:
389 soft.append(e)
390 return critical, soft
391
392
393def print_top_n(entries: list, n: int) -> None:
394 """Print the ``n`` largest frames across every app to stdout.
395
396 Ranks the whole corpus rather than each app separately, which is what makes
397 it useful on a CI log: the worst frame in the tree surfaces even when it
398 lives in an app nobody was looking at.
399 """
400 ranked = sorted(entries, key=lambda r: r.bytes_, reverse=True)[:n]
401 print(f"\nTop {len(ranked)} stack-frame offenders across all apps:")
402 print(f"{'bytes':>8} {'qualifier':<16} app/function (tu)")
403 for r in ranked:
404 print(f"{r.bytes_:>8} {r.qualifier:<16} {r.app}/{r.func} ({r.tu})")
405
406
407def _add_enumeration_args(parser: argparse.ArgumentParser) -> None:
408 """Add the #386 enumeration-floor and self-check options.
409
410 Kept out of ``_build_parser`` so that function stays within the 60-line
411 NASA Rule 4 budget the ``function-size`` gate enforces; these are the
412 controls that make an empty or collapsed sweep fail loudly.
413 """
414 parser.add_argument(
415 "--min-functions",
416 type=int,
417 default=DEFAULT_MIN_FUNCTIONS,
418 help=(
419 "Enumeration floor (issue #386). A sweep that parses fewer than "
420 "this many functions is treated as a collapsed enumeration and "
421 "FAILS, so a degraded run reporting fewer results cannot read as a "
422 "pass. Ignored under --allow-empty."
423 ),
424 )
425 parser.add_argument(
426 "--allow-empty",
427 action="store_true",
428 help=(
429 "Pre-commit-friendly: a freshly cloned tree has no .su files yet, "
430 "and forcing a full app build inside the hook would cost minutes "
431 "per commit. With this flag a sweep that finds NO .su files exits "
432 "0 and the function floor is not enforced. It does NOT excuse .su "
433 "files that decode to zero functions -- that is corruption, not a "
434 "fresh clone, and still fails. Omit this flag in CI so an empty or "
435 "collapsed sweep goes red."
436 ),
437 )
438 parser.add_argument(
439 "--selftest",
440 action="store_true",
441 help=(
442 "Assert the gate fires in BOTH directions on synthetic .su "
443 "fixtures -- an empty sweep and an over-budget frame go red, a "
444 "real clean run stays green -- then exit. Proves an enumeration "
445 "collapse cannot pass as clean."
446 ),
447 )
448
449
450def _build_parser() -> argparse.ArgumentParser:
451 """Every command-line option this gate accepts."""
452 parser = argparse.ArgumentParser(
453 description="Aggregate gcc .su files into a project-wide report."
454 )
455 parser.add_argument(
456 "--repo-root",
457 type=Path,
458 default=Path(__file__).resolve().parents[2],
459 )
460 parser.add_argument(
461 "--frame-limit",
462 type=int,
463 default=DEFAULT_FRAME_LIMIT,
464 )
465 parser.add_argument("--top", type=int, default=10)
466 parser.add_argument("--quiet", action="store_true")
467 parser.add_argument(
468 "--warn-only",
469 action="store_true",
470 help=(
471 "Pre-commit-friendly mode: report soft violations and the "
472 "top-N table without failing on per-app frame breaches "
473 "or critical-module budget breaches. The only hard "
474 "failure left is a `dynamic` qualifier anywhere (NASA "
475 "Power-of-10 Rule 3 -- VLAs / alloca are forbidden). "
476 "Skips the report entirely if no .su files are present "
477 "yet (so a fresh clone never blocks a commit)."
478 ),
479 )
480 parser.add_argument(
481 "--strict",
482 action="store_true",
483 help=(
484 "Promote the soft frame limit to a hard gate for "
485 "*first-party* TUs (everything outside both third-party roots). "
486 "Third-party SOUP remains exempt. First-party functions "
487 "with a justified large frame must be enrolled in "
488 "FIRST_PARTY_EXEMPTIONS at the top of this script."
489 ),
490 )
491 _add_enumeration_args(parser)
492 return parser
493
494
495def _report_strict(soft: list, frame_limit: int) -> int:
496 """Promote a first-party soft violation into a hard failure.
497
498 Third-party SOUP stays exempt; a first-party function with a justified
499 large frame must be enrolled in FIRST_PARTY_EXEMPTIONS with a rationale.
500 """
501 offenders = []
502 for e in soft:
503 if not is_first_party(e.tu):
504 continue
505 allowed = exemption_for(e.tu, e.func)
506 if allowed > 0 and e.bytes_ <= allowed:
507 continue
508 offenders.append(e)
509 if not offenders:
510 return 0
511 print(
512 f"\nSTRICT first-party violations: "
513 f"{len(offenders)} function(s) exceed "
514 f"{frame_limit} bytes and are not exempt:"
515 )
516 for e in sorted(offenders, key=lambda r: r.bytes_, reverse=True):
517 print(f" [{e.app}] {e.func} ({e.tu}): {e.bytes_} bytes [{e.qualifier}]")
518 print(
519 "\nFix: either reduce the frame size (move scratch "
520 "buffers to module-static storage) or enroll the "
521 "function in FIRST_PARTY_EXEMPTIONS at the top of "
522 "scripts/checks/stack_usage_check.py with a written "
523 "rationale."
524 )
525 return 1
526
527
528def _report_critical(critical: list) -> None:
529 """Print the critical-module frame breaches."""
530 print(
531 f"\nCRITICAL-PATH violations "
532 f"(>{CRITICAL_FRAME_LIMIT} bytes or dynamic in "
533 f"{','.join(CRITICAL_MODULES)}):"
534 )
535 for e in sorted(critical, key=lambda r: r.bytes_, reverse=True):
536 print(f" [{e.app}] {e.func} ({e.tu}): {e.bytes_} bytes [{e.qualifier}]")
537
538
539def _report_dynamic(all_entries: list) -> int:
540 """NASA P10 Rule 3: a `dynamic` qualifier is a VLA or alloca.
541
542 Forbidden in this firmware regardless of frame size, in any module, and
543 the gate applies even in --warn-only mode. No deviation procedure.
544 """
545 dyn = [e for e in all_entries if e.is_dynamic]
546 if not dyn:
547 return 0
548 print(
549 f"\nNASA P10 Rule 3 violation: "
550 f"{len(dyn)} function(s) carry a `dynamic` qualifier "
551 f"(VLA or alloca). Forbidden in this firmware -- "
552 f"replace with a fixed-size buffer or an enum-bounded "
553 f"static array."
554 )
555 for e in sorted(dyn, key=lambda r: r.bytes_, reverse=True):
556 print(f" [{e.app}] {e.func} ({e.tu}): {e.bytes_} bytes [{e.qualifier}]")
557 return 1
558
559
560def _collect_entries(repo_root: Path, su_files: list) -> list:
561 """Parse every .su file into entries tagged with their owning app."""
562 all_entries = []
563 for su in su_files:
564 app = app_name_for(su, repo_root)
565 all_entries.extend(parse_su(su, app))
566 return all_entries
567
568
569def _print_census(su_count: int, func_count: int) -> None:
570 """Print the file/function census that tells a real pass from an empty one.
571
572 Emitted on EVERY path -- pass and fail alike, and regardless of --quiet --
573 because it is the one line that makes a green result meaningful (#386): a
574 reader can see whether the sweep actually measured anything.
575 """
576 print(f"stack_usage_check: parsed {func_count} function(s) from {su_count} .su file(s).")
577
578
579def _check_enumeration(
580 su_count: int, func_count: int, min_functions: int, allow_empty: bool
581) -> int:
582 """Decide whether the sweep measured enough to trust its verdict (#386).
583
584 A gate that examined nothing must FAIL, not pass. The three collapse modes
585 are graded distinctly:
586
587 * NO .su files -- an unbuilt or moved build tree. Under ``--allow-empty``
588 (the pre-commit path) this is a fresh clone and is tolerated; otherwise
589 it is a build that never ran and fails.
590 * .su files that decode to ZERO functions -- corruption or a grammar the
591 parser no longer understands. This is never a fresh clone, so it fails
592 even under ``--allow-empty``.
593 * FEWER functions than the floor -- a partially collapsed enumeration. A
594 run reporting far fewer results than a real build must not read as an
595 improvement, so it fails; ``--allow-empty`` skips the floor because a
596 single-app local build legitimately parses fewer.
597
598 Returns ``RC_OK`` when the sweep is trustworthy, ``RC_ENUMERATION_BROKE``
599 otherwise (after printing the reason to stderr).
600 """
601 if su_count == 0:
602 if allow_empty:
603 print(
604 "stack_usage_check: no .su files found; nothing built with "
605 "-fstack-usage yet -- allowed under --allow-empty.",
606 file=sys.stderr,
607 )
608 return RC_OK
609 print(
610 "stack_usage_check: FAIL -- no .su files found under examples/**/build*/ "
611 "or build/shared_libs/.\n"
612 " The build never ran or its output moved: there is no stack budget to "
613 "measure, so this is a failure, not a pass.\n"
614 " Run the build-cross gate first (it compiles every app with "
615 "-fstack-usage).",
616 file=sys.stderr,
617 )
618 return RC_ENUMERATION_BROKE
619
620 if func_count == 0:
621 print(
622 f"stack_usage_check: FAIL -- found {su_count} .su file(s) but parsed 0 "
623 "functions.\n"
624 " .su files present that decode to nothing is corruption or a changed "
625 ".su grammar, not a fresh clone; the stack budget went unmeasured.",
626 file=sys.stderr,
627 )
628 return RC_ENUMERATION_BROKE
629
630 if not allow_empty and func_count < min_functions:
631 print(
632 f"stack_usage_check: FAIL -- parsed only {func_count} function(s) from "
633 f"{su_count} .su file(s), below the floor of {min_functions}.\n"
634 " The enumeration collapsed (a partial build, a moved build dir, or a "
635 "compiler swap). A degraded sweep reporting fewer frames must not read as "
636 "a pass.",
637 file=sys.stderr,
638 )
639 return RC_ENUMERATION_BROKE
640
641 return RC_OK
642
643
644def main(argv: list) -> int:
645 """Aggregate every .su file, write the reports, and gate on the violations.
646
647 A green run is only evidence that stack budgets were checked when the sweep
648 actually parsed functions. Every no-input path is therefore graded by
649 ``_check_enumeration`` and prints the file/function census: an empty sweep
650 or one collapsed below ``--min-functions`` FAILS with
651 ``RC_ENUMERATION_BROKE`` rather than passing vacuously (#386). The only
652 tolerated empty case is ``--allow-empty`` with no .su files at all -- the
653 pre-commit fresh-clone path, where forcing a full build inside the hook
654 would cost minutes per commit.
655
656 Severity is tiered rather than uniform. Critical-path modules breach at 256
657 bytes, everything else at ``--frame-limit`` (2048), and a ``dynamic``
658 qualifier anywhere is a P10 Rule 3 violation regardless of size --
659 that last check runs on ALL entries and is the one thing ``--warn-only``
660 cannot downgrade.
661
662 Returns ``RC_ENUMERATION_BROKE`` (2) when the sweep measured too little to
663 trust, ``RC_VIOLATION`` (1) on a strict-mode soft breach, an un-waived
664 critical breach, or any dynamic frame, and ``RC_OK`` (0) otherwise.
665 """
666 args = _build_parser().parse_args(argv)
667
668 if args.selftest:
669 return selftest()
670
671 repo_root = args.repo_root.resolve()
672 su_files = find_su_files(repo_root)
673 all_entries = _collect_entries(repo_root, su_files)
674
675 su_count = len(su_files)
676 func_count = len(all_entries)
677 _print_census(su_count, func_count)
678
679 verdict = _check_enumeration(su_count, func_count, args.min_functions, args.allow_empty)
680 if verdict != RC_OK:
681 return verdict
682 if su_count == 0:
683 # --allow-empty with a fresh clone: nothing to measure, nothing to gate.
684 return RC_OK
685
686 out_dir = repo_root / "build"
687 write_csv(out_dir / "stack_usage.csv", all_entries)
688 write_per_app(out_dir, all_entries)
689
690 critical, soft = find_violations(all_entries, args.frame_limit)
691
692 if not args.quiet:
693 print(f" CSV: {out_dir / 'stack_usage.csv'}")
694 print(f" per-app: {out_dir}/stack_usage_<app>.txt")
695 print_top_n(all_entries, args.top)
696
697 if soft:
698 print(
699 f"\nSOFT violations: {len(soft)} function(s) over {args.frame_limit} bytes or dynamic:"
700 )
701 for e in sorted(soft, key=lambda r: r.bytes_, reverse=True):
702 print(f" [{e.app}] {e.func} ({e.tu}): {e.bytes_} bytes [{e.qualifier}]")
703
704 if args.strict and _report_strict(soft, args.frame_limit):
705 return RC_VIOLATION
706
707 if critical:
708 _report_critical(critical)
709 if not args.warn_only:
710 return RC_VIOLATION
711
712 return _report_dynamic(all_entries)
713
714
715def _write_su_fixture(build_dir: Path, name: str, lines: list) -> None:
716 """Write a synthetic .su file under a fake app build tree for the selftest."""
717 build_dir.mkdir(parents=True, exist_ok=True)
718 (build_dir / name).write_text("".join(f"{line}\n" for line in lines), encoding="ascii")
719
720
721def selftest() -> int:
722 """Assert the gate fires in BOTH directions, so a collapse cannot pass clean.
723
724 Builds throwaway .su fixtures in a temp tree and drives ``main`` against
725 them. A vacuous selftest would defeat the whole point of #386, so each
726 direction is asserted against a real return code:
727
728 * an empty sweep (no .su files, no --allow-empty) FAILS;
729 * .su files that decode to zero functions FAIL even under --allow-empty;
730 * a sweep below the floor FAILS, but the same fixture PASSES once the floor
731 is lowered -- proving the floor, not something else, is what tripped;
732 * an over-budget first-party frame FAILS under --strict;
733 * a genuine clean run PASSES.
734
735 Returns ``RC_OK`` when every direction behaves, ``RC_VIOLATION`` otherwise.
736 """
737 failures: list = []
738
739 def expect_red(got: int, label: str) -> None:
740 if got == RC_OK:
741 failures.append(f"{label}: expected non-zero, got {got}")
742
743 def expect_green(got: int, label: str) -> None:
744 if got != RC_OK:
745 failures.append(f"{label}: expected 0, got {got}")
746
747 with tempfile.TemporaryDirectory() as tmp:
748 root = Path(tmp)
749 build = root / "examples" / "app" / "build"
750 low = ["--strict", "--min-functions", "1"]
751 clean_line = "apps/shared_libs/epub/src/ok.c:10:1:small_fn\t128\tstatic"
752
753 # 1. Empty sweep -- no .su anywhere. Must FAIL without --allow-empty ...
754 expect_red(main(["--repo-root", str(root)]), "empty sweep")
755 # ... and be tolerated WITH --allow-empty (fresh clone).
756 expect_green(main(["--repo-root", str(root), "--allow-empty"]), "empty sweep, allow-empty")
757
758 # 2. .su present but decoding to zero functions -- corruption. Must FAIL
759 # even under --allow-empty.
760 _write_su_fixture(build, "junk.su", ["not a stack-usage line at all", ""])
761 expect_red(
762 main(["--repo-root", str(root), "--allow-empty"]),
763 "zero functions, allow-empty",
764 )
765
766 # 3. One clean function. Below the default floor -> FAIL on the floor ...
767 _write_su_fixture(build, "junk.su", [clean_line])
768 expect_red(main(["--repo-root", str(root), "--strict"]), "below floor")
769 # ... but PASS once the floor is lowered to admit it (isolates the floor).
770 expect_green(main(["--repo-root", str(root), *low]), "clean run, floor=1")
771
772 # 4. An over-budget first-party frame must FAIL under --strict even with
773 # the floor satisfied.
774 _write_su_fixture(
775 build,
776 "over.su",
777 [clean_line, "apps/shared_libs/epub/src/big.c:20:1:priv_scratch\t9000\tstatic"],
778 )
779 expect_red(main(["--repo-root", str(root), *low]), "over-budget frame")
780
781 if failures:
782 print("SELFTEST FAILED:", file=sys.stderr)
783 for problem in failures:
784 print(f" - {problem}", file=sys.stderr)
785 return RC_VIOLATION
786 print("selftest: stack_usage_check empty-sweep + floor + over-budget detection OK")
787 return RC_OK
788
789
790if __name__ == "__main__":
791 sys.exit(main(sys.argv[1:]))
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298