Add renderer and package readiness validation gates

This commit is contained in:
2026-06-15 19:20:56 +02:00
parent 68617e8bc4
commit f78fc3076c
23 changed files with 2350 additions and 389 deletions

View 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())

View File

@@ -22,9 +22,16 @@ EXPECTED_PACKAGE_KINDS = [
EXPECTED_CMAKE_PACKAGE_TARGETS = [
"panopainter_package_readiness",
"panopainter_windows_app_package_smoke",
"panopainter_windows_appx_package_readiness",
"panopainter_apple_bundle_package_readiness",
"panopainter_android_standard_native_package",
"panopainter_android_standard_apk_package_readiness",
"panopainter_android_quest_apk_package_readiness",
"panopainter_android_focus_apk_package_readiness",
"panopainter_android_vr_native_package_configure",
"panopainter_android_native_package_smoke",
"panopainter_linux_app_package_readiness",
"panopainter_webgl_package_readiness",
"panopainter_linux_webgl_package_readiness",
]
@@ -43,7 +50,15 @@ def powershell_package_kinds(root: Path) -> list[str]:
def shell_package_kinds(root: Path) -> list[str]:
script = (root / "scripts" / "automation" / "package-smoke.sh").read_text(encoding="utf-8")
return sorted(set(re.findall(r'"kind":"([^"]+)"', script)))
match = re.search(r'package_kinds="([^"]+)"', script)
if match:
return sorted(set(filter(None, (value.strip() for value in match.group(1).split(",")))))
quoted_kinds = sorted(set(re.findall(r'"kind":"([^"]+)"', script)))
escaped_kinds = sorted(set(re.findall(r'\\"kind\\":\\"([^\\"]+)\\"', script)))
if quoted_kinds or escaped_kinds:
return sorted(set(quoted_kinds).union(escaped_kinds))
raise RuntimeError("Could not find package kinds defaults in package-smoke.sh")
def count_regex(root: Path, patterns: dict[str, str]) -> dict[str, int]:
@@ -59,13 +74,29 @@ def main() -> int:
expected = sorted(EXPECTED_PACKAGE_KINDS)
ps_kinds = powershell_package_kinds(root)
sh_kinds = shell_package_kinds(root)
script_texts = {
"package-smoke.ps1": (root / "scripts" / "automation" / "package-smoke.ps1").read_text(encoding="utf-8"),
"package-smoke.sh": (root / "scripts" / "automation" / "package-smoke.sh").read_text(encoding="utf-8"),
}
debt_counts = count_regex(root, {
"package-smoke.ps1": r'debt\s*=\s*"DEBT-0011"',
"package-smoke.sh": r'"debt":"DEBT-0011"',
"package-smoke.sh": r'\\"debt\\":\\"DEBT-0011\\"',
})
blocked_counts = count_regex(root, {
"package-smoke.ps1": r'-Status\s+"blocked"',
"package-smoke.sh": r'"status":"blocked"',
status_tokens = ("blocked", "compile-only", "validated")
status_modes = {
name: [token for token in status_tokens if f'"{token}"' in text]
for name, text in script_texts.items()
}
status_mode_present = {
name: {
token: f'"{token}"' in script_texts[name]
for token in ("blocked", "compile-only")
}
for name in ("package-smoke.ps1", "package-smoke.sh")
}
readiness_alignment = count_regex(root, {
"package-smoke.ps1": r'androidNativeValidation',
"package-smoke.sh": r'androidNativeValidation',
})
readiness_mode_counts = {
"package-smoke.ps1": (root / "scripts" / "automation" / "package-smoke.ps1").read_text(encoding="utf-8").count("ReadinessOnly"),
@@ -95,7 +126,10 @@ def main() -> int:
"package-smoke.sh": len(expected),
}
debt_complete = {name: count >= debt_thresholds[name] for name, count in debt_counts.items()}
blocked_complete = {name: count >= len(expected) for name, count in blocked_counts.items()}
status_gate_complete = {
"package-smoke.ps1": status_mode_present["package-smoke.ps1"]["blocked"] and status_mode_present["package-smoke.ps1"]["compile-only"],
"package-smoke.sh": status_mode_present["package-smoke.sh"]["blocked"] and status_mode_present["package-smoke.sh"]["compile-only"],
}
readiness_mode_present = {name: count > 0 for name, count in readiness_mode_counts.items()}
retained_android_native_complete = {
name: count >= 3 for name, count in retained_android_native_counts.items()
@@ -112,7 +146,8 @@ def main() -> int:
all(not values for values in missing.values())
and all(not values for values in unexpected.values())
and all(debt_complete.values())
and all(blocked_complete.values())
and all(status_gate_complete.values())
and all(readiness_alignment.values())
and all(readiness_mode_present.values())
and all(retained_android_native_complete.values())
and all(retained_platform_cmake_complete.values())
@@ -130,7 +165,9 @@ def main() -> int:
"missing": missing,
"unexpected": unexpected,
"debtComplete": debt_complete,
"blockedComplete": blocked_complete,
"statusModes": status_modes,
"statusModePresent": status_mode_present,
"readinessAlignment": readiness_alignment,
"readinessModePresent": readiness_mode_present,
"retainedAndroidNativeComplete": retained_android_native_complete,
"retainedPlatformCmakeComplete": retained_platform_cmake_complete,

View File

@@ -0,0 +1,188 @@
#!/usr/bin/env python3
"""Validate renderer API contract purity for key rendering components."""
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+(\"([^\"]+)\"|<([^>]+)>)""")
REPO_ROOT = Path(__file__).resolve().parents[2]
CHECKS = {
"renderer_api": {
"roots": [REPO_ROOT / "src" / "renderer_api"],
"allowed_include_prefixes": ("foundation/", "renderer_api/"),
"forbidden_include_tokens": (
"renderer_gl/",
"platform/",
"platform_api/",
"platform_",
"opengl",
"glad",
"vulkan",
"d3d",
"directx",
"metal",
"appkit",
"cocoa",
"objc/",
"windows.h",
"x11/",
"wayland-",
"android/",
),
"forbidden_body_tokens": (
"OpenGl",
"OpenGL",
"opengl_",
"GL_",
"Vulkan",
"Vk",
"MTL",
"D3D",
"vulkan",
"metal",
"renderer_gl",
"pp_platform_",
),
},
"paint_renderer": {
"roots": [REPO_ROOT / "src" / "paint_renderer"],
"allowed_include_prefixes": (
"assets/",
"document/",
"foundation/",
"paint/",
"paint_renderer/",
"renderer_api/",
),
"forbidden_include_tokens": (
"renderer_gl/",
"platform/",
"platform_api/",
"platform_",
"opengl",
"glad",
"vulkan",
"d3d",
"directx",
"metal",
"appkit",
"cocoa",
"objc/",
"windows.h",
"x11/",
"wayland-",
"android/",
),
"forbidden_body_tokens": (
"OpenGl",
"OpenGL",
"opengl_",
"GL_",
"Vulkan",
"Vk",
"MTL",
"D3D",
"vulkan",
"metal",
"renderer_gl",
"pp_platform_",
),
},
}
ALLOWED_EXTERNAL_PREFIXES = ("stb/",)
def is_forbidden_include(allowlist: tuple[str, ...], forbidden_tokens: tuple[str, ...], include: str) -> tuple[bool, str | None]:
include_lower = include.lower()
if any(token in include_lower for token in forbidden_tokens):
token = next(token for token in forbidden_tokens if token in include_lower)
return True, token
if "/" in include and include_lower.startswith(ALLOWED_EXTERNAL_PREFIXES):
return False, None
if "/" in include and not any(include_lower.startswith(prefix) for prefix in allowlist):
return True, "cross-component-include"
return False, None
def scan_component(name: str, config: dict[str, Any]) -> list[dict[str, Any]]:
violations: list[dict[str, Any]] = []
for root in config["roots"]:
for path in root.rglob("*"):
if not path.is_file():
continue
if path.suffix.lower() not in {".cpp", ".cc", ".c", ".h", ".hpp", ".hh"}:
continue
text = path.read_text(encoding="utf-8").splitlines()
for line_no, line in enumerate(text, 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(
config["allowed_include_prefixes"],
config["forbidden_include_tokens"],
include,
)
if forbidden:
violations.append(
{
"component": name,
"file": str(path.relative_to(REPO_ROOT)),
"line": line_no,
"kind": "forbidden-include",
"include": include,
"detail": reason,
"text": line.strip(),
}
)
joined = "\n".join(text)
for token in config["forbidden_body_tokens"]:
if token in joined:
violations.append(
{
"component": name,
"file": str(path.relative_to(REPO_ROOT)),
"line": 0,
"kind": "forbidden-body-token",
"include": token,
"detail": "backend- or platform-specific symbol in renderer contract path",
"text": "",
}
)
return violations
def main() -> int:
violations: list[dict[str, Any]] = []
for component, config in CHECKS.items():
violations.extend(scan_component(component, config))
print(
json.dumps(
{
"ok": len(violations) == 0,
"summary": {
"componentCount": len(CHECKS),
"violationCount": len(violations),
},
"violations": violations,
},
separators=(",", ":"),
)
)
return 0 if not violations else 1
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,119 @@
#!/usr/bin/env python3
"""Validate that renderer conformance fixtures are registered and labeled consistently."""
import re
from pathlib import Path
from typing import Any
REPO_ROOT = Path(__file__).resolve().parents[2]
TESTS_CMAKE = REPO_ROOT / "tests" / "CMakeLists.txt"
REQUIRED_TEST_LABELS = {
"pp_renderer_api_tests": {"renderer-conformance", "renderer"},
}
OPTIONAL_BACKEND_TEST_LABELS = {
"pp_renderer_gl_capabilities_tests": {"renderer-conformance", "renderer"},
"pp_renderer_gl_command_plan_tests": {"renderer-conformance", "renderer"},
"pp_renderer_gl_gpu_readback_tests": {"renderer-conformance", "renderer", "gpu"},
}
def parse_labels() -> dict[str, set[str]]:
labels_by_test: dict[str, set[str]] = {}
text = TESTS_CMAKE.read_text(encoding="utf-8").splitlines()
i = 0
while i < len(text):
line = text[i].strip()
if not line.startswith("set_tests_properties("):
i += 1
continue
if "set_tests_properties(" not in line or "PROPERTIES" not in line:
i += 1
continue
after_paren = line.split("set_tests_properties(", 1)[1]
test_name = after_paren.split()[0].strip()
test_name = test_name.strip()
label_value: str | None = None
j = i
while j < len(text):
search = text[j].strip()
if search.startswith("LABELS"):
colon = search.find("\"")
if colon != -1:
value = search[colon:].strip()
if value.startswith("\"") and value.endswith("\""):
label_value = value[1:-1]
break
# Fallback for multiline values: LABELS "a;b"; split on quotes in line.
quotes = re.findall(r'"([^"]+)"', search)
if quotes:
label_value = quotes[0]
break
if search == ")" or (search.startswith(")") and "LABELS" not in search):
break
j += 1
if label_value is not None:
labels_by_test[test_name] = {label.strip() for label in label_value.split(";") if label.strip()}
i = j + 1
return labels_by_test
def validate() -> tuple[bool, list[dict[str, Any]]]:
labels_by_test = parse_labels()
test_names = set(labels_by_test)
violations: list[dict[str, Any]] = []
for test_name, required_labels in REQUIRED_TEST_LABELS.items():
actual = labels_by_test.get(test_name)
if actual is None:
violations.append({"test": test_name, "kind": "missing-test", "detail": "required conformance test not registered"})
continue
missing = sorted(required_labels - actual)
if missing:
violations.append(
{
"test": test_name,
"kind": "missing-label",
"detail": f"required labels missing: {', '.join(missing)}",
}
)
for test_name, required_labels in OPTIONAL_BACKEND_TEST_LABELS.items():
if test_name not in test_names:
continue
actual = labels_by_test[test_name]
missing = sorted(required_labels - actual)
if missing:
violations.append(
{
"test": test_name,
"kind": "missing-label",
"detail": f"required labels missing: {', '.join(missing)}",
}
)
return (len(violations) == 0), violations
def main() -> int:
ok, violations = validate()
payload = {
"ok": ok,
"summary": {
"requiredTestCount": len(REQUIRED_TEST_LABELS),
"violationCount": len(violations),
},
"violations": violations,
}
print(payload)
return 0 if ok else 1
if __name__ == "__main__":
raise SystemExit(main())