From d7971023b693be0839d4bc348ecc36ca2c49421a Mon Sep 17 00:00:00 2001 From: Jon Chery Date: Wed, 19 Aug 2026 22:30:29 +0000 Subject: [PATCH] =?UTF-8?q?test(P01):=20tests/test=5Fcli=5Fsubcommands.py?= =?UTF-8?q?=20=E2=80=94=20CAP-033=20+=20CAP-034=20(REQ-324,=20cli-engineer?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CAP-033: `nova --help` exits 0 and lists a subcommand for every user-facing core/ module (15 expected subcommands parsed from help). CAP-034 (AST scan, parametrized per nova/.py excl. cli/__init__): - (a) line count ≤50 - (b) ≤3 FunctionDef/AsyncFunctionDef - (c) every bare ast.Call target resolves to a core.* import, a builtin, or a local function def (attribute/method calls allowed) - (d) no `if` statements except `if __name__ == "__main__"` nova init: in tmp_path, asserts .nova/, .nova/contract.yml.attestations/, .gitignore created with all 6 secrets-exclusion lines; refuses existing dir without --force. ---ci--- project: acdl phase: 1 milestone: v1.28 status: execute persona: cli-engineer ---/ci--- --- tests/test_cli_subcommands.py | 168 ++++++++++++++++++++++++++++++++++ 1 file changed, 168 insertions(+) create mode 100644 tests/test_cli_subcommands.py diff --git a/tests/test_cli_subcommands.py b/tests/test_cli_subcommands.py new file mode 100644 index 0000000..0f0d16f --- /dev/null +++ b/tests/test_cli_subcommands.py @@ -0,0 +1,168 @@ +"""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}" \ No newline at end of file