"""Lint: forbid Jinja delimiters inside Ansible conditional keys. Ansible evaluates ``when`` / ``failed_when`` / ``changed_when`` / ``until`` as bare expressions. Wrapping the value in ``{{ }}`` / ``{% %}`` triggers the ansible-core deprecation "Conditionals should not be surrounded by templating delimiters" (removed in 2.23) and, when the templated string flows into a ``(x | default(true)) | bool`` data conditional, the "``bool`` filter coerced invalid value (str)" deprecation. Scope: ``.yml`` / ``.yaml`` under ``roles/*/{tasks,handlers,vars}/``. Excluded: * ``roles/*/meta/{volumes,networks,services}.yml`` -- their ``when`` keys are custom filter fields evaluated by ``render_jinja`` and REQUIRE ``{{ }}``; they are never Ansible task conditionals. * ``.j2`` templates -- not parsed as Ansible conditionals. Per-item render/apply lists (``role_templates`` for the shared ``sys-svc-compose`` render helper, ``LISTMONK_SETTINGS``) carry their guard on a ``condition:`` data field, not ``when:``, precisely so a templated boolean is legal there without colliding with this check. Suppress a genuine exception with ``# nocheck: templated-conditional`` on the offending line or the line above. """ from __future__ import annotations import re import unittest from pathlib import Path from utils.annotations.suppress import is_suppressed_at from utils.cache.files import iter_project_files, read_text from . import PROJECT_ROOT _RULE = "templated-conditional" _SCAN_SUFFIXES = (".yml", ".yaml") _SCAN_SUBDIRS = ("tasks", "handlers", "vars") _EXCLUDED_META_FILES = frozenset( ( "volumes.yml", "networks.yml", "services.yml", ) ) _CONDITIONAL_KEYS = ("when", "failed_when", "changed_when", "until") _JINJA = re.compile(r"\{\{|\{%") _SCALAR_KEY = re.compile(rf"^\s*(?:{'|'.join(_CONDITIONAL_KEYS)}):\s*(?P\S.*)$") _LIST_KEY = re.compile(rf"^(?P\s*)(?:{'|'.join(_CONDITIONAL_KEYS)}):\s*$") _LIST_ITEM = re.compile(r"^(?P\s*)-\s*(?P.*)$") _BLOCK_KEY = re.compile( rf"^(?P\s*)(?:{'|'.join(_CONDITIONAL_KEYS)}):\s*[>|][+-]?\d*\s*$" ) def _candidate_paths() -> list[Path]: out: list[Path] = [] for s in iter_project_files(extensions=_SCAN_SUFFIXES): p = Path(s) try: rel = p.relative_to(PROJECT_ROOT) except ValueError: continue parts = rel.parts if len(parts) < 3 or parts[0] != "roles": continue if parts[1] == "meta" and p.name in _EXCLUDED_META_FILES: continue if not any(sub in parts for sub in _SCAN_SUBDIRS): continue out.append(p) return out def _hits_for(lines: list[str]) -> list[tuple[int, str]]: """Return (1-based line number, snippet) for each templated conditional.""" hits: list[tuple[int, str]] = [] for idx, line in enumerate(lines, start=1): block_key = _BLOCK_KEY.match(line) if block_key: key_indent = len(block_key.group("indent")) for follow_no in range(idx + 1, len(lines) + 1): follow = lines[follow_no - 1] if not follow.strip(): continue if len(follow) - len(follow.lstrip()) <= key_indent: break if _JINJA.search(follow): hits.append((idx, line.strip())) break continue scalar = _SCALAR_KEY.match(line) if scalar: if _JINJA.search(scalar.group("value")): hits.append((idx, line.strip())) continue list_key = _LIST_KEY.match(line) if not list_key: continue key_indent = len(list_key.group("indent")) for follow_no in range(idx + 1, len(lines) + 1): follow = lines[follow_no - 1] if not follow.strip(): continue item = _LIST_ITEM.match(follow) if item is None or len(item.group("indent")) <= key_indent: break if _JINJA.search(item.group("value")): hits.append((follow_no, follow.strip())) return hits class TestNoTemplatedConditionals(unittest.TestCase): def test_no_templated_conditionals(self) -> None: offenders: dict[Path, list[str]] = {} for path in _candidate_paths(): try: text = read_text(str(path)) except (OSError, UnicodeDecodeError): continue if not _JINJA.search(text): continue lines = text.splitlines() file_hits: list[str] = [] for line_no, snippet in _hits_for(lines): if is_suppressed_at(lines, line_no, _RULE, mode="same-or-above"): continue file_hits.append(f"line {line_no}: {snippet}") if file_hits: offenders[path] = file_hits if offenders: report = [ ( f"{sum(len(v) for v in offenders.values())} templated Ansible " "conditional(s) found. Conditionals (when / failed_when / " "changed_when / until) must be BARE expressions, never wrapped " "in {{ }} or {% %}." ), ( " Fix: drop the delimiters (`when: FLAG | bool`). For per-item " "render/apply data guards, use a `condition:` data field (the " "sys-svc-compose render helper and LISTMONK_SETTINGS honor it)." ), f" Suppress a genuine exception with `# nocheck: {_RULE}`.", ] for path, issues in sorted(offenders.items()): report.append(f" - {path.relative_to(PROJECT_ROOT)}:") report.extend(f" * {i}" for i in issues) self.fail("\n".join(report)) if __name__ == "__main__": unittest.main()