3"""Syntax-aware inventory for test, workflow, and Ansible controls."""
5from __future__
import annotations
9from dataclasses
import dataclass
10from pathlib
import Path
13from suppression_catalog
import ownership
14from suppression_hash_lex
import HashLexLine, hash_lines
15from suppression_model
import Finding, Suppression
17CMAKE_CONTROL_HINT = re.compile(
18 r"\b(?:DISABLED|WILL_FAIL|SKIP_RETURN_CODE|SKIP_REGULAR_EXPRESSION|"
19 r"PASS_REGULAR_EXPRESSION|FAIL_REGULAR_EXPRESSION)\b"
22YAML_CONTROL_ROOTS = (
".github/workflows/",
"infra/ansible/")
23YAML_CONTROL_HINT = re.compile(
24 r"\b(?:continue-on-error|if-no-files-found|ignore_errors|failed_when|"
25 r"changed_when|no_log)\s*:"
27CTEST_PROPERTIES = frozenset(
32 "SKIP_REGULAR_EXPRESSION",
33 "PASS_REGULAR_EXPRESSION",
34 "FAIL_REGULAR_EXPRESSION",
38 "pytest.mark.skip": (
"pytest",
"skip", 0),
39 "pytest.mark.skipif": (
"pytest",
"skipif",
None),
40 "pytest.mark.xfail": (
"pytest",
"xfail",
None),
41 "pytest.skip": (
"pytest",
"skip", 0),
42 "pytest.xfail": (
"pytest",
"xfail", 0),
43 "unittest.skip": (
"unittest",
"skip", 0),
44 "unittest.skipIf": (
"unittest",
"skipIf", 1),
45 "unittest.skipUnless": (
"unittest",
"skipUnless", 1),
46 "unittest.expectedFailure": (
"unittest",
"expectedFailure",
None),
47 "unittest.SkipTest": (
"unittest",
"SkipTest", 0),
48 "self.skipTest": (
"unittest",
"skipTest", 0),
50BARE_PYTHON_CONTROLS = {
51 "pytest.mark.skip": (
"pytest",
"skip"),
52 "pytest.mark.xfail": (
"pytest",
"xfail"),
53 "unittest.expectedFailure": (
"unittest",
"expectedFailure"),
57@dataclass(frozen=True)
59 """One active CMake command invocation."""
67def _concerns(reason: str, *extra: str) -> tuple[str, ...]:
68 """Return review concerns without treating inventory as approval."""
69 concerns = list(extra)
70 if not reason.strip():
71 concerns.append(
"blank-reason")
72 return tuple(dict.fromkeys(concerns))
75def _python_aliases(tree: ast.AST) -> dict[str, str]:
76 """Resolve explicit pytest/unittest import aliases used by controls."""
77 aliases: dict[str, str] = {}
78 for node
in ast.walk(tree):
79 if isinstance(node, ast.Import):
80 for item
in node.names:
81 if item.name
in {
"pytest",
"unittest"}:
82 aliases[item.asname
or item.name] = item.name
83 elif isinstance(node, ast.ImportFrom)
and node.module
in {
"pytest",
"unittest"}:
84 for item
in node.names:
86 aliases[item.asname
or item.name] = f
"{node.module}.{item.name}"
90def _dotted_name(node: ast.AST, aliases: dict[str, str]) -> str:
91 """Return a dotted name for one Python name/attribute expression."""
93 cursor: ast.AST = node
94 while isinstance(cursor, ast.Attribute):
95 parts.append(cursor.attr)
97 if isinstance(cursor, ast.Name):
98 parts.append(cursor.id)
99 resolved = list(reversed(parts))
100 resolved[0] = aliases.get(resolved[0], resolved[0])
101 return ".".join(resolved)
105def _string_value(node: ast.AST |
None) -> tuple[str, bool]:
106 """Return a literal rationale and whether its value is dynamic."""
109 if isinstance(node, ast.Constant)
and isinstance(node.value, str):
110 return node.value.strip(),
False
111 return "dynamic reason expression",
True
114def _python_reason(call: ast.Call, position: int |
None) -> tuple[str, bool]:
115 """Read a Python skip reason from its tool-specific argument slot."""
116 for keyword
in call.keywords:
117 if keyword.arg ==
"reason":
118 return _string_value(keyword.value)
119 if position
is not None and len(call.args) > position:
120 return _string_value(call.args[position])
124def _strict_xfail(call: ast.Call) -> bool:
125 """Return whether an xfail call makes an unexpected pass fail."""
126 for keyword
in call.keywords:
127 if keyword.arg ==
"strict":
128 return isinstance(keyword.value, ast.Constant)
and keyword.value.value
is True
132def _python_record(path: str, node: ast.AST, dotted: str, call: ast.Call |
None) -> Suppression:
133 """Build one Python test-control inventory row."""
135 tool, rule = BARE_PYTHON_CONTROLS[dotted]
136 reason, dynamic =
"",
False
138 tool, rule, position = PYTHON_CALLS[dotted]
139 reason, dynamic = _python_reason(call, position)
140 extras: list[str] = []
142 extras.append(
"dynamic-reason")
143 if dotted ==
"pytest.mark.xfail" and (call
is None or not _strict_xfail(call)):
144 extras.append(
"non-strict-xfail")
147 getattr(node,
"lineno", 1),
148 getattr(node,
"col_offset", 0) + 1,
157 _concerns(reason, *extras),
161def _bare_python_nodes(tree: ast.AST) -> list[ast.AST]:
162 """Return bare marker/decorator nodes that are not calls."""
163 nodes: list[ast.AST] = []
164 for node
in ast.walk(tree):
165 if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
166 nodes.extend(item
for item
in node.decorator_list
if not isinstance(item, ast.Call))
167 elif isinstance(node, (ast.Assign, ast.AnnAssign)):
169 if value
is not None and not isinstance(value, ast.Call):
174def _python_controls(path: str, text: str) -> tuple[list[Suppression], list[Finding]]:
175 """Parse Python test-control calls and decorators through the AST."""
176 if Path(path).suffix
not in {
".py",
".pyi"}
or ownership(path) !=
"first-party":
179 tree = ast.parse(text, filename=path)
180 except SyntaxError
as exc:
181 message = exc.msg
or "Python parser rejected test-control source"
182 return [], [Finding(
"malformed-test-control", message, path, exc.lineno
or 0)]
183 aliases = _python_aliases(tree)
184 records: list[Suppression] = []
185 for node
in ast.walk(tree):
186 if not isinstance(node, ast.Call):
188 dotted = _dotted_name(node.func, aliases)
189 if dotted
in PYTHON_CALLS:
190 records.append(_python_record(path, node, dotted, node))
191 for node
in _bare_python_nodes(tree):
192 dotted = _dotted_name(node, aliases)
193 if dotted
in BARE_PYTHON_CONTROLS:
194 records.append(_python_record(path, node, dotted,
None))
198def _cmake_source(path: str, text: str) -> tuple[str, list[HashLexLine]]:
199 """Mask CMake comments/bracket payloads while retaining line positions."""
200 lines, _ = hash_lines(path, text)
201 return "\n".join(line.code
for line
in lines), lines
204def _cmake_call_end(source: str, opening: int) -> int |
None:
205 """Find the matching close parenthesis outside quoted arguments."""
209 for index
in range(opening + 1, len(source)):
213 elif quote
and char ==
"\\":
217 elif not quote
and char ==
"(":
219 elif not quote
and char ==
")":
226def _quoted_cmake_end(source: str, opening: int) -> int |
None:
227 """Return the end of a top-level quoted token, honoring escapes."""
229 for index
in range(opening + 1, len(source)):
240def _cmake_calls(source: str) -> tuple[list[CMakeCall], bool]:
241 """Parse top-level CMake calls without searching argument strings."""
242 calls: list[CMakeCall] = []
244 while position < len(source):
245 if source[position] ==
'"':
246 end = _quoted_cmake_end(source, position)
251 if not (source[position].isalpha()
or source[position] ==
"_"):
255 while position < len(source)
and (source[position].isalnum()
or source[position] ==
"_"):
257 name = source[start:position]
258 while position < len(source)
and source[position].isspace():
260 if position >= len(source)
or source[position] !=
"(":
263 end = _cmake_call_end(source, opening)
266 line = source.count(
"\n", 0, start) + 1
267 prior = source.rfind(
"\n", 0, start)
268 calls.append(CMakeCall(name.lower(), source[opening + 1 : end], line, start - prior))
273def _cmake_tokens(body: str) -> list[str]:
274 """Tokenize CMake command arguments closely enough for property pairs."""
275 pattern = re.compile(
r'"(?:\\.|[^"\\])*"|[^\s()]+')
276 tokens: list[str] = []
277 for match
in pattern.finditer(body):
278 token = match.group(0)
279 if token.startswith(
'"')
and token.endswith(
'"'):
285def _nearby_comments(lines: list[HashLexLine], line_no: int) -> str:
286 """Collect one contiguous rationale immediately before a CMake call."""
287 notes: list[str] = []
291 if line.code.strip():
293 note = line.comment.strip()
298 return " ".join(reversed(notes))
301def _property_pairs(call: CMakeCall) -> tuple[list[str], list[tuple[str, str]]]:
302 """Return test targets and property/value pairs from one setter call."""
303 tokens = _cmake_tokens(call.body)
304 upper = [token.upper()
for token
in tokens]
305 if call.name ==
"set_tests_properties":
306 if "PROPERTIES" not in upper:
308 split = upper.index(
"PROPERTIES")
309 targets = tokens[:split]
310 elif call.name ==
"set_property" and upper
and upper[0] ==
"TEST":
311 if "PROPERTY" not in upper:
313 split = upper.index(
"PROPERTY")
315 item
for item
in tokens[1:split]
if item.upper()
not in {
"APPEND",
"APPEND_STRING"}
319 values = tokens[split + 1 :]
320 return targets, list(zip(values[::2], values[1::2], strict=
False))
323def _cmake_property_active(name: str, value: str) -> bool:
324 """Return whether a recognized CTest property actually softens selection."""
325 if name
not in {
"DISABLED",
"WILL_FAIL"}:
327 return value.strip().upper()
not in {
"0",
"FALSE",
"OFF",
"NO",
"N"}
330def _ctest_controls(path: str, text: str) -> tuple[list[Suppression], list[Finding]]:
331 """Inventory active CTest result/skip properties, not getter mentions."""
333 if item.name !=
"CMakeLists.txt" and item.suffix !=
".cmake":
335 if not CMAKE_CONTROL_HINT.search(text):
337 source, lines = _cmake_source(path, text)
338 calls, malformed = _cmake_calls(source)
340 [Finding(
"malformed-ctest-control",
"unterminated CMake call", path)]
if malformed
else []
342 records: list[Suppression] = []
344 targets, pairs = _property_pairs(call)
345 for raw_name, value
in pairs:
346 name = raw_name.upper()
347 if name
not in CTEST_PROPERTIES
or not _cmake_property_active(name, value):
349 reason = _nearby_comments(lines, call.line)
359 ",".join(targets)
or "test",
366 return records, findings
369def _yaml_scalar(node: yaml.Node |
None) -> str:
370 """Return a scalar's source spelling, or an empty marker for structures."""
371 return node.value
if isinstance(node, yaml.ScalarNode)
else ""
374def _yaml_true(value: str) -> bool:
375 """Return whether one YAML spelling is an unconditional true value."""
376 return value.strip().lower()
in {
"true",
"yes",
"on",
"1"}
379def _yaml_false(value: str) -> bool:
380 """Return whether one YAML spelling is an unconditional false value."""
381 return value.strip().lower()
in {
"false",
"no",
"off",
"0"}
384def _mapping_name(node: yaml.MappingNode, inherited: str) -> str:
385 """Return a task/step name from the closest containing mapping."""
386 for key, value
in node.value:
387 if _yaml_scalar(key) ==
"name" and isinstance(value, yaml.ScalarNode):
388 return value.value.strip()
393 path: str, key: yaml.ScalarNode, value: yaml.Node, name: str
394) -> Suppression |
None:
395 """Normalize one active workflow or Ansible suppression mapping."""
397 raw = _yaml_scalar(value).strip()
398 family =
"workflow" if path.startswith(
".github/workflows/")
else "ansible"
400 extras: tuple[str, ...] = ()
401 if family ==
"workflow" and control ==
"continue-on-error":
402 active =
not _yaml_false(raw)
403 elif family ==
"workflow" and control ==
"if-no-files-found":
404 active = raw.lower()
in {
"ignore",
"warn"}
405 elif family ==
"ansible" and control
in {
"ignore_errors",
"no_log"}:
406 active = _yaml_true(raw)
or (bool(raw)
and not _yaml_false(raw))
407 extras = (
"broad-result-mask",)
if control ==
"ignore_errors" else (
"broad-output-mask",)
408 elif family ==
"ansible" and control
in {
"failed_when",
"changed_when"}:
409 active = _yaml_false(raw)
410 if control ==
"failed_when":
411 extras = (
"broad-result-mask",)
416 key.start_mark.line + 1,
417 key.start_mark.column + 1,
419 "github-actions" if family ==
"workflow" else "ansible",
420 control
if control !=
"if-no-files-found" else raw.lower(),
422 "step" if family ==
"workflow" else "task",
426 _concerns(name, *extras),
430def _walk_yaml(node: yaml.Node, path: str, inherited: str, seen: set[int]) -> list[Suppression]:
431 """Walk composed YAML nodes while retaining source locations and names."""
435 records: list[Suppression] = []
436 if isinstance(node, yaml.MappingNode):
437 name = _mapping_name(node, inherited)
438 for key, value
in node.value:
439 if isinstance(key, yaml.ScalarNode):
440 record = _yaml_record(path, key, value, name)
441 if record
is not None:
442 records.append(record)
443 records.extend(_walk_yaml(value, path, name, seen))
444 elif isinstance(node, yaml.SequenceNode):
445 for value
in node.value:
446 records.extend(_walk_yaml(value, path, inherited, seen))
450def _yaml_controls(path: str, text: str) -> tuple[list[Suppression], list[Finding]]:
451 """Compose workflow/Ansible YAML and inventory only active mapping keys."""
452 relevant = path.startswith(YAML_CONTROL_ROOTS)
453 if not relevant
or Path(path).suffix
not in {
".yml",
".yaml"}:
455 if not YAML_CONTROL_HINT.search(text):
458 documents = list(yaml.compose_all(text))
459 except yaml.YAMLError
as exc:
460 return [], [Finding(
"malformed-yaml-control", str(exc), path)]
461 records: list[Suppression] = []
462 for document
in documents:
463 if document
is not None:
464 records.extend(_walk_yaml(document, path,
"", set()))
468def scan_control_file(path: str, text: str) -> tuple[list[Suppression], list[Finding]]:
469 """Run every non-overlapping test/infrastructure control recognizer."""
470 records: list[Suppression] = []
471 findings: list[Finding] = []
472 for scanner
in (_python_controls, _ctest_controls, _yaml_controls):
473 scanned, errors = scanner(path, text)
474 records.extend(scanned)
475 findings.extend(errors)
476 return records, findings