diff --git a/src/codealmanac/cli/render/health.py b/src/codealmanac/cli/render/health.py index 1806bcf7..c573d79e 100644 --- a/src/codealmanac/cli/render/health.py +++ b/src/codealmanac/cli/render/health.py @@ -93,10 +93,16 @@ def render_validate(result: ValidationResult, json_output: bool) -> None: print(f"{style.DIM}path:{style.RST} {result.almanac_path}") if result.index is not None: print(f"{style.DIM}index:{style.RST} {result.index.pages_indexed} pages") - if result.ok: + warnings = result.warnings + if warnings: + print(f"warnings ({len(warnings)}):") + for issue in warnings: + print(f" {format_validation_issue(issue)}") + errors = result.errors + if not errors: return - print(f"issues ({len(result.issues)}):") - for issue in result.issues: + print(f"issues ({len(errors)}):") + for issue in errors: print(f" {format_validation_issue(issue)}") diff --git a/src/codealmanac/services/health/models.py b/src/codealmanac/services/health/models.py index 96e615e4..3867b269 100644 --- a/src/codealmanac/services/health/models.py +++ b/src/codealmanac/services/health/models.py @@ -1,14 +1,29 @@ +from enum import StrEnum from pathlib import Path from codealmanac.core.models import CodeAlmanacModel from codealmanac.services.index.models import IndexRefreshResult +class IssueSeverity(StrEnum): + """How a validation issue affects the outcome. + + ERROR fails validation: the wiki is not in a publishable state. + WARNING is reported but does not fail, for conditions a maintainer should + see without blocking `validate`, `ensure_valid`, or the workflows gated on + them. + """ + + ERROR = "error" + WARNING = "warning" + + class ValidationIssue(CodeAlmanacModel): category: str message: str page: str | None = None path: str | None = None + severity: IssueSeverity = IssueSeverity.ERROR class ValidationResult(CodeAlmanacModel): @@ -17,3 +32,19 @@ class ValidationResult(CodeAlmanacModel): index: IndexRefreshResult | None = None issues: tuple[ValidationIssue, ...] = () ok: bool + + @property + def errors(self) -> tuple[ValidationIssue, ...]: + return tuple( + issue + for issue in self.issues + if issue.severity is IssueSeverity.ERROR + ) + + @property + def warnings(self) -> tuple[ValidationIssue, ...]: + return tuple( + issue + for issue in self.issues + if issue.severity is IssueSeverity.WARNING + ) diff --git a/src/codealmanac/services/health/reports.py b/src/codealmanac/services/health/reports.py index 60da4200..4289c132 100644 --- a/src/codealmanac/services/health/reports.py +++ b/src/codealmanac/services/health/reports.py @@ -3,12 +3,13 @@ def validation_failure_message(result: ValidationResult) -> str: - first = result.issues[0] + errors = result.errors or result.issues + first = errors[0] location = f" ({first.path})" if first.path else "" - if len(result.issues) == 1: + if len(errors) == 1: return f"validation failed: {first.category}: {first.message}{location}" return ( - f"validation failed: {len(result.issues)} issues; first: " + f"validation failed: {len(errors)} issues; first: " f"{first.category}: {first.message}{location}" ) diff --git a/src/codealmanac/services/health/service.py b/src/codealmanac/services/health/service.py index 4b84ce24..a531a4ea 100644 --- a/src/codealmanac/services/health/service.py +++ b/src/codealmanac/services/health/service.py @@ -1,7 +1,11 @@ from pathlib import Path from codealmanac.core.errors import ValidationFailed -from codealmanac.services.health.models import ValidationIssue, ValidationResult +from codealmanac.services.health.models import ( + IssueSeverity, + ValidationIssue, + ValidationResult, +) from codealmanac.services.health.reports import ( health_report_issues, validation_failure_message, @@ -74,5 +78,5 @@ def validation_result( almanac_path=repository.almanac_path, index=index, issues=tuple(issues), - ok=len(issues) == 0, + ok=not any(issue.severity is IssueSeverity.ERROR for issue in issues), ) diff --git a/tests/test_validate.py b/tests/test_validate.py index d5441220..5a11bdb2 100644 --- a/tests/test_validate.py +++ b/tests/test_validate.py @@ -1,4 +1,5 @@ import subprocess +from datetime import UTC, datetime from pathlib import Path import pytest @@ -14,7 +15,11 @@ HarnessRunStatus, ) from codealmanac.services.harnesses.requests import RunHarnessRequest +from codealmanac.services.health.models import IssueSeverity, ValidationIssue +from codealmanac.services.health.reports import validation_failure_message from codealmanac.services.health.requests import ValidateWikiRequest +from codealmanac.services.health.service import validation_result +from codealmanac.services.repositories.models import Repository, RepositoryName from codealmanac.services.runs.models import RunStatus from codealmanac.services.runs.requests import ListRunsRequest from codealmanac.settings import AppConfig @@ -271,3 +276,63 @@ def run_git(repo: Path, *args: str) -> None: capture_output=True, check=True, ) + + +def _repository() -> Repository: + return Repository( + repository_id="repo-1", + name=RepositoryName("demo"), + description="", + root_path=Path("/repo"), + almanac_path=Path("/repo/almanac"), + registered_at=datetime(2026, 1, 1, tzinfo=UTC), + ) + + +def test_validation_issue_defaults_to_error_severity(): + issue = ValidationIssue(category="sources", message="broken") + + assert issue.severity is IssueSeverity.ERROR + + +def test_warning_severity_does_not_fail_validation(): + repository = _repository() + warning = ValidationIssue( + category="reserved_directory", + message="page is not indexed", + severity=IssueSeverity.WARNING, + ) + + result = validation_result(repository, None, [warning]) + + assert result.ok is True + assert result.warnings == (warning,) + assert result.errors == () + + +def test_error_severity_still_fails_validation(): + repository = _repository() + error = ValidationIssue(category="sources", message="broken") + + result = validation_result(repository, None, [error]) + + assert result.ok is False + assert result.errors == (error,) + assert result.warnings == () + + +def test_warnings_do_not_mask_errors(): + repository = _repository() + warning = ValidationIssue( + category="reserved_directory", + message="page is not indexed", + severity=IssueSeverity.WARNING, + ) + error = ValidationIssue(category="sources", message="source is missing a target") + + result = validation_result(repository, None, [warning, error]) + + assert result.ok is False + # The failure message must describe an error, not the warning that precedes it. + assert "source is missing a target" in validation_failure_message(result) + assert "1 issues" not in validation_failure_message(result)