ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
lint_targets.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"""One definition of "which files are first-party code", for the size gates.
5
6Four checkers in this tree have now had the same defect: a hand-written
7``SCAN_ROOTS`` / ``SOURCE_SUFFIXES`` tuple that quietly stopped describing the
8repository. ``check_file_size.py`` and ``check_function_size.py`` were the
9worst of them -- their roots omitted ``scripts/`` and their suffixes covered
10only C/C++, so the documented 1000-line file cap and the 60-line NASA Rule 4
11function cap had never once applied to a Python or shell file (#359).
12
13The failure mode is specific and worth naming: a hardcoded list does not fail
14when it goes stale. It reports success over a shrinking slice of the tree, and
15the gate looks green precisely because it stopped looking. So the enumeration
16is derived instead:
17
18* the file set comes from ``git ls-files`` -- whatever is in the repository is
19 in scope, and a new top-level directory is covered the day it is added;
20* language is decided per file by suffix, by well-known basename, or by
21 shebang, so an extensionless executable cannot escape by having no suffix;
22* the only subtractions are vendored SOUP and generated tables, which
23 CLAUDE.md already exempts by name.
24
25``check_lint_coverage.py`` asks a parallel question ("is every code file
26claimed by some linter?") and this module answers the size gates' half of it
27with the same enumeration, so the two cannot disagree about what code is.
28
29Run::
30
31 lint_targets.py # every first-party code file
32 lint_targets.py c python # only the named languages
33 lint_targets.py --list # the language names this module knows
34
35Prints one repo-relative path per line, sorted. Exits non-zero, printing
36nothing, when a requested language resolves to zero files: a gate must never
37mistake a broken enumeration for a clean tree.
38"""
39
40from __future__ import annotations
41
42import subprocess
43import sys
44import tempfile
45from pathlib import Path
46
47REPO_ROOT = Path(__file__).resolve().parents[2]
48
49# Vendored SOUP and generated tables. Matches the CLAUDE.md exemption list and
50# the sibling gates' EXCLUDE_FRAGMENTS.
51EXCLUDED_PREFIXES = (
52 "libs/third_party/",
53 "apps/shared_libs/third_party/",
54 "libs/ra8_fonts/",
55 "tools/vela/generated/",
56)
57
58# Prefixes excluded for SOME languages only. A vendored tree is SOUP for the
59# language whose sources it carries, but the build glue that compiles it is
60# ours and is linted like any other first-party listfile: port/threadx/ holds
61# vendored ThreadX C, and a CMakeLists.txt we wrote and hold to the cmake gate.
62# Excluding the directory wholesale -- which this module originally did -- would
63# have silently dropped that listfile out of the cmake scope.
64LANGUAGE_EXCLUDED_PREFIXES = {
65 "c": ("port/threadx/",),
66}
67
68# ---------------------------------------------------------------------------
69# BUILD OUTPUT -- the single definition, shared by every checker in this tree.
70#
71# This used to be thirteen copies of the substring ``"/build/"``, one per
72# checker, and the substring is the defect (#377). ``"/build/" in path`` cannot
73# tell ``tools/ra8_emulator/build/`` -- genuine CMake output -- from a first-party
74# source directory that happens to be called ``build``. When #359's
75# reorganisation created ``scripts/build/``, every file in it # PATHREF-OK: #359
76# became invisible to shellcheck, shfmt and the rest, while every gate still
77# reported
78# a clean tree. The bare ``build/`` line in .gitignore did the same thing to
79# git, so a NEW file there would never have been added at all; the six that
80# survived did so only because ``git mv`` moves already-tracked files.
81#
82# The replacement is a repo-relative PATH check rather than a substring match.
83# A build directory counts as build output only where a build tree is actually
84# produced: at the repo root, or under one of the roots below. Anywhere else,
85# a directory named ``build`` is ordinary source and is linted like any other.
86#
87# .gitignore carries the matching anchored patterns, and
88# ``check_gitignore_scope.py`` fails on any new unanchored directory pattern,
89# so the two halves cannot drift back apart.
90# ---------------------------------------------------------------------------
91
92# Top-level directories beneath which a per-target build tree legitimately
93# appears, at any depth. Deliberately NOT "any directory anywhere": that is the
94# behaviour being removed. `scripts/`, `libs/` and friends are
95# absent because nothing builds into them, so a `build` directory appearing
96# there is source and must stay visible to the checkers.
97BUILD_TREE_ROOTS = frozenset(
98 {
99 "docs", # docs/build/ -- generated Doxygen HTML
100 "examples", # examples/**/<app>/build/ -- per-app CMake output
101 "local-poc", # local-poc/**/build/ -- git-excluded PoC tree
102 "port", # port/**/build/
103 "tests", # tests/build/, tests/build-cov/, tests/build-fuzz/
104 "tools", # tools/<tool>/build/ -- host tool output
105 "apps", # apps/<category>/<product>/build/ -- product build output
106 }
107)
108
109# Directory names owned by a tool, which can never be a first-party source
110# directory and are therefore matched at ANY depth. This is the ONLY
111# depth-agnostic rule left, and every name in it is reserved by the tool that
112# creates it: CMake writes CMakeFiles/ and _deps/, CPython writes __pycache__/,
113# npm writes node_modules/. Nobody can legitimately author a source directory
114# with one of these names, so matching them anywhere cannot swallow source.
115TOOL_OUTPUT_DIR_NAMES = frozenset({"CMakeFiles", "_deps", "__pycache__", "node_modules"})
116
117
118def is_build_dir_name(name: str) -> bool:
119 """True when one path COMPONENT names a build tree.
120
121 Exact ``build``, or a ``build-`` / ``build_`` / ``cmake-build-`` prefix.
122 The separator is required: ``builders`` starts with ``build`` and is NOT a
123 build directory, which is precisely the collision a ``build*`` glob would
124 reintroduce.
125 """
126 return name == "build" or name.startswith(("build-", "build_", "cmake-build-"))
127
128
129def is_build_output(rel: str) -> bool:
130 """True when repo-relative `rel` lives inside a build tree.
131
132 Directory components only -- a FILE called ``build`` is not a build tree.
133 """
134 parts = rel.split("/")
135 for index, part in enumerate(parts[:-1]):
136 if part in TOOL_OUTPUT_DIR_NAMES:
137 return True
138 if is_build_dir_name(part) and (index == 0 or parts[0] in BUILD_TREE_ROOTS):
139 return True
140 return False
141
142
143# suffix -> language
144SUFFIX_LANG = {
145 ".c": "c",
146 ".h": "c",
147 ".cpp": "c",
148 ".hpp": "c",
149 ".cc": "c",
150 ".cxx": "c",
151 ".hh": "c",
152 ".hxx": "c",
153 ".py": "python",
154 ".sh": "shell",
155 ".bash": "shell",
156 ".cmake": "cmake",
157 ".yml": "yaml",
158 ".yaml": "yaml",
159 ".mk": "make",
160 ".just": "just",
161 ".ld": "ld",
162}
163
164# Exact basenames that carry no suffix but are unambiguously one language.
165BASENAME_LANG = {
166 "CMakeLists.txt": "cmake",
167 "justfile": "just",
168 "Justfile": "just",
169}
170
171# Directories whose extensionless executables are shell by construction. The
172# git hooks are the case that matters: scripts/git/pre-commit is 670 lines of
173# shell that no suffix-driven scope has ever seen.
174SHEBANG_LANG = {
175 "sh": "shell",
176 "bash": "shell",
177 "zsh": "shell",
178 "dash": "shell",
179 "python": "python",
180 "python3": "python",
181}
182
183LANGUAGES = ("c", "python", "shell", "cmake", "yaml", "just", "ld")
184
185
186def is_build_output_path(path: object) -> bool:
187 """``is_build_output`` for a str or Path that may be absolute.
188
189 The checkers hold a mix of absolute paths, repo-relative paths and
190 slash-wrapped forms. Normalising here keeps every call site a single
191 predicate instead of thirteen hand-rolled substring tuples (#377).
192 """
193 text = str(path).replace("\\", "/").strip("/")
194 root = str(REPO_ROOT).replace("\\", "/").strip("/")
195 if text.startswith(root + "/"):
196 text = text[len(root) + 1 :]
197 elif text.startswith("./"):
198 text = text[2:]
199 return is_build_output(text)
200
201
202def _tracked() -> list[str]:
203 """Existing tracked plus untracked-but-not-ignored paths, from git itself.
204
205 ``git ls-files --cached`` also prints tracked paths deleted in the working
206 tree. Those are part of the index until the next commit, but they are not
207 lint targets: passing them to a formatter makes every local check fail with
208 ``ENOENT`` during an ordinary deletion. Filter on filesystem existence so
209 working-tree checks describe the tree that is actually present; committed
210 CI snapshots are unchanged by this distinction.
211 """
212 proc = subprocess.run(
213 [ # noqa: S607 -- trusted: fixed git argv
214 "git",
215 "ls-files",
216 "-z",
217 "--cached",
218 "--others",
219 "--exclude-standard",
220 ],
221 cwd=REPO_ROOT,
222 capture_output=True,
223 text=True,
224 check=False,
225 )
226 if proc.returncode != 0:
227 sys.stderr.write(proc.stderr)
228 sys.stderr.write("lint_targets.py: FATAL -- `git ls-files` failed\n")
229 sys.exit(2)
230 return [rel for rel in proc.stdout.split("\0") if rel and (REPO_ROOT / rel).is_file()]
231
232
233def _excluded(rel: str, lang: str | None = None) -> bool:
234 if rel.startswith(EXCLUDED_PREFIXES) or is_build_output(rel):
235 return True
236 extra = LANGUAGE_EXCLUDED_PREFIXES.get(lang or "", ())
237 return bool(extra) and rel.startswith(extra)
238
239
240def _shebang_lang(path: Path) -> str | None:
241 """Language named by a ``#!`` first line, or None.
242
243 This is the half of the enumeration a suffix list cannot do. An executable
244 with no extension is still code, and the git hooks are exactly that.
245 """
246 try:
247 with path.open("rb") as handle:
248 first = handle.readline(200).decode("utf-8", errors="replace")
249 except OSError:
250 return None
251 if not first.startswith("#!"):
252 return None
253 words = first[2:].replace("/usr/bin/env", " ").replace("/", " ").split()
254 for word in words:
255 base = word.split("-")[0]
256 if base in SHEBANG_LANG:
257 return SHEBANG_LANG[base]
258 return None
259
260
261def _raw_language(rel: str, root: Path) -> str | None:
262 """The language a path's name implies, before any exclusion is applied."""
263 path = Path(rel)
264 if path.name in BASENAME_LANG:
265 return BASENAME_LANG[path.name]
266 lang = SUFFIX_LANG.get(path.suffix)
267 if lang is not None:
268 return lang
269 if path.suffix:
270 return None # a suffix we know is not code (.md, .json, .pdf, ...)
271 return _shebang_lang(root / rel)
272
273
274def language_of(rel: str, root: Path = REPO_ROOT) -> str | None:
275 """The language of one repo-relative path, or None if it is not code.
276
277 The language is resolved BEFORE exclusion, because exclusion is now
278 per-language: a vendored tree can be SOUP for its sources and still hold
279 first-party build glue.
280 """
281 if _excluded(rel):
282 return None
283 lang = _raw_language(rel, root)
284 if lang is None or _excluded(rel, lang):
285 return None
286 return lang
287
288
289def files_for(languages: tuple[str, ...] = LANGUAGES) -> dict[str, list[str]]:
290 """Map each requested language to its sorted first-party file list."""
291 out: dict[str, list[str]] = {lang: [] for lang in languages}
292 for rel in _tracked():
293 lang = language_of(rel)
294 if lang in out:
295 out[lang].append(rel)
296 return {lang: sorted(paths) for lang, paths in out.items()}
297
298
299# A tree this size cannot legitimately collapse to a handful of files. A checker
300# that enumerates almost nothing reports a clean tree because it looked at
301# almost nothing -- the exact failure this module exists to prevent. Same
302# trip-wire as check_ruff.py and check_lint_coverage.py.
303TRACKED_FLOOR = 1000
304
305
306def first_party_paths(
307 suffixes: tuple[str, ...], *, respect_language_excludes: bool = True
308) -> list[str]:
309 """Every tracked first-party path ending in one of ``suffixes``.
310
311 The derived-scope primitive the policy checkers share (#358). Enumeration
312 is ``git ls-files`` -- never a hardcoded directory list -- so a newly added
313 top-level directory (``tools/`` was the one that had been silently omitted
314 for the life of six checkers) is in scope the day it lands, with no
315 allowlist to forget. The only subtractions are the named SOUP / generated /
316 build-output exemptions this module already defines; with
317 ``respect_language_excludes`` also the per-language vendored trees
318 (``port/threadx/`` is C SOUP), which are not ours to police.
319
320 Args:
321 suffixes: Extensions to keep, e.g. ``(".c", ".h")``. Matched with
322 ``str.endswith``, so pass lower-case dotted forms.
323 respect_language_excludes: When true, also drop a path that is a
324 vendored tree for the language its own suffix implies. Callers
325 scanning text (docs, config) pass false, where it is a no-op.
326
327 Returns:
328 The matching repo-relative paths, sorted.
329
330 Raises:
331 SystemExit: When ``git ls-files`` returns fewer than ``TRACKED_FLOOR``
332 paths -- a collapsed enumeration must fail, never read as clean.
333 """
334 rels = _tracked()
335 if len(rels) < TRACKED_FLOOR:
336 sys.stderr.write(
337 f"lint_targets.py: FATAL -- only {len(rels)} tracked path(s), floor "
338 f"is {TRACKED_FLOOR}. A collapsed enumeration reports a clean tree "
339 "because it enumerated nothing.\n"
340 )
341 sys.exit(2)
342 out: list[str] = []
343 for rel in rels:
344 if not rel.endswith(suffixes):
345 continue
346 if _excluded(rel):
347 continue
348 if respect_language_excludes:
349 lang = _raw_language(rel, REPO_ROOT)
350 if lang is not None and _excluded(rel, lang):
351 continue
352 out.append(rel)
353 return sorted(out)
354
355
356# ---------------------------------------------------------------------------
357# FIRMWARE PRODUCTS -- the single definition, shared by every checker that has
358# to tell a cross-compiled image from a host program.
359#
360# Top-level roots used to classify build domain on their own: examples/ and
361# port/ were firmware, tests/ and tools/ were hosted. apps/ -- the products
362# tier -- breaks that, because it carries BOTH kinds. The mdl CLI is a
363# host program the C runtime starts and whose exit status something reads; the
364# e-reader is a two-image TrustZone composition reached from Reset_Handler,
365# with no process and no exit status. "It lives under apps/" answers nothing.
366#
367# The discriminator is what the build actually does with the directory: an app
368# directory holding BOTH a linker script and a vector table is LINKED INTO AN
369# IMAGE. Neither half alone is enough -- a host program could carry a stray
370# .ld for some other purpose, and a vector_table.c with nothing placing it is
371# not an image -- and no host program has ever needed both.
372#
373# Derived from ``git ls-files`` rather than listed, so a firmware product that
374# lands tomorrow is classified the day it lands, with no allowlist to forget.
375# ---------------------------------------------------------------------------
376
377#: Products tier root. Only this root is ambiguous; the others classify by name.
378PRODUCTS_ROOT = "apps/"
379
380#: Proof that a directory is linked into an image rather than started by a C
381#: runtime. Any ``.ld`` counts -- the e-reader carries three.
382_IMAGE_MARKER_SUFFIX = ".ld"
383
384#: Proof that a directory owns a reset path.
385_IMAGE_MARKER_NAME = "vector_table.c"
386
387
388def firmware_app_dirs(paths: list[str] | None = None) -> tuple[str, ...]:
389 """Every directory under ``apps/`` that builds a cross-compiled image.
390
391 Args:
392 paths: Repo-relative paths to classify. Defaults to the tracked tree,
393 which is what every caller wants; the parameter exists so a
394 selftest can drive the rule with a fixture instead of the live
395 tree.
396
397 Returns:
398 The matching repo-relative directories, sorted, with no trailing slash.
399 """
400 if paths is None:
401 paths = [rel for rel in _tracked() if not is_build_output(rel)]
402 scripts: set[str] = set()
403 vectors: set[str] = set()
404 for rel in paths:
405 if not rel.startswith(PRODUCTS_ROOT):
406 continue
407 head, _, name = rel.rpartition("/")
408 if not head:
409 continue
410 if name.endswith(_IMAGE_MARKER_SUFFIX):
411 scripts.add(head)
412 elif name == _IMAGE_MARKER_NAME:
413 app_dir, separator, leaf = head.rpartition("/")
414 if separator and leaf == "src":
415 vectors.add(app_dir)
416 return tuple(sorted(scripts & vectors))
417
418
419def selftest() -> int:
420 """Prove source classification includes tricky code and excludes real outputs/SOUP."""
421 with tempfile.TemporaryDirectory(prefix="lint-targets-selftest-") as raw:
422 root = Path(raw)
423 hook = root / "scripts/git/pre-commit"
424 hook.parent.mkdir(parents=True)
425 hook.write_text("#!/usr/bin/env bash\nexit 0\n", encoding="ascii")
426 cases = (
427 (language_of("scripts/git/pre-commit", root) == "shell", "shebang-only hook is shell"),
428 (
429 language_of("internal/build/helper.sh", root) == "shell",
430 "source build dir is visible",
431 ),
432 (is_build_output("tools/demo/build/object.o"), "tool build output is excluded"),
433 (
434 not is_build_output("internal/build/helper.sh"),
435 "non-product build directory is not output",
436 ),
437 (
438 language_of("port/threadx/src/vendor.c", root) is None,
439 "language-specific vendored C is excluded",
440 ),
441 (
442 firmware_app_dirs(
443 [
444 "apps/board/reader/linker.ld",
445 "apps/board/reader/src/vector_table.c",
446 "apps/host/tool/linker.ld",
447 ]
448 )
449 == ("apps/board/reader",),
450 "firmware product needs linker and vector markers",
451 ),
452 )
453 failed = [label for passed, label in cases if not passed]
454 for passed, label in cases:
455 print(f" [{'ok' if passed else 'FAIL'}] {label}")
456 if failed:
457 print(f"lint_targets.py --selftest: {len(failed)} failure(s)", file=sys.stderr)
458 return 1
459 print("lint_targets.py --selftest: all cases pass (both directions).")
460 return 0
461
462
463def main(argv: list[str]) -> int:
464 """Print the first-party file list, optionally filtered by language.
465
466 Exits non-zero printing NOTHING when a requested language resolves to zero
467 files. That is the contract the size gates depend on: an empty list must
468 be distinguishable from a clean tree, or a broken enumeration reads as
469 success.
470
471 Returns 0 with the paths on stdout, 1 on an unknown or empty language.
472 """
473 args = argv[1:]
474 if args == ["--selftest"]:
475 return selftest()
476 if args == ["--list"]:
477 print("\n".join(LANGUAGES))
478 return 0
479 if any(arg.startswith("-") for arg in args):
480 sys.stderr.write("usage: lint_targets.py [--list|--selftest|LANGUAGE ...]\n")
481 return 2
482 requested = tuple(args) or LANGUAGES
483 unknown = [lang for lang in requested if lang not in LANGUAGES]
484 if unknown:
485 sys.stderr.write(f"lint_targets.py: unknown language(s): {unknown}\n")
486 return 2
487 grouped = files_for(requested)
488 empty = [lang for lang, paths in grouped.items() if not paths]
489 if empty:
490 sys.stderr.write(
491 f"lint_targets.py: FATAL -- language(s) {empty} resolved to zero "
492 f"files. The enumeration is broken; refusing to report a clean scope.\n"
493 )
494 return 2
495 for lang in requested:
496 for rel in grouped[lang]:
497 print(rel)
498 return 0
499
500
501if __name__ == "__main__":
502 sys.exit(main(sys.argv))
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298