ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
suppression_inline_scan.py
Go to the documentation of this file.
1# SPDX-License-Identifier: MIT
2# Copyright (c) 2026 Brighton Sikarskie
3"""Syntax-aware recognizers for inline suppression directives."""
4
5from __future__ import annotations
6
7import re
8from dataclasses import dataclass, replace
9from pathlib import Path
10
11from suppression_catalog import (
12 BANDIT_RE,
13 COVERAGE_RE,
14 CPPCHECK_RE,
15 KNOWN_CPPCHECK_RULES,
16 KNOWN_PROJECT_MARKERS,
17 MYPY_RE,
18 NOLINT_RAW_RE,
19 NOLINT_RE,
20 NOQA_RE,
21 PROJECT_MARKER_RE,
22 PYLINT_RE,
23 PYRIGHT_RE,
24 PYTHON_COVERAGE_RE,
25 PYTHON_FORMATTER_RE,
26 SHELLCHECK_RE,
27 TYPE_IGNORE_RE,
28 UNKNOWN_DIRECTIVE_RE,
29 ownership,
30)
31from suppression_comment_lex import Comment
32from suppression_model import Finding, Suppression
33from suppression_tool_controls import (
34 recognize_tool_comment,
35 tool_control_finding,
36 valid_tool_control,
37)
38
39
40@dataclass(frozen=True)
41class Recognition:
42 """Normalized fields produced by one directive recognizer."""
43
44 family: str
45 tool: str
46 rule: str
47 directive: str
48 scope: str
49 reason: str
50 reason_required: bool = True
51
52
53def _reason(tail: str) -> str:
54 """Normalize the conventional separator before an inline rationale."""
55 value = tail.strip()
56 if value.startswith("--"):
57 value = value[2:].strip()
58 elif value.startswith((";", "#", ":")):
59 value = value[1:].strip()
60 elif value.startswith("- "):
61 value = value[2:].strip()
62 return value.strip()
63
64
65def _concerns(rule: str, reason: str, *, reason_required: bool = True) -> tuple[str, ...]:
66 """Return machine-observable review concerns for one recognized row."""
67 concerns: list[str] = []
68 if rule == "*":
69 concerns.append("broad-rule")
70 if reason_required and not reason:
71 concerns.append("blank-reason")
72 return tuple(concerns)
73
74
75def _record(
76 path: str,
77 comment: Comment,
78 recognition: Recognition,
79) -> Suppression:
80 """Build one normalized suppression row from a recognized comment."""
81 return Suppression(
82 path,
83 comment.line,
84 comment.column,
85 recognition.family,
86 recognition.tool,
87 recognition.rule or "*",
88 recognition.directive,
89 recognition.scope,
90 recognition.reason,
91 "inline-comment",
92 ownership(path),
93 _concerns(
94 recognition.rule or "*",
95 recognition.reason,
96 reason_required=recognition.reason_required,
97 ),
98 )
99
100
101def _valid_rule_list(value: str | None) -> bool:
102 """Return whether a comma-separated rule list has no empty or bogus IDs."""
103 if value is None:
104 return True
105 rules = value.split(",")
106 return bool(rules) and all(
107 rule.strip() and re.fullmatch(r"[A-Za-z0-9_./-]+", rule.strip()) for rule in rules
108 )
109
110
111def _recognize_nolint(path: str, comment: Comment, body: str) -> Suppression | None:
112 """Recognize clang-tidy and cpplint NOLINT line and region directives."""
113 match = NOLINT_RE.fullmatch(body)
114 if match is None or not _valid_rule_list(match.group("rules")):
115 return None
116 suffix = match.group("scope") or ""
117 tail = match.group("tail")
118 if (
119 not suffix
120 and match.group("rules") is None
121 and tail.strip()
122 and not tail.lstrip().startswith("--")
123 ):
124 return None
125 scope = {
126 "": "line",
127 "NEXTLINE": "next-line",
128 "BEGIN": "region-start",
129 "END": "region-end",
130 }[suffix]
131 reason = _reason(tail)
132 rules = (match.group("rules") or "").strip()
133 tool = "cpplint" if "/" in rules else "clang-tidy"
134 return _record(
135 path,
136 comment,
137 Recognition(
138 "clang-tidy",
139 tool,
140 rules,
141 "NOLINT" + suffix,
142 scope,
143 reason,
144 reason_required=scope != "region-end",
145 ),
146 )
147
148
149def _raw_nolint_reason(tail: str) -> str:
150 """Return only an explicit raw-source rationale, not surrounding code."""
151 value = tail.lstrip()
152 if not value.startswith("--"):
153 return ""
154 reason = value[2:].strip()
155 if reason.endswith("*/"):
156 reason = reason[:-2].rstrip()
157 return reason
158
159
160def _scan_raw_nolint(path: str, text: str) -> tuple[list[Suppression], list[Finding]]:
161 """Match NOLINT using clang-tidy's raw-source, not comment-only, semantics."""
162 records: list[Suppression] = []
163 findings: list[Finding] = []
164 for line_no, raw in enumerate(text.splitlines(), start=1):
165 for match in NOLINT_RAW_RE.finditer(raw):
166 rules = match.group("rules")
167 bracket_rules = match.group("bracket_rules")
168 selected = rules if bracket_rules is None else bracket_rules
169 following = raw[match.end() :]
170 malformed_group = following.startswith(("(", "["))
171 if malformed_group or not _valid_rule_list(selected):
172 findings.append(
173 Finding("unknown-directive", raw[match.start() :].strip(), path, line_no)
174 )
175 continue
176 suffix = match.group("scope") or ""
177 scope = {
178 "": "line",
179 "NEXTLINE": "next-line",
180 "BEGIN": "region-start",
181 "END": "region-end",
182 }[suffix]
183 normalized_rules = (selected or "").strip()
184 bracketed = bracket_rules is not None
185 tool = "cpplint" if bracketed or "/" in normalized_rules else "clang-tidy"
186 record = _record(
187 path,
188 Comment(line_no, match.start() + 1, match.group(0)),
189 Recognition(
190 "clang-tidy",
191 tool,
192 normalized_rules,
193 "NOLINT" + suffix,
194 scope,
195 _raw_nolint_reason(following),
196 reason_required=scope != "region-end",
197 ),
198 )
199 records.extend(_split_rules(record))
200 if bracketed:
201 # cpplint owns the bracket rule, while clang-tidy recognizes
202 # only the preceding bare NOLINT and therefore suppresses all
203 # of its checks on the line. Preserve both real effects.
204 records.append(
205 _record(
206 path,
207 Comment(line_no, match.start() + 1, match.group(0)),
208 Recognition(
209 "clang-tidy",
210 "clang-tidy",
211 "",
212 "NOLINT" + suffix,
213 scope,
214 _raw_nolint_reason(following),
215 reason_required=scope != "region-end",
216 ),
217 )
218 )
219 return records, findings
220
221
222def _recognize_python(path: str, comment: Comment, body: str) -> Suppression | None:
223 """Recognize Ruff/noqa and Python type-checker ignore directives."""
224 match = NOQA_RE.fullmatch(body)
225 if match is not None:
226 reason = _reason(match.group("tail"))
227 tool = "ansible-lint" if Path(path).suffix in {".yml", ".yaml"} else "ruff"
228 return _record(
229 path,
230 comment,
231 Recognition(
232 "python",
233 tool,
234 (match.group("rules") or "").strip(),
235 "noqa",
236 "line",
237 reason,
238 ),
239 )
240 match = TYPE_IGNORE_RE.fullmatch(body)
241 if match is None or not _valid_rule_list(match.group("rules")):
242 return None
243 reason = _reason(match.group("tail"))
244 return _record(
245 path,
246 comment,
247 Recognition(
248 "python",
249 "type-checker",
250 (match.group("rules") or "").strip(),
251 "type: ignore",
252 "line",
253 reason,
254 ),
255 )
256
257
258def _recognize_python_coverage(path: str, comment: Comment, body: str) -> Suppression | None:
259 """Recognize coverage.py line/branch exclusions in Python comments."""
260 match = PYTHON_COVERAGE_RE.fullmatch(body)
261 if match is None:
262 return None
263 return _record(
264 path,
265 comment,
266 Recognition(
267 "coverage",
268 "coverage.py",
269 "no-cover",
270 "pragma: no cover",
271 "branch",
272 _reason(match.group("tail")),
273 ),
274 )
275
276
277def _recognize_pylint(path: str, comment: Comment, body: str) -> Suppression | None:
278 """Recognize Pylint file and region controls."""
279 match = PYLINT_RE.fullmatch(body)
280 if match is None:
281 return None
282 control = match.group("control").lower()
283 rules = (match.group("rules") or "").strip()
284 if control != "skip-file" and not rules:
285 return None
286 scopes = {"disable": "following-code", "enable": "following-code", "skip-file": "file"}
287 scope = scopes[control]
288 return _record(
289 path,
290 comment,
291 Recognition(
292 "python",
293 "pylint",
294 rules,
295 f"pylint: {control}",
296 scope,
297 _reason(match.group("tail")),
298 reason_required=control != "enable",
299 ),
300 )
301
302
303def _recognize_mypy(path: str, comment: Comment, body: str) -> Suppression | None:
304 """Recognize Mypy file-level controls."""
305 match = MYPY_RE.fullmatch(body)
306 if match is None:
307 return None
308 control = match.group("control").lower()
309 rules = (match.group("rules") or "").strip()
310 if control != "ignore-errors" and not rules:
311 return None
312 return _record(
313 path,
314 comment,
315 Recognition(
316 "python",
317 "mypy",
318 rules,
319 f"mypy: {control}",
320 "file",
321 _reason(match.group("tail")),
322 ),
323 )
324
325
326def _recognize_pyright(path: str, comment: Comment, body: str) -> Suppression | None:
327 """Recognize Pyright line and file controls."""
328 match = PYRIGHT_RE.fullmatch(body)
329 if match is None:
330 return None
331 setting = match.group("setting")
332 rules = (match.group("rules") or setting or "").strip()
333 directive = "pyright: ignore" if match.group("ignore") else f"pyright: {setting}"
334 scope = "line" if match.group("ignore") else "file"
335 return _record(
336 path,
337 comment,
338 Recognition(
339 "python",
340 "pyright",
341 rules,
342 directive,
343 scope,
344 _reason(match.group("tail")),
345 ),
346 )
347
348
349def _recognize_bandit(path: str, comment: Comment, body: str) -> Suppression | None:
350 """Recognize Bandit line controls in both supported spellings."""
351 match = BANDIT_RE.fullmatch(body)
352 if match is None:
353 return None
354 rules = (match.group("nosec_rules") or match.group("bandit_rules") or "").strip()
355 directive = "nosec" if match.group("nosec") else "bandit: skip"
356 return _record(
357 path,
358 comment,
359 Recognition(
360 "python",
361 "bandit",
362 rules,
363 directive,
364 "line",
365 _reason(match.group("tail")),
366 ),
367 )
368
369
370def _recognize_python_formatter(path: str, comment: Comment, body: str) -> Suppression | None:
371 """Recognize Ruff, Black-compatible, and isort formatter controls."""
372 match = PYTHON_FORMATTER_RE.fullmatch(body)
373 if match is None:
374 return None
375 reason = _reason(match.group("tail"))
376 if match.group("ruff"):
377 return _record(
378 path,
379 comment,
380 Recognition(
381 "python",
382 "ruff",
383 (match.group("ruff_rules") or "").strip(),
384 "ruff: noqa",
385 "file",
386 reason,
387 ),
388 )
389 control = (match.group("fmt_control") or match.group("isort_control")).lower()
390 is_isort = match.group("isort") is not None
391 tool = "isort" if is_isort else "ruff-format"
392 rule = "imports" if is_isort else "format"
393 scope = (
394 "region-start"
395 if control == "off"
396 else "region-end"
397 if control == "on"
398 else "file"
399 if control == "skip_file"
400 else "line"
401 )
402 return _record(
403 path,
404 comment,
405 Recognition(
406 "python",
407 tool,
408 rule,
409 f"{match.group('isort') or match.group('fmt')}: {control}",
410 scope,
411 reason,
412 reason_required=scope != "region-end",
413 ),
414 )
415
416
417def _recognize_shellcheck(path: str, comment: Comment, body: str) -> Suppression | None:
418 """Recognize ShellCheck waiver and analysis-context controls."""
419 match = SHELLCHECK_RE.fullmatch(body)
420 if match is None:
421 return None
422 control = match.group("control").lower()
423 return _record(
424 path,
425 comment,
426 Recognition(
427 "shellcheck",
428 "shellcheck",
429 match.group("value").strip(),
430 "shellcheck " + control,
431 "line",
432 _reason(match.group("tail")),
433 reason_required=control == "disable",
434 ),
435 )
436
437
438def _recognize_coverage(path: str, comment: Comment, body: str) -> Suppression | None:
439 """Recognize GCOVR and LCOV line, branch, and region exclusions."""
440 match = COVERAGE_RE.fullmatch(body)
441 if match is None:
442 return None
443 marker = match.group("marker")
444 scope = (
445 "region-start"
446 if marker.endswith("START")
447 else "region-end"
448 if marker.endswith("STOP")
449 else "branch"
450 if "_BR_" in marker
451 else "line"
452 )
453 return _record(
454 path,
455 comment,
456 Recognition(
457 "coverage",
458 marker.split("_", 1)[0].lower(),
459 marker,
460 marker,
461 scope,
462 _reason(match.group("tail")),
463 reason_required=scope != "region-end",
464 ),
465 )
466
467
468def _recognize_cppcheck(path: str, comment: Comment, body: str) -> Suppression | None:
469 """Recognize cppcheck inline line and region suppressions."""
470 match = CPPCHECK_RE.fullmatch(body)
471 if match is None or match.group("rule") not in KNOWN_CPPCHECK_RULES:
472 return None
473 suffix = match.group("scope") or ""
474 scope = {
475 "": "line",
476 "-file": "file",
477 "-begin": "region-start",
478 "-end": "region-end",
479 }[suffix]
480 return _record(
481 path,
482 comment,
483 Recognition(
484 "cppcheck",
485 "cppcheck",
486 match.group("rule"),
487 "cppcheck-suppress" + suffix,
488 scope,
489 _reason(match.group("tail")),
490 reason_required=scope != "region-end",
491 ),
492 )
493
494
495def _recognize_project(path: str, comment: Comment, body: str) -> Suppression | None:
496 """Recognize one cataloged project policy waiver marker."""
497 match = PROJECT_MARKER_RE.fullmatch(body)
498 if match is None or match.group("marker") not in KNOWN_PROJECT_MARKERS:
499 return None
500 marker = match.group("marker")
501 return _record(
502 path,
503 comment,
504 Recognition(
505 "project-policy",
506 "repository-policy",
507 marker,
508 marker,
509 "line",
510 (match.group("reason") or "").strip(),
511 ),
512 )
513
514
515RECOGNIZERS = (
516 _recognize_nolint,
517 _recognize_python,
518 _recognize_python_coverage,
519 _recognize_pylint,
520 _recognize_mypy,
521 _recognize_pyright,
522 _recognize_bandit,
523 _recognize_python_formatter,
524 _recognize_shellcheck,
525 _recognize_coverage,
526 _recognize_cppcheck,
527 _recognize_project,
528)
529
530
531def _scan_comments(
532 path: str,
533 comments: list[Comment],
534 active_tools: frozenset[str],
535 *,
536 include_nolint: bool = True,
537) -> tuple[list[Suppression], list[Finding]]:
538 """Recognize directives and fail closed on directive-like unknown syntax."""
539 records: list[Suppression] = []
540 findings: list[Finding] = []
541 recognizers = RECOGNIZERS if include_nolint else RECOGNIZERS[1:]
542 pending_reason, pending_line = "", 0
543 for comment in comments:
544 body = comment.text.strip().lstrip("*").strip()
545 if body.startswith("Suppression rationale:"):
546 pending_reason = body.removeprefix("Suppression rationale:").strip()
547 pending_line = comment.line
548 continue
549 valid_control = valid_tool_control(path, body, active_tools)
550 markdown_doxygen = Path(path).suffix.lower() == ".md" and re.match(
551 r"^(?:@|\\)(?:cond|endcond)(?:\s|$)", body, re.IGNORECASE
552 )
553 record = next((item for fn in recognizers if (item := fn(path, comment, body))), None)
554 record = record or recognize_tool_comment(path, comment, body, active_tools)
555 if record is not None:
556 if not record.reason and pending_reason and pending_line == comment.line - 1:
557 record = replace(
558 record,
559 reason=pending_reason,
560 concerns=_concerns(record.rule, pending_reason),
561 fingerprint="",
562 )
563 records.extend(_split_rules(record))
564 elif finding := tool_control_finding(path, comment, body, active_tools):
565 findings.append(finding)
566 elif (
567 UNKNOWN_DIRECTIVE_RE.match(body)
568 and (include_nolint or not body.startswith("NOLINT"))
569 and not valid_control
570 and not markdown_doxygen
571 ):
572 findings.append(Finding("unknown-directive", body, path, comment.line))
573 pending_reason, pending_line = "", 0
574 return records, findings
575
576
577# A branch marker only excludes the branch on its own physical line, and gcov
578# attributes a decision to the line where the controlling expression starts.
579# These recognise a line that cannot be a statement head.
580_BR_LINE_MARKER = re.compile(r"/\*\s*GCOVR_EXCL_BR_LINE\b")
581_LINE_MARKER = re.compile(r"(?:/\*|//)\s*GCOVR_EXCL_LINE\b")
582_STATEMENT_END = re.compile(r"[;{}]\s*$")
583_LABEL_OR_DIRECTIVE = re.compile(r"^\s*(?:#|case\b|default\b)")
584
585
586def _code_before_comment(line: str) -> str:
587 """The line's code with any trailing block comment removed."""
588 return line.split("/*", maxsplit=1)[0].rstrip()
589
590
591def _closes_unopened_bracket(code: str) -> bool:
592 """True when the line closes a parenthesis it never opened.
593
594 A line that pops a bracket depth it never pushed is, by construction, the
595 continuation of a statement that began further up, whatever punctuation it
596 happens to END with. That distinction is exactly what ``_STATEMENT_END``
597 alone cannot make, and the gap was not theoretical: a wrapped
598
599 .. code-block:: c
600
601 for (uint32_t i = 0U; i < limit;
602 i++) { /* GCOVR_EXCL_BR_LINE -- hardware only */
603
604 has a previous line ending in ``;``, so the backward walk read it as a
605 finished statement and stayed quiet -- while gcov attributes the loop
606 condition to the ``for`` line, leaving the marker excluding nothing. That
607 single shape accounted for most of the exclusions this detector exists to
608 catch (#790).
609
610 Args:
611 code: One physical line with any trailing comment already removed.
612
613 Returns:
614 True when bracket depth goes negative anywhere in ``code``.
615 """
616 depth = 0
617 lowest = 0
618 quote = ""
619 index = 0
620 while index < len(code):
621 char = code[index]
622 if quote:
623 if char == "\\":
624 index += 2
625 continue
626 if char == quote:
627 quote = ""
628 elif char in "\"'":
629 quote = char
630 elif char == "(":
631 depth += 1
632 elif char == ")":
633 depth -= 1
634 lowest = min(lowest, depth)
635 index += 1
636 return lowest < 0
637
638
639def stranded_branch_findings(path: str, text: str, comment_lines: frozenset[int]) -> list[Finding]:
640 """Report every branch marker sitting on a wrapped statement's continuation.
641
642 Args:
643 path: Repository-relative path, used only to locate the finding.
644 text: The file's full source text.
645 comment_lines: One-based line numbers whose content is comment
646 interior, so the backward walk does not mistake a multi-line
647 ``/* ... */`` block for an unfinished statement.
648
649 Returns:
650 One finding per stranded marker; empty when every marker sits on the
651 line that carries its branch.
652 """
653 findings: list[Finding] = []
654 lines = text.splitlines()
655 for index, line in enumerate(lines):
656 if not _BR_LINE_MARKER.search(line):
657 continue
658 own = _code_before_comment(line)
659 if not own:
660 continue
661 if _closes_unopened_bracket(own):
662 findings.append(
663 Finding(
664 "stranded-branch-marker",
665 "GCOVR_EXCL_BR_LINE sits on a continuation line, so it excludes no "
666 "branch; use a GCOVR_EXCL_BR_START/STOP region instead",
667 path,
668 index + 1,
669 )
670 )
671 continue
672 previous = ""
673 for back in range(index - 1, -1, -1):
674 if (back + 1) in comment_lines:
675 continue
676 candidate = _code_before_comment(lines[back])
677 if candidate.strip():
678 previous = candidate
679 break
680 if not previous or _STATEMENT_END.search(previous):
681 continue
682 if _LABEL_OR_DIRECTIVE.match(previous):
683 continue
684 findings.append(
685 Finding(
686 "stranded-branch-marker",
687 "GCOVR_EXCL_BR_LINE sits on a continuation line, so it excludes no "
688 "branch; use a GCOVR_EXCL_BR_START/STOP region instead",
689 path,
690 index + 1,
691 )
692 )
693 findings.extend(_stranded_line_findings(path, lines))
694 return findings
695
696
697def _stranded_line_findings(path: str, lines: list[str]) -> list[Finding]:
698 """Report line-exclusion markers sitting on a line that holds no code.
699
700 Args:
701 path: Repository-relative path, used only to locate the finding.
702 lines: The file's physical lines.
703
704 Returns:
705 One finding per marker on a comment-only line.
706 """
707 findings: list[Finding] = []
708 for index, line in enumerate(lines):
709 if not _LINE_MARKER.search(line):
710 continue
711 if _code_before_comment(line).strip():
712 continue
713 findings.append(
714 Finding(
715 "stranded-line-marker",
716 "GCOVR_EXCL_LINE sits on a comment-only line, so it excludes a line "
717 "gcov never counted; put it on the statement, or use a "
718 "GCOVR_EXCL_START/STOP region",
719 path,
720 index + 1,
721 )
722 )
723 return findings
724
725
726def _split_rules(item: Suppression) -> list[Suppression]:
727 """Split comma-separated rules into one stable inventory row per rule."""
728 if "," not in item.rule:
729 return [item]
730 rules = [rule.strip() for rule in item.rule.split(",") if rule.strip()]
731 return [replace(item, rule=rule, fingerprint="") for rule in rules]
#define min(x, y)
Untyped minimum shim used by the SOUP's buffer clamping.
Definition xz_config.h:157