4"""Speculative include cleaner for ra8-firmware.
6Finds genuinely unused `#include` directives in first-party C translation units
7using speculative compilation. Rather than relying on heuristic AST linters
8(which falsely reject intentional umbrella headers or demand redundant direct
9includes), this checker tests each `#include` by commenting it out and
10attempting compilation. If the compiler succeeds with identical semantics and
11no errors, the header is verified to be dead code.
15 python3 scripts/checks/check_unused_includes.py --selftest
16 python3 scripts/checks/check_unused_includes.py --check [paths...]
19from __future__
import annotations
29from pathlib
import Path
32def _include_re() -> re.Pattern[str]:
33 return re.compile(
r"^(#\s*include\s+[\"<][^\">]+[\">])", re.MULTILINE)
36def _repo_root() -> Path:
37 return Path(__file__).resolve().parents[2]
40def _find_compiler() -> str:
41 for candidate
in (
"cc",
"gcc",
"clang"):
42 found = shutil.which(candidate)
48def _load_compile_db() -> dict[str, list[str]]:
49 commands: dict[str, list[str]] = {}
51 _repo_root() /
"compile_commands.json",
52 _repo_root() /
"build" /
"tidy" /
"compile_commands.json",
53 _repo_root() /
"build" /
"compile_commands.json",
55 if not db_path.is_file():
58 data = json.loads(db_path.read_text(encoding=
"utf-8"))
60 file_rel = entry.get(
"file",
"")
64 rel = str(p.relative_to(_repo_root()))
67 if "command" in entry:
68 commands[rel] = shlex.split(entry[
"command"])
69 elif "arguments" in entry:
70 commands[rel] = list(entry[
"arguments"])
73 except (OSError, json.JSONDecodeError):
78def _default_compile_args(path: Path) -> list[str]:
88 "-Wno-unknown-warning-option",
90 for root
in (
"libs",
"apps",
"tools",
"port"):
91 base = _repo_root() / root
93 args.extend(f
"-I{p}" for p
in base.glob(
"**/inc")
if p.is_dir())
97def _compile_args_for_file(path: Path, db: dict[str, list[str]]) -> list[str]:
99 rel = str(path.resolve().relative_to(_repo_root()))
104 filtered: list[str] = []
110 if arg
in (
"-c",
"-o"):
114 if arg.endswith((
".c",
".cpp",
".cc",
".s",
".S")):
118 return _default_compile_args(path)
121def _conditional_depths(source: str) -> list[int]:
122 """Return the preprocessor conditional nesting depth at each line start."""
123 depths: list[int] = []
125 directive_re = re.compile(
r"^\s*#\s*(if|ifdef|ifndef|elif|else|endif)\b")
126 for line
in source.split(
"\n"):
127 m = directive_re.match(line)
131 depth = max(0, depth - 1)
133 elif kind
in (
"else",
"elif"):
134 depths.append(max(0, depth - 1))
143def _keep_reason(source: str, match_end: int) -> str |
None:
144 """Return the `ra8-keep-include` reason on an include line, if it has one."""
145 line_end = source.find(
"\n", match_end)
146 trailing = source[match_end : line_end
if line_end != -1
else len(source)]
147 m = re.search(
r"ra8-keep-include:\s*(\S.*)?$", trailing)
150 reason = (m.group(1)
or "").strip()
151 return reason
or None
154def _keep_claimed_tokens(reason: str) -> list[str]:
155 """Return the backticked symbol tokens a keep marker vouches for."""
156 return re.findall(
r"`([^`]+)`", reason)
159def _code_tokens(source: str) -> set[str]:
160 """Identifier tokens in non-include code with comments/strings stripped."""
161 no_comments = re.sub(
r"/\*.*?\*/|//[^\n]*",
" ", source, flags=re.DOTALL)
162 no_strings = re.sub(
r"\"(?:\\.|[^\"\\])*\"|'(?:\\.|[^'\\])*'",
" ", no_comments)
163 lines = [ln
for ln
in no_strings.split(
"\n")
if not re.match(
r"\s*#\s*include\b", ln)]
164 return set(re.findall(
r"[A-Za-z_][A-Za-z0-9_]*",
"\n".join(lines)))
167def _keep_honored(source: str, match_end: int) -> bool:
168 """Honor a keep marker only if it names used symbols in backticks."""
169 reason = _keep_reason(source, match_end)
172 claimed = _keep_claimed_tokens(reason)
175 code = _code_tokens(source)
176 return all(tok
in code
for tok
in claimed)
179def _run_compile(base_cmd: list[str]) -> int:
180 """Run one speculative compile, returning its exit code."""
181 proc = subprocess.run(
182 base_cmd, capture_output=
True, text=
True, check=
False
184 return proc.returncode
187def _comment_include(source: str, match_start: int, inc_text: str, match_end: int) -> str:
188 """Return source with one include line commented out."""
189 return source[:match_start] +
"// " + inc_text + source[match_end:]
192def _body_trivial_without_includes(
193 source: str, matches: list[re.Match[str]], base_cmd: list[str], scratch: Path
195 """Return True when the file still compiles with every include disabled."""
202 for m
in reversed(matches):
203 mutated_all = _comment_include(mutated_all, m.start(), m.group(1).strip(), m.end())
204 scratch.write_text(mutated_all, encoding=
"utf-8")
205 return _run_compile(base_cmd) == 0
208def _test_top_level_includes(
209 source: str, base_cmd: list[str], scratch: Path, path: Path, verbose: bool
210) -> list[tuple[int, str]]:
211 """Test each top-level include by speculative compilation."""
215 matches = list(_include_re().finditer(source))
216 depths = _conditional_depths(source)
217 unused: list[tuple[int, str]] = []
219 line_no = source[: m.start()].count(
"\n") + 1
220 if depths[line_no - 1] > 0:
222 msg = f
"check_unused_includes: skipping guarded include {path}:{line_no}\n"
223 sys.stderr.write(msg)
230 if _keep_honored(source, m.end()):
232 inc_text = m.group(1).strip()
233 scratch.write_text(_comment_include(source, m.start(), inc_text, m.end()), encoding=
"utf-8")
234 if _run_compile(base_cmd) == 0:
235 unused.append((line_no, inc_text))
240 path: Path, db: dict[str, list[str]], *, verbose: bool =
False
241) -> list[tuple[int, str]]:
242 """Test all `#include` lines in `path` by speculative compilation."""
243 if not path.is_file():
245 source = path.read_text(encoding=
"utf-8", errors=
"replace")
246 matches = list(_include_re().finditer(source))
250 compile_cmd = _compile_args_for_file(path, db)
252 with tempfile.TemporaryDirectory(prefix=
"ra8-include-check-")
as tmp:
253 scratch = Path(tmp) / path.name
254 out_obj = Path(tmp) / f
"{path.stem}.o"
256 scratch.write_text(source, encoding=
"utf-8")
257 base_cmd = [*compile_cmd,
"-c", str(scratch),
"-o", str(out_obj)]
258 if "-Wmissing-prototypes" not in base_cmd:
259 base_cmd.extend([
"-Wmissing-prototypes",
"-Werror=missing-prototypes"])
260 if _run_compile(base_cmd) != 0:
262 msg = f
"check_unused_includes: skipping {path} (baseline fails)\n"
263 sys.stderr.write(msg)
265 if _body_trivial_without_includes(source, matches, base_cmd, scratch):
267 msg = f
"check_unused_includes: skipping {path} (trivial body)\n"
268 sys.stderr.write(msg)
270 return _test_top_level_includes(source, base_cmd, scratch, path, verbose)
273def _git_changed_c_files() -> list[Path]:
274 c_files: list[Path] = []
275 for target
in (
"origin/dev",
"dev",
"HEAD~1"):
276 proc = subprocess.run(
277 [
"git",
"diff",
"--name-only", target,
"--",
"*.c"],
283 if proc.returncode == 0
and proc.stdout.strip():
284 for line
in proc.stdout.splitlines():
285 p = _repo_root() / line.strip()
286 if p.is_file()
and p.suffix ==
".c":
290 proc = subprocess.run(
291 [
"git",
"diff",
"--name-only",
"--",
"*.c"],
297 if proc.returncode == 0
and proc.stdout.strip():
298 for line
in proc.stdout.splitlines():
299 p = _repo_root() / line.strip()
300 if p.is_file()
and p.suffix ==
".c":
302 return sorted(set(c_files))
305def _selftest_basic(test_dir: Path) -> list[str]:
306 """Prove that unused includes are flagged while required ones are kept."""
307 failures: list[str] = []
308 c_file = test_dir /
"bad.c"
310 """#include <stdint.h>
313static uint32_t compute(void) {
319 unused = check_file(c_file, {}, verbose=
False)
320 inc_names = [item[1]
for item
in unused]
321 if "#include <stdbool.h>" not in inc_names:
322 failures.append(
"selftest: <stdbool.h> was not flagged as unused")
323 if "#include <stdint.h>" in inc_names:
324 failures.append(
"selftest: <stdint.h> was falsely flagged as unused")
328def _selftest_guarded(test_dir: Path) -> list[str]:
329 """Prove guarded includes stay quiet while top-level dead ones fire."""
330 failures: list[str] = []
331 guarded = test_dir /
"guarded.c"
333 """#include <stdint.h>
335#ifdef __ARM_FEATURE_MVE
336#include <dead_guarded.h>
339static uint32_t compute(void) {
345 guarded_unused = [item[1]
for item
in check_file(guarded, {}, verbose=
False)]
346 if "#include <dead_guarded.h>" in guarded_unused:
347 failures.append(
"selftest: guarded include judged without its configuration")
348 if "#include <stdbool.h>" not in guarded_unused:
349 failures.append(
"selftest: top-level dead include beside a guard missed")
353def _selftest_keep_markers(test_dir: Path) -> list[str]:
354 """Prove validated keep markers stay quiet and dishonest ones fire."""
355 failures: list[str] = []
356 valid = test_dir /
"valid.c"
358 """#include <stdint.h>
359#include <stdbool.h> // ra8-keep-include: `bool` used directly
361static bool ready(void) {
367 valid_unused = [item[1]
for item
in check_file(valid, {}, verbose=
False)]
368 if "#include <stdbool.h>" in valid_unused:
369 failures.append(
"selftest: validated keep marker was not honored")
370 forged = test_dir /
"forged.c"
372 """#include <stdint.h>
373#include <stdbool.h> // ra8-keep-include: `missing_symbol_xyz` used directly
375static uint32_t compute(void) {
381 forged_unused = [item[1]
for item
in check_file(forged, {}, verbose=
False)]
382 if "#include <stdbool.h>" not in forged_unused:
383 failures.append(
"selftest: fabricated keep token stayed quiet")
384 generic = test_dir /
"generic.c"
386 """#include <stdint.h>
387#include <stdbool.h> // ra8-keep-include: reviewed direct-use keep
389static uint32_t compute(void) {
395 generic_unused = [item[1]
for item
in check_file(generic, {}, verbose=
False)]
396 if "#include <stdbool.h>" not in generic_unused:
397 failures.append(
"selftest: generic prose keep marker stayed quiet")
398 bare = test_dir /
"bare.c"
400 """#include <stdint.h>
401#include <stdbool.h> // ra8-keep-include:
403static uint32_t compute(void) {
409 bare_unused = [item[1]
for item
in check_file(bare, {}, verbose=
False)]
410 if "#include <stdbool.h>" not in bare_unused:
411 failures.append(
"selftest: bare keep marker without reason stayed quiet")
415def selftest() -> int:
416 """Prove that unused includes are flagged while required includes are kept."""
417 with tempfile.TemporaryDirectory(prefix=
"ra8-selftest-inc-")
as tmp:
420 _selftest_basic(test_dir)
421 + _selftest_guarded(test_dir)
422 + _selftest_keep_markers(test_dir)
425 for failure
in failures:
426 sys.stderr.write(f
"selftest: FAILED -- {failure}\n")
428 print(
"check_unused_includes.py: selftest OK")
432def main(argv: list[str]) -> int:
433 """Run include checker over given paths or git-modified files."""
434 parser = argparse.ArgumentParser(description=__doc__)
436 "paths", nargs=
"*", help=
"C source files to check (defaults to branch-modified)"
438 parser.add_argument(
"--check", action=
"store_true", help=
"enforce zero unused includes")
439 parser.add_argument(
"--selftest", action=
"store_true", help=
"run regression selftest")
440 parser.add_argument(
"--verbose",
"-v", action=
"store_true", help=
"verbose diagnostics")
441 args = parser.parse_args(argv[1:])
446 files: list[Path] = []
450 if path.is_file()
and path.suffix
in (
".c",
".h"):
451 files.append(path.resolve())
453 files = _git_changed_c_files()
457 print(
"check_unused_includes.py: no C files in scope.")
460 db = _load_compile_db()
464 unused = check_file(f, db, verbose=args.verbose)
466 for line_no, inc_text
in unused:
467 print(f
"{f}:{line_no}: unused include: {inc_text}")
468 total_unused += len(unused)
471 print(f
"\ncheck_unused_includes.py: {total_unused} unused include(s) found.")
476if __name__ ==
"__main__":
477 sys.exit(
main(sys.argv))
void main(void)
The application entry point Reset_Handler hands control to.