147 lines
5.7 KiB
Python
147 lines
5.7 KiB
Python
"""Compatibility-ledger schema, expiry, and source-locator validation."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import datetime as dt
|
|
import re
|
|
from pathlib import Path
|
|
|
|
from .baseline import load_json_file
|
|
from .model import Diagnostic
|
|
from .repository import read_text
|
|
|
|
|
|
RULE_ID = "COMPAT-LEDGER"
|
|
_SEMVER_NUMBER = r"(?:0|[1-9][0-9]*)"
|
|
_SEMVER_PRERELEASE_IDENTIFIER = (
|
|
r"(?:0|[1-9][0-9]*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)"
|
|
)
|
|
SEMVER = re.compile(
|
|
rf"^{_SEMVER_NUMBER}\.{_SEMVER_NUMBER}\.{_SEMVER_NUMBER}"
|
|
rf"(?:-{_SEMVER_PRERELEASE_IDENTIFIER}"
|
|
rf"(?:\.{_SEMVER_PRERELEASE_IDENTIFIER})*)?"
|
|
rf"(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$"
|
|
)
|
|
STABLE_ID = re.compile(r"^[a-z0-9]+(?:[a-z0-9._-]*[a-z0-9])?$")
|
|
|
|
|
|
def diagnostic(path: str, message: str) -> Diagnostic:
|
|
return Diagnostic(RULE_ID, path, 1, message)
|
|
|
|
|
|
def nonempty_string(entry: dict[str, object], field: str) -> bool:
|
|
return isinstance(entry.get(field), str) and bool(str(entry[field]).strip())
|
|
|
|
|
|
def parse_date(value: object) -> dt.date | None:
|
|
if not isinstance(value, str):
|
|
return None
|
|
try:
|
|
return dt.date.fromisoformat(value)
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
def validate_ledger(root: Path, ledger_path: Path, tracked: set[str], today: dt.date) -> list[Diagnostic]:
|
|
data = load_json_file(ledger_path)
|
|
try:
|
|
label = ledger_path.relative_to(root).as_posix()
|
|
except ValueError:
|
|
label = str(ledger_path)
|
|
if not isinstance(data, dict) or data.get("version") != 1 or not isinstance(data.get("entries"), list):
|
|
return [diagnostic(label, "ledger must contain version 1 and an entries array")]
|
|
|
|
diagnostics: list[Diagnostic] = []
|
|
seen: set[str] = set()
|
|
for index, raw_entry in enumerate(data["entries"]):
|
|
prefix = f"entry {index + 1}"
|
|
if not isinstance(raw_entry, dict):
|
|
diagnostics.append(diagnostic(label, f"{prefix} must be an object"))
|
|
continue
|
|
entry: dict[str, object] = raw_entry
|
|
entry_id = entry.get("id")
|
|
if not isinstance(entry_id, str) or not STABLE_ID.fullmatch(entry_id):
|
|
diagnostics.append(diagnostic(label, f"{prefix} has an invalid stable id"))
|
|
entry_id = prefix
|
|
elif entry_id in seen:
|
|
diagnostics.append(diagnostic(label, f"duplicate compatibility id: {entry_id}"))
|
|
else:
|
|
seen.add(entry_id)
|
|
|
|
for field in ("reason", "owner", "removal_condition"):
|
|
if not nonempty_string(entry, field):
|
|
diagnostics.append(diagnostic(label, f"{entry_id}: required field {field} is empty or missing"))
|
|
|
|
diagnostics.extend(validate_introduction(label, str(entry_id), entry, today))
|
|
diagnostics.extend(validate_removal_target(label, str(entry_id), entry, today))
|
|
diagnostics.extend(validate_locator(root, label, str(entry_id), entry, tracked))
|
|
return diagnostics
|
|
|
|
|
|
def validate_introduction(
|
|
label: str, entry_id: str, entry: dict[str, object], today: dt.date
|
|
) -> list[Diagnostic]:
|
|
date_present = "introduced_on" in entry
|
|
version_present = "introduced_version" in entry
|
|
if date_present == version_present:
|
|
return [diagnostic(label, f"{entry_id}: set exactly one of introduced_on or introduced_version")]
|
|
if date_present:
|
|
introduced = parse_date(entry.get("introduced_on"))
|
|
if introduced is None:
|
|
return [diagnostic(label, f"{entry_id}: invalid introduced_on date")]
|
|
if introduced > today:
|
|
return [diagnostic(label, f"{entry_id}: introduced_on is in the future")]
|
|
elif not isinstance(entry.get("introduced_version"), str) or not SEMVER.fullmatch(
|
|
str(entry["introduced_version"])
|
|
):
|
|
return [diagnostic(label, f"{entry_id}: invalid introduced_version")]
|
|
return []
|
|
|
|
|
|
def validate_removal_target(
|
|
label: str, entry_id: str, entry: dict[str, object], today: dt.date
|
|
) -> list[Diagnostic]:
|
|
date_present = "target_removal_date" in entry
|
|
version_present = "target_removal_version" in entry
|
|
if date_present == version_present:
|
|
return [
|
|
diagnostic(
|
|
label,
|
|
f"{entry_id}: set exactly one of target_removal_date or target_removal_version",
|
|
)
|
|
]
|
|
if date_present:
|
|
target = parse_date(entry.get("target_removal_date"))
|
|
if target is None:
|
|
return [diagnostic(label, f"{entry_id}: invalid target_removal_date")]
|
|
if target < today:
|
|
return [diagnostic(label, f"{entry_id}: compatibility exception expired on {target.isoformat()}")]
|
|
elif not isinstance(entry.get("target_removal_version"), str) or not SEMVER.fullmatch(
|
|
str(entry["target_removal_version"])
|
|
):
|
|
return [diagnostic(label, f"{entry_id}: invalid target_removal_version")]
|
|
return []
|
|
|
|
|
|
def validate_locator(
|
|
root: Path,
|
|
label: str,
|
|
entry_id: str,
|
|
entry: dict[str, object],
|
|
tracked: set[str],
|
|
) -> list[Diagnostic]:
|
|
locator = entry.get("locator")
|
|
if not isinstance(locator, dict):
|
|
return [diagnostic(label, f"{entry_id}: locator must be an object")]
|
|
locator_path = locator.get("path")
|
|
marker = locator.get("marker")
|
|
if not isinstance(locator_path, str) or not locator_path:
|
|
return [diagnostic(label, f"{entry_id}: locator.path is required")]
|
|
if not isinstance(marker, str) or not marker:
|
|
return [diagnostic(label, f"{entry_id}: locator.marker is required")]
|
|
if locator_path not in tracked:
|
|
return [diagnostic(label, f"{entry_id}: referenced path is not tracked: {locator_path}")]
|
|
if marker not in read_text(root, locator_path):
|
|
return [diagnostic(label, f"{entry_id}: locator marker no longer exists in {locator_path}")]
|
|
return []
|