mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-01 12:32:19 +02:00
docs(discovery): publish provisional feature baseline
This commit is contained in:
+172
@@ -0,0 +1,172 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate the Odysseus public discovery documentation package."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
REPO_ROOT = ROOT.parents[1]
|
||||
CATALOG_PATH = ROOT / "feature-catalog.json"
|
||||
DOMAINS_DIR = ROOT / "domains"
|
||||
|
||||
EXPECTED_COMMIT = "d8a2059df8e53bc7275c45339849d14c8651e73c"
|
||||
ALLOWED_STATUSES = {
|
||||
"verified", "partial", "disabled", "experimental", "legacy",
|
||||
"dead-code-candidate", "unverified"
|
||||
}
|
||||
|
||||
LINE_RANGE_RE = re.compile(r"^L([1-9]\d*)-L([1-9]\d*)$")
|
||||
FEATURE_HEADING_RE = re.compile(
|
||||
r"^##\s+`?([A-Z][A-Z0-9]*-\d{3})`?\s+(?:—|-)\s+.+$",
|
||||
re.MULTILINE,
|
||||
)
|
||||
LINK_RE = re.compile(r"(?<!!)\[[^\]]*\]\(([^)]+)\)")
|
||||
FORBIDDEN_TERMS_RE = re.compile(
|
||||
r"(roadforge|kanban|matrix|owner link|editor link|github support|"
|
||||
r"private maintainer|OD-AUD-|[A-Z]{2,10}-AUD-\d+|TASK-\d+)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Validate Odysseus discovery docs.")
|
||||
parser.add_argument(
|
||||
"--repo-root",
|
||||
type=Path,
|
||||
default=REPO_ROOT,
|
||||
help="Path to Odysseus repository root.",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
def validate() -> int:
|
||||
args = parse_args()
|
||||
repo_root = args.repo_root.resolve()
|
||||
errors: list[str] = []
|
||||
|
||||
# 1. Validate Catalog JSON existence and content
|
||||
if not CATALOG_PATH.is_file():
|
||||
errors.append(f"Missing catalog file: {CATALOG_PATH}")
|
||||
print(f"Errors: {len(errors)}")
|
||||
for e in errors:
|
||||
print(f"ERROR: {e}")
|
||||
return 1
|
||||
|
||||
try:
|
||||
catalog = json.loads(CATALOG_PATH.read_text(encoding="utf-8"))
|
||||
except Exception as e:
|
||||
errors.append(f"Failed to parse catalog JSON: {e}")
|
||||
print(f"Errors: {len(errors)}")
|
||||
for err in errors:
|
||||
print(f"ERROR: {err}")
|
||||
return 1
|
||||
|
||||
if not isinstance(catalog, list):
|
||||
errors.append("feature-catalog.json must be a JSON array")
|
||||
return 1
|
||||
|
||||
feature_ids = [item.get("id") for item in catalog if isinstance(item, dict)]
|
||||
if len(feature_ids) != 79:
|
||||
errors.append(f"Expected 79 unique feature IDs, found {len(feature_ids)}")
|
||||
if len(feature_ids) != len(set(feature_ids)):
|
||||
errors.append("Duplicate feature IDs found in catalog JSON")
|
||||
|
||||
# Domain catalog counts
|
||||
catalog_domain_counts: dict[str, int] = {}
|
||||
for item in catalog:
|
||||
if not isinstance(item, dict):
|
||||
errors.append("Catalog item is not an object")
|
||||
continue
|
||||
fid = item.get("id", "<missing>")
|
||||
status = item.get("status")
|
||||
domain = item.get("domain", "").lower()
|
||||
catalog_domain_counts[domain] = catalog_domain_counts.get(domain, 0) + 1
|
||||
|
||||
if status not in ALLOWED_STATUSES:
|
||||
errors.append(f"{fid}: Invalid status '{status}'")
|
||||
|
||||
evidence_list = item.get("evidence")
|
||||
if not isinstance(evidence_list, list) or not evidence_list:
|
||||
errors.append(f"{fid}: Missing or empty evidence list")
|
||||
continue
|
||||
|
||||
for ev in evidence_list:
|
||||
if not isinstance(ev, dict):
|
||||
errors.append(f"{fid}: Evidence item is not an object")
|
||||
continue
|
||||
path_str = ev.get("path")
|
||||
lr_str = str(ev.get("line_range", ""))
|
||||
if not path_str or Path(path_str).is_absolute() or ".." in Path(path_str).parts:
|
||||
errors.append(f"{fid}: Unsafe or invalid path '{path_str}'")
|
||||
continue
|
||||
|
||||
# Check path exists in repo
|
||||
target_path = repo_root / path_str
|
||||
if not target_path.is_file():
|
||||
errors.append(f"{fid}: Referenced path '{path_str}' does not exist on disk")
|
||||
continue
|
||||
|
||||
# Check line range format & bounds
|
||||
m = LINE_RANGE_RE.fullmatch(lr_str)
|
||||
if not m:
|
||||
errors.append(f"{fid}: Invalid line range format '{lr_str}' for path '{path_str}'")
|
||||
continue
|
||||
|
||||
start, end = int(m.group(1)), int(m.group(2))
|
||||
lines_cnt = len(target_path.read_text(encoding="utf-8", errors="ignore").splitlines())
|
||||
if start > end or end > lines_cnt or start < 1:
|
||||
errors.append(
|
||||
f"{fid}: Line range '{lr_str}' exceeds file length ({lines_cnt} lines) in '{path_str}'"
|
||||
)
|
||||
|
||||
# 2. Check Domain Markdown files
|
||||
md_feature_ids: list[str] = []
|
||||
domain_files = sorted(DOMAINS_DIR.glob("*.md"))
|
||||
for df in domain_files:
|
||||
domain_name = df.stem.lower()
|
||||
content = df.read_text(encoding="utf-8")
|
||||
found_ids = FEATURE_HEADING_RE.findall(content)
|
||||
md_feature_ids.extend(found_ids)
|
||||
if len(found_ids) != catalog_domain_counts.get(domain_name, 0):
|
||||
errors.append(
|
||||
f"Domain '{domain_name}' count mismatch: catalog has {catalog_domain_counts.get(domain_name, 0)}, Markdown has {len(found_ids)}"
|
||||
)
|
||||
|
||||
if sorted(md_feature_ids) != sorted(feature_ids):
|
||||
errors.append("Markdown domain feature IDs do not match catalog JSON feature IDs")
|
||||
|
||||
# 3. Check for forbidden/private terms, sensitive credentials, and broken links across all docs
|
||||
for md_file in ROOT.rglob("*.md"):
|
||||
rel_md = md_file.relative_to(ROOT)
|
||||
content = md_file.read_text(encoding="utf-8")
|
||||
|
||||
# Forbidden terms scan
|
||||
forbidden_matches = FORBIDDEN_TERMS_RE.findall(content)
|
||||
if forbidden_matches:
|
||||
errors.append(
|
||||
f"{rel_md}: Found forbidden/internal terms: {set(forbidden_matches)}"
|
||||
)
|
||||
|
||||
# Broken local link check
|
||||
for target in LINK_RE.findall(content):
|
||||
target = target.strip().strip("<>")
|
||||
if not target or target.startswith(("#", "http://", "https://", "mailto:")):
|
||||
continue
|
||||
target_path = target.split("#", 1)[0]
|
||||
resolved = (md_file.parent / target_path).resolve()
|
||||
if not resolved.exists():
|
||||
errors.append(f"{rel_md}: Broken local link '{target}'")
|
||||
|
||||
print(f"Catalog Features: {len(catalog)}")
|
||||
print(f"Domain Files: {len(domain_files)}")
|
||||
print(f"Validation Errors: {len(errors)}")
|
||||
for err in errors:
|
||||
print(f"ERROR: {err}")
|
||||
|
||||
return 1 if errors else 0
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(validate())
|
||||
Reference in New Issue
Block a user