ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
suppression_tool_controls.py
Go to the documentation of this file.
1# SPDX-License-Identifier: MIT
2# Copyright (c) 2026 Brighton Sikarskie
3"""Recognize formatter, documentation, and tool-specific lint controls."""
4
5from __future__ import annotations
6
7import json
8import re
9from dataclasses import dataclass
10from pathlib import Path
11
12import yaml
13from suppression_catalog import C_FAMILY_SUFFIXES, ownership
14from suppression_comment_lex import Comment
15from suppression_model import Finding, Suppression
16
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_-]+)*)$"
21)
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.*))?$"
26)
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})*)*)$"
31)
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-]*))*)$"
37)
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))$"
42)
43IWYU_RE = re.compile(r"^IWYU pragma: (?P<command>[a-z_]+)(?P<argument>.*)$")
44
45PRETTIER_CONFIGS = frozenset(
46 {
47 ".prettierrc",
48 ".prettierrc.json",
49 ".prettierrc.yaml",
50 ".prettierrc.yml",
51 ".prettierrc.toml",
52 "prettier.config.js",
53 "prettier.config.cjs",
54 "prettier.config.mjs",
55 }
56)
57MARKDOWNLINT_CONFIG_PREFIXES = (".markdownlint", ".markdownlint-cli2")
58DOXYGEN_INPUT_ROOTS = frozenset(
59 {"apps", "coprocessor", "docs", "examples", "libs", "port", "scripts", "tools"}
60)
61DOXYGEN_SUFFIXES = frozenset({".c", ".h", ".cpp", ".hpp", ".md", ".dox", ".py"})
62DOXYGEN_EXCLUDED_PARTS = frozenset(
63 {"build", "docs/doxygen", "docs/doxygen_theme", "tests", "third_party"}
64)
65IWYU_NO_ARGUMENT = frozenset(
66 {
67 "always_keep",
68 "associated",
69 "begin_exports",
70 "begin_keep",
71 "end_exports",
72 "end_keep",
73 "export",
74 "keep",
75 }
76)
77IWYU_QUOTED_ARGUMENT = frozenset({"friend", "no_forward_declare"})
78
79
80def _concerns(rule: str, reason: str, *, reason_required: bool) -> tuple[str, ...]:
81 """Return review concerns for one recognized tool control."""
82 concerns: list[str] = []
83 if rule == "*":
84 concerns.append("broad-rule")
85 if reason_required and not reason:
86 concerns.append("blank-reason")
87 return tuple(concerns)
88
89
90@dataclass(frozen=True)
91class ToolRecognition:
92 """Normalized fields produced by one tool-control recognizer."""
93
94 family: str
95 tool: str
96 rule: str
97 directive: str
98 scope: str
99 reason: str
100 provenance: str = "inline-comment"
101 reason_required: bool = True
102
103
104def _row(path: str, line: int, column: int, item: ToolRecognition) -> Suppression:
105 """Build one normalized tool-control row."""
106 owner = ownership(path)
107 return Suppression(
108 path,
109 line,
110 column,
111 item.family,
112 item.tool,
113 item.rule,
114 item.directive,
115 item.scope,
116 item.reason,
117 item.provenance,
118 owner,
119 _concerns(
120 item.rule,
121 item.reason,
122 reason_required=item.reason_required and owner != "vendor",
123 ),
124 )
125
126
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
130
131
132def _is_cmake(path: str) -> bool:
133 """Return whether a path is parsed by cmakelang."""
134 item = Path(path)
135 return item.name == "CMakeLists.txt" or item.suffix.lower() == ".cmake"
136
137
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.")
142
143
144def _is_doxygen_input(path: str) -> bool:
145 """Return whether Doxyfile parses this first-party source path."""
146 item = Path(path)
147 if item.suffix.lower() not in DOXYGEN_SUFFIXES:
148 return False
149 if path != "README.md" and (not item.parts or item.parts[0] not in DOXYGEN_INPUT_ROOTS):
150 return False
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
154 )
155
156
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
161 )
162
163
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)
173
174
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:
178 return None
179 control = match.group("control").lower()
180 return _row(
181 path,
182 comment.line,
183 comment.column,
184 ToolRecognition(
185 "formatter",
186 "clang-format",
187 "layout",
188 f"clang-format {control}",
189 "region-start" if control == "off" else "region-end",
190 match.group("reason") or "",
191 reason_required=control == "off",
192 ),
193 )
194
195
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"}:
199 return None
200 if (match := YAMLLINT_RE.fullmatch(body)) is None:
201 return 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:
206 return None
207 scope = {
208 "disable-file": "file",
209 "disable-line": "line",
210 "disable": "region-start",
211 "enable": "region-end",
212 }[control]
213 return _row(
214 path,
215 comment.line,
216 comment.column,
217 ToolRecognition(
218 "lint-control",
219 "yamllint",
220 rules,
221 f"yamllint {control}",
222 scope,
223 "",
224 reason_required=control != "enable",
225 ),
226 )
227
228
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:
232 return None
233 scope = "file" if match.group("global") else "next-instruction"
234 return _row(
235 path,
236 comment.line,
237 comment.column,
238 ToolRecognition(
239 "lint-control",
240 "hadolint",
241 re.sub(r"\s+", "", match.group("rules")).upper(),
242 "hadolint ignore",
243 scope,
244 match.group("reason") or "",
245 ),
246 )
247
248
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:
252 return None
253 control = match.group("control")
254 reason = match.group("tail").strip()
255 if reason.startswith("--"):
256 reason = reason.removeprefix("--").strip()
257 return _row(
258 path,
259 comment.line,
260 comment.column,
261 ToolRecognition(
262 "formatter",
263 "cmake-format",
264 "layout",
265 f"{match.group('tool')} {control}",
266 "region-start" if control == "off" else "region-end",
267 reason,
268 reason_required=control == "off",
269 ),
270 )
271
272
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:
276 return None
277 rules = sorted(
278 rule
279 for option in match.group("options").split()
280 for rule in option.removeprefix("disable=").split(",")
281 )
282 return _row(
283 path,
284 comment.line,
285 comment.column,
286 ToolRecognition(
287 "lint-control",
288 "cmake-lint",
289 ",".join(rules),
290 "cmake-lint disable",
291 "enclosing-block",
292 "",
293 ),
294 )
295
296
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:
300 return None
301 try:
302 config = json.loads(match.group("config"))
303 except json.JSONDecodeError:
304 return None
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()
308 )
309 return config if valid else None
310
311
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."""
314 return (
315 Path(path).suffix.lower() == ".md"
316 and "markdownlint" in active_tools
317 and _markdownlint_config(body) is not None
318 )
319
320
321def _markdownlint_configure(
322 path: str,
323 comment: Comment,
324 body: str,
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:
329 return None
330 relaxed = sorted(
331 rule
332 for rule, value in config.items()
333 if value is False or (isinstance(value, dict) and bool(value))
334 )
335 if not relaxed:
336 return None
337 return _row(
338 path,
339 comment.line,
340 comment.column,
341 ToolRecognition(
342 "lint-control",
343 "markdownlint",
344 ",".join(relaxed),
345 "markdownlint configure-file",
346 "file",
347 "",
348 ),
349 )
350
351
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":
357 return None
358 if "prettier" in active_tools and PRETTIER_RE.fullmatch(body) is not None:
359 return _row(
360 path,
361 comment.line,
362 comment.column,
363 ToolRecognition(
364 "formatter",
365 "prettier",
366 "layout",
367 "prettier-ignore",
368 "next-node",
369 "",
370 ),
371 )
372 if configured := _markdownlint_configure(path, comment, body, active_tools):
373 return configured
374 if "markdownlint" not in active_tools or (match := MARKDOWNLINT_RE.fullmatch(body)) is None:
375 return None
376 control = match.group("control")
377 if control in {"capture", "restore"} and match.group("rules"):
378 return None
379 rules = ",".join(match.group("rules").split()) or "*"
380 reason_required = control.startswith("disable")
381 scope = (
382 "file"
383 if control.endswith("-file")
384 else "line"
385 if control.endswith("-line")
386 else "next-line"
387 if control == "disable-next-line"
388 else "region-start"
389 if control == "disable"
390 else "region-end"
391 if control == "enable"
392 else "state-capture"
393 if control == "capture"
394 else "state-restore"
395 if control == "restore"
396 else "state"
397 )
398 return _row(
399 path,
400 comment.line,
401 comment.column,
402 ToolRecognition(
403 "lint-control",
404 "markdownlint",
405 rules,
406 f"markdownlint {control}",
407 scope,
408 "",
409 reason_required=reason_required,
410 ),
411 )
412
413
414def _doxygen(
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):
420 return None
421 if suffix == ".py":
422 if not body.startswith("#"):
423 return None
424 body = body[1:].strip()
425 if (match := DOXYGEN_COND_RE.fullmatch(body)) is None:
426 return None
427 control = "cond" if match.group("start") else "endcond"
428 return _row(
429 path,
430 comment.line,
431 comment.column,
432 ToolRecognition(
433 "documentation",
434 "doxygen",
435 "conditional-doc",
436 f"doxygen {control}",
437 "region-start" if control == "cond" else "region-end",
438 "",
439 reason_required=control == "cond",
440 ),
441 )
442
443
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:
447 return not 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
455 )
456 return False
457
458
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:
462 return None
463 command = match.group("command")
464 if not _valid_iwyu_argument(command, match.group("argument")):
465 return None
466 scope = {
467 "begin_exports": "region-start",
468 "end_exports": "region-end",
469 "begin_keep": "region-start",
470 "end_keep": "region-end",
471 }.get(
472 command,
473 "file"
474 if command in {"always_keep", "friend", "no_forward_declare", "no_include", "private"}
475 else "line",
476 )
477 rule = (
478 "exports"
479 if command in {"begin_exports", "end_exports"}
480 else "keep-region"
481 if command in {"begin_keep", "end_keep"}
482 else command
483 )
484 return _row(
485 path,
486 comment.line,
487 comment.column,
488 ToolRecognition(
489 "include-analysis",
490 "iwyu",
491 rule,
492 f"IWYU pragma: {command}",
493 scope,
494 "",
495 reason_required=scope != "region-end",
496 ),
497 )
498
499
500COMMENT_RECOGNIZERS = (
501 _clang_format,
502 _yamllint,
503 _hadolint,
504 _cmake_format,
505 _cmake_lint,
506 _doxygen,
507 _iwyu,
508)
509
510
511def recognize_tool_comment(
512 path: str,
513 comment: Comment,
514 body: str,
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)
520
521
522def tool_control_finding(
523 path: str,
524 comment: Comment,
525 body: str,
526 active_tools: frozenset[str] = frozenset(),
527) -> Finding | None:
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):
533 return None
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:
537 return Finding(
538 "inactive-tool-control", "markdownlint is not configured", path, comment.line
539 )
540 directive_like = (
541 re.match(r"^clang-format\s+(?:off|on)(?:\s|:|$)", body, re.IGNORECASE)
542 or re.match(
543 r"^yamllint\s+(?:disable-file|disable-line|disable|enable)(?:\s|$)",
544 body,
545 re.IGNORECASE,
546 )
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)
552 or re.match(
553 r"^markdownlint-(?:disable-file|enable-file|disable-line|"
554 r"disable-next-line|disable|enable|capture|restore|configure-file)(?:\s|$)",
555 body,
556 re.IGNORECASE,
557 )
558 or re.match(r"^IWYU\s+pragma\s*:", body, re.IGNORECASE)
559 or (
560 _is_doxygen_control_path(path)
561 and Path(path).suffix.lower() != ".md"
562 and re.match(r"^(?:@|\\)(?:cond|endcond)(?:\s|$)", body, re.IGNORECASE)
563 )
564 )
565 if directive_like:
566 return Finding("malformed-tool-control", body, path, comment.line)
567 return None
568
569
570def _markdown_visible_lines(text: str) -> list[str]:
571 """Blank Markdown fenced code while preserving line numbering."""
572 visible: list[str] = []
573 fence = ""
574 html_comment = False
575 for raw in text.splitlines():
576 marker = re.match(r"^ {0,3}(`{3,}|~{3,})", raw)
577 if fence:
578 if re.match(rf"^ {{0,3}}{re.escape(fence[0])}{{{len(fence)},}}\s*$", raw):
579 fence = ""
580 visible.append("")
581 elif html_comment:
582 html_comment = "-->" not in raw
583 visible.append("")
584 elif marker:
585 fence = marker.group(1)
586 visible.append("")
587 elif "<!--" in raw:
588 html_comment = "-->" not in raw[raw.find("<!--") + 4 :]
589 visible.append("")
590 else:
591 visible.append(raw)
592 return visible
593
594
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)
600 else:
601 match = re.fullmatch(
602 r"(?:/\*\*?|//)\s*Suppression rationale:\s*(\S.*?)(?:\s*\*/)?",
603 stripped,
604 )
605 return match.group(1) if match else ""
606
607
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"}:
611 return [], []
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):
617 body = raw.strip()
618 if not body:
619 continue
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 ""
623 if reason:
624 record = Suppression(
625 record.path,
626 record.line,
627 record.column,
628 record.family,
629 record.tool,
630 record.rule,
631 record.directive,
632 record.scope,
633 reason,
634 record.provenance,
635 record.owner,
636 _concerns(
637 record.rule,
638 reason,
639 reason_required=record.scope == "region-start",
640 ),
641 )
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
646
647
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()
653 if not stripped:
654 if notes:
655 break
656 continue
657 if not stripped.startswith("#"):
658 break
659 note = stripped[1:].strip()
660 if note:
661 notes.append(note)
662 return " ".join(reversed(notes))
663
664
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):
669 return line_no
670 return 0
671
672
673def _yaml_config(path: str, text: str) -> tuple[object, list[Finding]]:
674 """Parse one YAML tool config and fail closed on malformed data."""
675 try:
676 return yaml.safe_load(text), []
677 except yaml.YAMLError as exc:
678 return None, [Finding("malformed-tool-config", str(exc), path)]
679
680
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)
687 if not line:
688 findings.append(Finding("malformed-tool-config", f"cannot locate {rule}: false", path))
689 return None
690 return _row(
691 path,
692 line,
693 1,
694 ToolRecognition(
695 "lint-control",
696 "yamllint",
697 str(rule),
698 "yamllint rule disabled",
699 "repository",
700 _preceding_reason(lines, line),
701 provenance="central-config",
702 ),
703 )
704
705
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:
711 return None
712 line = _line_for(lines, re.compile(r"^\s*check-keys:\s*false\s*(?:#.*)?$"))
713 if not line:
714 message = "cannot locate truthy check-keys: false"
715 findings.append(Finding("malformed-tool-config", message, path))
716 return None
717 reason = _preceding_reason(lines, line)
718 if not reason:
719 parent_line = _line_for(lines, re.compile(r"^\s*truthy:\s*(?:#.*)?$"))
720 if not parent_line:
721 findings.append(Finding("malformed-tool-config", "cannot locate truthy rule", path))
722 else:
723 reason = _preceding_reason(lines, parent_line)
724 return _row(
725 path,
726 line,
727 1,
728 ToolRecognition(
729 "lint-control",
730 "yamllint",
731 "truthy/check-keys",
732 "yamllint check-keys false",
733 "repository",
734 reason,
735 provenance="central-config",
736 ),
737 )
738
739
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":
743 return [], []
744 parsed, findings = _yaml_config(path, text)
745 if findings:
746 return [], findings
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"]
751 records = [
752 record
753 for rule, config in rules.items()
754 if config is False
755 if (record := _yamllint_disabled_record(path, rule, lines, findings)) is not None
756 ]
757 if (record := _yamllint_truthy_record(path, rules.get("truthy"), lines, findings)) is not None:
758 records.append(record)
759 return records, findings
760
761
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":
765 return [], []
766 parsed, findings = _yaml_config(path, text)
767 if findings:
768 return [], findings
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
772 ):
773 return [], [Finding("malformed-tool-config", "invalid hadolint ignored list", path)]
774 lines = text.splitlines()
775 records: list[Suppression] = []
776 for rule in ignored:
777 pattern = re.compile(rf"^\s*-\s*{re.escape(rule)}\s*(?:#.*)?$")
778 line = _line_for(lines, pattern)
779 if not line:
780 findings.append(Finding("malformed-tool-config", f"cannot locate {rule}", path))
781 continue
782 reason = _preceding_reason(lines, line)
783 records.append(
784 _row(
785 path,
786 line,
787 1,
788 ToolRecognition(
789 "lint-control",
790 "hadolint",
791 rule,
792 "hadolint ignored",
793 "repository",
794 reason,
795 provenance="central-config",
796 ),
797 )
798 )
799 return records, findings
800
801
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":
805 return [], []
806 lines = text.splitlines()
807 section = ""
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):
813 section = stripped
814 continue
815 match = re.fullmatch(
816 r"(?i:ignore)\s*=\s*(?P<value>true|false|unset)(?:\s*[#;].*)?", stripped
817 )
818 if match is not None and match.group("value") == "true":
819 if not section:
820 findings.append(
821 Finding("malformed-tool-config", "shfmt ignore has no section", path, line_no)
822 )
823 continue
824 records.append(
825 _row(
826 path,
827 line_no,
828 1,
829 ToolRecognition(
830 "formatter",
831 "shfmt",
832 section,
833 "EditorConfig ignore=true",
834 "path-pattern",
835 _preceding_reason(lines, line_no),
836 provenance="central-config",
837 ),
838 )
839 )
840 elif match is None and re.match(r"^ignore\s*=", stripped, re.IGNORECASE):
841 findings.append(
842 Finding(
843 "malformed-tool-config", f"inactive shfmt property: {stripped}", path, line_no
844 )
845 )
846 return records, findings
847
848
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