#!/usr/bin/env python3 """Check that all env vars used in the codebase are documented in .env.example.""" import os import re import sys REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # Matches `const foo = "ENV_NAME"` (with optional explicit string type) on a # single line, plus the same declarations inside a `const ( ... )` block. CONST_SINGLE_RE = re.compile(r'const\s+(\w+)(?:\s+string)?\s*=\s*"([A-Z_][A-Z0-9_]*)"') CONST_BLOCK_RE = re.compile(r'const\s*\(([^)]*)\)') CONST_BLOCK_ITEM_RE = re.compile(r'(\w+)(?:\s+string)?\s*=\s*"([A-Z_][A-Z0-9_]*)"') # Matches os.Getenv / os.LookupEnv with either a string literal # (os.Getenv("VAR")) or an identifier (os.Getenv(varName)) that may be a # const holding the env var name (const-indirect reads). GETENV_RE = re.compile( r'os\.(?:Getenv|LookupEnv)\(\s*(?:"([A-Z_][A-Z0-9_]*)"|([A-Za-z_]\w*))\s*\)' ) def go_const_map(): """Return {package_name: {const_name: "ENV_NAME"}} from all backend Go files. Consts are package-scoped in Go: a const may be defined in one file and referenced from os.Getenv in another file of the same package (e.g. twoFAPepperEnv defined in twofa.go, read in twofa_prod.go), so collection must be per package rather than per file. """ consts = {} # package -> {name -> value} go_dir = os.path.join(REPO_ROOT, 'backend') for root, dirs, files in os.walk(go_dir): dirs[:] = [d for d in dirs if d not in ('vendor', '.git', 'node_modules')] for f in files: if not f.endswith('.go'): continue path = os.path.join(root, f) with open(path) as fh: content = fh.read() pkg_match = re.search(r'^package\s+(\w+)', content, re.MULTILINE) if not pkg_match: continue pkg = pkg_match.group(1) pkg_consts = consts.setdefault(pkg, {}) for name, value in CONST_SINGLE_RE.findall(content): pkg_consts[name] = value for block in CONST_BLOCK_RE.findall(content): for name, value in CONST_BLOCK_ITEM_RE.findall(block): pkg_consts[name] = value return consts def find_env_vars_in_code(): """Find all env vars read in the codebase (os.Getenv/os.LookupEnv in backend Go files, import.meta.env.VITE_* in frontend files).""" env_vars = set() consts = go_const_map() # Search backend Go files for os.Getenv / os.LookupEnv reads, including # const-indirect reads (os.Getenv(constName) resolved via the const map). go_dir = os.path.join(REPO_ROOT, 'backend') for root, dirs, files in os.walk(go_dir): dirs[:] = [d for d in dirs if d not in ('vendor', '.git', 'node_modules')] for f in files: if not f.endswith('.go'): continue path = os.path.join(root, f) with open(path) as fh: content = fh.read() pkg_match = re.search(r'^package\s+(\w+)', content, re.MULTILINE) pkg_consts = consts.get(pkg_match.group(1), {}) if pkg_match else {} for literal, identifier in GETENV_RE.findall(content): if literal: env_vars.add(literal) elif identifier in pkg_consts: env_vars.add(pkg_consts[identifier]) # Non-const identifiers (e.g. function params like getEnv(key)) # cannot be resolved to a specific env var — skip them. # Search frontend files for import.meta.env.VITE_* / import.meta.env.* frontend_dir = os.path.join(REPO_ROOT, 'frontend') for root, dirs, files in os.walk(frontend_dir): dirs[:] = [d for d in dirs if d not in ('.git', 'node_modules', 'build')] for f in files: if f.endswith(('.ts', '.svelte', '.js')): path = os.path.join(root, f) with open(path) as fh: for line in fh: m = re.findall(r'import\.meta\.env\.([A-Z_][A-Z0-9_]*)', line) env_vars.update(m) # Vite built-ins (DEV, PROD, SSR, MODE, BASE_URL, BUILD) are provided by Vite # itself and cannot be defined in .env.example — never treat them as user env vars. env_vars -= {'DEV', 'PROD', 'SSR', 'MODE', 'BASE_URL', 'BUILD'} return sorted(env_vars) def find_documented_vars(): """Find all env vars documented in .env.example.""" env_path = os.path.join(REPO_ROOT, '.env.example') documented = set() if os.path.exists(env_path): with open(env_path) as fh: for line in fh: line = line.strip() if line and not line.startswith('#') and '=' in line: var_name = line.split('=')[0].strip() if var_name: documented.add(var_name) return documented def main(): code_vars = find_env_vars_in_code() documented = find_documented_vars() undocumented = [v for v in code_vars if v not in documented] if undocumented: print("ERROR: The following env vars are used in code but not documented in .env.example:\n") for v in undocumented: print(f" {v}") print(f"\nTotal: {len(undocumented)} undocumented vars out of {len(code_vars)} total") sys.exit(1) else: print(f"OK: All {len(code_vars)} env vars used in code are documented in .env.example") sys.exit(0) if __name__ == '__main__': main()