"""Validate IBMW public documentation links, authorities, and repository policy."""
from __future__ import annotations
import argparse
import re
import subprocess
import sys
from pathlib import Path
from urllib.parse import unquote, urlsplit
REQUIRED_DOCUMENTS = (
"README.md",
"README.zh.md",
"CONTRIBUTING.md",
"CHANGELOG.md",
"SECURITY.md",
"SUPPORT.md",
"CODE_OF_CONDUCT.md",
"LICENSE",
"NOTICE",
"docs/README.md",
"docs/guides/user-guide.md",
"docs/reference/api-reference.md",
"docs/reference/compatibility.md",
"docs/reference/support-matrix.md",
"docs/contributing/testing.md",
"docs/contributing/architecture-rules.md",
"docs/ecosystem/README.md",
"docs/releases/0.1.0.md",
)
PROTECTED_DOCUMENTS = REQUIRED_DOCUMENTS
def _absolute_path(*parts: str) -> str:
return "/" + "/".join(parts)
PRIVATE_HOME_PATH = _absolute_path("home", "oee")
PRIVATE_GO_ROOT = _absolute_path("go") + "/"
PRIVATE_TEMP_PATH = _absolute_path("tmp", "opencode")
STALE_PRIVATE_REFERENCES = (
("private home path", re.compile(re.escape(PRIVATE_HOME_PATH) + r"(?:/|\b)")),
("private Go root path", re.compile(re.escape(PRIVATE_GO_ROOT))),
("private temporary path", re.compile(re.escape(PRIVATE_TEMP_PATH) + r"(?:/|\b)")),
("private .sisyphus path", re.compile(r"\.sisyphus(?:[/\\]|\b)")),
("verification notes", re.compile(r"\bIBMW_VERIFICATION_NOTES(?:_V\d+)?(?:\.md)?\b", re.I)),
("project status", re.compile(r"\bPROJECT_STATUS(?:\.md)?\b", re.I)),
("internal refactor plan", re.compile(r"\bREFACTOR_PLAN_Y9(?:\.md)?\b", re.I)),
("private documentation data", re.compile(r"(?:^|[(/`'\"\s])docs/data/", re.I)),
)
PRIVATE_REFERENCE_FIXTURE_MARKER = "<!-- docs-governance: allow-private-reference -->"
INLINE_LINK = re.compile(r"!?\[[^\]\n]*\]\((<[^>]+>|[^\s)]+)")
REFERENCE_LINK = re.compile(r"^\s{0,3}\[[^\]]+\]:\s*(\S+)")
FENCE = re.compile(r"^\s*(`{3,}|~{3,})")
INLINE_CODE = re.compile(r"(`+)(.*?)\1")
def git(repo: Path, args: list[str]) -> subprocess.CompletedProcess[str]:
return subprocess.run(
["git", "-C", str(repo), *args],
capture_output=True,
text=True,
check=False,
)
def markdown_files(repo: Path) -> tuple[list[Path], list[str]]:
result = git(
repo,
["ls-files", "--cached", "--others", "--exclude-standard", "--", "*.md"],
)
if result.returncode != 0:
return [], [f"git ls-files failed: {result.stderr.strip()}"]
paths = sorted({repo / line for line in result.stdout.splitlines() if line})
return [path for path in paths if path.is_file()], []
def link_destination(raw: str) -> str:
value = raw.strip()
if value.startswith("<") and ">" in value:
return value[1 : value.index(">")]
return value.split(maxsplit=1)[0] if value else ""
def local_link_target(source: Path, destination: str) -> Path | None:
destination = destination.strip()
if not destination or destination.startswith("#"):
return None
parsed = urlsplit(destination)
if parsed.scheme.lower() in {"http", "https", "mailto"} or parsed.netloc:
return None
path_text = unquote(parsed.path)
if not path_text or path_text.startswith("/"):
return None
return source.parent / path_text
def links_in(path: Path) -> list[tuple[int, str]]:
links: list[tuple[int, str]] = []
fence_marker = ""
for line_number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
fence = FENCE.match(line)
if fence:
marker = fence.group(1)[0]
if not fence_marker:
fence_marker = marker
elif marker == fence_marker:
fence_marker = ""
continue
if fence_marker:
continue
link_line = INLINE_CODE.sub("", line)
for match in INLINE_LINK.finditer(link_line):
links.append((line_number, link_destination(match.group(1))))
reference = REFERENCE_LINK.match(line)
if reference:
links.append((line_number, link_destination(reference.group(1))))
return links
def check_required(repo: Path) -> list[str]:
return [
f"required document is missing: {name}"
for name in REQUIRED_DOCUMENTS
if not (repo / name).is_file()
]
def check_links(repo: Path) -> list[str]:
files, errors = markdown_files(repo)
for source in files:
try:
links = links_in(source)
except (OSError, UnicodeError) as exc:
errors.append(f"cannot read {source.relative_to(repo)}: {exc}")
continue
for line_number, destination in links:
target = local_link_target(source, destination)
if target is not None and not target.exists():
errors.append(
f"broken local link: {source.relative_to(repo)}:{line_number}: {destination}"
)
return errors
def check_stale_private_references(repo: Path) -> list[str]:
files, errors = markdown_files(repo)
for path in files:
relative = path.relative_to(repo)
try:
lines = path.read_text(encoding="utf-8").splitlines()
except (OSError, UnicodeError) as exc:
errors.append(f"cannot read {relative}: {exc}")
continue
for line_number, line in enumerate(lines, 1):
if PRIVATE_REFERENCE_FIXTURE_MARKER in line:
continue
for label, pattern in STALE_PRIVATE_REFERENCES:
if pattern.search(line):
errors.append(
f"stale private reference ({label}): {relative}:{line_number}"
)
return errors
def check_all(repo: Path) -> list[str]:
return check_required(repo) + check_links(repo) + check_stale_private_references(repo)
def check_deletions(repo: Path, base: str) -> list[str]:
result = git(repo, ["diff", "--name-status", "--find-renames", base, "--"])
if result.returncode != 0:
return [f"git diff failed for base {base!r}: {result.stderr.strip()}"]
protected = set(PROTECTED_DOCUMENTS)
errors: list[str] = []
for line in result.stdout.splitlines():
fields = line.split("\t")
status = fields[0] if fields else ""
removed = fields[1] if status.startswith("R") and len(fields) >= 3 else (
fields[1] if status == "D" and len(fields) >= 2 else ""
)
if removed in protected:
errors.append(f"protected document deleted or renamed: {removed}")
return errors
def parser() -> argparse.ArgumentParser:
cli = argparse.ArgumentParser(description=__doc__)
mode = cli.add_mutually_exclusive_group(required=True)
mode.add_argument("--check", action="store_true", help="run all documentation checks")
mode.add_argument(
"--check-deletions",
nargs="?",
const="HEAD",
metavar="BASE",
help="reject protected-document deletion or rename since BASE (default: HEAD)",
)
cli.add_argument(
"--repo-root",
type=Path,
default=Path(__file__).resolve().parents[1],
help="repository root (default: parent of scripts/)",
)
return cli
def main() -> int:
args = parser().parse_args()
repo = args.repo_root.resolve()
if not (repo / ".git").exists():
print(f"ERROR: not a Git repository: {repo}", file=sys.stderr)
return 2
errors = check_all(repo) if args.check else check_deletions(repo, args.check_deletions)
if errors:
for error in errors:
print(f"ERROR: {error}", file=sys.stderr)
print(f"docs governance: FAIL ({len(errors)} issue(s))", file=sys.stderr)
return 1
print("docs governance: PASS")
return 0
if __name__ == "__main__":
raise SystemExit(main())