ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
audit_init_order.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"""audit_init_order.py -- per-app init-order linter.
5
6Walks every ``main.c`` under ``examples/`` at ANY depth, extracts the
7sequence of ``ra8_*_init(`` and ``ra8_board_*_init(`` calls in source
8order, and verifies the sequence respects the project-wide canonical
9ordering:
10
11 CGC -> MSTP -> IOPORT -> peripherals
12
13The check is structural rather than full-graph: for every adjacent pair
14of init calls we ensure the earlier one has a lower-or-equal canonical
15rank. Calls outside the ranked set (e.g. ``ra8_acmphs_init``) are
16treated as "peripheral" and must come last. The script emits a warning
17for each offending pair and -- in the default STRICT mode -- exits
18non-zero if any app fails. Pass ``--no-strict`` to downgrade to
19warn-only output (useful while landing a fix).
20
21Optionally writes a Markdown report to a path given via ``--report``.
22
23Copyright (c) 2026 Brighton Sikarskie
24SPDX-License-Identifier: MIT
25"""
26
27from __future__ import annotations
28
29import argparse
30import re
31import sys
32import tempfile
33from dataclasses import dataclass
34from pathlib import Path
35
36sys.path.insert(0, str(Path(__file__).resolve().parent))
37
38from selftest_assert import expect, report # needs the sys.path line above
39
40# Minimum number of apps a healthy discovery finds. Measured at 217; the floor
41# sits well below that so adding or removing apps does not trip it, but far
42# above the 11 the old depth-capped glob returned -- which is the number this
43# floor exists to reject.
44APP_FLOOR = 150
45
46# Number of init calls in the selftest's well-ordered fixture. Named so the
47# assertion reads as an expectation rather than a bare literal.
48SELFTEST_ORDERED_CALLS = 3
49
50# Canonical init-order ranking. Higher rank = later in boot.
51#
52# Anything not listed here is implicitly rank PERIPHERAL_RANK (peripherals
53# must be initialized after the core CGC -> MSTP -> IOPORT chain).
54RANK_CGC = 10
55RANK_MSTP = 20
56RANK_IOPORT = 30
57RANK_TIME = 35 # SysTick is bound to the CPU clock; treat as core init
58RANK_ISR_CORE = 38 # ICU / NVIC bring-up before peripherals
59RANK_PERIPHERAL = 100
60
61# Token (substring of the called function) -> canonical rank.
62RANK_TABLE = {
63 "ra8_cgc_init": RANK_CGC,
64 "ra8_cgc_get_clock_hz": RANK_CGC,
65 "ra8_mstp_init": RANK_MSTP,
66 "ra8_pfs_init": RANK_IOPORT,
67 "ra8_pfs_route": RANK_IOPORT,
68 "ra8_gpio_init": RANK_IOPORT,
69 "ra8_gpio_output_init": RANK_IOPORT,
70 "ra8_gpio_input_init": RANK_IOPORT,
71 "ra8_port_init": RANK_IOPORT,
72 "ra8_pin_validator": RANK_IOPORT,
73 "ra8_time_init": RANK_TIME,
74 "ra8_systick_init": RANK_TIME,
75 "ra8_icu_init": RANK_ISR_CORE,
76 "ra8_isr_init": RANK_ISR_CORE,
77}
78
79INIT_CALL_RE = re.compile(r"\b(ra8_[a-z0-9_]+|ra8_board_[a-z0-9_]+)\‍(", re.IGNORECASE)
80
81
82@dataclass
83class InitCall:
84 """One ``ra8_*_init`` call site recorded from main.c."""
85
86 name: str # function symbol
87 line: int # 1-based line number in main.c
88 rank: int # canonical rank
89
90
91@dataclass
92class AppAudit:
93 """Audit result for a single app."""
94
95 app: str
96 main_path: Path
97 calls: list[InitCall]
98 violations: list[tuple[InitCall, InitCall]]
99
100
101def rank_for(symbol: str) -> int:
102 """Return the canonical rank for ``symbol`` (substring match)."""
103 for key, rank in RANK_TABLE.items():
104 if symbol.startswith(key):
105 return rank
106 return RANK_PERIPHERAL
107
108
109def is_init_call(symbol: str) -> bool:
110 """Return True for symbols we want to track."""
111 if symbol == "ra8_cgc_get_clock_hz":
112 return True
113 return symbol.endswith(("_init", "_pins_init")) or "_init_" in symbol
114
115
116_MAIN_SIG_RE = re.compile(r"\b(?:int|int32_t|void)\s+main\s*\‍(")
117
118
119def extract_calls(main_path: Path) -> list[InitCall]:
120 """Walk ``main_path`` and pull every init-style call in source order.
121
122 Only calls whose source position falls inside the body of ``main()``
123 are considered: helper functions defined above ``main`` (like a
124 static ``demo_pins_init``) routinely call ``ra8_*_init`` symbols in
125 an order that matches the helper's local logic, not the boot-time
126 sequence the audit cares about. The textual brace-tracker below is
127 intentionally simple -- it works for the project's hand-written
128 ``main()`` style (no nested function defs, no preprocessor games).
129 """
130 calls: list[InitCall] = []
131 text = main_path.read_text(encoding="ascii", errors="replace")
132 in_main = False
133 depth = 0
134 for lineno, line in enumerate(text.splitlines(), start=1):
135 # Skip pure comment lines.
136 stripped = line.lstrip()
137 if stripped.startswith(("//", "*")):
138 continue
139 if not in_main and _MAIN_SIG_RE.search(line) is not None:
140 in_main = True
141 depth = 0
142 if in_main:
143 for ch in line:
144 if ch == "{":
145 depth += 1
146 elif ch == "}":
147 depth -= 1
148 if depth <= 0:
149 # Closed main(); stop scanning.
150 return calls
151 if depth == 0:
152 # Haven't entered the body yet (signature line before '{').
153 continue
154 for match in INIT_CALL_RE.finditer(line):
155 sym = match.group(1)
156 if not is_init_call(sym):
157 continue
158 calls.append(InitCall(name=sym, line=lineno, rank=rank_for(sym)))
159 return calls
160
161
162def audit_app(app: str, main_path: Path) -> AppAudit:
163 """Run the rank-monotonicity check for one app."""
164 calls = extract_calls(main_path)
165 violations: list[tuple[InitCall, InitCall]] = []
166 last: InitCall | None = None
167 for call in calls:
168 if last is not None and call.rank < last.rank:
169 violations.append((last, call))
170 last = call
171 return AppAudit(app=app, main_path=main_path, calls=calls, violations=violations)
172
173
174def collect_apps(repo_root: Path) -> list[tuple[str, Path]]:
175 """Discover every app ``src/main.c`` under ``examples/``, at any depth.
176
177 The glob used to be ``examples/*/*/main.c`` -- exactly three levels -- while
178 the tree's real layout is ``examples/<tier>/.../<app>/``, up to five deep.
179 It therefore audited 11 of 217 apps and reported "0 with violations" on the
180 other 206, for as long as it had been wired into the pre-commit hook and the
181 ``pre-commit-checks`` gate. The same depth-capped glob had already been
182 found and fixed in the clang-tidy file collector; this is the second
183 instance, so the fix here is the recursive form plus the floor in ``main()``
184 that makes a collapsed discovery fail instead of pass.
185 """
186 return [
187 (main.parent.parent.name, main) for main in sorted(repo_root.glob("examples/**/src/main.c"))
188 ]
189
190
191def render_markdown(audits: list[AppAudit], repo_root: Path) -> str:
192 """Render a human-readable Markdown report of the audit run."""
193 lines: list[str] = []
194 lines.append("# Per-app Init-Order Audit")
195 lines.append("")
196 lines.append(
197 "Generated by ``scripts/checks/audit_init_order.py``. Validates that"
198 " every app's main.c follows the canonical CGC -> MSTP -> IOPORT ->"
199 " peripheral order."
200 )
201 lines.append("")
202 bad = [a for a in audits if a.violations]
203 lines.append(f"- Apps audited: {len(audits)}")
204 lines.append(f"- Apps with violations: {len(bad)}")
205 lines.append("")
206 if bad:
207 lines.append("## Violations")
208 lines.append("")
209 for audit in bad:
210 lines.append(f"### {audit.app}")
211 for prev, cur in audit.violations:
212 lines.append(
213 f"- {prev.name} (rank {prev.rank}, line {prev.line}) precedes "
214 f"{cur.name} (rank {cur.rank}, line {cur.line})"
215 )
216 lines.append("")
217 lines.append("## Per-app init sequences")
218 lines.append("")
219 for audit in audits:
220 rel = audit.main_path.relative_to(repo_root)
221 lines.append(f"### {audit.app}")
222 lines.append("")
223 lines.append(f"Source: ``{rel}``")
224 lines.append("")
225 if not audit.calls:
226 lines.append("- (no init calls detected)")
227 else:
228 lines.extend(f"- L{c.line}: {c.name} (rank {c.rank})" for c in audit.calls)
229 lines.append("")
230 return "\n".join(lines) + "\n"
231
232
233def selftest() -> int:
234 """Prove the ordering detector and the discovery floor, in both directions.
235
236 The two properties are asserted separately on purpose. A detector that has
237 stopped matching and a tree that is genuinely ordered produce identical
238 output, and so do a collapsed glob and an empty ``examples/``; only an
239 explicit assertion tells them apart.
240
241 Returns:
242 0 when every assertion held, 1 otherwise.
243 """
244 failures: list[str] = []
245 root = Path(__file__).resolve().parents[2]
246
247 with tempfile.TemporaryDirectory() as tmp:
248 app = Path(tmp) / "examples" / "tier" / "group" / "deep" / "app"
249 src = app / "src"
250 src.mkdir(parents=True)
251
252 # Written in the project's multi-line main() style: extract_calls walks
253 # braces to stay inside main()'s body, so a one-line body would open and
254 # close before any call is seen and BOTH directions would pass vacuously.
255 def app_main(*calls: str) -> str:
256 body = "\n".join(f" {call}();" for call in calls)
257 return f"int main(void)\n{{\n{body}\n return 0;\n}}\n"
258
259 (src / "main.c").write_text(app_main("ra8_cgc_init", "ra8_mstp_init", "ra8_sci_init"))
260 found = collect_apps(Path(tmp))
261 expect(len(found) == 1, "recursive discovery reaches a nested app src/main.c", failures)
262 ordered = audit_app("app", src / "main.c")
263 expect(
264 len(ordered.calls) == SELFTEST_ORDERED_CALLS,
265 "all three init calls are extracted from main()",
266 failures,
267 )
268 expect(not ordered.violations, "a correctly ordered app stays quiet", failures)
269
270 (src / "main.c").write_text(app_main("ra8_sci_init", "ra8_cgc_init"))
271 inverted = audit_app("app", src / "main.c")
272 expect(bool(inverted.violations), "a peripheral before CGC fires", failures)
273
274 live = collect_apps(root)
275 expect(
276 len(live) >= APP_FLOOR,
277 f"live discovery sees {len(live)} app(s) (floor {APP_FLOOR})",
278 failures,
279 )
280 return report(failures)
281
282
283def build_parser() -> argparse.ArgumentParser:
284 """Build the command-line parser for this gate."""
285 parser = argparse.ArgumentParser(description=__doc__)
286 parser.add_argument(
287 "--repo-root",
288 type=Path,
289 default=Path(__file__).resolve().parents[2],
290 help="Repository root (defaults to the script's grandparent).",
291 )
292 parser.add_argument(
293 "--report",
294 type=Path,
295 default=None,
296 help="Optional Markdown report output path.",
297 )
298 parser.add_argument(
299 "--selftest",
300 action="store_true",
301 help="prove the ordering detector fires on a bad sequence and spares a good one",
302 )
303 parser.add_argument(
304 "--strict",
305 action=argparse.BooleanOptionalAction,
306 default=True,
307 help=(
308 "Exit non-zero if any app has a violation (default: True). "
309 "Use --no-strict to suppress the non-zero exit (warn-only mode)."
310 ),
311 )
312 return parser
313
314
315def main() -> int:
316 """Gate the canonical CGC -> MSTP -> IOPORT -> peripheral init order.
317
318 Strict by default: an app whose init calls run out of canonical order
319 fails the run. ``--no-strict`` downgrades to warn-only output, which is
320 useful while landing a fix but is not what any caller in this tree passes.
321
322 Discovery is floored. A glob that stops matching does not report
323 violations it cannot see -- it reports a clean tree, which reads as an
324 improvement. So a discovery below ``APP_FLOOR`` is a hard error rather
325 than a quiet success.
326
327 Returns:
328 0 when every discovered app is ordered correctly, 1 when any is not
329 (strict mode), and 2 when discovery collapsed.
330 """
331 args = build_parser().parse_args()
332
333 if args.selftest:
334 return selftest()
335
336 apps = collect_apps(args.repo_root)
337 if len(apps) < APP_FLOOR:
338 print(
339 f"audit_init_order: FATAL -- discovered only {len(apps)} app(s) under "
340 f"{args.repo_root / 'examples'}, below the floor of {APP_FLOOR}.\n"
341 " A collapsed discovery reports success because it saw nothing. The\n"
342 " glob was depth-capped at three levels for the life of this gate and\n"
343 " audited 11 of 217 apps; the floor exists so that cannot recur.",
344 file=sys.stderr,
345 )
346 return 2
347
348 audits = [audit_app(app, main) for app, main in apps]
349 bad_count = 0
350 for audit in audits:
351 if not audit.violations:
352 continue
353 bad_count += 1
354 rel = audit.main_path.relative_to(args.repo_root)
355 for prev, cur in audit.violations:
356 print(
357 f"WARN {audit.app}: {rel}:{cur.line}: "
358 f"{cur.name} (rank {cur.rank}) follows {prev.name} "
359 f"(rank {prev.rank}) at line {prev.line}"
360 )
361
362 if args.report is not None:
363 args.report.write_text(render_markdown(audits, args.repo_root))
364 print(f"Report written to {args.report}")
365
366 print(f"audit_init_order: {len(audits)} apps audited, {bad_count} with violations.")
367 return 1 if (args.strict and bad_count) else 0
368
369
370if __name__ == "__main__":
371 sys.exit(main())
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298