diff --git a/doc_steward/freshness_validator.py b/doc_steward/freshness_validator.py index 0114d1d0..1a94d980 100644 --- a/doc_steward/freshness_validator.py +++ b/doc_steward/freshness_validator.py @@ -10,11 +10,22 @@ Mode is controlled by env var LIFEOS_DOC_FRESHNESS_MODE (default: off) """ + import json import os +import re from datetime import datetime, timedelta, timezone from pathlib import Path +import yaml + +ENTRYPOINT_REQUIRED_LINKS = ( + "docs/INDEX.md", + "docs/08_manuals/LifeOS_Operator_Onboarding.md", + "docs/11_admin/LIFEOS_STATE.md", + "docs/00_foundations/LifeOS Target Architecture v2.3c.md", +) + def get_freshness_mode() -> str: """Get freshness mode from environment.""" @@ -25,6 +36,171 @@ def get_freshness_mode() -> str: return mode +def _read_text(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +def _normalize_markdown_link_target(target: str) -> str: + return target.replace("%20", " ").strip("./") + + +def _markdown_links(text: str) -> set[str]: + links = set() + for target in re.findall(r"\[[^\]]+\]\(([^)]+)\)", text): + links.add(_normalize_markdown_link_target(target.split("#", 1)[0])) + return links + + +def _authority_registry_declares(path: str, registry_text: str, authority: str) -> bool: + """Return whether the YAML authority registry declares path with authority.""" + try: + registry = yaml.safe_load(registry_text) or {} + except yaml.YAMLError: + return False + if not isinstance(registry, dict): + return False + doc_groups = registry.get("doc_groups", []) + if not isinstance(doc_groups, list): + return False + for group in doc_groups: + if not isinstance(group, dict): + continue + paths = group.get("paths", []) + if not isinstance(paths, list): + continue + if group.get("authority") == authority and path in paths: + return True + return False + + +def check_entrypoint_freshness(repo_root: str | Path) -> list[dict[str, object]]: + """Detect low-noise README/operator entrypoint drift for maintenance sweeps. + + The check is read-only and issue-creator friendly: each finding contains stable + fields suitable for a single deduped sweep issue. It does not decide semantic + authority disputes or mutate docs. + """ + repo_path = Path(repo_root).resolve() + findings: list[dict[str, object]] = [] + + readme_path = repo_path / "README.md" + state_path = repo_path / "docs" / "11_admin" / "LIFEOS_STATE.md" + registry_path = repo_path / "config" / "docs" / "authority_registry.yaml" + + required_paths = (readme_path, state_path, registry_path) + missing = [str(p.relative_to(repo_path)) for p in required_paths if not p.exists()] + if missing: + findings.append( + { + "id": "entrypoint-required-file-missing", + "severity": "warning", + "paths": missing, + "evidence": "Required entrypoint freshness input file is missing.", + "recommended_recovery": ( + "Restore the missing file before running README entrypoint drift checks." + ), + "authority_class": "canonical", + } + ) + return findings + + readme = _read_text(readme_path) + state = _read_text(state_path) + registry = _read_text(registry_path) + links = _markdown_links(readme) + + missing_links = [target for target in ENTRYPOINT_REQUIRED_LINKS if target not in links] + if missing_links: + findings.append( + { + "id": "entrypoint-read-order-missing-links", + "severity": "warning", + "paths": ["README.md"], + "evidence": f"README operator read-order is missing: {', '.join(missing_links)}", + "recommended_recovery": "Add the missing canonical read-order links to README.md.", + "authority_class": "canonical", + } + ) + + stale_status_markers = ( + "Phase 4 Preparation", + "Tier-3 Authorized", + "Phase 4 — Tier-3", + ) + if any(marker in readme for marker in stale_status_markers) and "COO Bootstrap" in state: + findings.append( + { + "id": "entrypoint-readme-status-contradicts-lifeos-state", + "severity": "warning", + "paths": ["README.md", "docs/11_admin/LIFEOS_STATE.md"], + "evidence": ( + "README status still describes old Phase 4/Tier-3 state while " + "LIFEOS_STATE records later COO bootstrap/live COO state." + ), + "recommended_recovery": ( + "Refresh README current-status wording from LIFEOS_STATE.md " + "before enabling freshness automation." + ), + "authority_class": "canonical", + } + ) + + if "derived" not in readme.lower() or "Repo canon wins" not in readme: + findings.append( + { + "id": "entrypoint-derived-surface-boundary-missing", + "severity": "warning", + "paths": ["README.md", "docs/LifeOS_Strategic_Corpus.md"], + "evidence": ( + "README does not clearly state that strategic corpus/wiki " + "surfaces are derived and repo canon wins on conflict." + ), + "recommended_recovery": ( + "State the canonical-vs-derived conflict rule in README.md." + ), + "authority_class": "canonical/derived", + } + ) + + if not _authority_registry_declares("docs/INDEX.md", registry, "canonical"): + findings.append( + { + "id": "entrypoint-index-authority-registry-mismatch", + "severity": "warning", + "paths": ["config/docs/authority_registry.yaml", "docs/INDEX.md"], + "evidence": ( + "authority_registry.yaml does not declare docs/INDEX.md " + "as canonical root navigation." + ), + "recommended_recovery": ( + "Reconcile docs/INDEX.md authority classification before " + "relying on README read-order checks." + ), + "authority_class": "canonical", + } + ) + + if not _authority_registry_declares("docs/LifeOS_Strategic_Corpus.md", registry, "derived"): + findings.append( + { + "id": "entrypoint-corpus-authority-registry-mismatch", + "severity": "warning", + "paths": ["config/docs/authority_registry.yaml", "docs/LifeOS_Strategic_Corpus.md"], + "evidence": ( + "authority_registry.yaml does not declare " + "docs/LifeOS_Strategic_Corpus.md as derived." + ), + "recommended_recovery": ( + "Reconcile strategic corpus authority classification before " + "relying on README derived-surface checks." + ), + "authority_class": "derived", + } + ) + + return findings + + def check_freshness(repo_root: str) -> tuple[list[str], list[str]]: """ Check doc freshness and contradictions. @@ -66,10 +242,7 @@ def check_freshness(repo_root: str) -> tuple[list[str], list[str]]: if age > sla_threshold: hours_stale = int(age.total_seconds() / 3600) - msg = ( - f"Runtime status file is stale: {status_file} " - f"(age: {hours_stale}h, SLA: 24h)" - ) + msg = f"Runtime status file is stale: {status_file} (age: {hours_stale}h, SLA: 24h)" if mode == "warn": warnings.append(msg) elif mode == "block": @@ -99,10 +272,7 @@ def check_freshness(repo_root: str) -> tuple[list[str], list[str]]: refs = contradiction.get("refs", []) refs_str = ", ".join(refs) if refs else "no references" - full_msg = ( - f"Contradiction [{contradiction_id}]: {message} " - f"(refs: {refs_str})" - ) + full_msg = f"Contradiction [{contradiction_id}]: {message} (refs: {refs_str})" if severity == "block" and mode == "block": # Blocking contradiction in block mode diff --git a/tests_doc/test_freshness_validator.py b/tests_doc/test_freshness_validator.py index 8eeb4876..47e46b70 100644 --- a/tests_doc/test_freshness_validator.py +++ b/tests_doc/test_freshness_validator.py @@ -1,14 +1,230 @@ """Tests for freshness validator.""" + import json import os -import tempfile from datetime import datetime, timedelta, timezone from pathlib import Path from unittest import mock -import pytest +from doc_steward.freshness_validator import ( + check_entrypoint_freshness, + check_freshness, + get_freshness_mode, +) + + +def _write_entrypoint_fixture( + root: Path, readme: str | None = None, registry: str | None = None +) -> None: + (root / "docs" / "11_admin").mkdir(parents=True) + (root / "docs" / "08_manuals").mkdir(parents=True) + (root / "docs" / "00_foundations").mkdir(parents=True) + (root / "config" / "docs").mkdir(parents=True) + (root / "docs" / "INDEX.md").write_text("# Index\n", encoding="utf-8") + (root / "docs" / "LifeOS_Strategic_Corpus.md").write_text( + "# Derived corpus\n", encoding="utf-8" + ) + (root / "docs" / "11_admin" / "LIFEOS_STATE.md").write_text( + "# LifeOS State\n\n## COO Bootstrap Campaign\nLive COO operational.\n", + encoding="utf-8", + ) + (root / "README.md").write_text( + readme + or """# LifeOS + +**Current Status**: Live COO operations. + +Repo canon wins on conflict. The strategic corpus and wiki are derived. + +1. [docs/INDEX.md](docs/INDEX.md) +2. [onboarding](docs/08_manuals/LifeOS_Operator_Onboarding.md) +3. [state](docs/11_admin/LIFEOS_STATE.md) +4. [architecture](docs/00_foundations/LifeOS%20Target%20Architecture%20v2.3c.md) +""", + encoding="utf-8", + ) + (root / "config" / "docs" / "authority_registry.yaml").write_text( + registry + or """doc_groups: + - id: canonical-root-navigation + authority: canonical + paths: + - docs/INDEX.md + - id: derived-strategic-corpus + authority: derived + paths: + - docs/LifeOS_Strategic_Corpus.md +""", + encoding="utf-8", + ) + + +def test_entrypoint_freshness_clean_fixture_has_no_findings(tmp_path): + _write_entrypoint_fixture(tmp_path) + + assert check_entrypoint_freshness(tmp_path) == [] + + +def test_entrypoint_freshness_detects_missing_read_order_links(tmp_path): + _write_entrypoint_fixture( + tmp_path, + readme="# LifeOS\n\nRepo canon wins on conflict. The strategic corpus is derived.\n", + ) + + findings = check_entrypoint_freshness(tmp_path) + + assert {finding["id"] for finding in findings} >= {"entrypoint-read-order-missing-links"} + missing = next(f for f in findings if f["id"] == "entrypoint-read-order-missing-links") + assert "docs/INDEX.md" in str(missing["evidence"]) + assert missing["paths"] == ["README.md"] + + +def test_entrypoint_freshness_detects_stale_phase4_status(tmp_path): + _write_entrypoint_fixture( + tmp_path, + readme="""# LifeOS + +**Current Status**: Phase 4 Preparation — Tier-3 Authorized. + +Repo canon wins on conflict. The strategic corpus is derived. + +[docs/INDEX.md](docs/INDEX.md) +[onboarding](docs/08_manuals/LifeOS_Operator_Onboarding.md) +[state](docs/11_admin/LIFEOS_STATE.md) +[architecture](docs/00_foundations/LifeOS%20Target%20Architecture%20v2.3c.md) +""", + ) + + findings = check_entrypoint_freshness(tmp_path) + + assert any(f["id"] == "entrypoint-readme-status-contradicts-lifeos-state" for f in findings) + + +def test_entrypoint_freshness_detects_authority_registry_mismatch(tmp_path): + _write_entrypoint_fixture( + tmp_path, + registry="""doc_groups: + - id: wrong-root-navigation + authority: derived + paths: + - docs/INDEX.md +""", + ) + + findings = check_entrypoint_freshness(tmp_path) + + ids = {finding["id"] for finding in findings} + assert "entrypoint-index-authority-registry-mismatch" in ids + assert "entrypoint-corpus-authority-registry-mismatch" in ids + + +def test_entrypoint_freshness_parses_quoted_yaml_registry_paths(tmp_path): + _write_entrypoint_fixture( + tmp_path, + registry="""doc_groups: + - id: canonical-root-navigation + authority: "canonical" + paths: + - "docs/INDEX.md" + - id: derived-strategic-corpus + authority: "derived" + paths: + - "docs/LifeOS_Strategic_Corpus.md" +""", + ) + + assert check_entrypoint_freshness(tmp_path) == [] + + +def test_entrypoint_freshness_malformed_registry_fails_closed(tmp_path): + _write_entrypoint_fixture(tmp_path, registry="doc_groups: [unterminated") + + findings = check_entrypoint_freshness(tmp_path) + + ids = {finding["id"] for finding in findings} + assert "entrypoint-index-authority-registry-mismatch" in ids + assert "entrypoint-corpus-authority-registry-mismatch" in ids + + +def test_entrypoint_freshness_non_list_registry_groups_fail_closed(tmp_path): + _write_entrypoint_fixture(tmp_path, registry="doc_groups: 1") + + findings = check_entrypoint_freshness(tmp_path) + + ids = {finding["id"] for finding in findings} + assert "entrypoint-index-authority-registry-mismatch" in ids + assert "entrypoint-corpus-authority-registry-mismatch" in ids -from doc_steward.freshness_validator import check_freshness, get_freshness_mode + +def test_entrypoint_freshness_non_mapping_registry_root_fails_closed(tmp_path): + _write_entrypoint_fixture(tmp_path, registry="- not-a-mapping") + + findings = check_entrypoint_freshness(tmp_path) + + ids = {finding["id"] for finding in findings} + assert "entrypoint-index-authority-registry-mismatch" in ids + assert "entrypoint-corpus-authority-registry-mismatch" in ids + + +def test_entrypoint_freshness_non_mapping_registry_group_fails_closed(tmp_path): + _write_entrypoint_fixture( + tmp_path, + registry="""doc_groups: + - not-a-mapping +""", + ) + + findings = check_entrypoint_freshness(tmp_path) + + ids = {finding["id"] for finding in findings} + assert "entrypoint-index-authority-registry-mismatch" in ids + assert "entrypoint-corpus-authority-registry-mismatch" in ids + + +def test_entrypoint_freshness_non_list_registry_paths_fail_closed(tmp_path): + _write_entrypoint_fixture( + tmp_path, + registry="""doc_groups: + - id: bad-paths + authority: canonical + paths: docs/INDEX.md +""", + ) + + findings = check_entrypoint_freshness(tmp_path) + + assert any(f["id"] == "entrypoint-index-authority-registry-mismatch" for f in findings) + + +def test_entrypoint_freshness_detects_missing_derived_boundary(tmp_path): + _write_entrypoint_fixture( + tmp_path, + readme="""# LifeOS + +**Current Status**: Live COO operations. + +1. [docs/INDEX.md](docs/INDEX.md) +2. [onboarding](docs/08_manuals/LifeOS_Operator_Onboarding.md) +3. [state](docs/11_admin/LIFEOS_STATE.md) +4. [architecture](docs/00_foundations/LifeOS%20Target%20Architecture%20v2.3c.md) +""", + ) + + findings = check_entrypoint_freshness(tmp_path) + + assert any(f["id"] == "entrypoint-derived-surface-boundary-missing" for f in findings) + + +def test_entrypoint_freshness_missing_required_input_returns_single_finding(tmp_path): + findings = check_entrypoint_freshness(tmp_path) + + assert len(findings) == 1 + assert findings[0]["id"] == "entrypoint-required-file-missing" + assert findings[0]["paths"] == [ + "README.md", + "docs/11_admin/LIFEOS_STATE.md", + "config/docs/authority_registry.yaml", + ] def test_freshness_mode_off_by_default(): @@ -116,12 +332,7 @@ def test_freshness_check_contradictions_warn_severity(tmp_path): status_data = { "contradictions": [ - { - "id": "C1", - "severity": "warn", - "message": "Test warning", - "refs": ["ref1.md"] - } + {"id": "C1", "severity": "warn", "message": "Test warning", "refs": ["ref1.md"]} ] } status_file.write_text(json.dumps(status_data)) @@ -142,12 +353,7 @@ def test_freshness_check_contradictions_block_severity_warn_mode(tmp_path): status_data = { "contradictions": [ - { - "id": "C1", - "severity": "block", - "message": "Test blocking issue", - "refs": ["ref1.md"] - } + {"id": "C1", "severity": "block", "message": "Test blocking issue", "refs": ["ref1.md"]} ] } status_file.write_text(json.dumps(status_data)) @@ -171,7 +377,7 @@ def test_freshness_check_contradictions_block_severity_block_mode(tmp_path): "id": "C1", "severity": "block", "message": "Test blocking issue", - "refs": ["ref1.md", "ref2.md"] + "refs": ["ref1.md", "ref2.md"], } ] }