3"""Syntax-aware compiler and allocation suppression inventory."""
5from __future__
import annotations
8from dataclasses
import dataclass
9from pathlib
import Path
11from check_no_dynamic_alloc
import allocation_symbols
12from suppression_catalog
import ALLOC_ALLOW_RE, C_FAMILY_SUFFIXES, ownership
13from suppression_comment_lex
import Comment, extract_comments
14from suppression_model
import Finding, Suppression
16PRAGMA_HINT_RE = re.compile(
17 r"^\s*#\s*pragma\s+(?P<tool>GCC|clang)\s+diagnostic\b",
20PRAGMA_KIND_RE = re.compile(
21 r"^\s*#\s*pragma\s+(?P<tool>GCC|clang)\s+diagnostic\s+"
22 r"(?P<kind>push|pop|ignored|warning|error)\b(?P<tail>.*)$"
24PRAGMA_CONTROL_RE = re.compile(
25 r"^\s*#\s*pragma\s+(?P<tool>GCC|clang)\s+diagnostic\s+"
26 r'(?P<kind>ignored|warning|error)\s+"(?P<flag>-W[A-Za-z0-9_.=+\-]+)"\s*'
27 r"(?:(?://.*)|(?:/\*.*\*/\s*))?$"
29PRAGMA_OPERATOR_HINT_RE = re.compile(
r"\b_Pragma\b")
30PRAGMA_OPERATOR_RE = re.compile(
r'\b_Pragma\s*\(\s*"(?P<payload>(?:\\.|[^"\\])*)"\s*\)')
31PRAGMA_PAYLOAD_HINT_RE = re.compile(
32 r"^(?:GCC|clang)\s+diagnostic\b",
35PRAGMA_PAYLOAD_KIND_RE = re.compile(
36 r"^(?P<tool>GCC|clang)\s+diagnostic\s+"
37 r"(?P<kind>push|pop|ignored|warning|error)\b(?P<tail>.*)$"
39PRAGMA_PAYLOAD_CONTROL_RE = re.compile(
40 r"^(?P<tool>GCC|clang)\s+diagnostic\s+(?P<kind>ignored|warning|error)\s+"
41 r'"(?P<flag>-W[A-Za-z0-9_.=+\-]+)"$'
43ATTRIBUTE_BLOCK_RE = re.compile(
r"\[\[(?P<body>.*?)\]\]", re.DOTALL)
44ATTRIBUTE_CONTROL_HINT_RE = re.compile(
45 r"\bmaybe_unused\b|\bgnu::(?:__)?unused(?:__)?\b|\bclang::no_sanitize\b"
47GNU_ATTRIBUTE_HINT_RE = re.compile(
r"\b__attribute(?:__)?\b")
48GNU_CONTROL_HINT_RE = re.compile(
49 r"\b(?:__)?unused(?:__)?\b|\b(?:__)?no_sanitize(?:_[A-Za-z0-9_]+)?(?:__)?\b"
51GNU_UNUSED_ITEM_RE = re.compile(
r"^(?:__)?unused(?:__)?$")
52GNU_SANITIZER_CALL_RE = re.compile(
53 r"^(?:__)?no_sanitize(?:__)?\s*\((?P<args>.*)\)$",
56GNU_SANITIZER_SUFFIX_RE = re.compile(
57 r"^(?:__)?no_sanitize_(?P<rule>address|memory|thread|undefined)(?:__)?$"
59STANDARD_MAYBE_RE = re.compile(
r"^maybe_unused$")
60STANDARD_GNU_UNUSED_RE = re.compile(
r"^gnu::(?:__)?unused(?:__)?$")
61STANDARD_CLANG_SANITIZER_RE = re.compile(
62 r"^clang::no_sanitize\s*\((?P<args>.*)\)$",
65SANITIZER_ARGS_RE = re.compile(
66 r'^\s*"[A-Za-z0-9_.+\-]+"(?:\s*,\s*"[A-Za-z0-9_.+\-]+")*\s*$',
69SANITIZER_NAME_RE = re.compile(
r'"([A-Za-z0-9_.+\-]+)"')
70NASA_RE = re.compile(
r"\bRA8_NASA_RULE_3_OK\b")
71NASA_CALL_RE = re.compile(
r'RA8_NASA_RULE_3_OK\s*\(\s*"(?P<reason>(?:\\.|[^"\\])+)"\s*\)')
72ALLOC_HINT_RE = re.compile(
r"\balloc-allow\b", re.IGNORECASE)
73C_HINT_RE = re.compile(
74 r"#\s*pragma\s+(?:GCC|clang)\s+diagnostic|\b_Pragma\b|"
75 r"__attribute(?:__)?|\[\[\s*(?:maybe_unused|gnu::(?:__)?unused|"
76 r"clang::no_sanitize)|RA8_NASA_RULE_3_OK|alloc-allow",
81@dataclass(frozen=True)
83 """One backslash-spliced preprocessing line with its source location."""
91@dataclass(frozen=True)
93 """Normalized identity fields for one parsed C-family control."""
105 """Mutable state shared by one diagnostic pragma scan."""
108 comments: list[Comment]
109 records: list[Suppression]
110 findings: list[Finding]
111 depth: dict[str, int]
114def _blank(out: list[str], raw: str, start: int, end: int) ->
None:
115 """Blank one non-code span while preserving newlines and offsets."""
116 for index
in range(start,
min(end, len(raw))):
117 if raw[index]
not in "\r\n":
121def _raw_string(raw: str, index: int) -> tuple[str, int] |
None:
122 """Return a C++ raw-string terminator and body start at one offset."""
123 if index > 0
and (raw[index - 1].isalnum()
or raw[index - 1] ==
"_"):
125 match = re.match(
r'(?:u8|u|U|L)?R"([^ ()\\\t]{0,16})\(', raw[index:])
128 return ")" + match.group(1) +
'"', index + match.end()
131def _code_view(raw: str) -> str:
132 """Blank C comments and literals without changing source coordinates."""
135 while index < len(raw):
136 raw_string = _raw_string(raw, index)
137 if raw_string
is not None:
138 terminator, body = raw_string
139 end = raw.find(terminator, body)
140 stop = len(raw)
if end < 0
else end + len(terminator)
141 _blank(out, raw, index, stop)
144 if raw.startswith(
"//", index):
145 end = raw.find(
"\n", index)
146 stop = len(raw)
if end < 0
else end
147 _blank(out, raw, index, stop)
150 if raw.startswith(
"/*", index):
151 end = raw.find(
"*/", index + 2)
152 stop = len(raw)
if end < 0
else end + 2
153 _blank(out, raw, index, stop)
156 if raw[index]
in {
'"',
"'"}:
160 while index < len(raw):
161 if raw[index] ==
"\\" and index + 1 < len(raw):
164 if raw[index] == quote:
167 if raw[index] ==
"\n":
170 _blank(out, raw, start, index)
176def _logical_lines(raw: str, code: str) -> list[LogicalLine]:
177 """Join preprocessing line splices while retaining the first location."""
178 raw_lines = raw.splitlines(keepends=
True)
179 code_lines = code.splitlines(keepends=
True)
180 result: list[LogicalLine] = []
183 while index < len(code_lines):
185 raw_parts = [raw_lines[index]]
186 code_parts = [code_lines[index]]
187 while code_parts[-1].rstrip(
"\r\n").endswith(
"\\")
and index + 1 < len(code_lines):
189 raw_parts.append(raw_lines[index])
190 code_parts.append(code_lines[index])
191 joined_raw = re.sub(
r"\\\r?\n",
"",
"".join(raw_parts))
192 joined_code = re.sub(
r"\\\r?\n",
"",
"".join(code_parts))
193 result.append(LogicalLine(start + 1, offset, joined_code.rstrip(), joined_raw.rstrip()))
194 offset += sum(len(part)
for part
in code_lines[start : index + 1])
199def _line_column(raw: str, offset: int) -> tuple[int, int]:
200 """Convert a source offset to a one-based line and column."""
201 line = raw.count(
"\n", 0, offset) + 1
202 start = raw.rfind(
"\n", 0, offset)
203 return line, offset - start
206def _concerns(path: str, rule: str, reason: str, *, required: bool) -> tuple[str, ...]:
207 """Return first-party review concerns for one narrow control."""
208 if ownership(path) !=
"first-party":
210 concerns: list[str] = []
211 if required
and not reason:
212 concerns.append(
"blank-reason")
213 if rule
in {
"*",
"all",
"-W"}:
214 concerns.append(
"broad-rule")
215 return tuple(concerns)
220 location: tuple[int, int],
224 reason_required: bool,
226 """Build one compiler-control row with ownership-aware review policy."""
227 line, column = location
240 _concerns(path, spec.rule, reason, required=reason_required),
244def _comment_reason(comments: list[Comment], line: int) -> str:
245 """Return a local diagnostic-pragmas rationale on one source line."""
246 for comment
in comments:
247 if comment.line
not in {line - 1, line}:
249 text = comment.text.strip().lstrip(
"*").strip()
250 if text.startswith(
"Suppression rationale:"):
251 return text.removeprefix(
"Suppression rationale:").strip()
255def _record_pragma_region(
257 logical: LogicalLine,
262 """Record one exact diagnostic push/pop and update its tool-specific depth."""
263 scope =
"region-start" if kind ==
"push" else "region-end"
264 spelling =
"#pragma" if logical.raw.lstrip().startswith(
"#")
else "_Pragma"
269 f
"{spelling} {tool} diagnostic {kind}",
276 (logical.line, column),
279 reason_required=
False,
283 scan.depth[tool] += 1
284 elif scan.depth[tool] == 0:
285 scan.findings.append(Finding(
"unmatched-diagnostic-pop", tool, scan.path, logical.line))
287 scan.depth[tool] -= 1
290def _record_pragma_control(
292 logical: LogicalLine,
297 """Record one exact warning control or fail closed on malformed grammar."""
298 control = PRAGMA_CONTROL_RE.fullmatch(logical.raw)
300 control = PRAGMA_PAYLOAD_CONTROL_RE.fullmatch(logical.raw)
302 scan.findings.append(
304 "malformed-diagnostic-pragma",
311 flag = control.group(
"flag")
312 spelling =
"#pragma" if logical.raw.lstrip().startswith(
"#")
else "_Pragma"
313 reason = _comment_reason(scan.comments, logical.line)
318 f
"{spelling} {tool} diagnostic {kind}",
325 (logical.line, column),
328 reason_required=kind ==
"ignored",
331 if scan.depth[tool] == 0:
332 scan.findings.append(Finding(
"unscoped-diagnostic-control", tool, scan.path, logical.line))
335def _destringize_pragma(payload: str) -> str |
None:
336 """Apply the C _Pragma string-literal destringization rules."""
337 if re.search(
r'\\(?!["\\])', payload):
339 return payload.replace(
'\\"',
'"').replace(
"\\\\",
"\\")
342def _scan_pragma_operator_line(scan: PragmaScan, logical: LogicalLine) ->
None:
343 """Parse every diagnostic _Pragma operator on one logical source line."""
344 for hint
in PRAGMA_OPERATOR_HINT_RE.finditer(logical.code):
345 tail = logical.raw[hint.start() :]
346 operator = PRAGMA_OPERATOR_RE.match(logical.raw, hint.start())
348 if re.search(
r"(?:GCC|clang)\s+diagnostic", tail, re.IGNORECASE):
349 scan.findings.append(
350 Finding(
"malformed-diagnostic-pragma", tail.strip(), scan.path, logical.line)
353 payload = _destringize_pragma(operator.group(
"payload"))
355 if re.search(
r"(?:GCC|clang)\s+diagnostic", tail, re.IGNORECASE):
356 scan.findings.append(
357 Finding(
"malformed-diagnostic-pragma", tail.strip(), scan.path, logical.line)
360 if PRAGMA_PAYLOAD_HINT_RE.match(payload)
is None:
362 match = PRAGMA_PAYLOAD_KIND_RE.fullmatch(payload)
364 scan.findings.append(
365 Finding(
"malformed-diagnostic-pragma", payload, scan.path, logical.line)
368 tool = match.group(
"tool")
369 kind = match.group(
"kind")
370 column = hint.start() + 1
371 operator_line = LogicalLine(logical.line, logical.offset, payload, payload)
372 if kind
in {
"push",
"pop"}:
373 if match.group(
"tail").strip():
374 scan.findings.append(
375 Finding(
"malformed-diagnostic-pragma", payload, scan.path, logical.line)
378 _record_pragma_region(scan, operator_line, tool, kind, column)
380 _record_pragma_control(scan, operator_line, tool, kind, column)
384 path: str, raw: str, code: str, comments: list[Comment]
385) -> tuple[list[Suppression], list[Finding]]:
386 """Parse GCC/Clang diagnostic state controls and their paired regions."""
387 records: list[Suppression] = []
388 findings: list[Finding] = []
389 scan = PragmaScan(path, comments, records, findings, {
"GCC": 0,
"clang": 0})
390 for logical
in _logical_lines(raw, code):
391 _scan_pragma_operator_line(scan, logical)
392 hint = PRAGMA_HINT_RE.match(logical.code)
395 match = PRAGMA_KIND_RE.fullmatch(logical.code)
398 Finding(
"malformed-diagnostic-pragma", logical.raw.strip(), path, logical.line)
401 tool = match.group(
"tool")
402 kind = match.group(
"kind").lower()
403 column = hint.start() + 1
404 if kind
not in {
"push",
"pop"}:
405 _record_pragma_control(scan, logical, tool, kind, column)
406 elif match.group(
"tail").strip():
408 Finding(
"malformed-diagnostic-pragma", logical.raw.strip(), path, logical.line)
411 _record_pragma_region(scan, logical, tool, kind, column)
412 return records, findings
415def _balanced_end(code: str, start: int) -> int |
None:
416 """Return the offset after the parenthesis matching ``start``."""
418 for index
in range(start, len(code)):
419 if code[index] ==
"(":
421 elif code[index] ==
")":
428def _attribute_items(code: str, raw: str, start: int, end: int) -> list[tuple[int, str, str]]:
429 """Split one attribute list on top-level commas while preserving offsets."""
430 items: list[tuple[int, str, str]] = []
433 for index
in range(start, end):
434 if code[index] ==
"(":
436 elif code[index] ==
")":
437 depth = max(0, depth - 1)
438 elif code[index] ==
"," and depth == 0:
439 items.extend(_attribute_item(code, raw, item_start, index))
440 item_start = index + 1
441 items.extend(_attribute_item(code, raw, item_start, end))
445def _attribute_item(code: str, raw: str, start: int, end: int) -> list[tuple[int, str, str]]:
446 """Return one nonempty, left-trimmed attribute item."""
447 code_part = code[start:end]
448 leading = len(code_part) - len(code_part.lstrip())
449 offset = start + leading
450 if not code[offset:end].strip():
452 return [(offset, code[offset:end].strip(), raw[offset:end].strip())]
455def _macro_parameter(raw: str, offset: int, name: str) -> bool:
456 """Return whether one identifier is a parameter of its enclosing macro."""
457 line_start = raw.rfind(
"\n", 0, offset) + 1
458 line_end = raw.find(
"\n", offset)
462 r"\s*#\s*define\s+[A-Za-z_]\w*\s*\((?P<params>[^)]*)\)",
463 raw[line_start:line_end],
467 params = {item.strip()
for item
in match.group(
"params").split(
",")}
468 return name
in params
471def _sanitizer_rule(raw: str, offset: int, args: str, *, macro_ok: bool) -> str |
None:
472 """Normalize an exact sanitizer string list or a live macro parameter."""
473 if SANITIZER_ARGS_RE.fullmatch(args):
474 return ",".join(SANITIZER_NAME_RE.findall(args))
475 argument = args.strip()
476 if macro_ok
and re.fullmatch(
r"[A-Za-z_]\w*", argument):
477 return argument
if _macro_parameter(raw, offset, argument)
else None
481def _compiler_attribute(
488 """Build one declaration-scoped compiler attribute inventory row."""
489 tool =
"language-attribute" if directive ==
"[[maybe_unused]]" else "compiler-attribute"
492 _line_column(raw, offset),
499 "compiler-attribute",
502 reason_required=
False,
506def _gnu_body_span(code: str, start: int) -> tuple[int, int, int] |
None:
507 """Return the GNU attribute body and full-expression end offsets."""
509 while cursor < len(code)
and code[cursor].isspace():
511 if cursor >= len(code)
or code[cursor] !=
"(":
515 while cursor < len(code)
and code[cursor].isspace():
517 if cursor >= len(code)
or code[cursor] !=
"(":
520 inner_end = _balanced_end(code, inner)
521 outer_end = _balanced_end(code, outer)
522 if inner_end
is None or outer_end
is None:
524 if re.fullmatch(
r"\s*\)", code[inner_end:outer_end])
is None:
526 return inner + 1, inner_end - 1, outer_end
530 path: str, raw: str, offset: int, code_item: str, raw_item: str
531) -> tuple[Suppression |
None, Finding |
None]:
532 """Parse one GNU attribute item, failing closed on supported-name lookalikes."""
533 rule: str |
None =
None
535 if GNU_UNUSED_ITEM_RE.fullmatch(raw_item):
537 directive =
"__attribute__((unused))"
539 suffix = GNU_SANITIZER_SUFFIX_RE.fullmatch(raw_item)
540 call = GNU_SANITIZER_CALL_RE.fullmatch(raw_item)
541 if suffix
is not None:
542 rule = suffix.group(
"rule")
543 directive =
"__attribute__((no_sanitize))"
544 elif call
is not None:
545 rule = _sanitizer_rule(raw, offset, call.group(
"args"), macro_ok=
True)
546 directive =
"__attribute__((no_sanitize))"
548 return _compiler_attribute(path, raw, offset, rule, directive),
None
549 if GNU_CONTROL_HINT_RE.search(code_item)
is None:
551 line, _ = _line_column(raw, offset)
552 return None, Finding(
"malformed-gnu-attribute", raw_item, path, line)
555def _scan_gnu_attributes(path: str, raw: str, code: str) -> tuple[list[Suppression], list[Finding]]:
556 """Inventory exact GNU unused and sanitizer-disable attribute grammar."""
557 records: list[Suppression] = []
558 findings: list[Finding] = []
559 for marker
in GNU_ATTRIBUTE_HINT_RE.finditer(code):
560 span = _gnu_body_span(code, marker.end())
562 lookahead = code[marker.end() : marker.end() + 512]
563 if GNU_CONTROL_HINT_RE.search(lookahead)
is not None:
564 line, _ = _line_column(raw, marker.start())
565 findings.append(Finding(
"malformed-gnu-attribute", marker.group(), path, line))
567 body_start, body_end, _ = span
568 for offset, code_item, raw_item
in _attribute_items(code, raw, body_start, body_end):
569 record, finding = _gnu_item(path, raw, offset, code_item, raw_item)
570 if record
is not None:
571 records.append(record)
572 if finding
is not None:
573 findings.append(finding)
574 return records, findings
578 path: str, raw: str, offset: int, code_item: str, raw_item: str
579) -> tuple[Suppression |
None, Finding |
None]:
580 """Parse one C23/C++ attribute item from the supported suppression family."""
581 if STANDARD_MAYBE_RE.fullmatch(raw_item):
582 return _compiler_attribute(path, raw, offset,
"unused",
"[[maybe_unused]]"),
None
583 if STANDARD_GNU_UNUSED_RE.fullmatch(raw_item):
584 return _compiler_attribute(path, raw, offset,
"unused",
"[[gnu::unused]]"),
None
585 sanitizer = STANDARD_CLANG_SANITIZER_RE.fullmatch(raw_item)
586 if sanitizer
is not None:
587 rule = _sanitizer_rule(raw, offset, sanitizer.group(
"args"), macro_ok=
False)
589 return _compiler_attribute(
594 "[[clang::no_sanitize]]",
596 if ATTRIBUTE_CONTROL_HINT_RE.search(code_item)
is None:
598 line, _ = _line_column(raw, offset)
600 "malformed-maybe-unused"
601 if re.search(
r"\bmaybe_unused\b", code_item)
602 else "malformed-standard-attribute"
604 return None, Finding(code_name, raw_item, path, line)
607def _scan_standard_attributes(
608 path: str, raw: str, code: str
609) -> tuple[list[Suppression], list[Finding]]:
610 """Inventory standard/scoped unused and sanitizer-disable attributes."""
611 records: list[Suppression] = []
612 findings: list[Finding] = []
613 spans: list[tuple[int, int]] = []
614 for block
in ATTRIBUTE_BLOCK_RE.finditer(code):
615 spans.append(block.span())
616 for offset, code_item, raw_item
in _attribute_items(
617 code, raw, block.start(
"body"), block.end(
"body")
619 record, finding = _standard_item(path, raw, offset, code_item, raw_item)
620 if record
is not None:
621 records.append(record)
622 if finding
is not None:
623 findings.append(finding)
624 for hint
in ATTRIBUTE_CONTROL_HINT_RE.finditer(code):
625 if any(start <= hint.start() < end
for start, end
in spans):
627 line, _ = _line_column(raw, hint.start())
629 "malformed-maybe-unused"
630 if hint.group().startswith(
"maybe_unused")
631 else "malformed-standard-attribute"
633 findings.append(Finding(code_name,
"invalid attribute", path, line))
634 return records, findings
637def _scan_nasa(path: str, raw: str, code: str) -> tuple[list[Suppression], list[Finding]]:
638 """Inventory reason-bearing NASA Rule 3 function waivers."""
639 records: list[Suppression] = []
640 findings: list[Finding] = []
641 for marker
in NASA_RE.finditer(code):
642 line, column = _line_column(raw, marker.start())
643 line_start = raw.rfind(
"\n", 0, marker.start()) + 1
644 if re.match(
r"\s*#\s*define\b", code[line_start : marker.start()]):
646 call = NASA_CALL_RE.match(raw, marker.start())
647 reason = call.group(
"reason").strip()
if call
is not None else ""
654 "annotation-checker",
656 "RA8_NASA_RULE_3_OK",
661 reason_required=
True,
666 Finding(
"malformed-nasa-rule-3-waiver",
"reason string required", path, line)
668 return records, findings
671def _scan_alloc_allow(
672 path: str, code: str, comments: list[Comment]
673) -> tuple[list[Suppression], list[Finding]]:
674 """Inventory same-line, reasoned dynamic-allocation checker waivers."""
675 records: list[Suppression] = []
676 findings: list[Finding] = []
677 code_lines = code.splitlines()
678 brace_depths: list[int] = []
680 for source_line
in code_lines:
681 brace_depths.append(brace_depth)
682 if not source_line.lstrip().startswith(
"#"):
683 brace_depth += source_line.count(
"{") - source_line.count(
"}")
684 brace_depth = max(0, brace_depth)
685 for comment
in comments:
686 hint = ALLOC_HINT_RE.search(comment.text)
689 match = ALLOC_ALLOW_RE.search(comment.text.strip())
692 Finding(
"malformed-alloc-allow", comment.text.strip(), path, comment.line)
695 source_line = code_lines[comment.line - 1]
if comment.line <= len(code_lines)
else ""
696 line_depth = brace_depths[comment.line - 1]
if comment.line <= len(brace_depths)
else 0
697 symbols = allocation_symbols(source_line, line_depth)
700 Finding(
"orphan-alloc-allow",
"no allocator call on this line", path, comment.line)
706 (comment.line, comment.column + hint.start()),
708 "dynamic-allocation",
709 "check_no_dynamic_alloc.py",
715 match.group(
"reason").strip(),
716 reason_required=
True,
718 for symbol
in symbols
720 return records, findings
723def scan_c_control_file(path: str, text: str) -> tuple[list[Suppression], list[Finding]]:
724 """Scan one C-family file through every assigned compiler-control grammar."""
725 if Path(path).suffix.lower()
not in C_FAMILY_SUFFIXES
or C_HINT_RE.search(text)
is None:
727 code = _code_view(text)
728 comments, _ = extract_comments(path, text)
729 records: list[Suppression] = []
730 findings: list[Finding] = []
731 for scanner
in (_scan_pragmas,):
732 found, problems = scanner(path, text, code, comments)
733 records.extend(found)
734 findings.extend(problems)
735 for scanner
in (_scan_gnu_attributes, _scan_standard_attributes, _scan_nasa):
736 found, problems = scanner(path, text, code)
737 records.extend(found)
738 findings.extend(problems)
739 found, problems = _scan_alloc_allow(path, code, comments)
740 records.extend(found)
741 findings.extend(problems)
742 return records, findings
#define min(x, y)
Untyped minimum shim used by the SOUP's buffer clamping.