ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
check_no_legacy_make.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"""Reject legacy repository task invocations in authored surfaces.
5
6The repository task runner is Just. GNU Make can still be a real dependency
7of CMake or an upstream source build, so this checker deliberately matches
8only command-shaped task invocations: an executable shell/YAML/Docker line, a
9shell command array, or a command presented in quotes/backticks or after a
10user-guidance verb. Natural English, CMake/Makefile names, tool lists, and
11dependency probes such as ``command -v make`` stay outside that shape. A real
12upstream build belongs outside the CI, Just, developer-script, and MCP
13task-entry-point scope; adding one there requires a narrow, path-specific
14exception and a negative self-test.
15"""
16
17from __future__ import annotations
18
19import re
20import shutil
21import subprocess
22import sys
23from pathlib import Path
24
25REPO_ROOT = Path(__file__).resolve().parents[2]
26SELF = "scripts/checks/check_no_legacy_make.py"
27EXACT_FILES = frozenset(
28 {".clangd", ".cppcheck-suppressions", ".env.example", "CMakePresets.json", "justfile"}
29)
30PREFIXES = (
31 ".devcontainer/",
32 ".github/workflows/",
33 ".vscode/",
34 "just/",
35 "scripts/",
36 "tools/mcp/",
37)
38DOC_SUFFIXES = frozenset({".md", ".mdx", ".rst"})
39EXCLUDED_PREFIXES = (
40 "docs/sbom/upstream/",
41 "libs/third_party/",
42 "apps/shared_libs/third_party/",
43 "port/netxduo/",
44 "port/nimble/",
45 "port/threadx/",
46 "port/usbx/",
47 "tests/fixtures/",
48)
49BASELINE_RE = re.compile(r"^\.github/[^/]*baseline[^/]*\.txt$")
50MIN_SCOPED_FILES = 650
51
52# Quoting the executable does not change what runs. Keep these alternatives
53# explicit so the expression cannot accept mismatched quotes.
54MAKE_EXECUTABLE = r'(?:g?make|"g?make"|\'g?make\')'
55
56# Command at the beginning of a shell line, a one-line YAML ``run:``, or a
57# Dockerfile RUN. Matching the first argument even when it starts with ``-`` is
58# important: the legacy runner's ``-C apps/...`` form was the dominant
59# pre-migration build entry point.
60ACTIVE_COMMAND_RE = re.compile(
61 rf"^\s*(?:(?:RUN|run:)\s+)?({MAKE_EXECUTABLE})(?=\s|$)"
62 r"(?:\s+([^\s#;&|]+))?"
63)
64
65# Shell arrays are frequently executed later as ``"${cmd[@]}"``. Looking only
66# for a command in column zero lets such an invocation hide indefinitely.
67ARRAY_COMMAND_RE = re.compile(
68 rf"^\s*[A-Za-z_][A-Za-z0-9_]*\s*=\‍(\s*({MAKE_EXECUTABLE})(?=\s|\‍))"
69 r"(?:\s+([^\s)]+))?"
70)
71
72# A bare command in a comment must end after the target. This accepts old
73# one-line usage hints while rejecting a natural-language sentence.
74COMMENT_COMMAND_RE = re.compile(rf"^\s*#\s*({MAKE_EXECUTABLE})\s+([^\s]+)\s*[.`'\"]?\s*$")
75
76# A command shown to a reader verbatim in backticks or quotes. The checker
77# source builds its mutation strings dynamically so its own self-test does not
78# need a live exception.
79QUOTED_COMMAND_RE = re.compile(
80 r"(?:`|'|\")(g?make)(?:\s+([^\s`'\"]+)|(?:`|'|\")+\s+(?:target|recipe|task)\b)"
81)
82
83# Unquoted user guidance. Requiring an action verb avoids natural sentences
84# such as "these limits make an empty scan fail".
85GUIDANCE_COMMAND_RE = re.compile(
86 rf"\b(?:run|use|invoke|try|rerun|execute)\s+({MAKE_EXECUTABLE})\s+"
87 r"([^\s`'\"]+)",
88 re.IGNORECASE,
89)
90
91
92def scoped_files() -> list[str]:
93 """Return authored documentation and automation covered by the migration contract."""
94 git_bin = shutil.which("git") or "git"
95 proc = subprocess.run( # noqa: S603 -- resolved Git executable and fixed arguments
96 [
97 git_bin,
98 "ls-files",
99 "--cached",
100 "--others",
101 "--exclude-standard",
102 "-z",
103 ],
104 cwd=REPO_ROOT,
105 check=True,
106 capture_output=True,
107 )
108 rels = proc.stdout.decode("utf-8", errors="strict").split("\0")
109 selected = set()
110 for rel in rels:
111 if not rel or rel.startswith(EXCLUDED_PREFIXES):
112 continue
113 path = Path(rel)
114 if not (REPO_ROOT / path).is_file():
115 continue
116 if (
117 rel in EXACT_FILES
118 or rel.startswith(PREFIXES)
119 or BASELINE_RE.match(rel) is not None
120 or path.suffix.lower() in DOC_SUFFIXES
121 or path.name == "Dockerfile"
122 ):
123 selected.add(rel)
124 # The checker may be validated before its newly-created file is staged.
125 if (REPO_ROOT / SELF).is_file():
126 selected.add(SELF)
127 return sorted(selected)
128
129
130def legacy_invocation(line: str, *, active_commands: bool = True) -> str | None:
131 """Return the command-shaped legacy invocation on ``line``, if any."""
132 patterns = [COMMENT_COMMAND_RE, QUOTED_COMMAND_RE, GUIDANCE_COMMAND_RE]
133 if active_commands:
134 patterns[0:0] = [ACTIVE_COMMAND_RE, ARRAY_COMMAND_RE]
135 for pattern in patterns:
136 match = pattern.search(line)
137 if match is not None:
138 executable = match.group(1).strip("\"'")
139 first_arg = match.group(2)
140 return f"{executable} {first_arg}" if first_arg else executable
141 return None
142
143
144def scan(rels: list[str]) -> list[str]:
145 """Return path/line findings for every command-shaped legacy reference."""
146 findings: list[str] = []
147 for rel in rels:
148 path = REPO_ROOT / rel
149 active_commands = rel.endswith((".sh", ".yml", ".yaml")) or path.name == "Dockerfile"
150 try:
151 text = path.read_text(encoding="utf-8")
152 except UnicodeDecodeError:
153 continue
154 for number, line in enumerate(text.splitlines(), start=1):
155 invocation = legacy_invocation(line, active_commands=active_commands)
156 if invocation is not None:
157 findings.append(f"{rel}:{number}: legacy repository task: {invocation}")
158 return findings
159
160
161def selftest() -> int:
162 """Prove command forms fire and legitimate Make mentions stay quiet."""
163 command = "ma" + "ke"
164 gnu_command = "g" + command
165 cases = (
166 (f"{command} ci", True, "a direct shell task fires"),
167 (f"{command} -C apps/board/stand_alone/blink build", True, "a -C task fires"),
168 (f"{gnu_command} ci", True, "a gmake task fires"),
169 (f"cmd=({command} -C apps/blink)", True, "a command array fires"),
170 (f'cmd=("{command}" "-C" apps/blink)', True, "a quoted array command fires"),
171 (f'"{command}" -C apps/blink', True, "a quoted executable fires"),
172 (f"run: {command} -C apps/blink", True, "a one-line YAML command fires"),
173 (f"RUN {command} coverage", True, "a Dockerfile task fires"),
174 (f"# {command} ci-native", True, "a bare comment hint fires"),
175 (f"# `{command} sbom` regenerates it", True, "a backticked hint fires"),
176 (
177 f"CI (or a local ``{command}`` target) catches drift",
178 True,
179 "a quoted legacy task-runner reference fires",
180 ),
181 (f"Please run {command} misra", True, "an unquoted user hint fires"),
182 ("command -v make || missing=build-essential", False, "a dependency probe stays quiet"),
183 ("command -v gmake || missing=build-essential", False, "a gmake probe stays quiet"),
184 ("for tool in curl cmake make tar cc; do", False, "an upstream tool list stays quiet"),
185 ("these controls make an empty scan fail", False, "natural English stays quiet"),
186 ("# make the detector fail", False, "natural comment prose stays quiet"),
187 ("CMakeLists.txt and GNUmakefile", False, "build-system filenames stay quiet"),
188 (
189 "# Make is required by an upstream source build",
190 False,
191 "an explanatory mention stays quiet",
192 ),
193 )
194 failures = [
195 label for line, expected, label in cases if bool(legacy_invocation(line)) != expected
196 ]
197 if failures:
198 for failure in failures:
199 print(f"check_no_legacy_make.py --selftest: FAIL: {failure}", file=sys.stderr)
200 return 1
201 print(f"check_no_legacy_make.py --selftest: PASS ({len(cases)} both-direction cases)")
202 return 0
203
204
205def main() -> int:
206 """Run detector self-tests or scan the live tracked scope."""
207 if sys.argv[1:] == ["--selftest"]:
208 return selftest()
209 if sys.argv[1:]:
210 print("usage: check_no_legacy_make.py [--selftest]", file=sys.stderr)
211 return 2
212 try:
213 rels = scoped_files()
214 except (OSError, subprocess.CalledProcessError, UnicodeError) as exc:
215 print(f"check_no_legacy_make.py: cannot enumerate tracked files: {exc}", file=sys.stderr)
216 return 2
217 if len(rels) < MIN_SCOPED_FILES or SELF not in rels:
218 print(
219 f"check_no_legacy_make.py: scope collapsed to {len(rels)} file(s); "
220 f"expected at least {MIN_SCOPED_FILES} including {SELF}",
221 file=sys.stderr,
222 )
223 return 2
224 findings = scan(rels)
225 if findings:
226 print("check_no_legacy_make.py: legacy repository task references:", file=sys.stderr)
227 for finding in findings:
228 print(f" {finding}", file=sys.stderr)
229 print("Use the authoritative namespaced Just recipe instead.", file=sys.stderr)
230 return 1
231 print(f"check_no_legacy_make.py: clean ({len(rels)} authored files)")
232 return 0
233
234
235if __name__ == "__main__":
236 raise SystemExit(main())
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298