"""Lint guard: every ``.py`` file MUST reference role-relative paths via the ``ROLE_FILE_*`` constants in :mod:`utils.roles.mapping` instead of hard-coding the path string in any form. Background ========== :mod:`utils.roles.mapping` carries the role-file vocabulary (``ROLE_FILE_META_MAIN``, ``ROLE_FILE_VARS_MAIN``, …) plus the per-type ``mandatory`` / ``allowed`` / ``marker`` policy in :data:`ROLE_FILES`. Code that joins, compares, or merges role files MUST import the constant so a future rename (e.g. ``meta/services.yml`` → ``meta/compose.yml``) propagates from one edit. Hard-coded literals (``"meta/services.yml"``, ``role_dir / "meta" / "services.yml"``, ``os.path.join(role, "meta", "services.yml")``, ``f"{role}/meta/main.yml"``) bypass the schema and make the SPOT load- bearing only by convention. Detection ========= AST-walks every ``.py`` file under the production trees (``utils/``, ``cli/``, ``plugins/``, ``filter_plugins/``, ``lookup_plugins/``, ``roles/``, ``scripts/``, ``tasks/``) AND under ``tests/`` and flags the following composite forms whenever the value (or the joined value) matches a ``ROLE_FILE_*`` constant: * a standalone string constant (``"meta/services.yml"``); * an f-string segment that contains a flag literal as a path tail (``f"{role}/meta/main.yml"`` carries the constant ``"/meta/main.yml"``); * a pathlib ``/`` chain whose trailing string operands join to a flag literal (``role_dir / "meta" / "services.yml"``); * a ``+`` concatenation chain whose trailing string operands join to a flag literal (``prefix + "meta/" + "main.yml"``); * an ``os.path.join`` / ``posixpath.join`` / ``Path.joinpath`` call whose trailing string positional arguments join to a flag literal. A match in any form requires at least two adjacent literal segments for the chain forms so a single literal like ``role_dir / "meta/main.yml"`` is reported once (by the standalone-constant pass) instead of twice. The exact flag set is auto-discovered from :mod:`utils.roles.mapping` so adding a new ``ROLE_FILE_*`` constant extends coverage with zero further edits. ``ROLE_FILE_README`` is exempt because ``README.md`` is too generic to flag without context. Allowed ======= * ``utils/roles/mapping.py``: defines the constants. Hard-coded literals there are the SPOT itself. * Per-line ``# nocheck: role-file-spot`` (``same-or-above`` placement) for legitimate exceptions; e.g. tests that assert a literal value of the constant, migration scripts that operate on raw paths, doc comments embedding the path string. Caching ======= File contents are routed through :func:`utils.cache.files.read_text` so multiple lint tests scanning the same source pay one read. """ from __future__ import annotations import ast import unittest from pathlib import Path import utils.roles.mapping as _mapping from utils.annotations.suppress import is_suppressed_at from utils.cache.files import PROJECT_ROOT, iter_project_files, read_text _RULE = "role-file-spot" _EXEMPT_CONSTANT_NAMES: frozenset[str] = frozenset({"ROLE_FILE_README"}) def _build_literal_to_constant() -> dict[str, str]: """Return ``{literal_value: constant_name}`` for every ROLE_FILE_* constant in :mod:`utils.roles.mapping`, except the exempt ones. """ out: dict[str, str] = {} for name, value in vars(_mapping).items(): if not name.startswith("ROLE_FILE_"): continue if name in _EXEMPT_CONSTANT_NAMES: continue if not isinstance(value, str): continue out[value] = name return out _LITERAL_TO_CONSTANT: dict[str, str] = _build_literal_to_constant() _EXEMPT_FILES: frozenset[Path] = frozenset( { PROJECT_ROOT / "utils" / "roles" / "mapping.py", } ) _SCAN_DIRS: frozenset[str] = frozenset( { "utils", "cli", "plugins", "filter_plugins", "lookup_plugins", "roles", "scripts", "tasks", "tests", } ) def _flag_match(value: str) -> str | None: """Return the matching ``ROLE_FILE_*`` constant name when *value* equals a flag literal, ends with ``/``, starts with ``/``, or contains ``//`` as a path segment. """ if value in _LITERAL_TO_CONSTANT: return _LITERAL_TO_CONSTANT[value] for flag, name in _LITERAL_TO_CONSTANT.items(): if value.endswith("/" + flag): return name if value.startswith(flag + "/"): return name if "/" + flag + "/" in value: return name return None def _trailing_string_div_chain(node: ast.AST) -> list[str]: """Return the trailing string-constant operands of a ``/`` chain (innermost first). Empty when the chain is broken before any string constant or *node* is not a ``BinOp(Div)``. """ parts: list[str] = [] cur: ast.AST = node while isinstance(cur, ast.BinOp) and isinstance(cur.op, ast.Div): right = cur.right if isinstance(right, ast.Constant) and isinstance(right.value, str): parts.append(right.value) cur = cur.left else: break return parts def _trailing_string_add_chain(node: ast.AST) -> list[str]: """Same as :func:`_trailing_string_div_chain`, but for ``+`` concatenation chains. """ parts: list[str] = [] cur: ast.AST = node while isinstance(cur, ast.BinOp) and isinstance(cur.op, ast.Add): right = cur.right if isinstance(right, ast.Constant) and isinstance(right.value, str): parts.append(right.value) cur = cur.left else: break return parts def _is_path_join_call(node: ast.Call) -> bool: """Return ``True`` when *node* is ``os.path.join``, ``posixpath.join``, ``ntpath.join``, or any ``.joinpath(...)`` call. ``str.join`` and similar non-path joins return ``False``. """ func = node.func if not isinstance(func, ast.Attribute): return False if func.attr == "joinpath": return True if func.attr != "join": return False recv = func.value if isinstance(recv, ast.Attribute) and recv.attr == "path": inner = recv.value return isinstance(inner, ast.Name) and inner.id == "os" return isinstance(recv, ast.Name) and recv.id in {"posixpath", "ntpath"} def _trailing_string_call_args(node: ast.Call) -> list[str]: """Return the trailing string-constant positional arguments of *node* (innermost first), stopping at the first non-string arg. """ parts: list[str] = [] for arg in reversed(node.args): if isinstance(arg, ast.Constant) and isinstance(arg.value, str): parts.append(arg.value) else: break return parts def _check_div_chain(node: ast.AST) -> str | None: parts = _trailing_string_div_chain(node) if len(parts) < 2: return None return _flag_match("/".join(reversed(parts))) def _check_add_chain(node: ast.AST) -> str | None: parts = _trailing_string_add_chain(node) if len(parts) < 2: return None return _flag_match("".join(reversed(parts))) def _check_call_join(node: ast.Call) -> str | None: if not _is_path_join_call(node): return None parts = _trailing_string_call_args(node) if len(parts) < 2: return None return _flag_match("/".join(reversed(parts))) def _file_offenders(path: Path) -> list[str]: """Return ``[]`` if the file uses the SPOT throughout, or a list of human-readable ``line N: should use ROLE_FILE_`` strings for every flagged occurrence. """ try: src = read_text(str(path)) except (OSError, UnicodeDecodeError): return [] try: tree = ast.parse(src, filename=str(path)) except SyntaxError: return [] lines = src.splitlines() seen: set[tuple[int, str, str]] = set() offenders: list[str] = [] def _record(line_no: int, kind: str, name: str, detail: str) -> None: key = (line_no, kind, name) if key in seen: return if is_suppressed_at(lines, line_no, _RULE, mode="same-or-above"): return seen.add(key) offenders.append(f"line {line_no}: {detail} should use {name}") for node in ast.walk(tree): if isinstance(node, ast.Constant) and isinstance(node.value, str): name = _flag_match(node.value) if name is not None: _record(node.lineno, "literal", name, f'"{node.value}"') continue if isinstance(node, ast.BinOp): if isinstance(node.op, ast.Div): name = _check_div_chain(node) if name is not None: _record(node.lineno, "div-chain", name, "pathlib `/` chain") continue if isinstance(node.op, ast.Add): name = _check_add_chain(node) if name is not None: _record(node.lineno, "add-chain", name, "string `+` concat") continue if isinstance(node, ast.Call): name = _check_call_join(node) if name is not None: _record(node.lineno, "call-join", name, "path-join call") continue offenders.sort(key=lambda msg: int(msg.split(":", 1)[0].split()[-1])) return offenders def _scan_paths() -> list[Path]: """Iterate every production .py file via the shared file-walk cache.""" out: list[Path] = [] for s in iter_project_files(extensions=(".py",), exclude_tests=False): p = Path(s) try: rel = p.relative_to(PROJECT_ROOT) except ValueError: continue if not rel.parts or rel.parts[0] not in _SCAN_DIRS: continue if p in _EXEMPT_FILES: continue out.append(p) return sorted(out) class TestRoleFileSpot(unittest.TestCase): """Every .py file MUST reference role files via the ROLE_FILE_* constants in utils.roles.mapping instead of hard-coded literals.""" def test_role_files_are_referenced_via_spot(self) -> None: offenders: dict[Path, list[str]] = {} for path in _scan_paths(): issues = _file_offenders(path) if issues: offenders[path] = issues if not offenders: return rel = lambda p: p.relative_to(PROJECT_ROOT) # noqa: E731 lines = [ ( f"{len(offenders)} .py file(s) hard-code a role-file path " f"instead of importing the matching ROLE_FILE_* constant from " f"utils.roles.mapping (the SPOT):" ), ] for path, issues in sorted(offenders.items()): lines.append(f" - {rel(path)}:") lines.extend(f" * {issue}" for issue in issues) lines.append("") lines.append( "Fix: import the constant from utils.roles.mapping (e.g. " "`from utils.roles.mapping import ROLE_FILE_META_SERVICES`) " "and use it everywhere the path is constructed, compared, or " "merged. Add `# nocheck: role-file-spot` (same-or-above) only " "for exceptions documented in " "docs/contributing/actions/testing/suppression.md." ) self.fail("\n".join(lines)) if __name__ == "__main__": # pragma: no cover unittest.main()