ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
annot_clang.py
Go to the documentation of this file.
1# SPDX-License-Identifier: MIT
2# Copyright (c) 2026 Brighton Sikarskie
3"""Getting libclang to actually parse this tree, and proving that it did.
4
5Two responsibilities that belong together because one is the evidence for the
6other: build the compile flags that let a translation unit parse completely,
7then measure whether it did.
8
9The measurement is not optional bookkeeping. Every call-graph rule in this
10checker is a claim about a graph libclang built, so an include that stops
11resolving does not produce a loud error -- it removes edges, the rules stop
12firing, and the gate prints *fewer* violations, which reads as an improvement.
13:data:`MIN_CALL_RESOLUTION` and :func:`check_parse_integrity` exist to make
14that failure mode fatal, and they live in the same module as the flags whose
15degradation they detect.
16"""
17
18from __future__ import annotations
19
20import contextlib
21import os
22import pathlib
23import re
24import subprocess
25import sys
26import tempfile
27
28from annot_model import ParseStats, Violation
29from annot_scope import SCAN_DIRS, is_build_output, is_excluded, is_test_path, repo_root
30
31# --------------------------------------------------------------------------
32# libclang import. ``just setup-python`` creates the repository venv and
33# installs the pinned binding there. The wheel ships a bundled
34# libclang.dylib / .so, so no system-level libclang package is required.
35# --------------------------------------------------------------------------
36try:
37 from clang import cindex
38except ImportError:
39 sys.stderr.write(
40 "check_annotations.py: 'libclang' Python package missing.\n"
41 " install the pinned repository tools: just setup-python\n"
42 )
43 sys.exit(2)
44
45
46# ``CursorKind.SECTION_ATTR`` is not exposed by every libclang release -- the
47# 18.1.x wheels used on the runners omit it. Resolve it once and fall back to
48# None so a missing kind degrades to "no section attribute detected" instead of
49# raising AttributeError, which previously aborted the entire TU walk (silently
50# dropping every TU that defined an annotated function in-place, e.g.
51# ra8_widget_*, book, ra8_rabook_*).
52SECTION_ATTR_KIND = getattr(cindex.CursorKind, "SECTION_ATTR", None)
53
54#: libclang diagnostic severity for Error (3) and Fatal (4); anything at or
55#: above this level means the parse did not see the code it was given.
56DIAG_ERROR = 3
57
58#: Minimum fraction of ``CallExpr`` cursors whose callee declaration
59#: libclang must resolve for the call-graph rules to mean anything. The
60#: tree parses at >99.8%; the floor sits far enough below that to absorb
61#: genuinely unresolvable constructs (host-only intrinsics, test hooks
62#: behind build macros) while still catching an include path that has
63#: come apart. When resolution collapses the call-graph rules stop
64#: policing anything and report a clean tree, so this is fatal.
65MIN_CALL_RESOLUTION = 0.98
66
67#: Headers that only exist after a build step generated them. A clean
68#: checkout legitimately does not have these, so an unresolved include
69#: naming one is not an include-path defect.
70GENERATED_HEADERS = frozenset(
71 {
72 "literata_latin1.h", # libs/ra8_fonts generator output
73 "ra8_npu_model_addk_fake.h", # Vela model-compiler output
74 }
75)
76
77#: Headers a clean checkout legitimately lacks on THIS platform -- distinct
78#: from GENERATED_HEADERS (never produced without a build step, on any host):
79#: these exist only on the OS that ships them. ``sys/personality.h`` is
80#: glibc/Linux-only (the ``personality()`` syscall this checker's own probe
81#: (tests/mocks/src/ra8_fake_mmap.c) calls has no Darwin/BSD equivalent), so the
82#: include can never resolve on macOS. That is expected, not a parse defect:
83#: CLAUDE.md already documents that the host test suite does not run natively
84#: on macOS at all (mmap of peripheral RAM below 4 GiB is refused there), so
85#: this file's Linux-only code path is not exercised on that host either.
86#: Empty on Linux, where the header is real and a missing resolution there
87#: would still be the include-path defect this checker exists to catch.
88NON_LINUX_MISSING_HEADERS = frozenset({"personality.h"})
89
90#: Cached include flags -- the glob is stable for a whole run.
91_INCLUDE_ARGS: list[str] = []
92
93#: Extracts the header name out of clang's "'foo.h' file not found".
94_MISSING_INCLUDE_RE = re.compile(r"'([^']+)' file not found")
95
96#: Non-conventional include roots that are intentionally outside ``inc/`` or
97#: a private ``src/`` directory. Keep this list narrow: every ordinary
98#: first-party interface belongs under ``inc/`` and is discovered recursively.
99_EXTRA_INCLUDE_ROOTS = (
100 "libs/ra8_fonts", # generated Literata header
101 "port/esp-hosted/inc/idf_compat", # exposes freertos/... compatibility headers
102 "tools/vela/generated", # generated Vela model headers
103)
104
105
106def _first_party_include_roots() -> list[pathlib.Path]:
107 """Return all conventional first-party public and private include roots.
108
109 Source units may sit at different depths (for example
110 ``tests/mocks/inc`` and ``examples/<board>/<state>/<app>/inc``), so depth
111 globs are inherently stale after a reorganization. Public/shared headers
112 are rooted at every directory named ``inc``. A ``src`` directory joins the
113 include path only when it contains a sanctioned ``*_internal.h`` contract;
114 adding every implementation directory would hide misplaced public headers
115 and create unnecessary namesake shadowing.
116 """
117 root = repo_root()
118 roots: set[pathlib.Path] = set()
119 for top in SCAN_DIRS:
120 scan_root = root / top
121 if not scan_root.is_dir():
122 continue
123 roots.update(
124 path for path in scan_root.rglob("inc") if path.is_dir() and not is_excluded(path)
125 )
126 roots.update(
127 header.parent
128 for header in scan_root.rglob("*_internal.h")
129 if "src" in header.relative_to(scan_root).parts[:-1] and not is_excluded(header)
130 )
131 return sorted(roots)
132
133
134def _vendored_include_roots() -> list[pathlib.Path]:
135 """Return header roots inside both canonical vendored-source trees.
136
137 Vendored trees are excluded from *analysis* but must stay on the
138 *include* path: ``port/`` implements interfaces those headers declare --
139 ``ble_npl_*`` for NimBLE, ``tx_application_define`` for ThreadX,
140 ``_ux_device_class_storage_*`` for USBX. Without their headers those are
141 undeclared external symbols; with them they are what they actually are,
142 implementations of a published contract.
143 """
144 platform_third_party = repo_root() / "libs" / "third_party"
145 app_third_party = repo_root() / "apps" / "shared_libs" / "third_party"
146 vendored: list[pathlib.Path] = []
147 for third_party in (platform_third_party, app_third_party):
148 if not third_party.is_dir():
149 continue
150 vendored += list(third_party.iterdir())
151 vendored += list(third_party.rglob("inc"))
152 vendored += list(third_party.rglob("include"))
153 # Not every vendored tree uses an inc/ or include/ convention. esp-hosted
154 # puts its public headers directly in host/ and common/**, and its driver
155 # includes them by bare name, so the two conventions above miss them
156 # entirely -- `esp_hosted_os_abstraction.h` went unresolved and the whole
157 # OS-abstraction seam disappeared from the call graph.
158 #
159 # The tempting fix, deriving roots from wherever a `.h` actually sits, was
160 # tried and rejected: it puts ~600 directories on the path, several of them
161 # C++ trees, and a namesake header there then shadows the one a C
162 # translation unit meant -- forty examples/ TUs started failing to resolve
163 # `cstdint`. Include shadowing is exactly what a wider path buys, so the
164 # extra roots are named per tree instead. Add a row here when a vendored
165 # tree lands whose public headers are not under inc/ or include/.
166 # NetX Duo's optional protocols live under addons/<proto>/ with the public
167 # header sitting next to its .c (nxd_dhcp_client.h, nxd_dns.h, ...), not in
168 # an inc/ or include/ subdir, so the two conventions above miss them. A
169 # first-party app that pulls one in (c6_wifi_join uses the DHCP client)
170 # would otherwise fail parse-integrity on the unresolved include.
171 for extra in (
172 "esp-hosted/host",
173 "esp-hosted/host/api/priv",
174 "esp-hosted/common",
175 "netxduo/addons/dhcp",
176 ):
177 vendored.append(platform_third_party / extra)
178 vendored += sorted((platform_third_party / "esp-hosted" / "common").glob("*"))
179 vendored += sorted((platform_third_party / "esp-hosted" / "host" / "drivers").rglob("*"))
180 seen: set[pathlib.Path] = set()
181 ordered: list[pathlib.Path] = []
182 for d in vendored:
183 if d in seen or not d.is_dir() or is_build_output(d):
184 continue
185 seen.add(d)
186 ordered.append(d)
187 return ordered
188
189
190def _include_args() -> list[str]:
191 """Return -I flags covering every header root the tree can include.
192
193 The include path must be complete, and it must be derived only from
194 the repo layout. It used to list seven hand-picked directories on the
195 theory that unresolved headers still yield a good-enough AST. They do
196 not: a call whose declaration was never seen does not resolve, so
197 ``cursor.referenced`` is None and the call site is dropped. That
198 silently starved the call-graph rules (``ra8_priv``,
199 ``ra8_test_helper``) of exactly the cross-module call sites they
200 exist to police -- the light path resolved 43k of 126k call sites,
201 and which ones resolved varied with ambient state, so the same tree
202 could pass standalone and fail under the pre-commit hook.
203
204 Widening it again is not cosmetic either. The linkage rule reads a
205 function's published interface off the file its prototype lives in,
206 so a public header the parse cannot reach turns every symbol it
207 declares into a phantom violation: the secure substrate's ``inc/`` was
208 absent, and the whole key-vault and OTA-commit API looked undeclared.
209 """
210 root = repo_root()
211 roots = _first_party_include_roots()
212 roots.extend(root / relative for relative in _EXTRA_INCLUDE_ROOTS)
213 roots = [d for d in roots if d.is_dir() and not is_excluded(d)]
214 roots.extend(_vendored_include_roots())
215
216 out: list[str] = []
217 seen: set[str] = set()
218 for d in roots:
219 if str(d) in seen:
220 continue
221 seen.add(str(d))
222 out.append(f"-I{d}")
223 return out
224
225
226def _resource_dirs_on_disk() -> list[pathlib.Path]:
227 """Return candidate clang builtin-header directories found on disk."""
228 out: list[pathlib.Path] = []
229 for base in (pathlib.Path("/usr/lib"), pathlib.Path("/usr/local/lib")):
230 if not base.is_dir():
231 continue
232 out.extend(p for p in base.glob("llvm-*/lib/clang/*/include") if p.is_dir())
233 out.extend(p for p in base.glob("clang/*/include") if p.is_dir())
234 return out
235
236
237def _probe_is_clean(extra: list[str]) -> bool:
238 """True when ``#include <stddef.h>`` parses without a fatal error."""
239 with tempfile.NamedTemporaryFile(
240 mode="w", suffix=".c", prefix=".ra8_probe_", delete=False
241 ) as f:
242 f.write("#include <stddef.h>\n#include <stdint.h>\nsize_t ra8_probe(void);\n")
243 probe_path = f.name
244 try:
245 tu = cindex.Index.create().parse(
246 probe_path, args=["-std=c23", "-x", "c", "-DRA8_HOST_BUILD=1", *extra]
247 )
248 return not [d for d in (tu.diagnostics if tu else []) if d.severity >= DIAG_ERROR]
249 except cindex.TranslationUnitLoadError:
250 return False
251 finally:
252 with contextlib.suppress(OSError):
253 pathlib.Path(probe_path).unlink()
254
255
256def _homebrew_keg_only_clang_candidates() -> list[str]:
257 """Return full paths to keg-only Homebrew ``clang-<N>`` binaries, if any.
258
259 Homebrew's ``llvm@<N>`` formulas are keg-only (a system default `clang`
260 must stay whatever Apple or the unversioned `llvm` formula provides), so
261 their ``bin/`` is never linked onto PATH -- ``command -v clang-18`` fails
262 even when ``brew install llvm@18`` succeeded and the binary is sitting
263 right there. ``brew --prefix llvm@<N>`` finds it directly, tried at the
264 same versions ``_resource_dir_from_driver`` already tries by bare name
265 (which is enough on Linux, where the devcontainer's clang-18 package
266 installs onto PATH normally). A missing ``brew`` (Linux, or a Mac without
267 it) degrades to an empty list.
268 """
269 out: list[str] = []
270 for major in ("22", "21", "20", "19", "18"):
271 try:
272 res = subprocess.run( # noqa: S603 -- fixed argv, no shell
273 ["brew", "--prefix", f"llvm@{major}"], # noqa: S607 -- brew from PATH is intended
274 capture_output=True,
275 text=True,
276 timeout=20,
277 check=False,
278 )
279 except (OSError, subprocess.SubprocessError):
280 continue
281 if res.returncode != 0 or not res.stdout.strip():
282 continue
283 candidate = pathlib.Path(res.stdout.strip()) / "bin" / f"clang-{major}"
284 if candidate.is_file():
285 out.append(str(candidate))
286 return out
287
288
289def _resource_dir_from_driver() -> list[str]:
290 """Ask a real clang binary where its builtin headers live."""
291 candidates = [
292 os.environ.get("RA8_CLANG"),
293 "clang",
294 "clang-22",
295 "clang-21",
296 "clang-20",
297 "clang-19",
298 "clang-18",
299 *_homebrew_keg_only_clang_candidates(),
300 ]
301 for exe in candidates:
302 if not exe:
303 continue
304 try:
305 res = subprocess.run( # noqa: S603 # fixed argv, no shell
306 [exe, "-print-resource-dir"],
307 capture_output=True,
308 text=True,
309 timeout=20,
310 check=False,
311 )
312 except (OSError, subprocess.SubprocessError):
313 continue
314 if res.returncode != 0:
315 continue
316 inc = pathlib.Path(res.stdout.strip()) / "include"
317 if not (inc / "stddef.h").is_file():
318 continue
319 # Only adopt this resource dir if it is actually compatible with the
320 # loaded libclang. A mismatched pair (e.g. clang 22 headers against
321 # older bindings) fails inside stdint.h with "__INT32_C is not
322 # defined", which is worse than not setting it at all.
323 if _probe_is_clean([f"-isystem{inc}"]):
324 return [f"-isystem{inc}"]
325 return []
326
327
328def _darwin_sysroot_args() -> list[str]:
329 """Return -isysroot and Homebrew include flags, on macOS only.
330
331 libclang loaded through the Python bindings has no notion of the
332 platform SDK a real ``clang`` driver invocation picks up by default, so
333 on macOS libc headers behind the SDK sysroot (``<stdio.h>``, most of
334 ``<stdint.h>``'s transitive includes, and everything else under
335 ``usr/include``) do not resolve -- unlike the compiler's OWN builtin
336 headers (``stddef.h``, ``stdarg.h``, ...), which ``_builtin_include_args``
337 already supplies from the resource directory and which resolve with or
338 without a sysroot. ``xcrun --show-sdk-path`` reports the active SDK
339 regardless of whether it is a full Xcode install or just the Command
340 Line Tools, so ask it rather than hardcode a path that drifts with
341 every OS/Xcode update. Measured effect on this tree: 86.6% call-site
342 resolution without a sysroot, 99.9%+ with one (#488).
343
344 ``tools/ra8_emulator`` includes ``<unicorn/unicorn.h>``, which on macOS
345 is a Homebrew package living outside any path clang searches by
346 default (unlike Linux, where the devcontainer installs it under
347 ``/usr/local/include``). Add the active Homebrew prefix's ``include/``
348 so that header, and any other Homebrew-installed header, resolves the
349 same way it does for a real local ``clang`` invocation.
350
351 A missing ``xcrun`` / ``brew`` (this checker also runs on Linux, where
352 neither exists) degrades to an empty list rather than raising -- the
353 Linux parse already reaches the floor without either flag.
354 """
355 if sys.platform != "darwin":
356 return []
357 out: list[str] = []
358 try:
359 sdk = subprocess.run(
360 ["xcrun", "--show-sdk-path"], # noqa: S607 -- xcrun from PATH is intended
361 capture_output=True,
362 text=True,
363 timeout=20,
364 check=False,
365 )
366 except (OSError, subprocess.SubprocessError):
367 sdk = None
368 if sdk is not None and sdk.returncode == 0 and sdk.stdout.strip():
369 out.append(f"-isysroot{sdk.stdout.strip()}")
370 try:
371 brew = subprocess.run(
372 ["brew", "--prefix"], # noqa: S607 -- brew from PATH is intended
373 capture_output=True,
374 text=True,
375 timeout=20,
376 check=False,
377 )
378 except (OSError, subprocess.SubprocessError):
379 brew = None
380 if brew is not None and brew.returncode == 0 and brew.stdout.strip():
381 inc = pathlib.Path(brew.stdout.strip()) / "include"
382 if inc.is_dir():
383 out.append(f"-I{inc}")
384 return out
385
386
387def _builtin_include_args() -> list[str]:
388 """Return -isystem flags for clang's own builtin headers.
389
390 libclang loaded through the Python bindings does not know where its
391 resource directory lives, so ``stddef.h`` and friends may not resolve.
392 That is not cosmetic: a failed system include aborts the rest of the
393 include chain, later declarations are never seen, and the calls that
394 depend on them silently vanish from the call graph. Ask a real clang
395 binary where its resource dir is and put that on the path.
396 """
397 from_driver = _resource_dir_from_driver()
398 if from_driver:
399 return from_driver
400 # No clang driver on PATH: the pip wheel bundles libclang.so but no
401 # builtin headers, so fall back to whatever a distro LLVM package left
402 # on disk. Newest first, so a box with several LLVMs picks the one
403 # closest to the bindings.
404 for inc in sorted(_resource_dirs_on_disk(), reverse=True):
405 if (inc / "stddef.h").is_file() and _probe_is_clean([f"-isystem{inc}"]):
406 return [f"-isystem{inc}"]
407 return []
408
409
410def _crypto_config_args() -> list[str]:
411 """Return the Mbed TLS / TF-PSA config selectors ``cmake/mbedtls.cmake`` sets.
412
413 The vendored crypto headers are a maze of ``#if`` on the project
414 config, and without pointing them at the same config file the build
415 uses they expose a different API surface. ``psa/crypto_extra.h``
416 declares ``mbedtls_psa_external_get_random`` only under
417 ``MBEDTLS_PSA_CRYPTO_EXTERNAL_RNG``, which lives in that config -- so
418 without this the RNG callback the secure-boot app supplies looks like
419 an undeclared external symbol rather than the PSA hook it is.
420 """
421 port_inc = repo_root() / "port" / "mbedtls" / "inc"
422 out: list[str] = []
423 for macro, name in (
424 ("MBEDTLS_CONFIG_FILE", "mbedtls_config.h"),
425 ("TF_PSA_CRYPTO_CONFIG_FILE", "tf_psa_crypto_config.h"),
426 ):
427 header = port_inc / name
428 if header.is_file():
429 out.append(f'-D{macro}="{header}"')
430 return out
431
432
433def tu_args(path: pathlib.Path) -> list[str]:
434 """Return the compile flags for ``path``.
435
436 ``.cpp`` sources are parsed as C++. Forcing ``-x c`` over them, as
437 this used to, makes every ``<algorithm>`` / ``<cassert>`` include fail
438 and truncates the parse of the XML-shim and reflow-v2 translation
439 units.
440
441 Host tests carry the two macros ``tests/CMakeLists.txt`` compiles them
442 with. Without them the mock TUs parse under a configuration nothing
443 ever builds: the ``ra8_fake_mmio_*`` fault-seam prototypes in
444 ``ra8_hw_err.h`` sit behind ``RA8_OFF_TARGET && UNIT_TEST``, so
445 the definitions in ``tests/mocks/`` looked like undeclared external
446 symbols and every call through the seam went unresolved.
447 """
448 global _INCLUDE_ARGS # noqa: PLW0603 # one-shot memo of a pure repo-layout scan
449 if not _INCLUDE_ARGS:
450 _INCLUDE_ARGS = _builtin_include_args() + _darwin_sysroot_args() + _include_args()
451 # -fsized-deallocation matches GCC for the remaining C++ translation
452 # units and keeps the annotation parser aligned with the real build.
453 lang = (
454 ["-std=c++20", "-x", "c++", "-fsized-deallocation"]
455 if path.suffix == ".cpp"
456 else ["-std=c23", "-x", "c"]
457 )
458 config = ["-DRA8_HOST_BUILD=1", *_crypto_config_args()]
459 if is_test_path(str(path)):
460 config += ["-DRA8_OFF_TARGET", "-DUNIT_TEST"]
461 return [*lang, *config, *_INCLUDE_ARGS]
462
463
464def parse_tu(path: pathlib.Path, stats: ParseStats) -> cindex.TranslationUnit | None:
465 """Parse a single TU and record what the parse could not resolve."""
466 index = cindex.Index.create()
467 try:
468 tu = index.parse(
469 str(path),
470 args=tu_args(path),
471 options=cindex.TranslationUnit.PARSE_DETAILED_PROCESSING_RECORD,
472 )
473 except cindex.TranslationUnitLoadError:
474 stats.unparsed.append(str(path))
475 return None
476 if tu is None:
477 stats.unparsed.append(str(path))
478 return None
479 for diag in tu.diagnostics:
480 if diag.severity < DIAG_ERROR:
481 continue
482 match = _MISSING_INCLUDE_RE.search(diag.spelling)
483 if not match:
484 continue
485 header_name = pathlib.PurePath(match.group(1)).name
486 non_linux_expected = sys.platform != "linux" and header_name in NON_LINUX_MISSING_HEADERS
487 if header_name not in GENERATED_HEADERS and not non_linux_expected:
488 stats.missing_includes.add((str(path), match.group(1)))
489 return tu
490
491
492def check_parse_integrity(stats: ParseStats, tu_count: int) -> list[Violation]:
493 """Fail when the parse did not see the code it was handed.
494
495 Every call-graph rule here is a claim about a graph libclang built.
496 When headers stop resolving the graph loses edges, the rules stop
497 firing, and the gate prints a smaller number of violations -- which
498 reads as an improvement. It is the opposite, so a parse that came
499 apart fails the gate on its own terms, before any rule runs.
500 """
501 out: list[Violation] = []
502 out.extend(
503 Violation(
504 "ra8_parse_integrity",
505 src,
506 0,
507 f"include '{header}' does not resolve; every declaration behind it "
508 f"is invisible to the call graph",
509 )
510 for src, header in sorted(stats.missing_includes)
511 )
512 out.extend(
513 Violation("ra8_parse_integrity", src, 0, "translation unit failed to parse")
514 for src in sorted(stats.unparsed)
515 )
516 if not stats.calls_seen:
517 out.append(
518 Violation(
519 "ra8_parse_integrity",
520 str(repo_root()),
521 0,
522 f"no call sites found across {tu_count} translation units",
523 )
524 )
525 return out
526 rate = stats.calls_resolved / stats.calls_seen
527 if rate < MIN_CALL_RESOLUTION:
528 out.append(
529 Violation(
530 "ra8_parse_integrity",
531 str(repo_root()),
532 0,
533 f"only {stats.calls_resolved} of {stats.calls_seen} call sites "
534 f"resolved ({rate:.1%}, floor {MIN_CALL_RESOLUTION:.0%}) -- the "
535 f"call-graph rules are not seeing the tree",
536 )
537 )
538 return out