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())
|
||||
@@ -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,
|
||||
|
||||
188
scripts/dev/check_renderer_api_contract.py
Normal file
188
scripts/dev/check_renderer_api_contract.py
Normal 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())
|
||||
119
scripts/dev/check_renderer_conformance_matrix.py
Normal file
119
scripts/dev/check_renderer_conformance_matrix.py
Normal 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())
|
||||
Reference in New Issue
Block a user