test(P01): tests/test_cli_subcommands.py — CAP-033 + CAP-034 (REQ-324, cli-engineer)
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/<module>.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---
This commit is contained in:
@@ -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/<module>.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/<module>.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}"
|
||||
Reference in New Issue
Block a user