ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
build_cross_compile_db.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"""Emit ONE ``compile_commands.json`` covering every cross-compiled first-party TU.
5
6WHY THIS EXISTS
7---------------
8``clang_tidy.sh`` parses against the HOST compile database produced by the unit
9test build. That database describes ``libs/``, ``src/``, ``tests/`` and
10``tools/`` and nothing else: no ``-mcpu=cortex-m85``, no per-app include
11directories, no vendored RTOS paths. Pointing clang-tidy at the firmware anyway
12was measured, not guessed -- 135 findings across 96 files, every one a
13``clang-diagnostic-error`` and not one an actionable style finding (#369).
14
15The fix is not a wider glob, it is a database that actually describes how those
16translation units compile. CMake already knows: it emits
17``compile_commands.json`` from any configure. This script performs the
18configures and merges their output into a single database keyed by real,
19absolute source paths.
20
21WHAT IT COVERS AND HOW IT FINDS IT
22----------------------------------
231. The unified RA8D2 cross-configure with every ``RA8_USE_*`` middleware option
24 forced ON, so apps that ``USES threadx`` / ``usbx`` / ``mbedtls`` are
25 configured instead of skipped. This alone accounts for the overwhelming
26 majority of firmware TUs.
272. Whatever the unified configure still leaves out, discovered by DIFFING the
28 result against ``git ls-files`` -- never from a hardcoded app list. For each
29 app directory still missing, the shared ``scripts/dev/ra8_apps.py`` registry
30 supplies the source directory and toolchain used by ``just apps::build``.
31 The app's own CMake ``ra8_add_app(USES ...)`` declaration supplies its
32 middleware switches in a standalone configure. That is how the RA8P1 tier
33 and ``ra8_cache_store_demo`` are picked up without duplicating their build
34 choices here.
353. A last-resort derivation for TUs that NO configure compiles, described
36 below.
37
38Step 2 is the load-bearing one. A hardcoded residual list is the exact defect
39that #296 / #332 / #358 / #359 / #360 were each an instance of: a scan list
40that silently stops matching the tree. Deriving the residual means a new app,
41at any depth, in any tier, needing any middleware, is picked up the day it
42lands -- or fails this script loudly if it cannot be configured at all.
43
44TUs THAT NOTHING BUILDS
45-----------------------
46One first-party TU is compiled by no configure at all, for a reason that is a
47fact about the tree rather than a defect in the enumeration above:
48
49 * ``examples/.../dfu_copy_to_run/src/payload.c`` -- a freestanding image linked at
50 a fixed SRAM base by its own
51 ``examples/ek_ra8d2/hw_validated/hil/dfu_copy_to_run/scripts/build_payload.sh``,
52 never by CMake.
53
54Being unbuilt is not a licence to go unlinted: it is first-party firmware and
55CLAUDE.md holds it to the same bar. So a command is DERIVED from the nearest
56already-covered sibling TU -- same directory subtree, therefore the same
57middleware, the same board layer and the same include set -- and then VERIFIED
58by actually running the cross-compiler over the file with it.
59
60The verification is what separates this from guessing. A derived command that
61does not compile the file is a hard error here, so the fallback can never
62quietly hand clang-tidy a wrong command and let a bogus parse read as a clean
63lint. Nothing is exempted and no path is allowlisted; a file either gets a
64command that provably works or this script fails.
65
66THE COVERAGE ASSERTION IS THE POINT
67-----------------------------------
68Any first-party cross-compiled ``.c`` absent from the merged database fails the
69run. This script must not quietly emit a smaller database over which clang-tidy
70would report a clean result. ``--check`` remains as a compatibility spelling
71for CI, but completeness is mandatory for every caller.
72
73USAGE
74-----
75 build_cross_compile_db.py --selftest # assert the merge + gap logic fires
76 build_cross_compile_db.py -o DIR # write a complete compile_commands.json
77 build_cross_compile_db.py -o DIR --check # compatibility spelling; same strict verdict
78"""
79
80from __future__ import annotations
81
82import argparse
83import json
84import os
85import re
86import shlex
87import shutil
88import subprocess
89import sys
90import tempfile
91from dataclasses import dataclass, field
92from functools import cache
93from pathlib import Path
94
95REPO_ROOT = Path(__file__).resolve().parents[2]
96sys.path.insert(0, str(REPO_ROOT / "scripts" / "dev"))
97from git_environment import ( # noqa: E402 -- repository path added above
98 isolated_git_environment,
99 trusted_git_executable,
100)
101from ra8_apps import get_apps # noqa: E402 -- path is repository-derived above
102
103
104@cache
105def firmware_apps() -> tuple[dict, ...]:
106 """Return the Just app registry once for this generator invocation."""
107 return tuple(get_apps())
108
109
110# Middleware switches the unified configure forces ON so that apps declaring
111# `USES <m>` are configured rather than skipped. Derived from the option()
112# declarations in cmake/, checked below -- a new cmake/<m>.cmake that this list
113# does not know about is a hard error, not a silently smaller database.
114#
115# RA8_USE_LEVELX_STANDALONE is deliberately absent: cmake/levelx_standalone.cmake
116# FATAL_ERRORs when RA8_USE_LEVELX is also set, because both compile the same
117# lx_nor_*.c. The app that wants it is picked up by the per-app residual pass.
118UNIFIED_MIDDLEWARE_OFF = ("RA8_USE_LEVELX_STANDALONE",)
119
120# Roots whose C is cross-compiled firmware, i.e. exactly the code the host
121# compile database cannot describe.
122FIRMWARE_ROOTS = ("examples/", "port/")
123
124# ...minus the ports that are HOSTED, not cross-compiled. `port/posix/` binds
125# `fw_if_fs` and `ra8_io_stream` to the host kernel's open/read/getdents ABI;
126# it declares itself `[Ring 4 / Host Port] {World: Host}` and is compiled ONLY
127# by tests/cmake/unit_tests.cmake, so it is already in the HOST compile
128# database and is analysed by clang-tidy's host pass. No app cross-compiles it,
129# so demanding a cross command for it can only ever be satisfied by a donor
130# probe borrowing some unrelated app's flags -- which is how one of its three
131# TUs "passed" while the other three failed on an unreachable `fw_if_fs.h`.
132# Keep the two lists in step with route_bucket() in scripts/checks/tidy/collect.sh.
133HOST_PORT_ROOTS = ("port/posix/",)
134
135# Tests nested below a firmware example are host test translation units. The
136# host CMake database owns them; treating their conventional tests/src path as
137# firmware asks an Arm donor command to resolve host-only support such as
138# unity_minimal.h and fails without analysing either domain correctly.
139HOST_TEST_PATH_PART = "/tests/"
140
141# Vendored SOUP under a firmware root -- CLAUDE.md exempts it from first-party
142# standards, so it is not part of what this database must cover. None exists
143# today; kept as the extension point if a firmware root ever vendors SOUP.
144FIRMWARE_EXEMPT: tuple[str, ...] = ()
145
146# A firmware tree this size cannot legitimately collapse to a handful of TUs.
147# Fewer than this means a configure silently failed and the "merged" database
148# describes almost nothing -- over which clang-tidy would report a clean run.
149TU_FLOOR = 250
150
151# How many uncovered paths to print before truncating.
152MAX_SHOWN = 40
153
154# The absorb() selftest fixture plants exactly this many distinct source paths.
155EXPECTED_FIXTURE_ENTRIES = 2
156
157# How many donor compile commands to try for a TU that no configure builds.
158# Each probe is a real compilation, so this bounds the fallback's cost; the
159# ordering puts the plausible donors first, so a correct one is found in the
160# first few or the TU genuinely has no working command in the tree.
161MAX_DONOR_PROBES = 60
162
163
164# ---------------------------------------------------------------------------
165# Small helpers
166# ---------------------------------------------------------------------------
167def run(argv: list[str], cwd: Path | None = None) -> subprocess.CompletedProcess[str]:
168 """Run `argv`, capturing output. Never raises; callers inspect returncode.
169
170 A missing executable is reported as a failed run rather than an exception:
171 donor probing walks compile commands from several toolchains (Arm and
172 RISC-V), and only some of them are installed on any given machine. An
173 absent compiler must disqualify that donor, not abort the whole build.
174 """
175 try:
176 return subprocess.run( # noqa: S603 -- argv built from repo-local paths
177 argv,
178 cwd=str(cwd) if cwd else None,
179 capture_output=True,
180 text=True,
181 check=False,
182 )
183 except (FileNotFoundError, NotADirectoryError, PermissionError) as exc:
184 return subprocess.CompletedProcess(argv, returncode=127, stdout="", stderr=str(exc))
185
186
187def live_files(paths: list[str], root: Path) -> list[str]:
188 """Keep only paths whose regular file exists below ``root``."""
189 return [rel for rel in paths if (root / rel).is_file()]
190
191
192def working_tree_files(root: Path = REPO_ROOT) -> list[str]:
193 """Every cached or untracked, non-ignored live file, repo-relative."""
194 out = run(
195 [trusted_git_executable(), "ls-files", "--cached", "--others", "--exclude-standard"],
196 cwd=root,
197 )
198 if out.returncode != 0:
199 sys.stderr.write(f"git ls-files failed: {out.stderr.strip()}\n")
200 sys.exit(1)
201 return live_files(out.stdout.splitlines(), root)
202
203
204def is_firmware_source(rel: str) -> bool:
205 """Report whether `rel` is a first-party cross-compiled C source."""
206 return (
207 rel.endswith(".c")
208 and rel.startswith(FIRMWARE_ROOTS)
209 and not rel.startswith(FIRMWARE_EXEMPT)
210 and not rel.startswith(HOST_PORT_ROOTS)
211 and HOST_TEST_PATH_PART not in rel
212 )
213
214
215def firmware_sources() -> set[str]:
216 """Repo-relative first-party ``.c`` under the cross-compiled roots."""
217 return {rel for rel in working_tree_files() if is_firmware_source(rel)}
218
219
220# ---------------------------------------------------------------------------
221# Compile database entries
222# ---------------------------------------------------------------------------
223@dataclass
224class Database:
225 """Accumulates compile-command entries keyed by absolute source path."""
226
227 entries: dict[str, dict] = field(default_factory=dict)
228
229 def absorb(self, build_dir: Path) -> int:
230 """Merge ``build_dir/compile_commands.json``. Returns entries added."""
231 path = build_dir / "compile_commands.json"
232 if not path.is_file():
233 return 0
234 added = 0
235 for entry in json.loads(path.read_text(encoding="utf-8")):
236 key = str(Path(entry["directory"], entry["file"]).resolve())
237 if key in self.entries:
238 continue
239 self.entries[key] = entry
240 added += 1
241 return added
242
243 def add(self, directory: Path, source: Path, argv: list[str]) -> None:
244 """Add one hand-assembled compile-database entry (used by the derived-command pass)."""
245 key = os.path.realpath(str(source))
246 self.entries.setdefault(
247 key,
248 {
249 "directory": str(directory),
250 "file": str(source),
251 "arguments": argv,
252 },
253 )
254
255 def covered(self) -> set[str]:
256 """Repo-relative paths of every covered source inside the repo."""
257 root = str(REPO_ROOT)
258 return {os.path.relpath(key, root) for key in self.entries if key.startswith(root + os.sep)}
259
260 def write(self, out_dir: Path) -> Path:
261 """Serialise the accumulated entries to compile_commands.json.
262
263 Writes the values of the entry map, not a list built alongside it, so
264 a translation unit compiled twice (two configurations of one source)
265 contributes exactly one entry -- clangd and clang-tidy both take the
266 first match and a duplicate would make which flags apply arbitrary.
267
268 Returns the path written.
269 """
270 out_dir.mkdir(parents=True, exist_ok=True)
271 path = out_dir / "compile_commands.json"
272 path.write_text(
273 json.dumps(list(self.entries.values()), indent=1) + "\n",
274 encoding="utf-8",
275 )
276 return path
277
278
279# ---------------------------------------------------------------------------
280# Pass 1 -- the unified RA8D2 cross-configure
281# ---------------------------------------------------------------------------
282def known_middleware_options() -> list[str]:
283 """Every ``RA8_USE_*`` option cmake/ declares, read from cmake/ itself.
284
285 Read rather than listed so a new cmake/<middleware>.cmake is picked up
286 automatically; a hardcoded list here would be the same silently-stale scan
287 list this whole script exists to stop reintroducing.
288 """
289 names: set[str] = set()
290 pattern = re.compile(r"option\‍(\s*(RA8_USE_[A-Z0-9_]+)")
291 for cmake_file in sorted((REPO_ROOT / "cmake").glob("*.cmake")):
292 names.update(pattern.findall(cmake_file.read_text(encoding="utf-8")))
293 for match in pattern.findall((REPO_ROOT / "CMakeLists.txt").read_text(encoding="utf-8")):
294 names.add(match)
295 return sorted(names - set(UNIFIED_MIDDLEWARE_OFF))
296
297
298def configure_unified(build_dir: Path, verbose: bool) -> None:
299 """Configure the whole-tree RA8D2 cross build with all middleware ON."""
300 argv = [
301 "cmake",
302 "-B",
303 str(build_dir),
304 "-S",
305 str(REPO_ROOT),
306 f"-DCMAKE_TOOLCHAIN_FILE={REPO_ROOT / 'cmake' / 'toolchain-ra8d2.cmake'}",
307 "-DCMAKE_EXPORT_COMPILE_COMMANDS=ON",
308 "-Wno-dev",
309 ]
310 argv += [f"-D{name}=ON" for name in known_middleware_options()]
311 result = run(argv)
312 if result.returncode != 0 or not (build_dir / "compile_commands.json").is_file():
313 sys.stderr.write(result.stdout[-4000:])
314 sys.stderr.write(result.stderr[-4000:])
315 sys.stderr.write("ERROR: the unified RA8D2 cross-configure failed (see above).\n")
316 sys.exit(1)
317 if verbose:
318 print(f"[db] unified RA8D2 configure -> {build_dir}")
319
320
321# ---------------------------------------------------------------------------
322# Pass 2 -- per-app configures for whatever the unified build left out
323# ---------------------------------------------------------------------------
324def app_dir_for(rel: str) -> Path | None:
325 """Nearest ancestor directory of `rel` that is a discovered firmware app.
326
327 Discovery is shared with ``just apps::build`` through ``ra8_apps.py`` so
328 this pass cannot grow a second, subtly different definition of an app.
329 """
330 source = (REPO_ROOT / rel).resolve()
331 candidates = [
332 Path(app["dir"]).resolve()
333 for app in firmware_apps()
334 if Path(app["dir"]).resolve() in source.parents
335 ]
336 return max(candidates, key=lambda path: len(path.parts), default=None)
337
338
339def configure_argv_for_app(app_dir: Path) -> list[str] | None:
340 """Return the standalone configure consumed by ``just apps::build``."""
341 app = next(
342 (entry for entry in firmware_apps() if Path(entry["dir"]).resolve() == app_dir.resolve()),
343 None,
344 )
345 if app is None:
346 return None
347 toolchain = REPO_ROOT / app["toolchain"]
348 return [
349 "cmake",
350 "-S",
351 str(Path(app["dir"]).resolve()),
352 "-B",
353 str(app_dir / "build"),
354 f"-DCMAKE_TOOLCHAIN_FILE={toolchain}",
355 "-DCMAKE_BUILD_TYPE=RelWithDebInfo",
356 "-DCMAKE_EXPORT_COMPILE_COMMANDS=ON",
357 ]
358
359
360def rewrite_build_dir(argv: list[str], build_dir: Path) -> list[str]:
361 """Point a configure argv at `build_dir` and make it export its database."""
362 out: list[str] = []
363 skip_next = False
364 for arg in argv:
365 if skip_next:
366 skip_next = False
367 continue
368 if arg == "-B":
369 skip_next = True
370 continue
371 if arg.startswith("-B") and len(arg) > len("-B"):
372 continue
373 if arg.startswith("-DCMAKE_EXPORT_COMPILE_COMMANDS"):
374 continue
375 out.append(arg)
376 out += ["-B", str(build_dir), "-DCMAKE_EXPORT_COMPILE_COMMANDS=ON"]
377 return out
378
379
380def configure_residual_apps(db: Database, scratch: Path, verbose: bool) -> list[str]:
381 """Configure each app still missing from `db`. Returns paths still uncovered."""
382 missing = sorted(firmware_sources() - db.covered())
383 seen: set[Path] = set()
384 for rel in missing:
385 app_dir = app_dir_for(rel)
386 if app_dir is None or app_dir in seen:
387 continue
388 seen.add(app_dir)
389 argv = configure_argv_for_app(app_dir)
390 if argv is None:
391 continue
392 build_dir = scratch / f"app-{app_dir.name}"
393 result = run(rewrite_build_dir(argv, build_dir))
394 if result.returncode != 0:
395 if verbose:
396 print(f"[db] per-app configure FAILED for {app_dir.relative_to(REPO_ROOT)}")
397 continue
398 added = db.absorb(build_dir)
399 if verbose:
400 print(f"[db] per-app {app_dir.relative_to(REPO_ROOT)} -> +{added}")
401 return sorted(firmware_sources() - db.covered())
402
403
404# ---------------------------------------------------------------------------
405# Pass 3 -- derive a command for TUs no configure builds, then prove it works
406# ---------------------------------------------------------------------------
407def entry_argv(entry: dict) -> list[str]:
408 """The compile command of a database entry, as an argv list."""
409 argv = entry.get("arguments")
410 if argv:
411 return list(argv)
412 return shlex.split(entry.get("command", ""))
413
414
415def own_include_args(source: Path) -> list[str]:
416 """``-I`` flags for the include directories `source`'s own library declares.
417
418 Every library in this tree is laid out ``<lib>/inc`` + ``<lib>/src`` -- the
419 convention check_header_file_placement.py enforces -- and a port library
420 publishes ``<lib>/inc`` through target_include_directories(). A donor from
421 another library supplies the middleware and HAL context but cannot supply
422 that, so walk the TU's own ancestors and offer theirs.
423
424 This is reading the tree's structure, not inventing flags: a directory is
425 only added when it exists, and the result still has to compile the file.
426 """
427 args: list[str] = []
428 current = source.parent
429 while current == REPO_ROOT or REPO_ROOT in current.parents:
430 candidate = current / "inc"
431 if candidate.is_dir():
432 args.append(f"-I{candidate}")
433 if current == REPO_ROOT:
434 break
435 current = current.parent
436 return args
437
438
439def retarget(argv: list[str], source: Path, obj: Path) -> list[str]:
440 """Rewrite a sibling's compile command to compile `source` into `obj`."""
441 out: list[str] = []
442 skip_next = False
443 for arg in argv:
444 if skip_next:
445 skip_next = False
446 continue
447 if arg in ("-c", "-o"):
448 skip_next = arg == "-o"
449 continue
450 if arg.endswith((".c", ".o", ".obj")):
451 continue
452 out.append(arg)
453 return out + own_include_args(source) + ["-c", str(source), "-o", str(obj)]
454
455
456def shared_prefix_len(a: str, b: str) -> int:
457 """How many leading path components `a` and `b` have in common."""
458 left, right = a.split("/"), b.split("/")
459 count = 0
460 for one, other in zip(left, right, strict=False):
461 if one != other:
462 break
463 count += 1
464 return count
465
466
467def candidate_donors(db: Database, rel: str) -> list[dict]:
468 """Covered entries ordered by how close they sit to `rel` in the tree.
469
470 Closeness is shared path depth, so the same app or the same port library
471 comes first, then the same tier, then the rest. Order is a PREFERENCE, not
472 an answer -- the caller proves a donor correct by compiling with it.
473 """
474 root = str(REPO_ROOT)
475 seen_dirs: set[Path] = set()
476 scored: list[tuple[int, str, dict]] = []
477 for key, entry in db.entries.items():
478 if not key.startswith(root + os.sep) or not key.endswith(".c"):
479 continue
480 parent = Path(key).parent
481 if parent in seen_dirs:
482 continue
483 seen_dirs.add(parent)
484 candidate_rel = os.path.relpath(key, root)
485 scored.append((-shared_prefix_len(rel, candidate_rel), candidate_rel, entry))
486 scored.sort()
487 return [entry for _, _, entry in scored[:MAX_DONOR_PROBES]]
488
489
490def derive_unbuilt(db: Database, scratch: Path, verbose: bool) -> list[str]:
491 """Give every still-uncovered TU a sibling-derived, compile-verified command.
492
493 A donor is accepted only once the cross-compiler has compiled the file with
494 its flags. Candidates are tried nearest-first; the first that compiles wins.
495 Guessing is therefore not possible -- either a provably working command is
496 found or the TU is reported uncovered and the run fails.
497 """
498 still: list[str] = []
499 for rel in sorted(firmware_sources() - db.covered()):
500 source = REPO_ROOT / rel
501 obj = scratch / (rel.replace("/", "_") + ".o")
502 accepted = False
503 last_error = "no covered TU was available to derive a command from"
504 for donor in candidate_donors(db, rel):
505 argv = retarget(entry_argv(donor), source, obj)
506 probe = run(argv, cwd=Path(donor["directory"]))
507 if probe.returncode != 0:
508 last_error = probe.stderr.strip()[-1200:]
509 continue
510 db.add(Path(donor["directory"]), source, argv)
511 accepted = True
512 if verbose:
513 donor_rel = os.path.relpath(donor["file"], REPO_ROOT)
514 print(f"[db] derived {rel} from {donor_rel} (compiles clean)")
515 break
516 if not accepted:
517 print(
518 f"ERROR: no compile command could be derived for {rel}.\n"
519 f" last attempt failed with:\n {last_error}",
520 file=sys.stderr,
521 )
522 still.append(rel)
523 return still
524
525
526# ---------------------------------------------------------------------------
527# Reporting
528# ---------------------------------------------------------------------------
529def report_uncovered(uncovered: list[str], total: int) -> int:
530 """Print the coverage verdict. Returns the process exit code."""
531 covered = total - len(uncovered)
532 print(f"cross-compile database: {covered}/{total} first-party firmware TUs covered")
533 if not uncovered:
534 print("OK: every cross-compiled first-party .c has a compile command.")
535 return 0
536 print(file=sys.stderr)
537 print(f"ERROR: {len(uncovered)} firmware TU(s) have no compile command:", file=sys.stderr)
538 for rel in uncovered[:MAX_SHOWN]:
539 print(f" {rel}", file=sys.stderr)
540 if len(uncovered) > MAX_SHOWN:
541 print(f" ... and {len(uncovered) - MAX_SHOWN} more", file=sys.stderr)
542 print(file=sys.stderr)
543 print(
544 "Each needs a configure that reaches it. Firmware apps are discovered\n"
545 "through scripts/dev/ra8_apps.py, and each app's CMakeLists.txt must\n"
546 "declare its complete ra8_add_app() dependency surface.",
547 file=sys.stderr,
548 )
549 return 1
550
551
552# ---------------------------------------------------------------------------
553# Selftest -- the gate must be shown to fire, in both directions
554# ---------------------------------------------------------------------------
555def _selftest_database() -> list[str]:
556 """Assertions about Database merging and coverage accounting."""
557 failures: list[str] = []
558
559 # 1. absorb() merges, de-duplicates by real path, and reports what it added.
560 with tempfile.TemporaryDirectory() as tmp:
561 one = Path(tmp) / "one"
562 one.mkdir()
563 (one / "compile_commands.json").write_text(
564 json.dumps(
565 [
566 {"directory": str(REPO_ROOT), "file": "a.c", "arguments": ["cc", "a.c"]},
567 {"directory": str(REPO_ROOT), "file": "./a.c", "arguments": ["cc", "a.c"]},
568 {"directory": str(REPO_ROOT), "file": "b.c", "arguments": ["cc", "b.c"]},
569 ]
570 ),
571 encoding="utf-8",
572 )
573 db = Database()
574 added = db.absorb(one)
575 if added != EXPECTED_FIXTURE_ENTRIES:
576 failures.append(f"absorb() merged {added} entries, expected 2 after de-duplication")
577 if db.covered() != {"a.c", "b.c"}:
578 failures.append(f"covered() returned {sorted(db.covered())}, expected ['a.c', 'b.c']")
579
580 return failures
581
582
583def _selftest_reporting() -> list[str]:
584 """Assertions about the uncovered-TU verdict."""
585 failures: list[str] = []
586
587 # 2. A database missing a TU must be reported as uncovered, not passed over.
588 # The verdict this prints is a DELIBERATE probe against a planted gap --
589 # seeing it fire here is the evidence that a real gap would fail too.
590 print("selftest: probing the gap report with a planted uncovered TU ...")
591 if report_uncovered(["examples/broken/main.c"], 10) == 0:
592 failures.append("report_uncovered() returned success while a TU was uncovered")
593 if report_uncovered([], 10) != 0:
594 failures.append("report_uncovered() returned failure with nothing uncovered")
595
596 return failures
597
598
599def _selftest_command_building() -> list[str]:
600 """Assertions about option discovery and compile-command construction."""
601 failures: list[str] = []
602
603 # 3. The middleware sweep must actually find the vendored options, and must
604 # exclude the mutually-exclusive one. An empty sweep would silently
605 # configure away every `USES` app.
606 options = known_middleware_options()
607 if "RA8_USE_THREADX" not in options or "RA8_USE_USBX" not in options:
608 failures.append(f"known_middleware_options() missed a vendored switch: {options}")
609 if "RA8_USE_LEVELX_STANDALONE" in options:
610 failures.append("known_middleware_options() included the mutually-exclusive LevelX mode")
611
612 # 4. The residual-app configure must use the same discovered toolchain as
613 # `just apps::build`, rather than guessing from a path fragment here.
614 apps = firmware_apps()
615 if not apps:
616 failures.append("ra8_apps.py discovered no firmware apps")
617 for app in apps:
618 app_dir = Path(app["dir"]).resolve()
619 argv = configure_argv_for_app(app_dir)
620 expected_toolchain = f"-DCMAKE_TOOLCHAIN_FILE={REPO_ROOT / app['toolchain']}"
621 if argv is None or str(app_dir) not in argv or expected_toolchain not in argv:
622 failures.append(f"configure argv drifted from ra8_apps.py for {app_dir}")
623 break
624
625 # 5. rewrite_build_dir must repoint -B in both spellings and force the export.
626 rewritten = rewrite_build_dir(["cmake", "-S", ".", "-B", "/orig", "-DA=1"], Path("/new"))
627 if "/orig" in rewritten or "-B" not in rewritten or "/new" not in rewritten:
628 failures.append(f"rewrite_build_dir() left the original -B: {rewritten}")
629 if "-DCMAKE_EXPORT_COMPILE_COMMANDS=ON" not in rewritten:
630 failures.append("rewrite_build_dir() did not force the compile-command export")
631
632 # 6. retarget() must strip the donor's own source/object and substitute ours,
633 # otherwise the derived command would compile the DONOR and report a
634 # clean parse for a file it never opened.
635 donor_argv = ["arm-none-eabi-gcc", "-Iinc", "-c", "donor.c", "-o", "donor.o"]
636 retargeted = retarget(donor_argv, Path("/w/mine.c"), Path("/w/mine.o"))
637 if "donor.c" in retargeted or "donor.o" in retargeted:
638 failures.append(f"retarget() kept the donor's source or object: {retargeted}")
639 if retargeted[-4:] != ["-c", "/w/mine.c", "-o", "/w/mine.o"]:
640 failures.append(f"retarget() did not compile the requested file: {retargeted}")
641 if "-Iinc" not in retargeted:
642 failures.append("retarget() dropped the donor's include flags")
643
644 # 7. entry_argv() must read both database spellings; a database using
645 # "command" would otherwise derive an EMPTY compile line.
646 if entry_argv({"arguments": ["cc", "x.c"]}) != ["cc", "x.c"]:
647 failures.append("entry_argv() mishandled the 'arguments' form")
648 if entry_argv({"command": "cc x.c"}) != ["cc", "x.c"]:
649 failures.append("entry_argv() mishandled the 'command' form")
650
651 return failures
652
653
654def _selftest_source_classification() -> list[str]:
655 """Assert app-local host tests and absent index paths stay out of the database."""
656 failures: list[str] = []
657 if is_firmware_source("examples/demo/tests/src/test_demo.c"):
658 failures.append("is_firmware_source() claimed an app-local host test")
659 if not is_firmware_source("examples/demo/src/main.c"):
660 failures.append("is_firmware_source() dropped an example implementation")
661 with tempfile.TemporaryDirectory() as tmp:
662 root = Path(tmp)
663 (root / "present.c").write_text("void present(void) {}\n", encoding="ascii")
664 if live_files(["present.c", "missing.c"], root) != ["present.c"]:
665 failures.append("live_files() did not keep the real file and drop the absent path")
666 run([trusted_git_executable(), "init", "--quiet"], cwd=root)
667 (root / ".gitignore").write_text("._*\n", encoding="ascii")
668 (root / "ordinary.cc").write_text("void ordinary() {}\n", encoding="ascii")
669 (root / "._artifact.c").write_bytes(b"\x00AppleDouble")
670 enumerated = working_tree_files(root)
671 if "present.c" not in enumerated or "ordinary.cc" not in enumerated:
672 failures.append("working_tree_files() dropped an ordinary untracked C/C++ file")
673 if "._artifact.c" in enumerated:
674 failures.append("working_tree_files() included an ignored AppleDouble artifact")
675 return failures
676
677
678def _selftest_body() -> int:
679 """Assert the merge and the gap detection both work, and that they fail."""
680 failures = (
681 _selftest_database()
682 + _selftest_reporting()
683 + _selftest_command_building()
684 + _selftest_source_classification()
685 )
686
687 # 9. The floor must reject a database that collapsed to almost nothing.
688 sources = firmware_sources()
689 if len(sources) < TU_FLOOR:
690 failures.append(
691 f"firmware_sources() found {len(sources)} TUs, "
692 f"below the floor of {TU_FLOOR} -- enumeration is broken"
693 )
694
695 # 10. The hosted-port carve-out, both directions. A carve-out that widened
696 # to swallow the cross-compiled ports would drop real firmware out of
697 # the database and read as a smaller, cleaner run; one that stopped
698 # matching would put the host port back in and demand a cross command
699 # that no configure can ever supply.
700 if any(rel.startswith(HOST_PORT_ROOTS) for rel in sources):
701 failures.append("firmware_sources() still claims a hosted port root")
702 if not any(rel.startswith("port/") for rel in sources):
703 failures.append("firmware_sources() claims no port/ TU at all -- carve-out too wide")
704
705 if failures:
706 print("SELFTEST FAILED:", file=sys.stderr)
707 for problem in failures:
708 print(f" - {problem}", file=sys.stderr)
709 return 1
710 print("selftest: cross-compile database merge + gap detection OK")
711 return 0
712
713
714def selftest() -> int:
715 """Run compile-database fixtures without inheriting the caller's repo."""
716 with isolated_git_environment():
717 return _selftest_body()
718
719
720# ---------------------------------------------------------------------------
721# Entry point
722# ---------------------------------------------------------------------------
723def main() -> int:
724 """Build a complete cross-compilation database.
725
726 Completeness is mandatory for every caller. ``--check`` remains accepted
727 for the CI call site, but cannot weaken or strengthen the verdict.
728 """
729 parser = argparse.ArgumentParser(description=__doc__)
730 parser.add_argument("-o", "--out", help="directory to write compile_commands.json into")
731 parser.add_argument(
732 "--check",
733 action="store_true",
734 help="compatibility flag; completeness is always enforced",
735 )
736 parser.add_argument("--selftest", action="store_true", help="assert this script still fires")
737 parser.add_argument("-v", "--verbose", action="store_true")
738 args = parser.parse_args()
739
740 if args.selftest:
741 return selftest()
742 if not args.out:
743 parser.error("--out is required unless --selftest is given")
744
745 if shutil.which("cmake") is None:
746 print(
747 "ERROR: cmake not found; the cross-compile database cannot be built.", file=sys.stderr
748 )
749 return 1
750
751 total = len(firmware_sources())
752 if total < TU_FLOOR:
753 print(
754 f"ERROR: only {total} firmware TUs enumerated (floor {TU_FLOOR}). "
755 "The enumeration is broken; refusing to emit a database that would "
756 "let clang-tidy report a clean run over almost nothing.",
757 file=sys.stderr,
758 )
759 return 1
760
761 # The CMake build trees live INSIDE the output directory and are kept, not
762 # thrown away: every entry's "directory" field names the tree it came from
763 # and clang-tidy chdir()s there before parsing. Pointing those at a
764 # temporary directory produced a hard LLVM abort once the run cleaned up.
765 # Keeping them also makes a re-run an incremental CMake reconfigure.
766 # Absolute: donor probes run with cwd set to the DONOR's build directory,
767 # so a relative object path would be written relative to that instead --
768 # which -fstack-usage turns into "cannot open ....su for writing".
769 out_dir = Path(args.out).resolve()
770 scratch = out_dir / ".compile_commands_scratch"
771 scratch.mkdir(parents=True, exist_ok=True)
772
773 db = Database()
774 unified = scratch / "unified"
775 configure_unified(unified, args.verbose)
776 db.absorb(unified)
777 if args.verbose:
778 print(f"[db] unified -> {len(db.entries)} entries")
779 configure_residual_apps(db, scratch, args.verbose)
780 uncovered = derive_unbuilt(db, scratch, args.verbose)
781 code = report_uncovered(uncovered, total)
782 if code != 0:
783 return code
784 out_path = db.write(out_dir)
785 print(f"wrote {out_path} ({len(db.entries)} entries)")
786 return 0
787
788
789if __name__ == "__main__":
790 sys.exit(main())
-proof
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298