#!/usr/bin/env python3 """List and validate architecture decisions and specifications.""" from __future__ import annotations import argparse import json import re import sys from dataclasses import dataclass from pathlib import Path from typing import Iterable try: from spec_metadata import ( classify_path, metadata_text as spec_metadata_text, parse_frontmatter as parse_spec_frontmatter, validate_metadata, ) except ModuleNotFoundError: from scripts.spec_metadata import ( classify_path, metadata_text as spec_metadata_text, parse_frontmatter as parse_spec_frontmatter, validate_metadata, ) SCHEMA_VERSION = 1 DECISION_METADATA_KEYS = {"status", "date", "area", "scope", "tags"} SPEC_KINDS = ("system", "glossary", "requirement", "system-design", "product", "legacy") SPEC_KIND_ORDER = {kind: index for index, kind in enumerate(SPEC_KINDS)} FIELD_LINE = re.compile( r"^\*{0,2}(?PStatus|Date|Area|Scope|Tags)" r"(?::\s*\*{0,2}\s*|\*{0,2}\s*:\s*)(?P.*?)\s*$", re.IGNORECASE, ) HEADING_FIELD = re.compile(r"^##\s+(?PStatus|Date|Area|Scope|Tags)\s*$", re.IGNORECASE) FIRST_HEADING = re.compile(r"^#\s+(?P\S.*)$") DECISION_ID_PREFIX = re.compile( r"^(?:ADR-)?(?:\d{4}|(?:\d{4}-\d{2}-\d{2}(?:-[a-z0-9-]+)?))\s*(?::|[—–])\s+", re.IGNORECASE, ) class CatalogError(Exception): """A source file cannot be represented in the catalog.""" class SkipSource(Exception): """A Markdown file is an authoring entry page, not a catalog source.""" @dataclass(frozen=True) class Document: type: str path: str title: str text: str id: str status: str | None = None area: str | None = None date: str | None = None system: str | None = None kind: str | None = None def repository_path(root: Path, path: Path) -> str: try: return path.resolve().relative_to(root.resolve()).as_posix() except ValueError as exc: raise CatalogError(f"{path}: path is outside the repository root") from exc def read_source(root: Path, path: Path) -> tuple[str, str]: relative = repository_path(root, path) if path.is_symlink() or not path.is_file(): raise CatalogError(f"{relative}: source must be a regular file") try: return relative, path.read_text(encoding="utf-8") except (OSError, UnicodeDecodeError) as exc: raise CatalogError(f"{relative}: cannot read source file: {exc}") from exc def first_heading(lines: list[str], start: int, relative: str) -> str: for line in lines[start:]: match = FIRST_HEADING.match(line) if match: return match.group("title").strip() raise CatalogError(f"{relative}: source must contain a level-one title heading") def decision_title(heading: str) -> str: return DECISION_ID_PREFIX.sub("", heading, count=1).strip() def normalize_field_value(value: str) -> str: return value.strip().strip("|").strip() def metadata_key(raw_key: str) -> str: key = raw_key.strip().strip("*").lower() return "area" if key in {"scope", "tags"} else key def add_metadata_value( fields: dict[str, str], raw_key: str, raw_value: str, relative: str, ) -> None: key = metadata_key(raw_key) value = normalize_field_value(raw_value) if not value: return if key == "area" and raw_key.strip().lower() != "area" and "area" in fields: return if key in fields: raise CatalogError(f"{relative}: duplicate {key} metadata in the ADR header") fields[key] = value def parse_metadata_line(line: str) -> tuple[str, str] | None: stripped = line.strip() stripped = re.sub(r"^[-*]\s+", "", stripped) if stripped.startswith("|"): cells = [cell.strip() for cell in stripped.strip("|").split("|")] if len(cells) >= 2: key = cells[0].strip().strip("*") if key.lower() in DECISION_METADATA_KEYS: return key, cells[1] return None match = FIELD_LINE.match(stripped) if match: return match.group("key"), match.group("value") return None def parse_decision_metadata( lines: list[str], start: int, frontmatter: dict[str, object], relative: str ) -> dict[str, str]: fields: dict[str, str] = {} for key in ("status", "date", "area"): value = frontmatter.get(key) if isinstance(value, list): value = ", ".join(str(item) for item in value) if isinstance(value, str): add_metadata_value(fields, key, value, relative) pending_key: str | None = None pending_value: list[str] = [] def flush_pending() -> None: nonlocal pending_key, pending_value if pending_key is not None: add_metadata_value(fields, pending_key, " ".join(pending_value), relative) pending_key = None pending_value = [] index = start while index < len(lines): line = lines[index] heading_match = HEADING_FIELD.match(line.strip()) if heading_match: flush_pending() value_index = index + 1 while value_index < len(lines) and not lines[value_index].strip(): value_index += 1 if value_index < len(lines) and not lines[value_index].lstrip().startswith("#"): pending_key = heading_match.group("key") pending_value = [lines[value_index].strip()] index = value_index + 1 continue elif re.match(r"^##\s+", line): flush_pending() break parsed = parse_metadata_line(line) if parsed: flush_pending() pending_key = parsed[0] pending_value = [parsed[1]] elif not line.strip(): flush_pending() elif pending_key is not None: pending_value.append(line.strip()) index += 1 flush_pending() if not fields.get("status"): raise CatalogError(f"{relative}: ADR metadata must define a non-empty status") date = fields.get("date") if date and not re.match(r"^\d{4}-\d{2}-\d{2}\b", date): raise CatalogError(f"{relative}: ADR date must start with YYYY-MM-DD") return fields def parse_decision(root: Path, path: Path) -> Document: relative, text = read_source(root, path) lines = text.splitlines() parsed_frontmatter = parse_spec_frontmatter(text) if parsed_frontmatter.error: raise CatalogError( f"{relative}:{parsed_frontmatter.line}: {parsed_frontmatter.error}" ) frontmatter = parsed_frontmatter.metadata or {} frontmatter_end = parsed_frontmatter.end heading = first_heading(lines, frontmatter_end, relative) fields = parse_decision_metadata(lines, frontmatter_end, frontmatter, relative) return Document( type="decision", path=relative, title=decision_title(heading), text=text, id=path.stem, status=fields.get("status"), area=fields.get("area"), date=fields.get("date"), ) def decision_files(root: Path) -> Iterable[Path]: directory = root / "docs" / "decisions" if not directory.exists(): return () return (path for path in sorted(directory.rglob("*.md")) if path.name != "INDEX.md") def spec_files(root: Path) -> Iterable[Path]: directory = root / "docs" / "specs" if not directory.exists(): return () return ( path for path in sorted(directory.rglob("*.md")) if path.name != "INDEX.md" and path != directory / "README.md" and "guide" not in path.relative_to(directory).parts and "templates" not in path.relative_to(directory).parts ) def parse_spec_kind( relative: str, frontmatter: dict[str, object] | None = None ) -> tuple[str, str | None]: del frontmatter inside = Path(relative).relative_to("docs/specs").parts if not inside: raise CatalogError(f"{relative}: specification path has no system directory") source_kind, source_system = classify_path(Path(relative)) if source_kind in {"guide", "template"}: raise CatalogError(f"{relative}: authoring support files are not catalog sources") if source_kind == "system-index": if inside[-1] == "README.md": return "system", source_system return "glossary", source_system if source_kind in {"requirement", "system-design", "product", "legacy"}: return source_kind, source_system raise CatalogError(f"{relative}: unsupported specification source") def validate_spec_metadata( relative: str, kind: str, system: str | None, metadata: dict[str, object], has_frontmatter: bool, ) -> None: issues = validate_metadata(kind, system, metadata, has_frontmatter) if issues: raise CatalogError(f"{relative}: {issues[0].message}") def parse_spec(root: Path, path: Path) -> Document: relative, text = read_source(root, path) if relative == "docs/specs/product/README.md": raise SkipSource parsed_frontmatter = parse_spec_frontmatter(text) if parsed_frontmatter.error: raise CatalogError( f"{relative}:{parsed_frontmatter.line}: {parsed_frontmatter.error}" ) metadata = parsed_frontmatter.metadata or {} frontmatter_end = parsed_frontmatter.end kind, system = parse_spec_kind(relative, metadata) has_frontmatter = bool(text.splitlines() and text.splitlines()[0] == "---") validate_spec_metadata(relative, kind, system, metadata, has_frontmatter) title = spec_metadata_text(metadata, "title") if not title: try: title = first_heading(text.splitlines(), frontmatter_end, relative) except CatalogError: title = " ".join( word.capitalize() for word in re.split(r"[-_]+", Path(relative).stem) if word ) identifier = spec_metadata_text(metadata, "id") or relative.removesuffix(".md") return Document( type="specification", path=relative, title=title, text=text, id=identifier, status=spec_metadata_text(metadata, "status"), system=system, kind=kind, ) def load_documents( root: Path, files: Iterable[Path], parser ) -> tuple[list[Document], list[str]]: documents: list[Document] = [] errors: list[str] = [] identities: dict[str, str] = {} for path in files: try: document = parser(root, path) except SkipSource: continue except CatalogError as exc: errors.append(str(exc)) continue identity = document.id if document.type == "decision" else f"{document.type}:{document.id}" if identity in identities: errors.append( f"{document.path}: duplicate catalog identity {document.id} " f"(already used by {identities[identity]})" ) continue identities[identity] = document.path documents.append(document) return documents, sorted(errors) def decision_sort_key(document: Document) -> tuple[int, int | str, str]: if re.match(r"^\d{4}-\d{2}-\d{2}-", document.id): return (1, document.id, document.path) numeric = re.match(r"^(\d+)(?:-|$)", document.id) if numeric: return (0, int(numeric.group(1)), document.path) return (2, document.id, document.path) def spec_sort_key(document: Document) -> tuple[str, int, str]: assert document.kind is not None return (document.system or "", SPEC_KIND_ORDER[document.kind], document.path) def filter_decisions( documents: Iterable[Document], status: str | None, area: str | None, text: str | None, ) -> list[Document]: result = [] for document in documents: if status and leading_status_class(document.status) != status.lower(): continue if area and not area_matches(document.area, area): continue if text and text.lower() not in document.text.lower(): continue result.append(document) return sorted(result, key=decision_sort_key) def leading_status_class(value: str | None) -> str: if not value: return "" match = re.match(r"^\s*([a-z][a-z0-9_-]*)", value, re.IGNORECASE) return match.group(1).lower() if match else "" def area_matches(value: str | None, requested: str) -> bool: if not value: return False wanted = requested.strip().lower() if value.lower() == wanted: return True tokens = [token.strip().lower() for token in value.split(",") if token.strip()] if wanted in tokens: return True return wanted in { token.lower() for token in re.findall(r"[a-z0-9][a-z0-9_-]*", value) } def filter_specs( documents: Iterable[Document], system: str | None, kind: str | None, status: str | None, text: str | None, ) -> list[Document]: result = [] for document in documents: if system and (not document.system or document.system.lower() != system.lower()): continue if kind and document.kind != kind: continue if status and (not document.status or document.status.lower() != status.lower()): continue if text and text.lower() not in document.text.lower(): continue result.append(document) return sorted(result, key=spec_sort_key) def markdown_escape(value: object) -> str: return ( str(value if value is not None else "") .replace("\\", "\\\\") .replace("|", "\\|") .replace("\n", " ") ) def markdown_table(documents: list[Document], document_type: str) -> str: if document_type == "decision": headers = ("ID", "Title", "Status", "Area", "Date", "Path") rows = [ ( document.id, document.title, document.status, document.area, document.date, document.path, ) for document in documents ] else: headers = ("System", "Kind", "Status", "Title", "Path") rows = [ ( document.system, document.kind, document.status, document.title, document.path, ) for document in documents ] lines = [ "| " + " | ".join(headers) + " |", "| " + " | ".join("---" for _ in headers) + " |", ] lines.extend( "| " + " | ".join(markdown_escape(value) for value in row) + " |" for row in rows ) return "\n".join(lines) + "\n" def json_output(documents: list[Document]) -> str: items = [] for document in documents: item = { "id": document.id, "path": document.path, "title": document.title, "type": document.type, } if document.type == "decision": item.update( { "area": document.area, "date": document.date, "status": document.status, } ) else: item.update( { "kind": document.kind, "status": document.status, "system": document.system, } ) items.append(item) return ( json.dumps( {"schema_version": SCHEMA_VERSION, "documents": items}, ensure_ascii=False, indent=2, ) + "\n" ) def format_documents( documents: list[Document], output_format: str, document_type: str ) -> str: if output_format == "paths": return "".join(f"{document.path}\n" for document in documents) if output_format == "json": return json_output(documents) return markdown_table(documents, document_type) def add_common_arguments(parser: argparse.ArgumentParser) -> None: parser.add_argument( "--format", choices=("markdown", "paths", "json"), default="markdown", help="output format", ) def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( description="List and validate Kandev documentation sources." ) parser.add_argument( "--root", type=Path, default=Path.cwd(), help="repository root (defaults to the current directory)", ) subparsers = parser.add_subparsers(dest="command", required=True) decisions = subparsers.add_parser("decisions", help="list architecture decisions") decisions.add_argument("--status") decisions.add_argument("--area") decisions.add_argument("--text") add_common_arguments(decisions) specs = subparsers.add_parser("specs", help="list specifications") specs.add_argument("--system") specs.add_argument("--kind", choices=SPEC_KINDS) specs.add_argument("--status") specs.add_argument("--text") add_common_arguments(specs) subparsers.add_parser("validate", help="validate both documentation catalogs") return parser def run(args: argparse.Namespace) -> int: root = args.root.resolve() if not root.is_dir(): print(f"error: repository root does not exist: {root}", file=sys.stderr) return 2 if args.command == "decisions": documents, errors = load_documents(root, decision_files(root), parse_decision) if errors: for error in errors: print(f"error: {error}", file=sys.stderr) return 1 selected = filter_decisions(documents, args.status, args.area, args.text) print(format_documents(selected, args.format, "decision"), end="") return 0 if args.command == "specs": documents, errors = load_documents(root, spec_files(root), parse_spec) if errors: for error in errors: print(f"error: {error}", file=sys.stderr) return 1 selected = filter_specs(documents, args.system, args.kind, args.status, args.text) print(format_documents(selected, args.format, "specification"), end="") return 0 decisions, decision_errors = load_documents(root, decision_files(root), parse_decision) specs, spec_errors = load_documents(root, spec_files(root), parse_spec) errors = sorted(decision_errors + spec_errors) if errors: for error in errors: print(f"error: {error}", file=sys.stderr) return 1 print(f"Validated {len(decisions)} decisions and {len(specs)} specifications.") return 0 def main() -> int: parser = build_parser() try: args = parser.parse_args() return run(args) except CatalogError as exc: print(f"error: {exc}", file=sys.stderr) return 1 if __name__ == "__main__": raise SystemExit(main())