"""Tests for nova CLI subcommands (P1, CAP-033 + CAP-034, REQ-324). CAP-033: `nova --help` lists a subcommand for every user-facing core/ module. CAP-034: AST-scan every nova/.py (except cli.py, __init__.py) for line count ≤50, ≤3 FunctionDef, calls resolve to core.* imports, and no `if` statements except `if __name__ == "__main__"`. Also: `nova init` scaffolds .nova/ + .gitignore in a tmp dir. """ from __future__ import annotations import ast import os import subprocess import sys import pytest NOVA_DIR = os.path.join(os.path.dirname(__file__), "..", "nova") NOVA_DIR = os.path.abspath(NOVA_DIR) # Expected subcommand for every user-facing core/ module # (skip internal-only: env, local_emulators, *_cli shims, init_scaffold, # mode_resolver, confidence_signal has its own nova subcommand). EXPECTED_SUBCOMMANDS = { "contract_resolver": "resolve", "decommission_transform": "decommission", "env_transition": "env-transition", "environment_check": "env-check", "hitl_gates": "hitl", "onboarding": "onboard", "outbox_writer": "outbox", "output_publisher": "publish-outputs", "policy_engine": "policy", "regression_verify": "regression", "separation_of_duties": "sod", "submission_readiness": "readiness", "attestation_matrix": "attestation-matrix", "confidence_signal": "confidence", "init_scaffold": "init", } # Builtins / stdlib names allowed as bare Call targets (everything else # must resolve to a name imported from core.*). _BUILTIN_CALLS = { "print", "open", "len", "str", "int", "bool", "dict", "list", "tuple", "range", "isinstance", "getattr", "setattr", "hasattr", "sorted", "min", "max", "sum", "any", "all", "enumerate", "zip", "map", "filter", "format", "repr", "type", "abs", "round", } def _nova_help_cmd(): """Return the command list to invoke `nova --help` (prefer installed entry).""" nova = os.path.join(os.path.dirname(sys.executable), "nova") if os.path.isfile(nova): return [nova, "--help"] return [sys.executable, "-m", "nova.cli", "--help"] # --- CAP-033: help lists every expected subcommand --- def test_help_lists_all_subcommands(): cmd = _nova_help_cmd() proc = subprocess.run(cmd, capture_output=True, text=True, cwd=os.getcwd()) assert proc.returncode == 0, f"nova --help failed: {proc.stderr}" help_text = proc.stdout for core_mod, subname in EXPECTED_SUBCOMMANDS.items(): assert subname in help_text, ( f"subcommand {subname!r} (for core/{core_mod}.py) not in nova --help output" ) # --- CAP-034: AST scan of nova/.py --- def _nova_modules(): out = [] for fn in sorted(os.listdir(NOVA_DIR)): if not fn.endswith(".py"): continue if fn in ("cli.py", "__init__.py"): continue out.append(os.path.join(NOVA_DIR, fn)) return out def _core_imported_names(tree): """Collect names imported from `core` or `core.*` modules.""" names = set() for node in ast.walk(tree): if isinstance(node, ast.ImportFrom) and node.module and ( node.module == "core" or node.module.startswith("core.") ): for alias in node.names: names.add(alias.asname or alias.name) return names @pytest.mark.parametrize("modpath", _nova_modules()) def test_module_caps034_constraints(modpath): src = open(modpath, encoding="utf-8").read() lines = src.splitlines() # (a) ≤50 lines assert len(lines) <= 50, f"{modpath}: {len(lines)} lines > 50" tree = ast.parse(src, filename=modpath) # (b) ≤3 FunctionDef/AsyncFunctionDef func_defs = [ n for n in ast.walk(tree) if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef)) ] assert len(func_defs) <= 3, f"{modpath}: {len(func_defs)} function defs > 3" local_func_names = {f.name for f in func_defs} # (c) every bare Call target resolves to a core.* import, a builtin, # or a function defined in this module (local helper). core_names = _core_imported_names(tree) allowed = core_names | _BUILTIN_CALLS | local_func_names for node in ast.walk(tree): if isinstance(node, ast.Call): func = node.func if isinstance(func, ast.Name): assert func.id in allowed, ( f"{modpath}: call to {func.id!r} not from a core.* import, " f"a builtin, or a local function def" ) # ast.Attribute calls (method calls on locals/args) are allowed # (d) no `if` statements except `if __name__ == "__main__"` if isinstance(node, ast.If): test = node.test is_main_guard = ( isinstance(test, ast.Compare) and isinstance(test.left, ast.Name) and test.left.id == "__name__" ) assert is_main_guard, f"{modpath}: non-__main__ `if` statement" # --- nova init scaffolding --- def test_nova_init_scaffolds(tmp_path): cmd = _nova_help_cmd() # build an init command (replace --help with init) init_cmd = cmd[:-1] + ["init"] proc = subprocess.run(init_cmd, capture_output=True, text=True, cwd=str(tmp_path)) assert proc.returncode == 0, f"nova init failed: {proc.stderr}" nova_dir = tmp_path / ".nova" attest_dir = nova_dir / "contract.yml.attestations" gitignore = tmp_path / ".gitignore" assert nova_dir.is_dir(), ".nova/ not created" assert attest_dir.is_dir(), ".nova/contract.yml.attestations/ not created" assert gitignore.is_file(), ".gitignore not created" content = gitignore.read_text() for line in ( "~/.nova/credentials.json", ".nova/credentials.json", "*.pem", "*.key", ".env", ".env.*", ): assert line in content, f"{line!r} missing from .gitignore" def test_nova_init_refuses_without_force(tmp_path): (tmp_path / ".nova").mkdir() cmd = _nova_help_cmd() init_cmd = cmd[:-1] + ["init"] proc = subprocess.run(init_cmd, capture_output=True, text=True, cwd=str(tmp_path)) assert proc.returncode == 1, f"nova init should refuse existing dir: {proc.stdout}"