Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions src/codealmanac/cli/render/health.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)}")


Expand Down
31 changes: 31 additions & 0 deletions src/codealmanac/services/health/models.py
Original file line number Diff line number Diff line change
@@ -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):
Expand All @@ -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
)
7 changes: 4 additions & 3 deletions src/codealmanac/services/health/reports.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
)

Expand Down
8 changes: 6 additions & 2 deletions src/codealmanac/services/health/service.py
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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),
)
65 changes: 65 additions & 0 deletions tests/test_validate.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import subprocess
from datetime import UTC, datetime
from pathlib import Path

import pytest
Expand All @@ -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
Expand Down Expand Up @@ -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)