ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
suppression_build_controls.py
Go to the documentation of this file.
1# SPDX-License-Identifier: MIT
2# Copyright (c) 2026 Brighton Sikarskie
3"""Inventory active warning controls in shell, CMake, Make, and YAML syntax."""
4
5from __future__ import annotations
6
7import re
8from dataclasses import dataclass
9from pathlib import Path
10
11from suppression_catalog import (
12 BLANKET_WARNING_RE,
13 WARNING_FLAG_RE,
14 is_build_control,
15 is_shell_control,
16 ownership,
17)
18from suppression_hash_lex import hash_lines
19from suppression_model import Suppression
20
21_COMPILER_NAME = r"(?:[A-Za-z0-9_.+-]*-)?(?:cc|c\+\+|gcc|g\+\+|clang|clang\+\+)"
22_COMPILER_CONTEXT_RE = re.compile(
23 r"\b[A-Za-z0-9_]*(?:CFLAGS|CXXFLAGS|CPPFLAGS|COMPILE_OPTIONS)\b"
24 r"|--extra-arg(?:-before)?=|(?:^|[\s(])-(?:D|I|f|std=)",
25 re.IGNORECASE,
26)
27_COMPILER_OWNER_RE = re.compile(
28 r"\b[A-Za-z0-9_]*(?:CFLAGS|CXXFLAGS|CPPFLAGS|COMPILE_OPTIONS)\b"
29 r"|--extra-arg(?:-before)?=",
30 re.IGNORECASE,
31)
32_COMPILER_OPTION_RE = re.compile(r"(?:^|[\s(])-(?:D|I|f|std=)")
33_CMAKE_VARIABLES = frozenset(("$CMAKE", "$" + "{CMAKE}", "$" + "{CMAKE_COMMAND}"))
34_CMAKE_WARNING_SUPPRESSIONS = frozenset(
35 {
36 "-Wno-deprecated",
37 "-Wno-dev",
38 "-Wno-error=deprecated",
39 "-Wno-error=dev",
40 }
41)
42_COMMAND_PREFIXES = frozenset(
43 {"!", "COMMAND", "command", "do", "elif", "env", "exec", "if", "then"}
44)
45_SHELL_OPERATORS = frozenset({"&", "&&", "(", ")", ";", ";;", "|", "||"})
46_YAML_KEY_RE = re.compile(r"^\s*(?:-\s*)?(?P<key>[A-Za-z0-9_.-]+)\s*:\s*(?P<value>.*)$")
47_YAML_BLOCK_RE = re.compile(
48 r"^(?P<indent>\s*)(?:-\s*)?(?P<key>[A-Za-z0-9_.-]+)\s*:\s*"
49 r"(?P<indicator>[>|])(?P<modifiers>(?:[+-][1-9]?|[1-9][+-]?))?"
50 r"\s*(?:#.*)?$"
51)
52_CMAKE_COMPILER_RE = re.compile(
53 r"\b(?:add_compile_options|target_compile_options)\s*\‍("
54 r"|\b(?:CMAKE_[A-Za-z0-9_]*(?:C|CXX|ASM)[A-Za-z0-9_]*FLAGS|COMPILE_OPTIONS)\b"
55 r"|\b(?:set|list)\s*\‍([^\n)]*(?:WNO[A-Za-z0-9_]*|C_FLAGS|CXX_FLAGS|WARNING_FLAGS)\b",
56 re.IGNORECASE,
57)
58_CMAKE_NONCONFIGURE_RE = re.compile(
59 r"(?:^|\s)(?:--build|--install|--open|--find-package|--help(?:-[A-Za-z-]+)?|"
60 r"--version|--system-information|-E|-P)(?=$|\s)"
61)
62
63
64@dataclass(frozen=True)
65class ActiveBuildCode:
66 """One flag-bearing source line and the context that owns it."""
67
68 path: str
69 line: int
70 code: str
71 source_offset: int
72 blanket_is_compiler: bool
73 context: str
74 reason: str = ""
75
76
77@dataclass(frozen=True)
78class YamlCommandLine:
79 """One source-mapped line from a YAML-owned shell command."""
80
81 line: int
82 code: str
83 source_offset: int
84 context: str = ""
85 reason: str = ""
86
87
88@dataclass(frozen=True)
89class ShellToken:
90 """One quote-decoded shell word or command-separating operator."""
91
92 value: str
93 start: int
94 end: int
95 operator: bool = False
96
97
98@dataclass(frozen=True)
99class ToolCommand:
100 """One compiler or CMake executable occupying a command-word position."""
101
102 kind: str
103 start: int
104 end: int
105
106
107def _shell_tokens(source: str) -> list[ShellToken]:
108 """Tokenize shell command words with quote and backslash semantics."""
109 tokens: list[ShellToken] = []
110 index = 0
111 while index < len(source):
112 if source[index].isspace():
113 index += 1
114 continue
115 pair = source[index : index + 2]
116 if pair in _SHELL_OPERATORS:
117 tokens.append(ShellToken(pair, index, index + 2, operator=True))
118 index += 2
119 continue
120 if source[index] in _SHELL_OPERATORS:
121 tokens.append(ShellToken(source[index], index, index + 1, operator=True))
122 index += 1
123 continue
124 start = index
125 value: list[str] = []
126 while index < len(source):
127 char = source[index]
128 if char.isspace() or char in _SHELL_OPERATORS:
129 break
130 if char in {"'", '"'}:
131 quote = char
132 index += 1
133 while index < len(source) and source[index] != quote:
134 char = source[index]
135 if char == "\\" and quote == '"' and index + 1 < len(source):
136 escaped = source[index + 1]
137 if escaped in {'"', "$", chr(96), "\\", "\n"}:
138 value.append(escaped)
139 index += 2
140 continue
141 value.append(char)
142 index += 1
143 if index >= len(source):
144 return tokens
145 index += 1
146 continue
147 if char == "\\" and index + 1 < len(source):
148 value.append(source[index + 1])
149 index += 2
150 continue
151 value.append(char)
152 index += 1
153 tokens.append(ShellToken("".join(value), start, index))
154 return tokens
155
156
157def _tool_kind(value: str) -> str | None:
158 """Classify one decoded command word by executable basename."""
159 if value in _CMAKE_VARIABLES:
160 return "cmake"
161 basename = re.split(r"[/\\]", value)[-1]
162 if basename == "cmake":
163 return "cmake"
164 if re.fullmatch(_COMPILER_NAME, basename, re.IGNORECASE) is not None:
165 return "compiler"
166 return None
167
168
169def _is_assignment(value: str) -> bool:
170 """Return whether a shell word is an environment assignment."""
171 return re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*=.*", value, re.DOTALL) is not None
172
173
174def _tool_commands(source: str) -> list[ToolCommand]:
175 """Return tools in executable positions, excluding data arguments."""
176 commands: list[ToolCommand] = []
177 expect_command = True
178 wrapper = ""
179 active_kind: str | None = None
180 cmake_dash_e = False
181 for token in _shell_tokens(source):
182 if token.operator:
183 expect_command = True
184 wrapper = ""
185 active_kind = None
186 cmake_dash_e = False
187 continue
188 basename = re.split(r"[/\\]", token.value)[-1]
189 if expect_command:
190 if _is_assignment(token.value):
191 continue
192 if basename in _COMMAND_PREFIXES:
193 wrapper = basename
194 continue
195 if wrapper and token.value.startswith("-"):
196 continue
197 kind = _tool_kind(token.value)
198 if kind is not None:
199 commands.append(ToolCommand(kind, token.start, token.end))
200 active_kind = kind
201 expect_command = False
202 wrapper = ""
203 cmake_dash_e = False
204 continue
205 if active_kind == "cmake" and token.value == "-E":
206 cmake_dash_e = True
207 elif active_kind == "cmake" and cmake_dash_e and basename == "env":
208 expect_command = True
209 wrapper = "env"
210 active_kind = None
211 cmake_dash_e = False
212 return commands
213
214
215def _has_compiler_context(source: str) -> bool:
216 """Return whether source owns a compiler command or option context."""
217 return _COMPILER_CONTEXT_RE.search(source) is not None or any(
218 command.kind == "compiler" for command in _tool_commands(source)
219 )
220
221
222def _cmake_commands(source: str) -> list[ToolCommand]:
223 """Return source-mapped CMake executables in command positions."""
224 return [command for command in _tool_commands(source) if command.kind == "cmake"]
225
226
227def _has_cmake_context(source: str) -> bool:
228 """Return whether source contains an active CMake command."""
229 return bool(_cmake_commands(source))
230
231
232def _is_cmake_configure_context(source: str) -> bool:
233 """Return whether source invokes CMake's configure mode."""
234 commands = _cmake_commands(source)
235 if not commands:
236 return False
237 invocation = source[commands[-1].end :]
238 return _CMAKE_NONCONFIGURE_RE.search(invocation) is None
239
240
241def _control_kind_at(source: str, flag_position: int) -> str | None:
242 """Return the nearest active compiler/CMake command owning one flag."""
243 prefix = source[:flag_position]
244 compiler_positions = [match.start() for match in _COMPILER_OWNER_RE.finditer(prefix)]
245 compiler_positions.extend(
246 command.start for command in _tool_commands(prefix) if command.kind == "compiler"
247 )
248 compiler_positions.extend(match.start() for match in _CMAKE_COMPILER_RE.finditer(prefix))
249 cmake_matches = _cmake_commands(prefix)
250 compiler_position = max(compiler_positions, default=-1)
251 cmake_match = cmake_matches[-1] if cmake_matches else None
252 if cmake_match is not None and cmake_match.start > compiler_position:
253 invocation = prefix[cmake_match.end :]
254 if _CMAKE_NONCONFIGURE_RE.search(invocation) is None:
255 return "cmake"
256 return None
257 if compiler_position >= 0:
258 return "compiler"
259 if _COMPILER_OPTION_RE.search(prefix) is not None:
260 return "compiler"
261 return None
262
263
264def _is_cmake_warning_control(source: str, flag_position: int, flag: str) -> bool:
265 """Return whether one exact CMake diagnostic option owns the match."""
266 if flag not in _CMAKE_WARNING_SUPPRESSIONS:
267 return False
268 return any(
269 not token.operator and token.value == flag and token.start <= flag_position < token.end
270 for token in _shell_tokens(source)
271 )
272
273
274def _folded_yaml_commands(
275 block: list[tuple[int, str, int]], content_indent: int
276) -> list[YamlCommandLine]:
277 """Join folded YAML paragraphs while retaining source and rationale."""
278 commands: list[YamlCommandLine] = []
279 group: list[tuple[int, str, int]] = []
280 pending_reason = ""
281
282 def flush() -> None:
283 nonlocal pending_reason
284 if not group:
285 return
286 pieces: list[str] = []
287 spans: list[tuple[int, int, int]] = []
288 cursor = 0
289 for line_no, content, _indent in group:
290 if pieces:
291 cursor += 1
292 start = cursor
293 pieces.append(content)
294 cursor += len(content)
295 spans.append((line_no, start, cursor))
296 joined = " ".join(pieces)
297 lexical_lines, _ = hash_lines("yaml-command.sh", joined + "\n")
298 lexical = lexical_lines[0] if lexical_lines else None
299 active = lexical.code if lexical is not None else ""
300 comment = lexical.comment.strip() if lexical is not None else ""
301 if not active.strip():
302 pending_reason = (
303 _inline_reason(comment) if comment.startswith("Suppression rationale:") else ""
304 )
305 group.clear()
306 return
307 reason = _inline_reason(comment) or pending_reason
308 for line_no, start, end in spans:
309 active_end = min(end, len(active))
310 code = active[start:active_end] if start < active_end else ""
311 commands.append(
312 YamlCommandLine(
313 line_no,
314 code,
315 content_indent,
316 active[:active_end],
317 reason,
318 )
319 )
320 pending_reason = ""
321 group.clear()
322
323 for source in block:
324 _line_no, content, indent = source
325 if not content.strip() or indent > content_indent:
326 flush()
327 if content.strip():
328 group.append(source)
329 flush()
330 continue
331 group.append(source)
332 flush()
333 return commands
334
335
336def _yaml_command_lines(text: str) -> list[YamlCommandLine]:
337 """Return de-indented shell owned only by active run/shell block keys."""
338 raw_lines = text.splitlines()
339 commands: list[YamlCommandLine] = []
340 index = 0
341 while index < len(raw_lines):
342 header = _YAML_BLOCK_RE.match(raw_lines[index])
343 if header is None:
344 index += 1
345 continue
346 header_indent = len(header.group("indent"))
347 key = header.group("key").lower()
348 block: list[tuple[int, str, int]] = []
349 index += 1
350 content_indent: int | None = None
351 while index < len(raw_lines):
352 raw = raw_lines[index]
353 indent = len(raw) - len(raw.lstrip(" "))
354 if raw.strip():
355 if content_indent is None:
356 if indent <= header_indent:
357 break
358 content_indent = indent
359 elif indent < content_indent:
360 break
361 if content_indent is not None:
362 block.append((index + 1, raw[content_indent:], indent))
363 elif not raw.strip():
364 block.append((index + 1, "", 0))
365 index += 1
366 if key not in {"run", "shell"} or content_indent is None:
367 continue
368 if header.group("indicator") == ">":
369 commands.extend(_folded_yaml_commands(block, content_indent))
370 continue
371 # Preserve a final blank payload line: str.splitlines(), used by the
372 # shell lexer, otherwise drops it and breaks source-line ownership.
373 shell_text = "\n".join(line for _, line, _ in block) + "\n"
374 shell_lines, _ = hash_lines("yaml-command.sh", shell_text)
375 for line_index, (source, lexical) in enumerate(zip(block, shell_lines, strict=True)):
376 reason = _inline_reason(lexical.comment) or _structured_reason_above(
377 shell_lines, line_index
378 )
379 commands.append(
380 YamlCommandLine(
381 source[0],
382 lexical.code,
383 content_indent,
384 reason=reason,
385 )
386 )
387 return commands
388
389
390def _active_build_code(path: str, code: str, inherited_context: str = "") -> tuple[str, bool, int]:
391 """Return flag-bearing code, blanket -w status, and its source offset."""
392 item = Path(path)
393 if item.name == "CMakeLists.txt" or item.suffix == ".cmake":
394 source = f"{inherited_context} {code}"
395 active = _CMAKE_COMPILER_RE.search(source) is not None or _is_cmake_configure_context(
396 source
397 )
398 return (code, True, 0) if active else ("", False, 0)
399 if item.suffix in {".yaml", ".yml"}:
400 match = _YAML_KEY_RE.match(code)
401 if match is None:
402 return "", False, 0
403 key = match.group("key").lower()
404 value = match.group("value")
405 flag_key = key.endswith(("cflags", "cxxflags", "cppflags", "compile_options"))
406 command_key = key in {"command", "run", "shell"}
407 command_context = _has_compiler_context(value) or _has_cmake_context(value)
408 if flag_key or (command_key and command_context):
409 return value, True, match.start("value")
410 return "", False, 0
411 active_source = f"{inherited_context} {code}"
412 active = _has_compiler_context(active_source) or _has_cmake_context(active_source)
413 return (code, True, 0) if active else ("", False, 0)
414
415
416def _inline_reason(comment: str) -> str:
417 """Return an attached reason, normalizing the structured prefix."""
418 prefix = "Suppression rationale:"
419 reason = comment.strip()
420 if not reason:
421 return ""
422 if reason.startswith(prefix):
423 return reason.removeprefix(prefix).strip()
424 return reason
425
426
427def _structured_reason_above(lines: list[object], index: int) -> str:
428 """Return one explicit rationale block immediately above a command."""
429 prefix = "Suppression rationale:"
430 notes: list[str] = []
431 for candidate in reversed(lines[:index]):
432 code = getattr(candidate, "code", "")
433 comment = getattr(candidate, "comment", "").strip()
434 if code.strip() or not comment:
435 break
436 notes.append(comment)
437 notes.reverse()
438 for note_index, note in enumerate(notes):
439 if not note.startswith(prefix):
440 continue
441 first = note.removeprefix(prefix).strip()
442 if not first:
443 return ""
444 return " ".join([first, *notes[note_index + 1 :]]).strip()
445 return ""
446
447
448def _reason_concerns(flag: str, reason: str) -> tuple[str, ...]:
449 """Report missing reasons and retain the blanket-warning concern."""
450 concerns = [] if reason else ["blank-reason"]
451 if flag == "-w":
452 concerns.append("broad-rule")
453 return tuple(concerns)
454
455
456def _append_records(
457 records: list[Suppression],
458 source: ActiveBuildCode,
459) -> None:
460 """Append warning-control records with truthful CMake/compiler ownership."""
461 matches = list(WARNING_FLAG_RE.finditer(source.code))
462 if source.blanket_is_compiler:
463 matches.extend(BLANKET_WARNING_RE.finditer(source.code))
464 reason = _inline_reason(source.reason)
465 for match in sorted(matches, key=lambda item: item.start()):
466 flag = match.group("flag")
467 code_position = source.context.rfind(source.code)
468 if code_position < 0:
469 continue
470 control_kind = _control_kind_at(source.context, code_position + match.start())
471 if control_kind is None:
472 continue
473 flag_position = code_position + match.start()
474 cmake_control = control_kind == "cmake" and _is_cmake_warning_control(
475 source.context, flag_position, flag
476 )
477 family = "cmake" if cmake_control else "compiler"
478 records.append(
479 Suppression(
480 source.path,
481 source.line,
482 source.source_offset + match.start() + 1,
483 family,
484 family,
485 flag,
486 flag,
487 "configure-command" if cmake_control else "build-target",
488 reason,
489 "build-config",
490 ownership(source.path),
491 _reason_concerns(flag, reason),
492 )
493 )
494
495
496def _continued_build_context(
497 line: object,
498 current: str,
499 *,
500 shell_control: bool,
501 cmake_control: bool,
502) -> str:
503 """Return the active command context carried to the next source line."""
504 code = getattr(line, "code", "")
505 active_source = f"{current} {code}".strip()
506 if shell_control and code.rstrip().endswith("\\"):
507 return f"{current} {code.rstrip()[:-1]}".strip()
508 if (
509 cmake_control
510 and (current or code.count("(") > code.count(")"))
511 and active_source.count("(") > active_source.count(")")
512 ):
513 return active_source
514 return ""
515
516
517def _append_line_build_records(
518 records: list[Suppression], path: str, text: str, first_line: str
519) -> None:
520 """Append controls found in ordinary build-control source lines."""
521 lines, _ = hash_lines(path, text)
522 shell_control = is_shell_control(path, first_line)
523 cmake_control = Path(path).name == "CMakeLists.txt" or Path(path).suffix == ".cmake"
524 continued_context = ""
525 continued_reason = ""
526 for index, line in enumerate(lines):
527 structured_reason = _structured_reason_above(lines, index)
528 line_reason = _inline_reason(line.comment) or continued_reason or structured_reason
529 code, blanket_is_compiler, source_offset = _active_build_code(
530 path, line.code, continued_context
531 )
532 active_source = f"{continued_context} {line.code}"
533 _append_records(
534 records,
535 ActiveBuildCode(
536 path=path,
537 line=line.line,
538 code=code,
539 source_offset=source_offset,
540 blanket_is_compiler=blanket_is_compiler,
541 context=active_source,
542 reason=line_reason,
543 ),
544 )
545 next_context = _continued_build_context(
546 line,
547 continued_context,
548 shell_control=shell_control,
549 cmake_control=cmake_control,
550 )
551 if next_context and not continued_context:
552 continued_reason = _inline_reason(line.comment) or structured_reason
553 elif not next_context:
554 continued_reason = ""
555 continued_context = next_context
556
557
558def _append_yaml_build_records(records: list[Suppression], path: str, text: str) -> None:
559 """Append controls from YAML-owned shell blocks with source-line identity."""
560 block_context = ""
561 for command in _yaml_command_lines(text):
562 active_source = command.context or f"{block_context} {command.code}"
563 active = _has_compiler_context(active_source) or _is_cmake_configure_context(active_source)
564 if active:
565 _append_records(
566 records,
567 ActiveBuildCode(
568 path=path,
569 line=command.line,
570 code=command.code,
571 source_offset=command.source_offset,
572 blanket_is_compiler=True,
573 context=active_source,
574 reason=command.reason,
575 ),
576 )
577 if command.context:
578 block_context = ""
579 elif command.code.rstrip().endswith("\\"):
580 block_context = f"{block_context} {command.code.rstrip()[:-1]}".strip()
581 else:
582 block_context = ""
583
584
585def compiler_records(path: str, text: str) -> list[Suppression]:
586 """Inventory active warning-disable flags in build-control syntax."""
587 first_line = text.partition("\n")[0]
588 if not is_build_control(path, first_line):
589 return []
590 records: list[Suppression] = []
591 _append_line_build_records(records, path, text, first_line)
592 if Path(path).suffix in {".yaml", ".yml"}:
593 _append_yaml_build_records(records, path, text)
594 return records
#define min(x, y)
Untyped minimum shim used by the SOUP's buffer clamping.
Definition xz_config.h:157