4"""One definition of "which files are first-party code", for the size gates.
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).
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
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.
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.
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
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.
40from __future__
import annotations
45from pathlib
import Path
47REPO_ROOT = Path(__file__).resolve().parents[2]
53 "apps/shared_libs/third_party/",
55 "tools/vela/generated/",
64LANGUAGE_EXCLUDED_PREFIXES = {
65 "c": (
"port/threadx/",),
97BUILD_TREE_ROOTS = frozenset(
115TOOL_OUTPUT_DIR_NAMES = frozenset({
"CMakeFiles",
"_deps",
"__pycache__",
"node_modules"})
118def is_build_dir_name(name: str) -> bool:
119 """True when one path COMPONENT names a build tree.
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
126 return name ==
"build" or name.startswith((
"build-",
"build_",
"cmake-build-"))
129def is_build_output(rel: str) -> bool:
130 """True when repo-relative `rel` lives inside a build tree.
132 Directory components only -- a FILE called ``build`` is not a build tree.
134 parts = rel.split(
"/")
135 for index, part
in enumerate(parts[:-1]):
136 if part
in TOOL_OUTPUT_DIR_NAMES:
138 if is_build_dir_name(part)
and (index == 0
or parts[0]
in BUILD_TREE_ROOTS):
166 "CMakeLists.txt":
"cmake",
183LANGUAGES = (
"c",
"python",
"shell",
"cmake",
"yaml",
"just",
"ld")
186def is_build_output_path(path: object) -> bool:
187 """``is_build_output`` for a str or Path that may be absolute.
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).
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(
"./"):
199 return is_build_output(text)
202def _tracked() -> list[str]:
203 """Existing tracked plus untracked-but-not-ignored paths, from git itself.
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.
212 proc = subprocess.run(
219 "--exclude-standard",
226 if proc.returncode != 0:
227 sys.stderr.write(proc.stderr)
228 sys.stderr.write(
"lint_targets.py: FATAL -- `git ls-files` failed\n")
230 return [rel
for rel
in proc.stdout.split(
"\0")
if rel
and (REPO_ROOT / rel).is_file()]
233def _excluded(rel: str, lang: str |
None =
None) -> bool:
234 if rel.startswith(EXCLUDED_PREFIXES)
or is_build_output(rel):
236 extra = LANGUAGE_EXCLUDED_PREFIXES.get(lang
or "", ())
237 return bool(extra)
and rel.startswith(extra)
240def _shebang_lang(path: Path) -> str |
None:
241 """Language named by a ``#!`` first line, or None.
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.
247 with path.open(
"rb")
as handle:
248 first = handle.readline(200).decode(
"utf-8", errors=
"replace")
251 if not first.startswith(
"#!"):
253 words = first[2:].replace(
"/usr/bin/env",
" ").replace(
"/",
" ").split()
255 base = word.split(
"-")[0]
256 if base
in SHEBANG_LANG:
257 return SHEBANG_LANG[base]
261def _raw_language(rel: str, root: Path) -> str |
None:
262 """The language a path's name implies, before any exclusion is applied."""
264 if path.name
in BASENAME_LANG:
265 return BASENAME_LANG[path.name]
266 lang = SUFFIX_LANG.get(path.suffix)
271 return _shebang_lang(root / rel)
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.
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.
283 lang = _raw_language(rel, root)
284 if lang
is None or _excluded(rel, lang):
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)
295 out[lang].append(rel)
296 return {lang: sorted(paths)
for lang, paths
in out.items()}
306def first_party_paths(
307 suffixes: tuple[str, ...], *, respect_language_excludes: bool =
True
309 """Every tracked first-party path ending in one of ``suffixes``.
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.
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.
328 The matching repo-relative paths, sorted.
331 SystemExit: When ``git ls-files`` returns fewer than ``TRACKED_FLOOR``
332 paths -- a collapsed enumeration must fail, never read as clean.
335 if len(rels) < TRACKED_FLOOR:
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"
344 if not rel.endswith(suffixes):
348 if respect_language_excludes:
349 lang = _raw_language(rel, REPO_ROOT)
350 if lang
is not None and _excluded(rel, lang):
378PRODUCTS_ROOT =
"apps/"
382_IMAGE_MARKER_SUFFIX =
".ld"
385_IMAGE_MARKER_NAME =
"vector_table.c"
388def firmware_app_dirs(paths: list[str] |
None =
None) -> tuple[str, ...]:
389 """Every directory under ``apps/`` that builds a cross-compiled image.
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
398 The matching repo-relative directories, sorted, with no trailing slash.
401 paths = [rel
for rel
in _tracked()
if not is_build_output(rel)]
402 scripts: set[str] = set()
403 vectors: set[str] = set()
405 if not rel.startswith(PRODUCTS_ROOT):
407 head, _, name = rel.rpartition(
"/")
410 if name.endswith(_IMAGE_MARKER_SUFFIX):
412 elif name == _IMAGE_MARKER_NAME:
413 app_dir, separator, leaf = head.rpartition(
"/")
414 if separator
and leaf ==
"src":
416 return tuple(sorted(scripts & vectors))
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:
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")
427 (language_of(
"scripts/git/pre-commit", root) ==
"shell",
"shebang-only hook is shell"),
429 language_of(
"internal/build/helper.sh", root) ==
"shell",
430 "source build dir is visible",
432 (is_build_output(
"tools/demo/build/object.o"),
"tool build output is excluded"),
434 not is_build_output(
"internal/build/helper.sh"),
435 "non-product build directory is not output",
438 language_of(
"port/threadx/src/vendor.c", root)
is None,
439 "language-specific vendored C is excluded",
444 "apps/board/reader/linker.ld",
445 "apps/board/reader/src/vector_table.c",
446 "apps/host/tool/linker.ld",
449 == (
"apps/board/reader",),
450 "firmware product needs linker and vector markers",
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}")
457 print(f
"lint_targets.py --selftest: {len(failed)} failure(s)", file=sys.stderr)
459 print(
"lint_targets.py --selftest: all cases pass (both directions).")
463def main(argv: list[str]) -> int:
464 """Print the first-party file list, optionally filtered by language.
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
471 Returns 0 with the paths on stdout, 1 on an unknown or empty language.
474 if args == [
"--selftest"]:
476 if args == [
"--list"]:
477 print(
"\n".join(LANGUAGES))
479 if any(arg.startswith(
"-")
for arg
in args):
480 sys.stderr.write(
"usage: lint_targets.py [--list|--selftest|LANGUAGE ...]\n")
482 requested = tuple(args)
or LANGUAGES
483 unknown = [lang
for lang
in requested
if lang
not in LANGUAGES]
485 sys.stderr.write(f
"lint_targets.py: unknown language(s): {unknown}\n")
487 grouped = files_for(requested)
488 empty = [lang
for lang, paths
in grouped.items()
if not paths]
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"
495 for lang
in requested:
496 for rel
in grouped[lang]:
501if __name__ ==
"__main__":
502 sys.exit(
main(sys.argv))
void main(void)
The application entry point Reset_Handler hands control to.