"""E.2.7 — Static decoupling lint for ``decnet/ttp/``. The "no SPOF" decoupling rule §2 of TTP_TAGGING.md: tagging code must NEVER import an intel-provider module directly. Intel-derived data flows through ``decnet.web.db.models`` (the ``AttackerIntel`` row), not through a function call into ``decnet.intel.{abuseipdb,greynoise,feodo,threatfox}``. A failed provider produces an empty join, not a crash inside the tagger worker. The same property holds for biometrics: nothing under ``decnet/ttp/`` may reach into ``decnet.profiler.keystroke.*``. Future biometric ingesters will land in their own subpackages with the same DB-mediated bridge. This is a pure AST walk — no module-level import side effects, no runtime dependencies. The check runs on every TTP source file (skipping ``__pycache__`` and dunder names) and surfaces a ``ImportFrom``/``Import`` violation with file path + line number on failure. """ from __future__ import annotations import ast from pathlib import Path import pytest import decnet.ttp as _ttp_pkg _TTP_ROOT: Path = Path(_ttp_pkg.__file__).resolve().parent # Exact module names the tagger MUST NOT import. _FORBIDDEN_EXACT: frozenset[str] = frozenset({ "decnet.intel.abuseipdb", "decnet.intel.greynoise", "decnet.intel.feodo", "decnet.intel.threatfox", }) # Forbidden by-prefix: anything under these subpackages is off-limits. _FORBIDDEN_PREFIXES: tuple[str, ...] = ( "decnet.profiler.keystroke", ) def _is_forbidden(module_name: str) -> bool: if module_name in _FORBIDDEN_EXACT: return True return any( module_name == prefix or module_name.startswith(prefix + ".") for prefix in _FORBIDDEN_PREFIXES ) def _ttp_python_files() -> list[Path]: files: list[Path] = [] for p in _TTP_ROOT.rglob("*.py"): # Skip caches and any future stubs/shims that might slip in. if "__pycache__" in p.parts: continue files.append(p) return files def test_at_least_one_ttp_file_visited() -> None: """Sanity guard: a future refactor that moves the package or renames the import root must not silently neuter the lint by walking zero files.""" files = _ttp_python_files() assert files, f"no .py files under {_TTP_ROOT} — refactor regressed the lint" # Spot-check that the lifters and the worker live under the root, # otherwise the lint scope is wrong. names = {p.name for p in files} assert "worker.py" in names assert "base.py" in names @pytest.mark.parametrize("path", _ttp_python_files(), ids=lambda p: str(p.relative_to(_TTP_ROOT))) def test_no_forbidden_imports(path: Path) -> None: source = path.read_text(encoding="utf-8") tree = ast.parse(source, filename=str(path)) violations: list[str] = [] for node in ast.walk(tree): if isinstance(node, ast.Import): for alias in node.names: if _is_forbidden(alias.name): violations.append( f"{path}:{node.lineno} import {alias.name}" ) elif isinstance(node, ast.ImportFrom): mod = node.module or "" if _is_forbidden(mod): violations.append( f"{path}:{node.lineno} from {mod} import …" ) assert not violations, ( "decoupling rule §2 violated — TTP code must reach intel " "data via decnet.web.db.models only:\n " + "\n ".join(violations) )