diff --git a/configs/exit_code_registry.json b/configs/exit_code_registry.json new file mode 100644 index 00000000..84474c1e --- /dev/null +++ b/configs/exit_code_registry.json @@ -0,0 +1,23 @@ +{ + "description": "OpenAMP Foundry CLI exit code registry", + "codes": { + "0": {"meaning": "success", "description": "Command completed successfully"}, + "1": {"meaning": "internal_error", "description": "Unexpected error during execution"}, + "2": {"meaning": "input_error", "description": "Missing or invalid input (file not found, bad arguments)"}, + "3": {"meaning": "validation_failure", "description": "Validation or gate check failed (data did not pass criteria)"} + }, + "command_patterns": { + "rank": {"expected": [0, 2], "notes": "0=success, 2=missing input"}, + "validate": {"expected": [0, 2], "notes": "0=valid, 2=missing input"}, + "bench leakage": {"expected": [0, 2], "notes": "0=success, 2=missing input"}, + "pilot-panel": {"expected": [0, 2], "notes": "0=success, 2=missing input"}, + "calibration-intake": {"expected": [0, 2], "notes": "0=success, 2=missing input"}, + "check_wave1_pass_fail.py": {"expected": [0, 2, 3], "notes": "0=all pass, 2=input error, 3=criteria fail"}, + "validate_lab_data_return.py": {"expected": [0, 2, 3], "notes": "0=all valid, 2=input error, 3=validation fail"}, + "validate_negative_archive.py": {"expected": [0, 2, 3], "notes": "0=all valid, 2=input error, 3=validation fail"}, + "validate_panel_csv.py": {"expected": [0, 2, 3], "notes": "0=valid, 2=input error, 3=invalid"}, + "check_claims.py": {"expected": [0, 3], "notes": "0=ok, 3=findings (advisory)"}, + "check_doc_links.py": {"expected": [0, 3], "notes": "0=ok, 3=broken links found"}, + "check_pyproject.py": {"expected": [0, 2, 3], "notes": "0=ok, 2=input error, 3=inconsistency found"} + } +} diff --git a/scripts/check_pyproject.py b/scripts/check_pyproject.py new file mode 100644 index 00000000..f7673a7f --- /dev/null +++ b/scripts/check_pyproject.py @@ -0,0 +1,93 @@ +"""Check pyproject.toml consistency with the actual package.""" + +from __future__ import annotations + +import importlib +import sys +import tomllib +from pathlib import Path + + +def check_pyproject(pyproject_path: str = "pyproject.toml") -> dict: + p = Path(pyproject_path) + if not p.exists(): + return {"error": f"pyproject.toml not found: {pyproject_path}"} + + with p.open("rb") as f: + data = tomllib.load(f) + + issues = [] + checks = {} + + # 1. Check Python version requirement + requires_python = data.get("project", {}).get("requires-python", "") + checks["python_requirement"] = requires_python + + # 2. Check console scripts entrypoints + scripts = data.get("project", {}).get("scripts", {}) + entrypoint_issues = [] + for name, ref in scripts.items(): + module_path = ref.split(":")[0] + try: + importlib.import_module(module_path) + except ImportError as e: + entrypoint_issues.append(f"Entrypoint '{name}' -> '{ref}': {e}") + if entrypoint_issues: + issues.extend(entrypoint_issues) + checks["entrypoints_ok"] = len(entrypoint_issues) == 0 + checks["entrypoint_issues"] = entrypoint_issues + + # 3. Check optional extras reference valid dependencies + optional_deps = data.get("project", {}).get("optional-dependencies", {}) + extras_ok = len(optional_deps) > 0 + checks["extras_defined"] = extras_ok + checks["extras_count"] = len(optional_deps) + + # 4. Check project name matches package + pkg_name = data.get("project", {}).get("name", "") + # Convert hyphens to underscores for import check + import_name = pkg_name.replace("-", "_") + try: + importlib.import_module(import_name) + checks["package_importable"] = True + except ImportError: + checks["package_importable"] = False + issues.append(f"Package '{import_name}' cannot be imported") + + return { + "pyproject": pyproject_path, + "project_name": pkg_name, + "python_requirement": requires_python, + "entrypoints": len(scripts), + "entrypoints_ok": checks["entrypoints_ok"], + "extras": len(optional_deps), + "issues": issues, + "all_ok": len(issues) == 0, + } + + +def main() -> int: + import argparse + parser = argparse.ArgumentParser(description="Check pyproject.toml consistency") + parser.add_argument("--pyproject", default="pyproject.toml") + args = parser.parse_args() + + result = check_pyproject(args.pyproject) + if "error" in result: + print(result["error"], file=sys.stderr) + return 2 + + print(f"Project: {result['project_name']}") + print(f"Python: {result['python_requirement']}") + eok = result.get("entrypoints_ok", "?") + print(f"Entrypoints: {result['entrypoints']}, OK: {eok}") + print(f"Extras: {result['extras']}") + print(f"All OK: {result['all_ok']}") + for issue in result["issues"]: + print(f" ❌ {issue}") + + return 0 if result["all_ok"] else 3 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_check_pyproject.py b/tests/test_check_pyproject.py new file mode 100644 index 00000000..8e3a391f --- /dev/null +++ b/tests/test_check_pyproject.py @@ -0,0 +1,46 @@ +"""Tests for pyproject consistency checker.""" +import subprocess +import sys +import tomllib +from pathlib import Path +import pytest + +from scripts.check_pyproject import check_pyproject + + +def test_check_runs(): + r = check_pyproject() + assert "project_name" in r + assert r["project_name"] + + +def test_python_requirement_present(): + r = check_pyproject() + assert r["python_requirement"] != "" + + +def test_entrypoints_defined(): + r = check_pyproject() + assert r["entrypoints"] > 0 + + +def test_extras_defined(): + r = check_pyproject() + assert r["extras"] >= 1 + + +def test_cli_exit(): + r = subprocess.run([sys.executable, "scripts/check_pyproject.py"], + capture_output=True, text=True, env={"PYTHONPATH": "src"}) + assert r.returncode == 0 + + +def test_pyproject_is_valid_toml(): + data = tomllib.loads(Path("pyproject.toml").read_text()) + assert "project" in data + assert data["project"]["name"] == "openamp-foundry" + + +def test_missing_file(): + r = check_pyproject("/nonexistent.toml") + assert "error" in r diff --git a/tests/test_exit_code_registry.py b/tests/test_exit_code_registry.py new file mode 100644 index 00000000..fe3a8c00 --- /dev/null +++ b/tests/test_exit_code_registry.py @@ -0,0 +1,46 @@ +"""Tests for CLI exit code registry.""" +import json +from pathlib import Path + + +def _registry(): + return json.loads(Path("configs/exit_code_registry.json").read_text()) + + +def test_file_exists(): + assert Path("configs/exit_code_registry.json").exists() + + +def test_is_valid_json(): + r = _registry() + assert "codes" in r + assert "command_patterns" in r + + +def test_exit_codes_defined(): + r = _registry() + for code_str, info in r["codes"].items(): + assert "meaning" in info + assert "description" in info + assert isinstance(int(code_str), int) + + +def test_rank_has_expected_codes(): + r = _registry() + assert "rank" in r["command_patterns"] + assert 0 in r["command_patterns"]["rank"]["expected"] + + +def test_check_scripts_have_exit_3(): + r = _registry() + for name, info in r["command_patterns"].items(): + if name.endswith(".py"): + assert 3 in info["expected"], f"{name} should have exit code 3" + + +def test_all_expected_codes_are_defined(): + r = _registry() + defined = set(int(k) for k in r["codes"]) + for name, info in r["command_patterns"].items(): + for code in info["expected"]: + assert code in defined, f"{name} expects code {code} which is not defined"