d7971023b6
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---
168 lines
6.1 KiB
Python
168 lines
6.1 KiB
Python
"""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}" |