"""Validate the maintained DocForge Markdown documentation graph.""" from __future__ import annotations import argparse import html import json import re import stat import sys import tomllib import unicodedata from collections import deque from collections.abc import Iterable, Sequence from dataclasses import dataclass from pathlib import Path from typing import Protocol, cast from urllib.parse import unquote, urlsplit from jsonschema import Draft202012Validator from jsonschema.exceptions import ValidationError from markdown_it import MarkdownIt from markdown_it.token import Token REPOSITORY_ROOT = Path(__file__).resolve().parents[1] DEFAULT_PENDING_INVENTORY = Path("tools/docs_pending_m4_pages.txt") COMMAND_REFERENCE = Path("docs/COMMAND_REFERENCE.md") COMMAND_REFERENCE_H1 = "DocForge command reference" COMMAND_REFERENCE_NOTICE = ( "> Generated from the live CLI parser and MCP registrations. Do not edit this file by hand." ) REFERENCE_ADAPTER_SCHEMA = Path("schemas/reference-adapter.schema.json") MAX_MARKDOWN_PAGES = 256 MAX_PAGE_BYTES = 2_000_000 MAX_TOTAL_BYTES = 20_000_000 MAX_DIAGNOSTICS = 100 REQUIRED_MILESTONE_4_PAGES = ( Path("README.md"), Path("docs/ADAPTER_AUTHORING_GUIDE.md"), Path("docs/AGENT_INTEGRATION.md"), Path("docs/APPLICATION_DECISION.md"), Path("docs/COMMAND_REFERENCE.md"), Path("docs/COMPATIBILITY.md"), Path("docs/CONTRACT.md"), Path("docs/CORE_CONCEPTS_AND_AUTHORITY.md"), Path("docs/INCREMENTAL_INDEXING.md"), Path("docs/LEGACY_AND_NO_AST.md"), Path("docs/MCP_CONTRACT.md"), Path("docs/MIGRATING_FROM_V1.md"), Path("docs/MILESTONE_4_BASELINE.md"), Path("docs/MILESTONE_4_CLOSEOUT.md"), Path("docs/NEW_PROJECT_QUICKSTART.md"), Path("docs/POLICY_PRECEDENCE.md"), Path("docs/PROJECT_DESCRIPTOR.md"), Path("docs/PROJECT_ONBOARDING.md"), Path("docs/RECOVERY_AND_PERFORMANCE.md"), Path("docs/REFERENCE_ADAPTERS.md"), Path("docs/RENDERING_AND_VISUALIZATION.md"), Path("docs/SECURITY.md"), Path("docs/USER_MANUAL.md"), Path("docs/VIEWER_MANAGER.md"), ) _HISTORICAL_RECORD = re.compile(r"^docs/MILESTONE_[0-3]_(?:BASELINE|CLOSEOUT)\.md$") _HTML_ID = re.compile( r"""\bid\s*=\s*(?:"([^"]+)"|'([^']+)')""", re.IGNORECASE, ) _REFERENCE_ADAPTER_KEY = re.compile(r"(?m)^(?:language|source_roots|compilation_database)\s*=") _URI_SCHEME = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*:") @dataclass(frozen=True, order=True) class Diagnostic: """One deterministic documentation validation failure.""" path: str line: int code: str message: str def render(self) -> str: """Render one bounded compiler-style diagnostic.""" return f"{self.path}:{self.line}: {self.code}: {self.message}" @dataclass(frozen=True) class DocumentationPolicy: """Repository-relative pages and temporary inventory accepted by one run.""" required_pages: tuple[Path, ...] = REQUIRED_MILESTONE_4_PAGES pending_inventory: Path | None = DEFAULT_PENDING_INVENTORY @dataclass(frozen=True) class DocumentationReport: """Bounded deterministic result from one complete documentation check.""" diagnostics: tuple[Diagnostic, ...] omitted_diagnostics: int markdown_pages: int pending_pages: int reference_adapter_examples: int @property def ok(self) -> bool: """Return whether every documentation invariant passed.""" return not self.diagnostics and self.omitted_diagnostics == 0 @dataclass(frozen=True) class _Page: relative: Path source: str tokens: tuple[Token, ...] anchors: frozenset[str] class _Diagnostics: def __init__(self) -> None: self._items: list[Diagnostic] = [] self._omitted = 0 def add(self, path: Path | str, line: int, code: str, message: str) -> None: relative = path.as_posix() if isinstance(path, Path) else path item = Diagnostic(relative, max(1, line), code, message) if len(self._items) < MAX_DIAGNOSTICS: self._items.append(item) else: self._omitted += 1 def result(self) -> tuple[tuple[Diagnostic, ...], int]: return tuple(sorted(self._items)), self._omitted class _Validator(Protocol): def iter_errors(self, instance: object) -> Iterable[ValidationError]: ... def check_documentation( repository_root: Path, *, policy: DocumentationPolicy | None = None, ) -> DocumentationReport: """Check one repository's maintained Markdown pages without modifying it.""" root = repository_root.resolve(strict=True) if not root.is_dir(): raise ValueError(f"repository root is not a directory: {root}") effective_policy = policy or DocumentationPolicy() diagnostics = _Diagnostics() parser = MarkdownIt("commonmark", {"html": True, "typographer": False}) page_paths = _discover_pages(root, diagnostics) pages = _load_pages(root, page_paths, parser, diagnostics) pending = _load_pending_inventory(root, effective_policy, diagnostics) _check_required_inventory( root, effective_policy.required_pages, pending, diagnostics, ) _check_h1s(pages, diagnostics) link_graph = _check_links(root, pages, parser, diagnostics) _check_reachability(pages, link_graph, diagnostics) reference_examples = _check_reference_adapter_examples(root, pages, diagnostics) _check_command_reference(pages, diagnostics) items, omitted = diagnostics.result() return DocumentationReport( diagnostics=items, omitted_diagnostics=omitted, markdown_pages=len(pages), pending_pages=len(pending), reference_adapter_examples=reference_examples, ) def _discover_pages(root: Path, diagnostics: _Diagnostics) -> tuple[Path, ...]: candidates = [Path("README.md")] docs = root / "docs" if not docs.is_dir(): diagnostics.add("docs", 1, "DOC001", "maintained documentation directory is missing") return tuple(candidates) candidates.extend( path.relative_to(root) for path in docs.rglob("*.md") if not path.is_symlink() and path.is_file() ) unique = tuple(sorted(set(candidates), key=Path.as_posix)) if len(unique) > MAX_MARKDOWN_PAGES: diagnostics.add( "docs", 1, "DOC002", f"found {len(unique)} Markdown pages; limit is {MAX_MARKDOWN_PAGES}", ) return unique[:MAX_MARKDOWN_PAGES] return unique def _load_pages( root: Path, paths: tuple[Path, ...], parser: MarkdownIt, diagnostics: _Diagnostics, ) -> dict[Path, _Page]: pages: dict[Path, _Page] = {} total_bytes = 0 for relative in paths: absolute = root / relative try: metadata = absolute.lstat() except FileNotFoundError: diagnostics.add(relative, 1, "DOC003", "maintained Markdown page is missing") continue if not stat.S_ISREG(metadata.st_mode): diagnostics.add(relative, 1, "DOC004", "maintained Markdown page is not a regular file") continue if metadata.st_size > MAX_PAGE_BYTES: diagnostics.add( relative, 1, "DOC005", f"page is {metadata.st_size} bytes; limit is {MAX_PAGE_BYTES}", ) continue total_bytes += metadata.st_size if total_bytes > MAX_TOTAL_BYTES: diagnostics.add( relative, 1, "DOC006", f"total Markdown input exceeds {MAX_TOTAL_BYTES} bytes", ) break try: source = absolute.read_text(encoding="utf-8") except UnicodeDecodeError: diagnostics.add(relative, 1, "DOC007", "page is not valid UTF-8") continue tokens = tuple(parser.parse(source)) pages[relative] = _Page( relative=relative, source=source, tokens=tokens, anchors=_anchors(tokens), ) return pages def _load_pending_inventory( root: Path, policy: DocumentationPolicy, diagnostics: _Diagnostics, ) -> frozenset[Path]: inventory = policy.pending_inventory if inventory is None: return frozenset() if inventory.is_absolute(): diagnostics.add( inventory.as_posix(), 1, "DOC008", "pending inventory path must be repository-relative", ) return frozenset() absolute = root / inventory try: lines = absolute.read_text(encoding="utf-8").splitlines() except FileNotFoundError: diagnostics.add(inventory, 1, "DOC009", "pending inventory file is missing") return frozenset() except UnicodeDecodeError: diagnostics.add(inventory, 1, "DOC010", "pending inventory is not valid UTF-8") return frozenset() required = set(policy.required_pages) pending: set[Path] = set() for line_number, raw in enumerate(lines, start=1): value = raw.strip() if not value or value.startswith("#"): continue path = Path(value) if path.is_absolute() or ".." in path.parts or path.as_posix() != value: diagnostics.add( inventory, line_number, "DOC011", f"unsafe pending inventory path: {value!r}", ) continue if path not in required: diagnostics.add( inventory, line_number, "DOC012", f"pending page is not in the required inventory: {value}", ) continue if path in pending: diagnostics.add( inventory, line_number, "DOC013", f"duplicate pending page: {value}", ) continue pending.add(path) return frozenset(pending) def _check_required_inventory( root: Path, required_pages: tuple[Path, ...], pending: frozenset[Path], diagnostics: _Diagnostics, ) -> None: for relative in sorted(set(required_pages), key=Path.as_posix): exists = (root / relative).is_file() if relative in pending: if exists: diagnostics.add( relative, 1, "DOC014", "page exists but remains in the pending inventory", ) continue if not exists: diagnostics.add(relative, 1, "DOC015", "required Milestone 4 page is missing") def _check_h1s(pages: dict[Path, _Page], diagnostics: _Diagnostics) -> None: for relative, page in pages.items(): h1_lines = [ _token_line(token) for token in page.tokens if token.type == "heading_open" and token.tag == "h1" ] if len(h1_lines) != 1: rendered = ", ".join(str(line) for line in h1_lines) or "none" diagnostics.add( relative, h1_lines[1] if len(h1_lines) > 1 else 1, "DOC016", f"expected exactly one H1; found {len(h1_lines)} (lines: {rendered})", ) def _check_links( root: Path, pages: dict[Path, _Page], parser: MarkdownIt, diagnostics: _Diagnostics, ) -> dict[Path, frozenset[Path]]: graph: dict[Path, frozenset[Path]] = {} anchor_cache = {relative: page.anchors for relative, page in pages.items()} for relative, page in pages.items(): destinations: set[Path] = set() for href, line in _page_links(page.tokens): target = _resolve_local_link(root, relative, href, line, diagnostics) if target is None: continue target_relative, fragment = target if target_relative in pages: destinations.add(target_relative) if not fragment: continue anchors = anchor_cache.get(target_relative) if anchors is None: anchors = _load_link_target_anchors( root, target_relative, parser, diagnostics, ) if anchors is None: continue anchor_cache[target_relative] = anchors if fragment not in anchors: diagnostics.add( relative, line, "DOC017", f"missing anchor #{fragment} in {target_relative.as_posix()}", ) graph[relative] = frozenset(destinations) return graph def _page_links(tokens: tuple[Token, ...]) -> Iterable[tuple[str, int]]: for token in tokens: children = token.children or [] for child in children: attribute = "href" if child.type == "link_open" else "src" if child.type not in {"link_open", "image"}: continue raw = child.attrs.get(attribute) if isinstance(raw, str): yield raw, _token_line(token) def _resolve_local_link( root: Path, source: Path, href: str, line: int, diagnostics: _Diagnostics, ) -> tuple[Path, str] | None: if not href or href.startswith("//") or _URI_SCHEME.match(href): return None try: parsed = urlsplit(href) except ValueError as error: diagnostics.add(source, line, "DOC018", f"invalid link {href!r}: {error}") return None if parsed.netloc: return None decoded_path = unquote(parsed.path) fragment = unquote(parsed.fragment) if "\x00" in decoded_path or decoded_path.startswith("/"): diagnostics.add(source, line, "DOC019", f"local link escapes the repository: {href!r}") return None absolute = (root / source if not decoded_path else root / source.parent / decoded_path).resolve( strict=False ) try: target = absolute.relative_to(root) except ValueError: diagnostics.add(source, line, "DOC019", f"local link escapes the repository: {href!r}") return None if not absolute.is_file(): diagnostics.add(source, line, "DOC020", f"missing local link target: {target.as_posix()}") return None return target, fragment def _load_link_target_anchors( root: Path, relative: Path, parser: MarkdownIt, diagnostics: _Diagnostics, ) -> frozenset[str] | None: if relative.suffix.lower() not in {".md", ".markdown"}: diagnostics.add( relative, 1, "DOC021", "link uses an anchor on a non-Markdown target", ) return None absolute = root / relative try: metadata = absolute.lstat() if not stat.S_ISREG(metadata.st_mode) or metadata.st_size > MAX_PAGE_BYTES: raise ValueError("target is not a bounded regular Markdown file") source = absolute.read_text(encoding="utf-8") except (OSError, UnicodeDecodeError, ValueError) as error: diagnostics.add(relative, 1, "DOC022", f"cannot inspect linked Markdown anchors: {error}") return None return _anchors(tuple(parser.parse(source))) def _anchors(tokens: tuple[Token, ...]) -> frozenset[str]: anchors: set[str] = set() slug_counts: dict[str, int] = {} for index, token in enumerate(tokens): if token.type in {"html_block", "html_inline"}: anchors.update(_html_ids(token.content)) if token.children: for child in token.children: if child.type == "html_inline": anchors.update(_html_ids(child.content)) if token.type != "heading_open" or index + 1 >= len(tokens): continue inline = tokens[index + 1] base = _heading_slug(_inline_text(inline)) duplicate = slug_counts.get(base, 0) slug_counts[base] = duplicate + 1 anchors.add(base if duplicate == 0 else f"{base}-{duplicate}") return frozenset(anchors) def _inline_text(token: Token) -> str: if not token.children: return token.content pieces: list[str] = [] for child in token.children: if child.type in {"text", "code_inline"}: pieces.append(child.content) elif child.type in {"softbreak", "hardbreak"}: pieces.append(" ") elif child.type == "image": pieces.append(child.content) elif child.type == "html_inline": pieces.append(re.sub(r"<[^>]*>", "", child.content)) return "".join(pieces) def _heading_slug(value: str) -> str: characters: list[str] = [] for character in html.unescape(value).casefold().strip(): category = unicodedata.category(character) if category[0] in {"L", "M", "N"} or character in {"-", "_"}: characters.append(character) elif character.isspace(): characters.append("-") return "".join(characters) def _html_ids(value: str) -> set[str]: return {first or second for first, second in _HTML_ID.findall(value)} def _check_reachability( pages: dict[Path, _Page], graph: dict[Path, frozenset[Path]], diagnostics: _Diagnostics, ) -> None: root_page = Path("README.md") if root_page not in pages: return reached = {root_page} queue = deque([root_page]) while queue: current = queue.popleft() for target in sorted(graph.get(current, frozenset()), key=Path.as_posix): if target not in reached: reached.add(target) queue.append(target) for relative in sorted(set(pages) - reached, key=Path.as_posix): if _HISTORICAL_RECORD.fullmatch(relative.as_posix()): continue diagnostics.add( relative, 1, "DOC023", "maintained page is not reachable from README.md", ) def _check_reference_adapter_examples( root: Path, pages: dict[Path, _Page], diagnostics: _Diagnostics, ) -> int: schema_path = root / REFERENCE_ADAPTER_SCHEMA try: schema_document = cast( dict[str, object], json.loads(schema_path.read_text(encoding="utf-8")), ) Draft202012Validator.check_schema(schema_document) except (OSError, UnicodeDecodeError, json.JSONDecodeError, TypeError) as error: diagnostics.add( REFERENCE_ADAPTER_SCHEMA, 1, "DOC024", f"cannot load reference-adapter schema: {error}", ) return 0 validator = cast(_Validator, Draft202012Validator(schema_document)) count = 0 for relative, page in pages.items(): previous_inline: Token | None = None for token in page.tokens: if token.type == "inline": previous_inline = token continue if token.type != "fence" or token.info.split(maxsplit=1)[:1] != ["toml"]: continue if not _is_reference_adapter_example(token, previous_inline): continue count += 1 line = _token_line(token) try: document = tomllib.loads(token.content) except tomllib.TOMLDecodeError as error: diagnostics.add( relative, line, "DOC025", f"invalid reference-adapter TOML example: {error}", ) continue errors = sorted( validator.iter_errors(document), key=lambda error: tuple(str(part) for part in error.absolute_path), ) for error in errors: location = ".".join(str(part) for part in error.absolute_path) or "" diagnostics.add( relative, line, "DOC026", f"reference-adapter example {location}: {error.message}", ) return count def _is_reference_adapter_example(token: Token, previous_inline: Token | None) -> bool: info = token.info.casefold() if "reference-adapter" in info or "reference_adapter" in info: return True if previous_inline is not None and previous_inline.map is not None and token.map is not None: adjacent = token.map[0] - previous_inline.map[1] <= 1 context = previous_inline.content.casefold() if adjacent and ( "reference-adapter.toml" in context or "reference adapter configuration" in context ): return True keys = set(_REFERENCE_ADAPTER_KEY.findall(token.content)) return {"language", "source_roots"}.issubset(keys) def _check_command_reference( pages: dict[Path, _Page], diagnostics: _Diagnostics, ) -> None: page = pages.get(COMMAND_REFERENCE) if page is None: return lines = page.source.splitlines() if not lines or lines[0] != f"# {COMMAND_REFERENCE_H1}": diagnostics.add( COMMAND_REFERENCE, 1, "DOC027", f"generated reference must begin with '# {COMMAND_REFERENCE_H1}'", ) if COMMAND_REFERENCE_NOTICE not in lines[:5]: diagnostics.add( COMMAND_REFERENCE, 2, "DOC028", "generated reference notice is missing or changed", ) def _token_line(token: Token) -> int: return token.map[0] + 1 if token.map else 1 def _parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( description="Check deterministic DocForge documentation invariants." ) parser.add_argument("--repository-root", type=Path, default=REPOSITORY_ROOT) parser.add_argument( "--pending-inventory", type=Path, default=DEFAULT_PENDING_INVENTORY, help="repository-relative list of required pages not authored yet", ) return parser def main(arguments: Sequence[str] | None = None) -> int: parsed = _parser().parse_args(arguments) try: report = check_documentation( parsed.repository_root, policy=DocumentationPolicy(pending_inventory=parsed.pending_inventory), ) except Exception as error: print(f"documentation check failed: {error}", file=sys.stderr) return 2 if not report.ok: for diagnostic in report.diagnostics: print(diagnostic.render(), file=sys.stderr) if report.omitted_diagnostics: print( f"... {report.omitted_diagnostics} additional diagnostics omitted " f"(limit {MAX_DIAGNOSTICS})", file=sys.stderr, ) return 1 print( json.dumps( { "markdown_pages": report.markdown_pages, "pending_pages": report.pending_pages, "reference_adapter_examples": report.reference_adapter_examples, "status": "ok", }, sort_keys=True, separators=(",", ":"), ) ) return 0 if __name__ == "__main__": raise SystemExit(main())