3"""Recognize formatter, documentation, and tool-specific lint controls."""
5from __future__
import annotations
9from dataclasses
import dataclass
10from pathlib
import Path
13from suppression_catalog
import C_FAMILY_SUFFIXES, ownership
14from suppression_comment_lex
import Comment
15from suppression_model
import Finding, Suppression
17CLANG_FORMAT_RE = re.compile(
r"^clang-format (?P<control>off|on)(?::\s*(?P<reason>\S.*)?)?$")
18YAMLLINT_RE = re.compile(
19 r"^yamllint (?P<control>disable-file|disable-line|disable|enable)"
20 r"(?P<rules>(?: rule:[a-z0-9_-]+)*)$"
22HADOLINT_RE = re.compile(
23 r"^hadolint (?P<global>global )?ignore\s*=\s*"
24 r"(?P<rules>(?:DL|SC)\d{4}(?:\s*,\s*(?:DL|SC)\d{4})*)"
25 r"(?:\s+#\s*(?P<reason>\S.*))?$"
27CMAKE_FORMAT_RE = re.compile(
r"^(?P<tool>cmake-format|cmf): (?P<control>off|on)(?P<tail>[^\n]*)$")
28CMAKE_LINT_RE = re.compile(
29 r"^cmake-lint: [ \t]*(?P<options>disable=[A-Z]\d{4}(?:,[A-Z]\d{4})*"
30 r"(?:[ \t]+disable=[A-Z]\d{4}(?:,[A-Z]\d{4})*)*)$"
32PRETTIER_RE = re.compile(
r"^prettier-ignore$")
33MARKDOWNLINT_RE = re.compile(
34 r"^markdownlint-(?P<control>disable-file|enable-file|disable-line|"
35 r"disable-next-line|disable|enable|capture|restore)"
36 r"(?P<rules>(?: (?:MD\d{3}|[a-z][a-z0-9-]*))*)$"
38MARKDOWNLINT_CONFIGURE_RE = re.compile(
r"^markdownlint-configure-file\s+(?P<config>\{.*\})$")
39MARKDOWNLINT_RULE_RE = re.compile(
r"^(?:MD\d{3}|[a-z][a-z0-9-]*)$")
40DOXYGEN_COND_RE = re.compile(
41 r"^(?P<prefix>@|\\)(?:(?P<start>cond)(?:\s+(?P<label>\S+))?|(?P<end>endcond))$"
43IWYU_RE = re.compile(
r"^IWYU pragma: (?P<command>[a-z_]+)(?P<argument>.*)$")
45PRETTIER_CONFIGS = frozenset(
53 "prettier.config.cjs",
54 "prettier.config.mjs",
57MARKDOWNLINT_CONFIG_PREFIXES = (
".markdownlint",
".markdownlint-cli2")
58DOXYGEN_INPUT_ROOTS = frozenset(
59 {
"apps",
"coprocessor",
"docs",
"examples",
"libs",
"port",
"scripts",
"tools"}
61DOXYGEN_SUFFIXES = frozenset({
".c",
".h",
".cpp",
".hpp",
".md",
".dox",
".py"})
62DOXYGEN_EXCLUDED_PARTS = frozenset(
63 {
"build",
"docs/doxygen",
"docs/doxygen_theme",
"tests",
"third_party"}
65IWYU_NO_ARGUMENT = frozenset(
77IWYU_QUOTED_ARGUMENT = frozenset({
"friend",
"no_forward_declare"})
80def _concerns(rule: str, reason: str, *, reason_required: bool) -> tuple[str, ...]:
81 """Return review concerns for one recognized tool control."""
82 concerns: list[str] = []
84 concerns.append(
"broad-rule")
85 if reason_required
and not reason:
86 concerns.append(
"blank-reason")
87 return tuple(concerns)
90@dataclass(frozen=True)
92 """Normalized fields produced by one tool-control recognizer."""
100 provenance: str =
"inline-comment"
101 reason_required: bool =
True
104def _row(path: str, line: int, column: int, item: ToolRecognition) -> Suppression:
105 """Build one normalized tool-control row."""
106 owner = ownership(path)
122 reason_required=item.reason_required
and owner !=
"vendor",
127def _is_c_family(path: str) -> bool:
128 """Return whether clang-format, Doxygen, and IWYU comments are active."""
129 return Path(path).suffix.lower()
in C_FAMILY_SUFFIXES
132def _is_cmake(path: str) -> bool:
133 """Return whether a path is parsed by cmakelang."""
135 return item.name ==
"CMakeLists.txt" or item.suffix.lower() ==
".cmake"
138def _is_dockerfile(path: str) -> bool:
139 """Return whether a path uses Dockerfile comment directives."""
140 name = Path(path).name
141 return name ==
"Dockerfile" or name.startswith(
"Dockerfile.")
144def _is_doxygen_input(path: str) -> bool:
145 """Return whether Doxyfile parses this first-party source path."""
147 if item.suffix.lower()
not in DOXYGEN_SUFFIXES:
149 if path !=
"README.md" and (
not item.parts
or item.parts[0]
not in DOXYGEN_INPUT_ROOTS):
151 normalized = path.replace(
"\\",
"/")
152 return not any(part.startswith(
"build-")
for part
in item.parts)
and not any(
153 part
in item.parts
or normalized.startswith(f
"{part}/")
for part
in DOXYGEN_EXCLUDED_PARTS
157def _is_doxygen_control_path(path: str) -> bool:
158 """Return whether Doxygen syntax is authored here or vendor-owned."""
159 return _is_doxygen_input(path)
or (
160 ownership(path) ==
"vendor" and Path(path).suffix.lower()
in DOXYGEN_SUFFIXES
164def configured_optional_tools(paths: list[str]) -> frozenset[str]:
165 """Return optional comment-driven tools configured by the scanned tree."""
166 names = {Path(path).name
for path
in paths}
167 active: set[str] = set()
168 if names & PRETTIER_CONFIGS:
169 active.add(
"prettier")
170 if any(name.startswith(MARKDOWNLINT_CONFIG_PREFIXES)
for name
in names):
171 active.add(
"markdownlint")
172 return frozenset(active)
175def _clang_format(path: str, comment: Comment, body: str) -> Suppression |
None:
176 """Recognize a real clang-format region delimiter in C-family source."""
177 if not _is_c_family(path)
or (match := CLANG_FORMAT_RE.fullmatch(body))
is None:
179 control = match.group(
"control").lower()
188 f
"clang-format {control}",
189 "region-start" if control ==
"off" else "region-end",
190 match.group(
"reason")
or "",
191 reason_required=control ==
"off",
196def _yamllint(path: str, comment: Comment, body: str) -> Suppression |
None:
197 """Recognize an active yamllint inline control in YAML source."""
198 if Path(path).suffix.lower()
not in {
".yaml",
".yml"}:
200 if (match := YAMLLINT_RE.fullmatch(body))
is None:
202 control = match.group(
"control")
203 rule_tokens = re.findall(
r"rule:([a-z0-9_-]+)", match.group(
"rules"))
204 rules =
",".join(sorted(rule_tokens))
or "*"
205 if control ==
"disable-file" and comment.line != 1:
208 "disable-file":
"file",
209 "disable-line":
"line",
210 "disable":
"region-start",
211 "enable":
"region-end",
221 f
"yamllint {control}",
224 reason_required=control !=
"enable",
229def _hadolint(path: str, comment: Comment, body: str) -> Suppression |
None:
230 """Recognize an active Hadolint line or file ignore in a Dockerfile."""
231 if not _is_dockerfile(path)
or (match := HADOLINT_RE.fullmatch(body))
is None:
233 scope =
"file" if match.group(
"global")
else "next-instruction"
241 re.sub(
r"\s+",
"", match.group(
"rules")).upper(),
244 match.group(
"reason")
or "",
249def _cmake_format(path: str, comment: Comment, body: str) -> Suppression |
None:
250 """Recognize a real cmake-format/cmf region delimiter."""
251 if not _is_cmake(path)
or (match := CMAKE_FORMAT_RE.fullmatch(body))
is None:
253 control = match.group(
"control")
254 reason = match.group(
"tail").strip()
255 if reason.startswith(
"--"):
256 reason = reason.removeprefix(
"--").strip()
265 f
"{match.group('tool')} {control}",
266 "region-start" if control ==
"off" else "region-end",
268 reason_required=control ==
"off",
273def _cmake_lint(path: str, comment: Comment, body: str) -> Suppression |
None:
274 """Recognize cmake-lint's sole inline pragma: disable=<codes>."""
275 if not _is_cmake(path)
or (match := CMAKE_LINT_RE.fullmatch(body))
is None:
279 for option
in match.group(
"options").split()
280 for rule
in option.removeprefix(
"disable=").split(
",")
290 "cmake-lint disable",
297def _markdownlint_config(body: str) -> dict[str, object] |
None:
298 """Parse one valid markdownlint-configure-file JSON object."""
299 if (match := MARKDOWNLINT_CONFIGURE_RE.fullmatch(body))
is None:
302 config = json.loads(match.group(
"config"))
303 except json.JSONDecodeError:
305 valid = isinstance(config, dict)
and all(
306 MARKDOWNLINT_RULE_RE.fullmatch(rule)
is not None and isinstance(value, (bool, dict))
307 for rule, value
in config.items()
309 return config
if valid
else None
312def valid_tool_control(path: str, body: str, active_tools: frozenset[str] = frozenset()) -> bool:
313 """Return whether directive-like text is a valid configured control."""
315 Path(path).suffix.lower() ==
".md"
316 and "markdownlint" in active_tools
317 and _markdownlint_config(body)
is not None
321def _markdownlint_configure(
325 active_tools: frozenset[str],
326) -> Suppression |
None:
327 """Inventory disabled rules and non-empty option relaxations."""
328 if "markdownlint" not in active_tools
or (config := _markdownlint_config(body))
is None:
332 for rule, value
in config.items()
333 if value
is False or (isinstance(value, dict)
and bool(value))
345 "markdownlint configure-file",
352def _prettier_markdown(
353 path: str, comment: Comment, body: str, active_tools: frozenset[str]
354) -> Suppression |
None:
355 """Recognize configured Markdown-only Prettier/markdownlint controls."""
356 if Path(path).suffix.lower() !=
".md":
358 if "prettier" in active_tools
and PRETTIER_RE.fullmatch(body)
is not None:
372 if configured := _markdownlint_configure(path, comment, body, active_tools):
374 if "markdownlint" not in active_tools
or (match := MARKDOWNLINT_RE.fullmatch(body))
is None:
376 control = match.group(
"control")
377 if control
in {
"capture",
"restore"}
and match.group(
"rules"):
379 rules =
",".join(match.group(
"rules").split())
or "*"
380 reason_required = control.startswith(
"disable")
383 if control.endswith(
"-file")
385 if control.endswith(
"-line")
387 if control ==
"disable-next-line"
389 if control ==
"disable"
391 if control ==
"enable"
393 if control ==
"capture"
395 if control ==
"restore"
406 f
"markdownlint {control}",
409 reason_required=reason_required,
415 path: str, comment: Comment, body: str, *, plain_source: bool =
False
416) -> Suppression |
None:
417 """Recognize a Doxygen conditional-documentation region delimiter."""
418 suffix = Path(path).suffix.lower()
419 if not _is_doxygen_control_path(path)
or (suffix ==
".md" and not plain_source):
422 if not body.startswith(
"#"):
424 body = body[1:].strip()
425 if (match := DOXYGEN_COND_RE.fullmatch(body))
is None:
427 control =
"cond" if match.group(
"start")
else "endcond"
436 f
"doxygen {control}",
437 "region-start" if control ==
"cond" else "region-end",
439 reason_required=control ==
"cond",
444def _valid_iwyu_argument(command: str, argument: str) -> bool:
445 """Validate the documented argument form for one case-sensitive pragma."""
446 if command
in IWYU_NO_ARGUMENT:
448 if command
in IWYU_QUOTED_ARGUMENT:
449 return re.fullmatch(
r' "[^"\n]+"', argument)
is not None
450 if command ==
"no_include":
451 return re.fullmatch(
r' (?:"[^"\n]+"|<[^>\n]+>)', argument)
is not None
452 if command ==
"private":
453 return not argument
or (
454 re.fullmatch(
r', include (?:"[^"\n]+"|<[^>\n]+>)', argument)
is not None
459def _iwyu(path: str, comment: Comment, body: str) -> Suppression |
None:
460 """Recognize an include-what-you-use pragma in C-family source."""
461 if not _is_c_family(path)
or (match := IWYU_RE.fullmatch(body))
is None:
463 command = match.group(
"command")
464 if not _valid_iwyu_argument(command, match.group(
"argument")):
467 "begin_exports":
"region-start",
468 "end_exports":
"region-end",
469 "begin_keep":
"region-start",
470 "end_keep":
"region-end",
474 if command
in {
"always_keep",
"friend",
"no_forward_declare",
"no_include",
"private"}
479 if command
in {
"begin_exports",
"end_exports"}
481 if command
in {
"begin_keep",
"end_keep"}
492 f
"IWYU pragma: {command}",
495 reason_required=scope !=
"region-end",
500COMMENT_RECOGNIZERS = (
511def recognize_tool_comment(
515 active_tools: frozenset[str] = frozenset(),
516) -> Suppression |
None:
517 """Recognize one tool control from a syntax-extracted comment."""
518 found = next((item
for fn
in COMMENT_RECOGNIZERS
if (item := fn(path, comment, body))),
None)
519 return found
or _prettier_markdown(path, comment, body, active_tools)
522def tool_control_finding(
526 active_tools: frozenset[str] = frozenset(),
528 """Fail closed on directive-like text that is not active tool syntax."""
529 if Path(path).suffix.lower() ==
".py" and body.startswith(
"#"):
530 body = body[1:].strip()
531 folded = body.casefold()
532 if valid_tool_control(path, body, active_tools):
534 if folded ==
"prettier-ignore" and "prettier" not in active_tools:
535 return Finding(
"inactive-tool-control",
"Prettier is not configured", path, comment.line)
536 if folded.startswith(
"markdownlint-")
and "markdownlint" not in active_tools:
538 "inactive-tool-control",
"markdownlint is not configured", path, comment.line
541 re.match(
r"^clang-format\s+(?:off|on)(?:\s|:|$)", body, re.IGNORECASE)
543 r"^yamllint\s+(?:disable-file|disable-line|disable|enable)(?:\s|$)",
547 or re.match(
r"^hadolint\s+(?:global\s+)?ignore\s*=", body, re.IGNORECASE)
548 or re.match(
r"^(?:cmake-format|cmf)\s*:", body, re.IGNORECASE)
549 or re.match(
r"^cmake-lint\s*:", body, re.IGNORECASE)
550 or re.match(
r"^shfmt\s*:", body, re.IGNORECASE)
551 or re.match(
r"^prettier-ignore(?:\s|$)", body, re.IGNORECASE)
553 r"^markdownlint-(?:disable-file|enable-file|disable-line|"
554 r"disable-next-line|disable|enable|capture|restore|configure-file)(?:\s|$)",
558 or re.match(
r"^IWYU\s+pragma\s*:", body, re.IGNORECASE)
560 _is_doxygen_control_path(path)
561 and Path(path).suffix.lower() !=
".md"
562 and re.match(
r"^(?:@|\\)(?:cond|endcond)(?:\s|$)", body, re.IGNORECASE)
566 return Finding(
"malformed-tool-control", body, path, comment.line)
570def _markdown_visible_lines(text: str) -> list[str]:
571 """Blank Markdown fenced code while preserving line numbering."""
572 visible: list[str] = []
575 for raw
in text.splitlines():
576 marker = re.match(
r"^ {0,3}(`{3,}|~{3,})", raw)
578 if re.match(rf
"^ {{0,3}}{re.escape(fence[0])}{{{len(fence)},}}\s*$", raw):
582 html_comment =
"-->" not in raw
585 fence = marker.group(1)
588 html_comment =
"-->" not in raw[raw.find(
"<!--") + 4 :]
595def _plain_source_reason(path: str, previous: str) -> str:
596 """Extract a precise rationale immediately above a plain Doxygen command."""
597 stripped = previous.strip()
598 if Path(path).suffix.lower() ==
".md":
599 match = re.fullmatch(
r"<!--\s*Suppression rationale:\s*(\S.*?)\s*-->", stripped)
601 match = re.fullmatch(
602 r"(?:/\*\*?|//)\s*Suppression rationale:\s*(\S.*?)(?:\s*\*/)?",
605 return match.group(1)
if match
else ""
608def scan_tool_sources(path: str, text: str) -> tuple[list[Suppression], list[Finding]]:
609 """Inventory Doxygen conditionals in its plain Markdown and dox inputs."""
610 if not _is_doxygen_input(path)
or Path(path).suffix.lower()
not in {
".md",
".dox"}:
612 source_lines = text.splitlines()
613 lines = _markdown_visible_lines(text)
if path.endswith(
".md")
else source_lines
614 records: list[Suppression] = []
615 findings: list[Finding] = []
616 for line_no, raw
in enumerate(lines, start=1):
620 comment = Comment(line_no, len(raw) - len(raw.lstrip()) + 1, body)
621 if (record := _doxygen(path, comment, body, plain_source=
True))
is not None:
622 reason = _plain_source_reason(path, source_lines[line_no - 2])
if line_no > 1
else ""
624 record = Suppression(
639 reason_required=record.scope ==
"region-start",
642 records.append(record)
643 elif body.casefold().startswith((
"@cond",
"@endcond",
"\\cond",
"\\endcond")):
644 findings.append(Finding(
"malformed-tool-control", body, path, line_no))
645 return records, findings
648def _preceding_reason(lines: list[str], line: int) -> str:
649 """Collect the contiguous explanatory comment block above one config row."""
650 notes: list[str] = []
651 for raw
in reversed(lines[: line - 1]):
652 stripped = raw.strip()
657 if not stripped.startswith(
"#"):
659 note = stripped[1:].strip()
662 return " ".join(reversed(notes))
665def _line_for(lines: list[str], pattern: re.Pattern[str], start: int = 1) -> int:
666 """Return the first matching one-based source line at or after start."""
667 for line_no, raw
in enumerate(lines[start - 1 :], start=start):
668 if pattern.fullmatch(raw):
673def _yaml_config(path: str, text: str) -> tuple[object, list[Finding]]:
674 """Parse one YAML tool config and fail closed on malformed data."""
676 return yaml.safe_load(text), []
677 except yaml.YAMLError
as exc:
678 return None, [Finding(
"malformed-tool-config", str(exc), path)]
681def _yamllint_disabled_record(
682 path: str, rule: object, lines: list[str], findings: list[Finding]
683) -> Suppression |
None:
684 """Build one source-located disabled-rule row."""
685 pattern = re.compile(rf
"^\s*{re.escape(str(rule))}:\s*false\s*(?:#.*)?$")
686 line = _line_for(lines, pattern)
688 findings.append(Finding(
"malformed-tool-config", f
"cannot locate {rule}: false", path))
698 "yamllint rule disabled",
700 _preceding_reason(lines, line),
701 provenance=
"central-config",
706def _yamllint_truthy_record(
707 path: str, truthy: object, lines: list[str], findings: list[Finding]
708) -> Suppression |
None:
709 """Build the source-located truthy-key narrowing row when active."""
710 if not isinstance(truthy, dict)
or truthy.get(
"check-keys")
is not False:
712 line = _line_for(lines, re.compile(
r"^\s*check-keys:\s*false\s*(?:#.*)?$"))
714 message =
"cannot locate truthy check-keys: false"
715 findings.append(Finding(
"malformed-tool-config", message, path))
717 reason = _preceding_reason(lines, line)
719 parent_line = _line_for(lines, re.compile(
r"^\s*truthy:\s*(?:#.*)?$"))
721 findings.append(Finding(
"malformed-tool-config",
"cannot locate truthy rule", path))
723 reason = _preceding_reason(lines, parent_line)
732 "yamllint check-keys false",
735 provenance=
"central-config",
740def _yamllint_config(path: str, text: str) -> tuple[list[Suppression], list[Finding]]:
741 """Inventory disabled yamllint rules and narrowed truthy-key checking."""
742 if path !=
".yamllint.yaml":
744 parsed, findings = _yaml_config(path, text)
747 if not isinstance(parsed, dict)
or not isinstance(parsed.get(
"rules"), dict):
748 return [], [Finding(
"malformed-tool-config",
"yamllint rules table missing", path)]
749 lines = text.splitlines()
750 rules = parsed[
"rules"]
753 for rule, config
in rules.items()
755 if (record := _yamllint_disabled_record(path, rule, lines, findings))
is not None
757 if (record := _yamllint_truthy_record(path, rules.get(
"truthy"), lines, findings))
is not None:
758 records.append(record)
759 return records, findings
762def _hadolint_config(path: str, text: str) -> tuple[list[Suppression], list[Finding]]:
763 """Inventory every central Hadolint ignored rule with source rationale."""
764 if path !=
".hadolint.yaml":
766 parsed, findings = _yaml_config(path, text)
769 ignored = parsed.get(
"ignored", [])
if isinstance(parsed, dict)
else None
770 if not isinstance(ignored, list)
or not all(
771 isinstance(rule, str)
and re.fullmatch(
r"(?:DL|SC)\d{4}", rule)
for rule
in ignored
773 return [], [Finding(
"malformed-tool-config",
"invalid hadolint ignored list", path)]
774 lines = text.splitlines()
775 records: list[Suppression] = []
777 pattern = re.compile(rf
"^\s*-\s*{re.escape(rule)}\s*(?:#.*)?$")
778 line = _line_for(lines, pattern)
780 findings.append(Finding(
"malformed-tool-config", f
"cannot locate {rule}", path))
782 reason = _preceding_reason(lines, line)
795 provenance=
"central-config",
799 return records, findings
802def _editorconfig(path: str, text: str) -> tuple[list[Suppression], list[Finding]]:
803 """Inventory shfmt's real ignore mechanism: EditorConfig path sections."""
804 if Path(path).name !=
".editorconfig":
806 lines = text.splitlines()
808 records: list[Suppression] = []
809 findings: list[Finding] = []
810 for line_no, raw
in enumerate(lines, start=1):
811 stripped = raw.strip()
812 if re.fullmatch(
r"\[[^\]]+\]|\[\[(?:shell|bash|zsh)\]\]", stripped):
815 match = re.fullmatch(
816 r"(?i:ignore)\s*=\s*(?P<value>true|false|unset)(?:\s*[#;].*)?", stripped
818 if match
is not None and match.group(
"value") ==
"true":
821 Finding(
"malformed-tool-config",
"shfmt ignore has no section", path, line_no)
833 "EditorConfig ignore=true",
835 _preceding_reason(lines, line_no),
836 provenance=
"central-config",
840 elif match
is None and re.match(
r"^ignore\s*=", stripped, re.IGNORECASE):
843 "malformed-tool-config", f
"inactive shfmt property: {stripped}", path, line_no
846 return records, findings
849def scan_tool_configs(path: str, text: str) -> tuple[list[Suppression], list[Finding]]:
850 """Inventory supported repository-global tool-control files."""
851 records: list[Suppression] = []
852 findings: list[Finding] = []
853 for parser
in (_yamllint_config, _hadolint_config, _editorconfig):
854 parsed_records, parsed_findings = parser(path, text)
855 records.extend(parsed_records)
856 findings.extend(parsed_findings)
857 return records, findings