Add renderer and package readiness validation gates
This commit is contained in:
246
scripts/dev/check_component_boundaries.py
Normal file
246
scripts/dev/check_component_boundaries.py
Normal file
@@ -0,0 +1,246 @@
|
||||
#!/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",
|
||||
"<gl",
|
||||
"glad/",
|
||||
"<GL/",
|
||||
"<openGL/",
|
||||
"vulkan/",
|
||||
"directx",
|
||||
"d3d",
|
||||
"android/",
|
||||
"ios/",
|
||||
"objc/",
|
||||
"metal/",
|
||||
"windows.h",
|
||||
"wingdi.h",
|
||||
"afxwin.h",
|
||||
"App::I",
|
||||
"Canvas::I",
|
||||
)
|
||||
|
||||
|
||||
def repo_root() -> 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())
|
||||
Reference in New Issue
Block a user