Newer
Older
gnexus-book / server / app / freshness.py
from __future__ import annotations

from datetime import date, datetime, timedelta
from pathlib import Path

from .config import Settings
from .docs_repository import DocsRepository


REQUIRED_FRONTMATTER = {
    "owner",
    "status",
    "last_reviewed",
    "review_interval",
    "confidence",
    "source_of_truth",
}


def build_freshness_report(settings: Settings) -> dict[str, object]:
    repo = DocsRepository(settings)
    docs = repo.list_docs()
    issues: list[dict[str, str]] = []

    for doc in docs:
        path = str(doc["path"])
        if path == "README.md" or path.startswith("server/"):
            continue
        frontmatter = doc.get("frontmatter") or {}
        if not isinstance(frontmatter, dict):
            continue

        missing = sorted(REQUIRED_FRONTMATTER - set(frontmatter))
        if missing:
            issues.append(
                {
                    "path": path,
                    "severity": "warning",
                    "code": "missing-frontmatter-fields",
                    "message": "Missing frontmatter fields: " + ", ".join(missing),
                }
            )
            continue

        stale = _is_stale(str(frontmatter["last_reviewed"]), str(frontmatter["review_interval"]))
        if stale:
            issues.append(
                {
                    "path": path,
                    "severity": "warning",
                    "code": "stale-document",
                    "message": "Document is past its review interval",
                }
            )

    missing_docs = _find_missing_inventory_docs(settings.repo_root)
    for inventory_file, target in missing_docs:
        issues.append(
            {
                "path": inventory_file,
                "severity": "error",
                "code": "missing-doc-link-target",
                "message": f"docs target does not exist: {target}",
            }
        )

    return {
        "status": "ok" if not issues else "issues",
        "document_count": len(docs),
        "issue_count": len(issues),
        "issues": issues,
    }


def _is_stale(last_reviewed: str, interval: str) -> bool:
    try:
        reviewed = datetime.strptime(last_reviewed, "%Y-%m-%d").date()
    except ValueError:
        return True

    if not interval.endswith("d"):
        return True
    try:
        days = int(interval[:-1])
    except ValueError:
        return True

    return reviewed + timedelta(days=days) < date.today()


def _find_missing_inventory_docs(repo_root: Path) -> list[tuple[str, str]]:
    missing: list[tuple[str, str]] = []
    inventory_dir = repo_root / "40-inventory"
    for inventory_file in inventory_dir.glob("*.yml"):
        for line in inventory_file.read_text(encoding="utf-8").splitlines():
            stripped = line.strip()
            if not stripped.startswith("docs: "):
                continue
            rel = stripped.split(": ", 1)[1].strip().strip("\"'")
            target = (inventory_file.parent / rel).resolve()
            if not target.exists():
                missing.append((inventory_file.relative_to(repo_root).as_posix(), rel))
    return missing