"""Check the environment reference against source without importing application settings. Only tracked-source locations are inspected. Deployment .env files, process environment values and runtime data are never opened or evaluated. """ import ast from dataclasses import dataclass import json from pathlib import Path import re import sys ROOT = Path(__file__).resolve().parents[1] ENV_NAME = re.compile(r"[A-Z][A-Z0-9_]*\Z") @dataclass(frozen=True) class Setting: names: tuple[str, ...] default: str def settings_inventory(source: str) -> list[Setting]: tree = ast.parse(source.lstrip("\ufeff")) settings = next(node for node in tree.body if isinstance(node, ast.ClassDef) and node.name == "Settings") result = [] for node in settings.body: if not isinstance(node, ast.AnnAssign) or not isinstance(node.target, ast.Name): continue name = node.target.id names = (name.upper(),) default = node.value if isinstance(default, ast.Call): arguments = {keyword.arg: keyword.value for keyword in default.keywords} alias = arguments.get("validation_alias") if isinstance(alias, ast.Constant): names = (alias.value,) elif isinstance(alias, ast.Call): names = tuple(ast.literal_eval(argument) for argument in alias.args) default = arguments.get("default") if isinstance(default, ast.Name): value = "@" + default.id else: value = json.dumps(ast.literal_eval(default), ensure_ascii=True) result.append(Setting(names, value)) return result def python_environment_names(source: str) -> set[str]: names = set() for node in ast.walk(ast.parse(source.lstrip("\ufeff"))): argument = None if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) and node.args: receiver = ast.unparse(node.func.value) if (node.func.attr == "getenv" and receiver == "os") or ( node.func.attr == "get" and receiver in {"os.environ", "environ", "environment", "prepared"} ): argument = node.args[0] elif isinstance(node, ast.Subscript) and ast.unparse(node.value) in { "os.environ", "environ", "environment", "prepared" }: argument = node.slice if isinstance(argument, ast.Constant) and isinstance(argument.value, str) and ENV_NAME.fullmatch(argument.value): names.add(argument.value) return names def runtime_environment_names(root: Path) -> set[str]: names = set() sources = [*root.glob("backend/app/**/*.py"), *root.glob("scripts/*.py")] for path in sources: names.update(python_environment_names(path.read_text(encoding="utf-8"))) javascript = [*root.glob("frontend/app/**/*.ts"), *root.glob("frontend/app/**/*.tsx"), *root.glob("scripts/*.cjs"), root / "frontend/proxy.ts", root / "frontend/next.config.js"] for path in javascript: if ".test." not in path.name: names.update(re.findall(r"process\.env\.([A-Z][A-Z0-9_]*)", path.read_text(encoding="utf-8"))) deployment = [*root.glob("*compose*.yml"), *root.glob("scripts/*.sh"), *root.glob("scripts/*.ps1"), *root.glob(".gitea/workflows/*.yml")] for path in deployment: source = path.read_text(encoding="utf-8") names.update(re.findall(r"\$\{([A-Z][A-Z0-9_]*)", source)) names.update(re.findall(r"\$env:([A-Z][A-Z0-9_]*)", source)) names.update(re.findall(r"secrets\.([A-Z][A-Z0-9_]*)", source)) # These are shell syntax/builtins, not Magent configuration options. names.difference_update({"BASH_SOURCE", "HOME", "RANDOM"}) dockerfile = (root / "Dockerfile").read_text(encoding="utf-8").replace("\\\n", " ") for line in dockerfile.splitlines(): if line.startswith(("ENV ", "ARG ")): names.update(re.findall(r"\b([A-Z][A-Z0-9_]*)=", line)) supervisor = (root / "docker/supervisord.conf").read_text(encoding="utf-8") for line in supervisor.splitlines(): if line.startswith("environment="): names.update(re.findall(r"\b([A-Z][A-Z0-9_]*)=", line)) return names def check_documentation(root: Path = ROOT) -> tuple[list[str], int]: document = (root / "docs/ENVIRONMENT.md").read_text(encoding="utf-8") documented = set(re.findall(r"`([A-Z][A-Z0-9_]*)`", document)) settings = settings_inventory((root / "backend/app/config.py").read_text(encoding="utf-8")) required = runtime_environment_names(root) | {name for setting in settings for name in setting.names} errors = [f"Undocumented environment variable: {name}" for name in sorted(required - documented)] defaults = {} for line in document.splitlines(): cells = line.split("|") if len(cells) >= 4 and cells[1].strip().startswith("`"): for name in re.findall(r"`([A-Z][A-Z0-9_]*)`", cells[1]): defaults[name] = cells[2].strip().strip("`") for setting in settings: for name in setting.names: if name in documented and defaults.get(name) != setting.default: errors.append(f"Stale source default for {name}: expected {setting.default!r}, documented {defaults.get(name)!r}") return errors, len(required) def main() -> int: errors, count = check_documentation() if errors: print("\n".join(errors), file=sys.stderr) return 1 print(f"Environment documentation covers {count} source-declared variables; Settings defaults match.") return 0 if __name__ == "__main__": raise SystemExit(main())