#!/usr/bin/env python3 """Fail when a change set mixes Terraform with deployable application files. APP_PATHS is newline-separated. A trailing slash is a directory prefix. Any other entry is an exact file. Paths that are not listed are neutral, so workflows, docs, and tests may travel with either side. An empty APP_PATHS skips the check. """ from __future__ import annotations import argparse import os import sys def parse_app_rules(raw: str) -> tuple[frozenset[str], frozenset[str]]: prefixes: set[str] = set() exact: set[str] = set() for line in raw.splitlines(): item = line.strip().replace("\\", "/") if not item or item.startswith("#"): continue if item.endswith("/"): prefixes.add(item) else: exact.add(item) return frozenset(prefixes), frozenset(exact) def is_terraform_path(path: str) -> bool: normalized = path.replace("\\", "/") return normalized == "terraform" or normalized.startswith("terraform/") def is_app_path(path: str, prefixes: frozenset[str], exact: frozenset[str]) -> bool: normalized = path.replace("\\", "/") if normalized in exact: return True for prefix in prefixes: if normalized.startswith(prefix) or f"{normalized}/" == prefix: return True return False def isolation_violation( paths: list[str], app_paths: str ) -> tuple[list[str], list[str]] | None: prefixes, exact = parse_app_rules(app_paths) if not prefixes and not exact: return None terraform_files = sorted({path for path in paths if is_terraform_path(path)}) app_files = sorted({path for path in paths if is_app_path(path, prefixes, exact)}) if terraform_files and app_files: return terraform_files, app_files return None def first_isolation_violation( file_sets: list[list[str]], app_paths: str ) -> tuple[list[str], list[str]] | None: for paths in file_sets: violation = isolation_violation(paths, app_paths) if violation is not None: return violation return None def main() -> int: parser = argparse.ArgumentParser() parser.add_argument( "paths", nargs="*", help="Changed paths. Omit and pass newline-separated paths on stdin.", ) args = parser.parse_args() paths = list(args.paths) if not paths and not sys.stdin.isatty(): paths = [line.strip() for line in sys.stdin if line.strip()] violation = isolation_violation(paths, os.environ.get("APP_PATHS", "")) if violation is None: print("PASS: application and Terraform changes are isolated") return 0 terraform_files, app_files = violation print("FAIL: do not mix deployable application files with terraform/", file=sys.stderr) print("terraform:", file=sys.stderr) for path in terraform_files: print(f" {path}", file=sys.stderr) print("application:", file=sys.stderr) for path in app_files: print(f" {path}", file=sys.stderr) return 1 if __name__ == "__main__": raise SystemExit(main())