#!/usr/bin/env python3 """Validate component boundary rules for pure architectural targets.""" from __future__ import annotations import json import re from pathlib import Path from typing import Any INCLUDE_RE = re.compile(r"""^\s*#\s*include\s+(\"([^\"]+)\"|<([^>]+)>)""") SINGLETON_RE = re.compile(r"\b(?:App::I|Canvas::I)\b") REPO_ROOT = Path(__file__).resolve().parents[2] SRC_ROOT = REPO_ROOT / "src" CMAKE_FILE = REPO_ROOT / "CMakeLists.txt" COMPONENT_BY_DIR = { "foundation": "pp_foundation", "assets": "pp_assets", "paint": "pp_paint", "document": "pp_document", "renderer_api": "pp_renderer_api", "paint_renderer": "pp_paint_renderer", "ui_core": "pp_ui_core", "app_core": "pp_app_core", } PURE_TARGETS = set(COMPONENT_BY_DIR.values()) TARGET_INFRA = {"pp_project_options", "pp_project_warnings", "pp_xml_tinyxml2"} ALLOWED_LINKS = { "pp_foundation": set(), "pp_assets": {"pp_foundation"}, "pp_paint": {"pp_foundation"}, "pp_document": {"pp_foundation", "pp_assets", "pp_paint"}, "pp_renderer_api": {"pp_foundation"}, "pp_paint_renderer": {"pp_foundation", "pp_paint", "pp_document", "pp_renderer_api"}, "pp_ui_core": {"pp_foundation", "pp_xml_tinyxml2"}, "pp_app_core": {"pp_foundation", "pp_document", "pp_assets", "pp_paint", "pp_ui_core"}, } ALLOWED_LOCAL_INCLUDES = { "pp_foundation": ("foundation/",), "pp_assets": ("foundation/", "assets/"), "pp_paint": ("foundation/", "paint/"), "pp_document": ("foundation/", "assets/", "paint/", "document/"), "pp_renderer_api": ("foundation/", "renderer_api/"), "pp_paint_renderer": ( "assets/", "document/", "foundation/", "paint/", "paint_renderer/", "renderer_api/", ), "pp_ui_core": ("foundation/", "ui_core/"), "pp_app_core": ("app_core/", "assets/", "document/", "foundation/", "paint/", "ui_core/"), } ALLOWED_EXTERNAL_PREFIXES = ( "stb/", ) FORBIDDEN_INCLUDE_TOKENS = ( "platform/windows", "platform/windows", "platform_apple", "platform_legacy", "platform_api/", "platform_legacy/", "platform_apple/", "platform_windows/", "opengl/", "/opengl", " Path: return REPO_ROOT def component_for_path(path: Path) -> str | None: try: rel = path.relative_to(SRC_ROOT) except ValueError: return None if not rel.parts: return None return COMPONENT_BY_DIR.get(rel.parts[0]) def collect_target_links(cmake_text: str, target: str) -> list[str] | None: pattern = re.compile(rf"target_link_libraries\(\s*{re.escape(target)}\s+(.*?)\)", re.S | re.I) matches = pattern.findall(cmake_text) if not matches: return None tokens: list[str] = [] for block in matches: block = block.replace("\\\n", " ") for token in re.split(r"\s+", block): token = token.strip() if not token or token.upper() in {"PUBLIC", "PRIVATE", "INTERFACE"}: continue token = token.strip("\"'") if token.startswith("$<") or token.startswith("SHELL:"): continue tokens.append(token) return tokens def check_link_dependencies(cmake_text: str) -> list[dict[str, Any]]: violations: list[dict[str, Any]] = [] for target in sorted(PURE_TARGETS): deps = collect_target_links(cmake_text, target) if deps is None: violations.append( { "target": target, "dependency": None, "kind": "missing-target-link-declaration", "message": "No target_link_libraries block found", } ) continue allowed = ALLOWED_LINKS[target] for dependency in deps: if dependency in TARGET_INFRA: continue if dependency in allowed: continue if dependency.startswith("pp_"): violations.append( { "target": target, "dependency": dependency, "kind": "invalid-target-edge", "message": f"{target} must not depend on {dependency}", } ) return violations def is_forbidden_include(component: str, include: str) -> tuple[bool, str | None]: include_lower = include.lower() if any(token in include_lower for token in FORBIDDEN_INCLUDE_TOKENS): token = next(token for token in FORBIDDEN_INCLUDE_TOKENS if token in include_lower) return True, token if "/" in include_lower and any(include_lower.startswith(prefix) for prefix in ALLOWED_EXTERNAL_PREFIXES): return False, None if "/" in include: allowed_prefixes = ALLOWED_LOCAL_INCLUDES[component] if not any(include_lower.startswith(prefix) for prefix in allowed_prefixes): return True, "component-boundary-crossing-include" return False, None def check_pure_component_sources() -> list[dict[str, Any]]: violations: list[dict[str, Any]] = [] for path in SRC_ROOT.rglob("*"): if not path.is_file(): continue if path.suffix.lower() not in {".cpp", ".cc", ".c", ".h", ".hpp", ".hh"}: continue component = component_for_path(path) if component is None: continue for line_no, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1): include_match = INCLUDE_RE.match(line) if include_match: include = (include_match.group(2) or include_match.group(3) or "").strip() forbidden, reason = is_forbidden_include(component, include) if forbidden: violations.append( { "file": str(path.relative_to(REPO_ROOT)), "line": line_no, "kind": "forbidden-include", "include": include, "detail": reason, "text": line.strip(), } ) if SINGLETON_RE.search(line): violations.append( { "file": str(path.relative_to(REPO_ROOT)), "line": line_no, "kind": "legacy-singleton-reference", "detail": "App::I/Canvas::I is not allowed in pure components", "text": line.strip(), } ) return violations def main() -> int: source_violations = check_pure_component_sources() cmake_text = CMAKE_FILE.read_text(encoding="utf-8") link_violations = check_link_dependencies(cmake_text) all_violations = source_violations + link_violations ok = len(all_violations) == 0 print( json.dumps( { "ok": ok, "summary": { "sourceViolationCount": len(source_violations), "linkViolationCount": len(link_violations), }, "violations": all_violations, }, separators=(",", ":"), ) ) return 0 if ok else 1 if __name__ == "__main__": raise SystemExit(main())