3"""Getting libclang to actually parse this tree, and proving that it did.
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.
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.
18from __future__
import annotations
28from annot_model
import ParseStats, Violation
29from annot_scope
import SCAN_DIRS, is_build_output, is_excluded, is_test_path, repo_root
37 from clang
import cindex
40 "check_annotations.py: 'libclang' Python package missing.\n"
41 " install the pinned repository tools: just setup-python\n"
52SECTION_ATTR_KIND = getattr(cindex.CursorKind,
"SECTION_ATTR",
None)
65MIN_CALL_RESOLUTION = 0.98
70GENERATED_HEADERS = frozenset(
73 "ra8_npu_model_addk_fake.h",
88NON_LINUX_MISSING_HEADERS = frozenset({
"personality.h"})
91_INCLUDE_ARGS: list[str] = []
94_MISSING_INCLUDE_RE = re.compile(
r"'([^']+)' file not found")
99_EXTRA_INCLUDE_ROOTS = (
101 "port/esp-hosted/inc/idf_compat",
102 "tools/vela/generated",
106def _first_party_include_roots() -> list[pathlib.Path]:
107 """Return all conventional first-party public and private include roots.
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.
118 roots: set[pathlib.Path] = set()
119 for top
in SCAN_DIRS:
120 scan_root = root / top
121 if not scan_root.is_dir():
124 path
for path
in scan_root.rglob(
"inc")
if path.is_dir()
and not is_excluded(path)
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)
134def _vendored_include_roots() -> list[pathlib.Path]:
135 """Return header roots inside both canonical vendored-source trees.
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.
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():
150 vendored += list(third_party.iterdir())
151 vendored += list(third_party.rglob(
"inc"))
152 vendored += list(third_party.rglob(
"include"))
173 "esp-hosted/host/api/priv",
175 "netxduo/addons/dhcp",
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] = []
183 if d
in seen
or not d.is_dir()
or is_build_output(d):
190def _include_args() -> list[str]:
191 """Return -I flags covering every header root the tree can include.
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.
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.
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())
217 seen: set[str] = set()
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():
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())
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
242 f.write(
"#include <stddef.h>\n#include <stdint.h>\nsize_t ra8_probe(void);\n")
245 tu = cindex.Index.create().parse(
246 probe_path, args=[
"-std=c23",
"-x",
"c",
"-DRA8_HOST_BUILD=1", *extra]
248 return not [d
for d
in (tu.diagnostics
if tu
else [])
if d.severity >= DIAG_ERROR]
249 except cindex.TranslationUnitLoadError:
252 with contextlib.suppress(OSError):
253 pathlib.Path(probe_path).unlink()
256def _homebrew_keg_only_clang_candidates() -> list[str]:
257 """Return full paths to keg-only Homebrew ``clang-<N>`` binaries, if any.
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.
270 for major
in (
"22",
"21",
"20",
"19",
"18"):
272 res = subprocess.run(
273 [
"brew",
"--prefix", f
"llvm@{major}"],
279 except (OSError, subprocess.SubprocessError):
281 if res.returncode != 0
or not res.stdout.strip():
283 candidate = pathlib.Path(res.stdout.strip()) /
"bin" / f
"clang-{major}"
284 if candidate.is_file():
285 out.append(str(candidate))
289def _resource_dir_from_driver() -> list[str]:
290 """Ask a real clang binary where its builtin headers live."""
292 os.environ.get(
"RA8_CLANG"),
299 *_homebrew_keg_only_clang_candidates(),
301 for exe
in candidates:
305 res = subprocess.run(
306 [exe,
"-print-resource-dir"],
312 except (OSError, subprocess.SubprocessError):
314 if res.returncode != 0:
316 inc = pathlib.Path(res.stdout.strip()) /
"include"
317 if not (inc /
"stddef.h").is_file():
323 if _probe_is_clean([f
"-isystem{inc}"]):
324 return [f
"-isystem{inc}"]
328def _darwin_sysroot_args() -> list[str]:
329 """Return -isysroot and Homebrew include flags, on macOS only.
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).
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.
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.
355 if sys.platform !=
"darwin":
359 sdk = subprocess.run(
360 [
"xcrun",
"--show-sdk-path"],
366 except (OSError, subprocess.SubprocessError):
368 if sdk
is not None and sdk.returncode == 0
and sdk.stdout.strip():
369 out.append(f
"-isysroot{sdk.stdout.strip()}")
371 brew = subprocess.run(
372 [
"brew",
"--prefix"],
378 except (OSError, subprocess.SubprocessError):
380 if brew
is not None and brew.returncode == 0
and brew.stdout.strip():
381 inc = pathlib.Path(brew.stdout.strip()) /
"include"
383 out.append(f
"-I{inc}")
387def _builtin_include_args() -> list[str]:
388 """Return -isystem flags for clang's own builtin headers.
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.
397 from_driver = _resource_dir_from_driver()
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}"]
410def _crypto_config_args() -> list[str]:
411 """Return the Mbed TLS / TF-PSA config selectors ``cmake/mbedtls.cmake`` sets.
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.
421 port_inc = repo_root() /
"port" /
"mbedtls" /
"inc"
424 (
"MBEDTLS_CONFIG_FILE",
"mbedtls_config.h"),
425 (
"TF_PSA_CRYPTO_CONFIG_FILE",
"tf_psa_crypto_config.h"),
427 header = port_inc / name
429 out.append(f
'-D{macro}="{header}"')
433def tu_args(path: pathlib.Path) -> list[str]:
434 """Return the compile flags for ``path``.
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
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.
449 if not _INCLUDE_ARGS:
450 _INCLUDE_ARGS = _builtin_include_args() + _darwin_sysroot_args() + _include_args()
454 [
"-std=c++20",
"-x",
"c++",
"-fsized-deallocation"]
455 if path.suffix ==
".cpp"
456 else [
"-std=c23",
"-x",
"c"]
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]
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()
471 options=cindex.TranslationUnit.PARSE_DETAILED_PROCESSING_RECORD,
473 except cindex.TranslationUnitLoadError:
474 stats.unparsed.append(str(path))
477 stats.unparsed.append(str(path))
479 for diag
in tu.diagnostics:
480 if diag.severity < DIAG_ERROR:
482 match = _MISSING_INCLUDE_RE.search(diag.spelling)
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)))
492def check_parse_integrity(stats: ParseStats, tu_count: int) -> list[Violation]:
493 """Fail when the parse did not see the code it was handed.
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.
501 out: list[Violation] = []
504 "ra8_parse_integrity",
507 f
"include '{header}' does not resolve; every declaration behind it "
508 f
"is invisible to the call graph",
510 for src, header
in sorted(stats.missing_includes)
513 Violation(
"ra8_parse_integrity", src, 0,
"translation unit failed to parse")
514 for src
in sorted(stats.unparsed)
516 if not stats.calls_seen:
519 "ra8_parse_integrity",
522 f
"no call sites found across {tu_count} translation units",
526 rate = stats.calls_resolved / stats.calls_seen
527 if rate < MIN_CALL_RESOLUTION:
530 "ra8_parse_integrity",
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",