3"""Syntax-aware recognizers for inline suppression directives."""
5from __future__
import annotations
8from dataclasses
import dataclass, replace
9from pathlib
import Path
11from suppression_catalog
import (
16 KNOWN_PROJECT_MARKERS,
31from suppression_comment_lex
import Comment
32from suppression_model
import Finding, Suppression
33from suppression_tool_controls
import (
34 recognize_tool_comment,
40@dataclass(frozen=True)
42 """Normalized fields produced by one directive recognizer."""
50 reason_required: bool =
True
53def _reason(tail: str) -> str:
54 """Normalize the conventional separator before an inline rationale."""
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()
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] = []
69 concerns.append(
"broad-rule")
70 if reason_required
and not reason:
71 concerns.append(
"blank-reason")
72 return tuple(concerns)
78 recognition: Recognition,
80 """Build one normalized suppression row from a recognized comment."""
87 recognition.rule
or "*",
88 recognition.directive,
94 recognition.rule
or "*",
96 reason_required=recognition.reason_required,
101def _valid_rule_list(value: str |
None) -> bool:
102 """Return whether a comma-separated rule list has no empty or bogus IDs."""
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
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")):
116 suffix = match.group(
"scope")
or ""
117 tail = match.group(
"tail")
120 and match.group(
"rules")
is None
122 and not tail.lstrip().startswith(
"--")
127 "NEXTLINE":
"next-line",
128 "BEGIN":
"region-start",
131 reason = _reason(tail)
132 rules = (match.group(
"rules")
or "").strip()
133 tool =
"cpplint" if "/" in rules
else "clang-tidy"
144 reason_required=scope !=
"region-end",
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(
"--"):
154 reason = value[2:].strip()
155 if reason.endswith(
"*/"):
156 reason = reason[:-2].rstrip()
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):
173 Finding(
"unknown-directive", raw[match.start() :].strip(), path, line_no)
176 suffix = match.group(
"scope")
or ""
179 "NEXTLINE":
"next-line",
180 "BEGIN":
"region-start",
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"
188 Comment(line_no, match.start() + 1, match.group(0)),
195 _raw_nolint_reason(following),
196 reason_required=scope !=
"region-end",
199 records.extend(_split_rules(record))
207 Comment(line_no, match.start() + 1, match.group(0)),
214 _raw_nolint_reason(following),
215 reason_required=scope !=
"region-end",
219 return records, findings
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"
234 (match.group(
"rules")
or "").strip(),
240 match = TYPE_IGNORE_RE.fullmatch(body)
241 if match
is None or not _valid_rule_list(match.group(
"rules")):
243 reason = _reason(match.group(
"tail"))
250 (match.group(
"rules")
or "").strip(),
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)
272 _reason(match.group(
"tail")),
277def _recognize_pylint(path: str, comment: Comment, body: str) -> Suppression |
None:
278 """Recognize Pylint file and region controls."""
279 match = PYLINT_RE.fullmatch(body)
282 control = match.group(
"control").lower()
283 rules = (match.group(
"rules")
or "").strip()
284 if control !=
"skip-file" and not rules:
286 scopes = {
"disable":
"following-code",
"enable":
"following-code",
"skip-file":
"file"}
287 scope = scopes[control]
295 f
"pylint: {control}",
297 _reason(match.group(
"tail")),
298 reason_required=control !=
"enable",
303def _recognize_mypy(path: str, comment: Comment, body: str) -> Suppression |
None:
304 """Recognize Mypy file-level controls."""
305 match = MYPY_RE.fullmatch(body)
308 control = match.group(
"control").lower()
309 rules = (match.group(
"rules")
or "").strip()
310 if control !=
"ignore-errors" and not rules:
321 _reason(match.group(
"tail")),
326def _recognize_pyright(path: str, comment: Comment, body: str) -> Suppression |
None:
327 """Recognize Pyright line and file controls."""
328 match = PYRIGHT_RE.fullmatch(body)
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"
344 _reason(match.group(
"tail")),
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)
354 rules = (match.group(
"nosec_rules")
or match.group(
"bandit_rules")
or "").strip()
355 directive =
"nosec" if match.group(
"nosec")
else "bandit: skip"
365 _reason(match.group(
"tail")),
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)
375 reason = _reason(match.group(
"tail"))
376 if match.group(
"ruff"):
383 (match.group(
"ruff_rules")
or "").strip(),
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"
399 if control ==
"skip_file"
409 f
"{match.group('isort') or match.group('fmt')}: {control}",
412 reason_required=scope !=
"region-end",
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)
422 control = match.group(
"control").lower()
429 match.group(
"value").strip(),
430 "shellcheck " + control,
432 _reason(match.group(
"tail")),
433 reason_required=control ==
"disable",
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)
443 marker = match.group(
"marker")
446 if marker.endswith(
"START")
448 if marker.endswith(
"STOP")
458 marker.split(
"_", 1)[0].lower(),
462 _reason(match.group(
"tail")),
463 reason_required=scope !=
"region-end",
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:
473 suffix = match.group(
"scope")
or ""
477 "-begin":
"region-start",
478 "-end":
"region-end",
487 "cppcheck-suppress" + suffix,
489 _reason(match.group(
"tail")),
490 reason_required=scope !=
"region-end",
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:
500 marker = match.group(
"marker")
510 (match.group(
"reason")
or "").strip(),
518 _recognize_python_coverage,
523 _recognize_python_formatter,
524 _recognize_shellcheck,
533 comments: list[Comment],
534 active_tools: frozenset[str],
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
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
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:
559 reason=pending_reason,
560 concerns=_concerns(record.rule, pending_reason),
563 records.extend(_split_rules(record))
564 elif finding := tool_control_finding(path, comment, body, active_tools):
565 findings.append(finding)
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
572 findings.append(Finding(
"unknown-directive", body, path, comment.line))
573 pending_reason, pending_line =
"", 0
574 return records, findings
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)")
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()
591def _closes_unopened_bracket(code: str) -> bool:
592 """True when the line closes a parenthesis it never opened.
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
601 for (uint32_t i = 0U; i < limit;
602 i++) { /* GCOVR_EXCL_BR_LINE -- hardware only */
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
611 code: One physical line with any trailing comment already removed.
614 True when bracket depth goes negative anywhere in ``code``.
620 while index < len(code):
634 lowest =
min(lowest, depth)
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.
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.
650 One finding per stranded marker; empty when every marker sits on the
651 line that carries its branch.
653 findings: list[Finding] = []
654 lines = text.splitlines()
655 for index, line
in enumerate(lines):
656 if not _BR_LINE_MARKER.search(line):
658 own = _code_before_comment(line)
661 if _closes_unopened_bracket(own):
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",
673 for back
in range(index - 1, -1, -1):
674 if (back + 1)
in comment_lines:
676 candidate = _code_before_comment(lines[back])
677 if candidate.strip():
680 if not previous
or _STATEMENT_END.search(previous):
682 if _LABEL_OR_DIRECTIVE.match(previous):
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",
693 findings.extend(_stranded_line_findings(path, lines))
697def _stranded_line_findings(path: str, lines: list[str]) -> list[Finding]:
698 """Report line-exclusion markers sitting on a line that holds no code.
701 path: Repository-relative path, used only to locate the finding.
702 lines: The file's physical lines.
705 One finding per marker on a comment-only line.
707 findings: list[Finding] = []
708 for index, line
in enumerate(lines):
709 if not _LINE_MARKER.search(line):
711 if _code_before_comment(line).strip():
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",
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:
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.