diff --git a/src/openenv/cli/commands/validate.py b/src/openenv/cli/commands/validate.py index a706757f5..8d3f7b90b 100644 --- a/src/openenv/cli/commands/validate.py +++ b/src/openenv/cli/commands/validate.py @@ -12,13 +12,6 @@ from typing import Annotated import typer -from openenv.cli._validation import ( - build_local_validation_json_report, - format_validation_report, - get_deployment_modes, - validate_multi_mode_deployment, - validate_running_environment, -) from openenv.validation import ( format_shared_validation_report, run_local_validation, @@ -60,7 +53,7 @@ def validate( str | None, typer.Option( "--profile", - help="Validation profile: static, runtime, or full", + help="Validation profile: static, runtime, full, or publish", ), ] = None, output: Annotated[ @@ -93,7 +86,9 @@ def validate( Runtime validation checks if a live OpenEnv server conforms to the versioned runtime API contract and returns a criteria-based JSON report. - Explicit static, runtime, and full profiles emit the RFC 008 shared report. + Every profile emits the RFC 008 shared report. The publish profile is a + strict release gate: blocking skipped checks are incomplete and exit + non-zero. Reports identify the served OpenEnv spec and pinned adapter; external task package formats are intentionally outside this command's dispatch surface. Automatic runtime launch is intended for trusted local source; connect to @@ -141,158 +136,76 @@ def validate( raise typer.Exit(1) runtime_target = target - # Machine-readable and explicit-profile invocations use the RFC 008 shared - # report. Only the unqualified human rendering stays on the legacy path. - if profile is not None or output is not None or json_output: - if profile is None: - selected_profile = ( - ValidationProfile.RUNTIME - if runtime_target is not None - else ValidationProfile.STATIC - ) - else: - try: - selected_profile = ValidationProfile(profile.lower()) - except ValueError as exc: - typer.echo( - "Error: --profile must be one of: static, runtime, full", - err=True, - ) - raise typer.Exit(1) from exc - - if runtime_target is not None and selected_profile is ValidationProfile.STATIC: + if profile is None: + selected_profile = ( + ValidationProfile.RUNTIME + if runtime_target is not None + else ValidationProfile.STATIC + ) + else: + try: + selected_profile = ValidationProfile(profile.lower()) + except ValueError as exc: typer.echo( - "Error: The static profile requires a local source directory", + "Error: --profile must be one of: static, runtime, full, publish", err=True, ) - raise typer.Exit(1) - - if runtime_target is not None: - shared_target: str | Path = runtime_target - shared_runtime_url = runtime_target - else: - shared_target = Path.cwd() if target is None else Path(target) - shared_runtime_url = None - if not shared_target.exists(): - typer.echo(f"Error: Path does not exist: {shared_target}", err=True) - raise typer.Exit(1) - if not shared_target.is_dir(): - typer.echo(f"Error: Path is not a directory: {shared_target}", err=True) - raise typer.Exit(1) - - try: - report = run_local_validation( - shared_target, - profile=selected_profile, - runtime_url=shared_runtime_url, - timeout_s=timeout, - ) - except ValueError as exc: - typer.echo(f"Error: {exc}", err=True) - raise typer.Exit(1) from exc - payload = report.to_dict() - serialized = json.dumps(payload, indent=2) - - if output is not None: - try: - output.parent.mkdir(parents=True, exist_ok=True) - output.write_text(f"{serialized}\n", encoding="utf-8") - except OSError as exc: - typer.echo( - f"Error: Unable to write validation report: {type(exc).__name__}", - err=True, - ) - raise typer.Exit(1) from exc - - if json_output: - typer.echo(serialized) - else: - typer.echo(format_shared_validation_report(report)) - if output is not None: - typer.echo(f"Report written to {output}") - - if not report.passed: - raise typer.Exit(1) - return - - if runtime_target is not None: - try: - report = validate_running_environment(runtime_target, timeout_s=timeout) - except ValueError as exc: - typer.echo(f"Error: {exc}", err=True) raise typer.Exit(1) from exc - typer.echo(json.dumps(report, indent=2)) - if not report.get("passed", False): - raise typer.Exit(1) - return - - # Determine environment path (default to current directory) - if target is None: - env_path_obj = Path.cwd() - else: - env_path_obj = Path(target) - - if not env_path_obj.exists(): - typer.echo(f"Error: Path does not exist: {env_path_obj}", err=True) - raise typer.Exit(1) - - if not env_path_obj.is_dir(): - typer.echo(f"Error: Path is not a directory: {env_path_obj}", err=True) - raise typer.Exit(1) - - # Check for openenv.yaml to confirm this is an environment directory - openenv_yaml = env_path_obj / "openenv.yaml" - if not openenv_yaml.exists(): - typer.echo( - f"Error: Not an OpenEnv environment directory (missing openenv.yaml): {env_path_obj}", - err=True, - ) + if runtime_target is not None and selected_profile in { + ValidationProfile.STATIC, + ValidationProfile.PUBLISH, + }: typer.echo( - "Hint: Run this command from the environment root directory or specify the path", + f"Error: The {selected_profile.value} profile requires a local source directory", err=True, ) raise typer.Exit(1) - env_name = env_path_obj.name - if env_name.endswith("_env"): - base_name = env_name[:-4] + if runtime_target is not None: + shared_target: str | Path = runtime_target + shared_runtime_url = runtime_target else: - base_name = env_name - - # Run validation - is_valid, issues = validate_multi_mode_deployment(env_path_obj) - modes = get_deployment_modes(env_path_obj) - - if json_output: - report = build_local_validation_json_report( - env_name=base_name, - env_path=env_path_obj, - is_valid=is_valid, - issues=issues, - deployment_modes=modes if verbose else None, - ) - typer.echo(json.dumps(report, indent=2)) - if not is_valid: + shared_target = Path.cwd() if target is None else Path(target) + shared_runtime_url = None + if not shared_target.exists(): + typer.echo(f"Error: Path does not exist: {shared_target}", err=True) + raise typer.Exit(1) + if not shared_target.is_dir(): + typer.echo(f"Error: Path is not a directory: {shared_target}", err=True) raise typer.Exit(1) - return - # Show validation report - report = format_validation_report(base_name, is_valid, issues) - typer.echo(report) + try: + report = run_local_validation( + shared_target, + profile=selected_profile, + runtime_url=shared_runtime_url, + timeout_s=timeout, + ) + except ValueError as exc: + typer.echo(f"Error: {exc}", err=True) + raise typer.Exit(1) from exc - # Show deployment modes if verbose - if verbose: - typer.echo("\nSupported deployment modes:") - for mode, supported in modes.items(): - status = "[YES]" if supported else "[NO]" - typer.echo(f" {status} {mode}") + serialized = json.dumps(report.to_dict(), indent=2) + if output is not None: + try: + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(f"{serialized}\n", encoding="utf-8") + except OSError as exc: + typer.echo( + f"Error: Unable to write validation report: {type(exc).__name__}", + err=True, + ) + raise typer.Exit(1) from exc - if is_valid: - typer.echo("\nUsage examples:") - typer.echo(f" cd {env_path_obj.name} && uv run server") - typer.echo(f" cd {env_path_obj.name} && openenv build") - typer.echo(f" cd {env_path_obj.name} && openenv push") + # URL validation historically emitted JSON without an explicit --json; + # retain that presentation while using the shared planner and schema. + if json_output or (runtime_target is not None and profile is None): + typer.echo(serialized) + else: + typer.echo(format_shared_validation_report(report)) + if output is not None: + typer.echo(f"Report written to {output}") - if not is_valid: + if not report.passed: raise typer.Exit(1) diff --git a/src/openenv/validation/checks.py b/src/openenv/validation/checks.py index d177ad7dd..77eaebbb0 100644 --- a/src/openenv/validation/checks.py +++ b/src/openenv/validation/checks.py @@ -36,9 +36,16 @@ _STATIC_PROFILES = frozenset( - {ValidationProfile.STATIC, ValidationProfile.RUNTIME, ValidationProfile.FULL} + { + ValidationProfile.STATIC, + ValidationProfile.RUNTIME, + ValidationProfile.FULL, + ValidationProfile.PUBLISH, + } +) +_RUNTIME_PROFILES = frozenset( + {ValidationProfile.RUNTIME, ValidationProfile.FULL, ValidationProfile.PUBLISH} ) -_RUNTIME_PROFILES = frozenset({ValidationProfile.RUNTIME, ValidationProfile.FULL}) _FULL_PROFILE = frozenset({ValidationProfile.FULL}) _OPENENV_DEPENDENCY = re.compile(r"^openenv(?:\s*(?:$|[<>=!~@;])|\[)") _OPENENV_CORE_DEPENDENCY = re.compile(r"^openenv-core(?:\s*(?:$|[<>=!~@;])|\[)") diff --git a/src/openenv/validation/local.py b/src/openenv/validation/local.py index 0ea664eca..fda581946 100644 --- a/src/openenv/validation/local.py +++ b/src/openenv/validation/local.py @@ -524,5 +524,8 @@ def format_shared_validation_report(report: ValidationReport) -> str: ) if result.message: lines.append(f" {result.message}") + issues = result.evidence.get("issues") + if isinstance(issues, list): + lines.extend(f" {issue}" for issue in issues if isinstance(issue, str)) lines.append("Certification: not claimed by local validation") return "\n".join(lines) diff --git a/src/openenv/validation/models.py b/src/openenv/validation/models.py index 72f840de8..58704c5db 100644 --- a/src/openenv/validation/models.py +++ b/src/openenv/validation/models.py @@ -62,6 +62,7 @@ class ValidationProfile(str, Enum): STATIC = "static" RUNTIME = "runtime" FULL = "full" + PUBLISH = "publish" class ValidationCapability(str, Enum): @@ -443,7 +444,13 @@ def __post_init__(self) -> None: @property def passed(self) -> bool: - """Return whether all executed blocking criteria avoid failure/error.""" + """Return whether blocking criteria satisfy this profile's gate.""" + if self.profile is ValidationProfile.PUBLISH: + return not any( + result.severity is ValidationSeverity.BLOCKING + and result.status is not ValidationStatus.PASS + for result in self.results + ) return not any( result.severity is ValidationSeverity.BLOCKING and result.status in {ValidationStatus.FAIL, ValidationStatus.ERROR} @@ -471,6 +478,12 @@ def _summary(self) -> dict[str, Any]: if result.severity is ValidationSeverity.BLOCKING and result.status in {ValidationStatus.FAIL, ValidationStatus.ERROR} ] + blocking_skipped = [ + result.criterion_id + for result in self.results + if result.severity is ValidationSeverity.BLOCKING + and result.status is ValidationStatus.SKIP + ] return { "passed_count": counts[ValidationStatus.PASS.value], "failed_count": counts[ValidationStatus.FAIL.value], @@ -479,6 +492,7 @@ def _summary(self) -> dict[str, Any]: "total_count": len(self.results), "failed_criteria": failed, "blocking_failed_criteria": blocking_failed, + "blocking_skipped_criteria": blocking_skipped, "required_passed_count": sum( 1 for result in self.results @@ -548,4 +562,7 @@ def to_dict(self) -> dict[str, Any]: serialized_summary["blocking_failed_criteria"] = payload["summary"][ "blocking_failed_criteria" ] + serialized_summary["blocking_skipped_criteria"] = payload["summary"][ + "blocking_skipped_criteria" + ] return serialized diff --git a/tests/test_cli/test_validate.py b/tests/test_cli/test_validate.py index 80c797d5d..d19f36d4f 100644 --- a/tests/test_cli/test_validate.py +++ b/tests/test_cli/test_validate.py @@ -6,10 +6,11 @@ import json from pathlib import Path -from unittest.mock import patch +from unittest.mock import MagicMock, patch from openenv.cli.__main__ import app from openenv.cli._validation import validate_running_environment +from openenv.validation import ValidationProfile from typer.testing import CliRunner @@ -177,24 +178,33 @@ def _fake_post( def test_validate_command_runtime_target_outputs_json() -> None: - """CLI validates runtime targets and prints JSON report.""" - mock_report = { + """CLI validates runtime targets through the shared report path.""" + payload = { "target": "https://example.com", - "validation_type": "running_environment", - "standard_version": "1.0.0", + "validation_type": "openenv_validation", + "report_schema_version": "1.0", + "profile": "runtime", "passed": True, "criteria": [], } + mock_report = MagicMock() + mock_report.passed = True + mock_report.to_dict.return_value = payload with patch( - "openenv.cli.commands.validate.validate_running_environment", + "openenv.cli.commands.validate.run_local_validation", return_value=mock_report, ) as mock_validate: result = runner.invoke(app, ["validate", "https://example.com"]) assert result.exit_code == 0 - assert json.loads(result.output) == mock_report - mock_validate.assert_called_once_with("https://example.com", timeout_s=5.0) + assert json.loads(result.output) == payload + mock_validate.assert_called_once_with( + "https://example.com", + profile=ValidationProfile.RUNTIME, + runtime_url="https://example.com", + timeout_s=5.0, + ) def test_validate_shared_profile_reports_invalid_runtime_url() -> None: @@ -218,6 +228,21 @@ def test_validate_command_local_path_still_works(tmp_path: Path) -> None: assert "[OK]" in result.output +def test_unqualified_local_validate_uses_shared_static_semantics( + tmp_path: Path, +) -> None: + env_dir = tmp_path / "test_env" + _write_minimal_valid_env(env_dir) + (env_dir / "openenv.yaml").write_text("spec_version: [\n") + + result = runner.invoke(app, ["validate", str(env_dir)]) + + assert result.exit_code == 1 + assert "static validation" in result.output + assert "source.validation_spec" in result.output + assert "The validation spec could not be loaded" in result.output + + def test_validate_command_local_json_output(tmp_path: Path) -> None: """CLI can emit JSON report for local validation via --json.""" env_dir = tmp_path / "test_env" diff --git a/tests/test_validation/test_executor.py b/tests/test_validation/test_executor.py index ff5aa8a52..34a52e565 100644 --- a/tests/test_validation/test_executor.py +++ b/tests/test_validation/test_executor.py @@ -78,6 +78,46 @@ def _absent_spec() -> SpecLoad: return SpecLoad(state=SpecLoadState.ABSENT) +def _blocking_capability_skip_report(tmp_path: Path, profile: ValidationProfile): + check = ValidationCheck( + criterion_id="test.publish-readiness", + requirement="Publish readiness requires source inspection", + capabilities=frozenset({ValidationCapability.SOURCE}), + severity=ValidationSeverity.BLOCKING, + timeout_s=1.0, + evaluator=lambda _context: CheckOutcome.pass_(), + ) + plan = ValidationPlan( + target=str(tmp_path), + profile=profile, + policy_version="test-policy", + capabilities=RunnerCapabilities(runner="local"), + checks=(check,), + ) + context = ValidationContext(target=tmp_path, spec_load=_absent_spec()) + return execute_validation_plan(plan, context) + + +def test_publish_profile_makes_a_blocking_skip_fail_readiness( + tmp_path: Path, +) -> None: + report = _blocking_capability_skip_report( + tmp_path, + ValidationProfile.PUBLISH, + ) + + assert report.results[0].status is ValidationStatus.SKIP + assert report.passed is False + + +def test_existing_profiles_keep_blocking_skips_nonfatal(tmp_path: Path) -> None: + for profile in (ValidationProfile.STATIC, ValidationProfile.RUNTIME): + report = _blocking_capability_skip_report(tmp_path, profile) + + assert report.results[0].status is ValidationStatus.SKIP + assert report.passed is True + + def test_echo_env_local_and_remote_executors_share_criterion_results() -> None: env_dir = Path(__file__).resolve().parents[2] / "envs" / "echo_env" local_plan, local_context = build_validation_plan(