Files
tqcq 85a0ff93c2
cpp-template / format (ubuntu-22.04) (push) Successful in 57s
cpp-template / format (ubuntu-24.04) (push) Successful in 56s
cpp-template / build-test (asan, ubuntu-22.04) (push) Successful in 1m34s
cpp-template / build-test (asan, ubuntu-24.04) (push) Successful in 1m35s
cpp-template / build-test (debug, ubuntu-22.04) (push) Successful in 1m55s
cpp-template / build-test (debug, ubuntu-24.04) (push) Successful in 1m50s
cpp-template / build-test (fuzz, ubuntu-22.04) (push) Successful in 6m18s
cpp-template / build-test (fuzz, ubuntu-24.04) (push) Successful in 7m0s
cpp-template / build-test (release, ubuntu-22.04) (push) Successful in 1m49s
cpp-template / build-test (release, ubuntu-24.04) (push) Successful in 1m53s
cpp-template / clang-tidy (ubuntu-22.04) (push) Successful in 1m37s
cpp-template / clang-tidy (ubuntu-24.04) (push) Successful in 1m52s
cpp-template / install-consumer (ubuntu-22.04) (push) Successful in 1m41s
cpp-template / install-consumer (ubuntu-24.04) (push) Successful in 1m36s
cpp-template / no-network-negative (ubuntu-22.04) (push) Successful in 6m50s
cpp-template / no-network-negative (ubuntu-24.04) (push) Successful in 7m32s
cpp-template / format (ubuntu-20.04) (push) Has been cancelled
cpp-template / build-test (asan, ubuntu-20.04) (push) Has been cancelled
cpp-template / build-test (debug, ubuntu-20.04) (push) Has been cancelled
cpp-template / build-test (fuzz, ubuntu-20.04) (push) Has been cancelled
cpp-template / build-test (release, ubuntu-20.04) (push) Has been cancelled
cpp-template / clang-tidy (ubuntu-20.04) (push) Has been cancelled
cpp-template / install-consumer (ubuntu-20.04) (push) Has been cancelled
cpp-template / no-network-negative (ubuntu-20.04) (push) Has been cancelled
chore: remove ThreadSanitizer (TSan) support
2026-05-19 14:19:49 +08:00

781 lines
28 KiB
Python

"""CMake helper API fixture runner for dev_check.py.
Discovers and runs positive/negative CMake helper API fixture directories,
verifying configure success (positive) or expected diagnostic substrings
(negative). Build directories live under build/cmake-helper-fixtures/<case-id>.
Also provides install-consumer QA: two-phase flow that installs a fixture
package into a temp prefix, then configures/builds/runs a separate consumer
using only find_package(CONFIG REQUIRED), with source-tree leakage checks.
"""
from __future__ import annotations
import dataclasses
import pathlib
import re
import shutil
import subprocess
from collections.abc import Sequence
from lib import common
STATUS_PASS = "PASS"
STATUS_FAIL = "FAIL"
STATUS_DEFERRED = "DEFERRED"
FIXTURES_DIR_NAME = "helper-api"
BUILD_SUBDIR = "cmake-helper-fixtures"
# ---------------------------------------------------------------------------
# Negative fixture expected diagnostic substrings.
# Key = fixture directory name (e.g. "02-cc-library-missing-type").
# Value = substring that must appear in cmake stderr when the fixture fails.
# ---------------------------------------------------------------------------
NEGATIVE_DIAGNOSTICS: dict[str, str] = {
"01-cc-project-no-project-call": "cc_project() requires a prior project() call",
"02-cc-library-missing-type": "TYPE is required",
"03-cc-library-unqualified-deps": "appears without a visibility keyword",
"04-cc-library-interface-public": "INTERFACE libraries do not support PUBLIC",
"05-cc-executable-interface-visibility": "INTERFACE dependencies are not valid for executable targets",
"06-cc-executable-unqualified-deps": "appears without a visibility keyword",
"07-cc-executable-invalid-alias": "is not a valid CMake target name",
"08-cc-test-unqualified-deps": "appears without a visibility keyword",
"09-cc-test-interface-visibility": "INTERFACE dependencies are not valid for test targets",
"10-cc-test-gmock-main-without-no-main": "GTest::gmock_main requires NO_MAIN",
"11-cc-test-missing-srcs": "SRCS is required and must be non-empty",
"12-cc-benchmark-missing-srcs": "SRCS is required and must be non-empty",
"13-cc-benchmark-unqualified-deps": "appears without a visibility keyword",
"14-cc-benchmark-interface-visibility": "INTERFACE dependencies are not valid for benchmark targets",
"15-cc-benchmark-unknown-args": "unknown arguments",
"16-cc-library-object-type": "TYPE must be STATIC, SHARED, or INTERFACE",
# Negative cc_fuzz fixtures (T14)
"17-cc-fuzz-missing-srcs": "SRCS is required and must be non-empty",
"18-cc-fuzz-interface-visibility": "INTERFACE dependencies are not valid for fuzz targets",
"19-cc-fuzz-unknown-args": "unknown arguments",
"20-cc-fuzz-required-unavailable": "REQUIRED but FuzzTest is not available",
# Negative install-export fixtures
# Negative install-export fixtures
"01-install-missing-alias": "has no ALIAS",
"02-install-invalid-alias": "must contain exactly one",
"03-install-non-namespaced-alias": "is not namespaced",
# Negative install-export fixtures (T25 dependency registry)
"04-install-unknown-dep": "reference unknown install-time dependency",
"05-install-dep-no-package": "PACKAGE is required",
"06-install-static-private-project-not-installed": "PRIVATE project-target dependency",
# Negative fuzz-smoke fixtures (T35/T36)
"gtest-isolation": "Build tree already has normal-lane",
"fuzztest-optional-features": "FuzzTest patched mode forbids optional dependency target",
}
@dataclasses.dataclass(frozen=True)
class FixtureResult:
"""Outcome of running a single fixture."""
fixture_id: str
status: str # PASS / FAIL / DEFERRED
message: str
@dataclasses.dataclass(frozen=True)
class InstallConsumerResult:
"""Outcome of running an install-consumer QA test."""
fixture_id: str
status: str # PASS / FAIL / DEFERRED
message: str
log_dir: pathlib.Path | None = None
def _fixture_base_dir(project_root: pathlib.Path) -> pathlib.Path:
return project_root / "cmake" / "tests" / "fixtures"
def _build_root(project_root: pathlib.Path) -> pathlib.Path:
return project_root / "build" / BUILD_SUBDIR
def discover_positive_fixtures(
project_root: pathlib.Path,
) -> list[pathlib.Path]:
"""Return sorted list of positive helper-api fixture directories."""
base = _fixture_base_dir(project_root) / "positive" / FIXTURES_DIR_NAME
if not base.is_dir():
return []
return sorted(p for p in base.iterdir() if p.is_dir() and (p / "CMakeLists.txt").is_file())
def discover_subproject_fixtures(
project_root: pathlib.Path,
) -> list[pathlib.Path]:
"""Return sorted list of positive subproject fixture directories."""
base = _fixture_base_dir(project_root) / "positive" / "subproject"
if not base.is_dir():
return []
return sorted(p for p in base.iterdir() if p.is_dir() and (p / "CMakeLists.txt").is_file())
def discover_install_export_fixtures(
project_root: pathlib.Path,
) -> list[pathlib.Path]:
"""Return sorted list of positive install-export fixture directories."""
base = _fixture_base_dir(project_root) / "positive" / "install-export"
if not base.is_dir():
return []
return sorted(p for p in base.iterdir() if p.is_dir() and (p / "CMakeLists.txt").is_file())
def discover_negative_fixtures(
project_root: pathlib.Path,
) -> list[pathlib.Path]:
"""Return sorted list of negative helper-api fixture directories."""
base = _fixture_base_dir(project_root) / "negative" / FIXTURES_DIR_NAME
if not base.is_dir():
return []
return sorted(p for p in base.iterdir() if p.is_dir() and (p / "CMakeLists.txt").is_file())
def discover_negative_install_export_fixtures(
project_root: pathlib.Path,
) -> list[pathlib.Path]:
"""Return sorted list of negative install-export fixture directories."""
base = _fixture_base_dir(project_root) / "negative" / "install-export"
if not base.is_dir():
return []
return sorted(p for p in base.iterdir() if p.is_dir() and (p / "CMakeLists.txt").is_file())
def discover_negative_fuzz_smoke_fixtures(
project_root: pathlib.Path,
) -> list[pathlib.Path]:
"""Return sorted list of negative fuzz-smoke fixture directories."""
base = _fixture_base_dir(project_root) / "negative" / "fuzz-smoke"
if not base.is_dir():
return []
return sorted(p for p in base.iterdir() if p.is_dir() and (p / "CMakeLists.txt").is_file())
def _case_id(fixture_path: pathlib.Path) -> str:
"""Derive a stable case ID from the fixture directory name."""
return fixture_path.name
def discover_install_consumer_fixtures(
project_root: pathlib.Path,
) -> list[pathlib.Path]:
"""Return sorted list of install-consumer fixture directories.
Each fixture directory must contain package/ and consumer/ subdirectories.
"""
base = _fixture_base_dir(project_root) / "positive" / "install-consumer"
if not base.is_dir():
return []
return sorted(
p for p in base.iterdir()
if p.is_dir()
and (p / "package" / "CMakeLists.txt").is_file()
and (p / "consumer" / "CMakeLists.txt").is_file()
)
def _check_source_tree_leakage(
source_root: pathlib.Path,
consumer_build_dir: pathlib.Path,
install_prefix: pathlib.Path,
consumer_source_dir: pathlib.Path,
) -> str | None:
"""Check consumer build artifacts for unexpected project-root references.
Scans CMakeCache.txt and compile_commands.json for the project root path.
Allows only the consumer's own source dir, its build dir, and the install
prefix — any other project-root reference (e.g. package source/build)
is flagged as leakage.
Returns a diagnostic string if leakage is detected, or None if clean.
"""
project_prefix = str(source_root)
if not project_prefix.endswith('/'):
project_prefix += '/'
# Known-safe paths that legitimately appear under the project root
build_output_dir = str(source_root / "build")
allowlist = [
# Consumer's own source directory (fixture path)
str(consumer_source_dir) + "/",
str(consumer_source_dir),
# Consumer's own build directory
str(consumer_build_dir) + "/",
str(consumer_build_dir),
# Install prefix (under build/ by fixture convention)
str(install_prefix) + "/",
str(install_prefix),
# Build output directory tree
build_output_dir + "/",
build_output_dir,
]
def _is_line_allowed(line: str) -> bool:
"""Check if a line referencing the project root is allowed."""
stripped = line.lstrip()
# Skip CMake comments (metadata, not build logic)
if stripped.startswith('#'):
return True
# References to CC_PROJECT_ROOT in cmake invocations are expected
if "-DCC_PROJECT_ROOT=" in line:
return True
# Check allowlist
for allowed in allowlist:
if allowed in line:
return True
return False
# Check CMake cache for unexpected project-root references
cache_file = consumer_build_dir / "CMakeCache.txt"
if cache_file.is_file():
cache_content = cache_file.read_text(encoding="utf-8", errors="replace")
for line in cache_content.splitlines():
if project_prefix in line:
if not _is_line_allowed(line):
return (
f"source-tree leak in CMake cache: "
f"{line.strip()[:200]}"
)
# Check compile_commands.json for unexpected project-root paths
compile_commands = consumer_build_dir / "compile_commands.json"
if compile_commands.is_file():
cc_content = compile_commands.read_text(encoding="utf-8", errors="replace")
for line in cc_content.splitlines():
if project_prefix in line:
if not _is_line_allowed(line):
return (
f"source-tree leak in compile_commands: "
f"{line.strip()[:200]}"
)
return None
def run_install_consumer(
project_root: pathlib.Path,
fixture_path: pathlib.Path,
*,
dry_run: bool = False,
log_dir: pathlib.Path | None = None,
) -> InstallConsumerResult:
"""Run a two-phase install-consumer QA test.
Phase 1: configure, build, and install the package into a temp prefix.
Phase 2: configure, build, and run a separate consumer using only
find_package(CONFIG REQUIRED) against the installed prefix.
Checks for source-tree path leakage in the consumer build.
"""
fixture_id = _case_id(fixture_path)
pkg_dir = fixture_path / "package"
consumer_dir = fixture_path / "consumer"
if dry_run:
print(f"DRY-RUN install-consumer-{fixture_id}")
return InstallConsumerResult(
fixture_id=fixture_id,
status=STATUS_DEFERRED,
message="dry-run",
)
# Ensure cmake is available
try:
_ = common.require_tool("cmake")
except common.ScriptError as exc:
return InstallConsumerResult(
fixture_id=fixture_id,
status=STATUS_DEFERRED,
message=f"tool missing: {exc}",
)
# Set up directories
build_base = _build_root(project_root) / f"install-consumer-{fixture_id}"
pkg_build = build_base / "pkg-build"
consumer_build = build_base / "consumer-build"
install_prefix = build_base / "install-prefix"
# Clean previous runs
if build_base.is_dir():
shutil.rmtree(build_base)
pkg_build.mkdir(parents=True, exist_ok=True)
consumer_build.mkdir(parents=True, exist_ok=True)
# Set up log directory
actual_log_dir = log_dir
if actual_log_dir is None:
actual_log_dir = project_root / ".sisyphus" / "evidence" / "install-consumer" / fixture_id
actual_log_dir.mkdir(parents=True, exist_ok=True)
# --- Phase 1: Configure, build, install package ---
pkg_configure_cmd = (
"cmake", "-S", str(pkg_dir), "-B", str(pkg_build),
f"-DCC_PROJECT_ROOT={project_root}",
f"-DCMAKE_INSTALL_PREFIX={install_prefix}",
"-DCMAKE_BUILD_TYPE=Release",
)
print(f"install-consumer-{fixture_id}: phase 1 — configure package")
pkg_configure = subprocess.run(
list(pkg_configure_cmd),
cwd=str(project_root),
capture_output=True, text=True, check=False,
)
_log = (
f"command: {common.format_command(pkg_configure_cmd)}\n"
f"returncode: {pkg_configure.returncode}\n"
f"--- stdout ---\n{pkg_configure.stdout}"
f"--- stderr ---\n{pkg_configure.stderr}"
)
_ = (actual_log_dir / "pkg-configure.log").write_text(_log, encoding="utf-8")
if pkg_configure.returncode != 0:
msg = (
f"{STATUS_FAIL}: install-consumer-{fixture_id} "
f"package configure failed (rc={pkg_configure.returncode})\n"
f" stderr: {pkg_configure.stderr[:500]}"
)
print(msg)
return InstallConsumerResult(
fixture_id=fixture_id, status=STATUS_FAIL, message=msg,
log_dir=actual_log_dir,
)
pkg_build_cmd = ("cmake", "--build", str(pkg_build))
print(f"install-consumer-{fixture_id}: phase 1 — build package")
pkg_build_result = subprocess.run(
list(pkg_build_cmd),
cwd=str(project_root),
capture_output=True, text=True, check=False,
)
_log = (
f"command: {common.format_command(pkg_build_cmd)}\n"
f"returncode: {pkg_build_result.returncode}\n"
f"--- stdout ---\n{pkg_build_result.stdout}"
f"--- stderr ---\n{pkg_build_result.stderr}"
)
_ = (actual_log_dir / "pkg-build.log").write_text(_log, encoding="utf-8")
if pkg_build_result.returncode != 0:
msg = (
f"{STATUS_FAIL}: install-consumer-{fixture_id} "
f"package build failed (rc={pkg_build_result.returncode})\n"
f" stderr: {pkg_build_result.stderr[:500]}"
)
print(msg)
return InstallConsumerResult(
fixture_id=fixture_id, status=STATUS_FAIL, message=msg,
log_dir=actual_log_dir,
)
pkg_install_cmd = ("cmake", "--install", str(pkg_build))
print(f"install-consumer-{fixture_id}: phase 1 — install package")
pkg_install_result = subprocess.run(
list(pkg_install_cmd),
cwd=str(project_root),
capture_output=True, text=True, check=False,
)
_log = (
f"command: {common.format_command(pkg_install_cmd)}\n"
f"returncode: {pkg_install_result.returncode}\n"
f"--- stdout ---\n{pkg_install_result.stdout}"
f"--- stderr ---\n{pkg_install_result.stderr}"
)
_ = (actual_log_dir / "pkg-install.log").write_text(_log, encoding="utf-8")
if pkg_install_result.returncode != 0:
msg = (
f"{STATUS_FAIL}: install-consumer-{fixture_id} "
f"package install failed (rc={pkg_install_result.returncode})\n"
f" stderr: {pkg_install_result.stderr[:500]}"
)
print(msg)
return InstallConsumerResult(
fixture_id=fixture_id, status=STATUS_FAIL, message=msg,
log_dir=actual_log_dir,
)
# --- Phase 2: Configure, build, run consumer ---
consumer_configure_cmd = (
"cmake", "-S", str(consumer_dir), "-B", str(consumer_build),
f"-DCMAKE_PREFIX_PATH={install_prefix}",
"-DCMAKE_BUILD_TYPE=Release",
)
print(f"install-consumer-{fixture_id}: phase 2 — configure consumer")
consumer_configure = subprocess.run(
list(consumer_configure_cmd),
cwd=str(consumer_dir),
capture_output=True, text=True, check=False,
)
_log = (
f"command: {common.format_command(consumer_configure_cmd)}\n"
f"returncode: {consumer_configure.returncode}\n"
f"--- stdout ---\n{consumer_configure.stdout}"
f"--- stderr ---\n{consumer_configure.stderr}"
)
_ = (actual_log_dir / "consumer-configure.log").write_text(_log, encoding="utf-8")
if consumer_configure.returncode != 0:
msg = (
f"{STATUS_FAIL}: install-consumer-{fixture_id} "
f"consumer configure failed (rc={consumer_configure.returncode})\n"
f" stderr: {consumer_configure.stderr[:500]}"
)
print(msg)
return InstallConsumerResult(
fixture_id=fixture_id, status=STATUS_FAIL, message=msg,
log_dir=actual_log_dir,
)
# Source-tree leakage detection
leakage = _check_source_tree_leakage(project_root, consumer_build, install_prefix, consumer_dir)
if leakage is not None:
msg = (
f"{STATUS_FAIL}: install-consumer-{fixture_id} "
f"source-tree leakage detected: {leakage}"
)
print(msg)
return InstallConsumerResult(
fixture_id=fixture_id, status=STATUS_FAIL, message=msg,
log_dir=actual_log_dir,
)
consumer_build_cmd = ("cmake", "--build", str(consumer_build))
print(f"install-consumer-{fixture_id}: phase 2 — build consumer")
consumer_build_result = subprocess.run(
list(consumer_build_cmd),
cwd=str(consumer_dir),
capture_output=True, text=True, check=False,
)
_log = (
f"command: {common.format_command(consumer_build_cmd)}\n"
f"returncode: {consumer_build_result.returncode}\n"
f"--- stdout ---\n{consumer_build_result.stdout}"
f"--- stderr ---\n{consumer_build_result.stderr}"
)
_ = (actual_log_dir / "consumer-build.log").write_text(_log, encoding="utf-8")
if consumer_build_result.returncode != 0:
msg = (
f"{STATUS_FAIL}: install-consumer-{fixture_id} "
f"consumer build failed (rc={consumer_build_result.returncode})\n"
f" stderr: {consumer_build_result.stderr[:500]}"
)
print(msg)
return InstallConsumerResult(
fixture_id=fixture_id, status=STATUS_FAIL, message=msg,
log_dir=actual_log_dir,
)
# Run the consumer executable
# Find the built executable
consumer_exe = consumer_build / "consumer_app"
if not consumer_exe.is_file():
# Try with common extensions
for ext in ("", ".exe"):
candidate = consumer_build / f"consumer_app{ext}"
if candidate.is_file():
consumer_exe = candidate
break
if not consumer_exe.is_file():
msg = (
f"{STATUS_FAIL}: install-consumer-{fixture_id} "
f"consumer executable not found at {consumer_exe}"
)
print(msg)
return InstallConsumerResult(
fixture_id=fixture_id, status=STATUS_FAIL, message=msg,
log_dir=actual_log_dir,
)
print(f"install-consumer-{fixture_id}: phase 2 — run consumer")
consumer_run_result = subprocess.run(
[str(consumer_exe)],
capture_output=True, text=True, check=False,
)
_log = (
f"command: {consumer_exe}\n"
f"returncode: {consumer_run_result.returncode}\n"
f"--- stdout ---\n{consumer_run_result.stdout}"
f"--- stderr ---\n{consumer_run_result.stderr}"
)
_ = (actual_log_dir / "consumer-run.log").write_text(_log, encoding="utf-8")
if consumer_run_result.returncode != 0:
msg = (
f"{STATUS_FAIL}: install-consumer-{fixture_id} "
f"consumer run failed (rc={consumer_run_result.returncode})\n"
f" stdout: {consumer_run_result.stdout[:500]}\n"
f" stderr: {consumer_run_result.stderr[:500]}"
)
print(msg)
return InstallConsumerResult(
fixture_id=fixture_id, status=STATUS_FAIL, message=msg,
log_dir=actual_log_dir,
)
# Write summary log
summary = (
f"install-consumer-{fixture_id}: PASS\n"
f"install_prefix: {install_prefix}\n"
f"source_tree_leakage: none\n"
f"consumer_output: {consumer_run_result.stdout.strip()}\n"
)
_ = (actual_log_dir / "summary.log").write_text(summary, encoding="utf-8")
msg = (
f"{STATUS_PASS}: install-consumer-{fixture_id} "
"— package installed, consumer built and ran successfully"
)
print(msg)
return InstallConsumerResult(
fixture_id=fixture_id, status=STATUS_PASS, message=msg,
log_dir=actual_log_dir,
)
def run_all_install_consumers(
project_root: pathlib.Path,
*,
dry_run: bool = False,
log_dir: pathlib.Path | None = None,
) -> list[InstallConsumerResult]:
"""Discover and run all install-consumer fixtures. Returns per-fixture results."""
fixtures = discover_install_consumer_fixtures(project_root)
results: list[InstallConsumerResult] = []
for fixture_path in fixtures:
result = run_install_consumer(
project_root, fixture_path, dry_run=dry_run, log_dir=log_dir,
)
results.append(result)
return results
def summarize_install_consumer_results(
results: Sequence[InstallConsumerResult],
) -> int:
"""Print summary and return exit code (0 = all pass, 1 = any fail)."""
passed = sum(1 for r in results if r.status == STATUS_PASS)
failed = sum(1 for r in results if r.status == STATUS_FAIL)
deferred = sum(1 for r in results if r.status == STATUS_DEFERRED)
total = len(results)
print(f"\ninstall-consumer summary: {total} fixtures, {passed} passed, {failed} failed, {deferred} deferred")
if failed > 0:
print(f"{STATUS_FAIL}: {failed} install-consumer fixture(s) failed")
return 1
if total == 0:
print(f"{STATUS_DEFERRED}: no install-consumer fixtures discovered")
return 2
if deferred > 0 and passed == 0:
print(f"{STATUS_DEFERRED}: {deferred} install-consumer fixture(s) planned (dry-run)")
return 0
if deferred > 0:
print(f"{STATUS_PASS}: {passed} executed, {deferred} deferred (dry-run)")
return 0
print(f"{STATUS_PASS}: all {total} install-consumer fixture(s) passed")
return 0
def build_cmake_plans(
project_root: pathlib.Path,
*,
include_positive: bool = True,
include_negative: bool = True,
include_subproject: bool = True,
include_install_export: bool = True,
subset: Sequence[str] | None = None,
) -> list[common.CommandPlan]:
"""Build CommandPlan entries for all selected fixtures."""
build_root = _build_root(project_root)
plans: list[common.CommandPlan] = []
fixtures: list[tuple[pathlib.Path, str]] = [] # (path, kind)
if include_positive:
for p in discover_positive_fixtures(project_root):
fixtures.append((p, "positive"))
if include_subproject:
for p in discover_subproject_fixtures(project_root):
fixtures.append((p, "positive"))
if include_install_export:
for p in discover_install_export_fixtures(project_root):
fixtures.append((p, "positive"))
if include_negative:
for p in discover_negative_fixtures(project_root):
fixtures.append((p, "negative"))
for p in discover_negative_install_export_fixtures(project_root):
fixtures.append((p, "negative"))
for p in discover_negative_fuzz_smoke_fixtures(project_root):
fixtures.append((p, "negative"))
for fixture_path, _kind in fixtures:
case_id = _case_id(fixture_path)
if subset is not None and case_id not in subset:
continue
fixture_build = build_root / case_id
plans.append(
common.CommandPlan(
label=f"cmake-helper-fixture-{case_id}",
command=(
"cmake",
"-S",
str(fixture_path),
"-B",
str(fixture_build),
f"-DCC_PROJECT_ROOT={project_root}",
),
cwd=project_root,
required_tool="cmake",
)
)
return plans
def run_fixture(
plan: common.CommandPlan,
*,
dry_run: bool = False,
) -> FixtureResult:
"""Execute a single fixture plan and return a FixtureResult."""
case_id = plan.label.removeprefix("cmake-helper-fixture-")
build_root = _build_root(plan.cwd)
if dry_run:
print(f"DRY-RUN {plan.label}: {common.format_command(plan.command)}")
return FixtureResult(
fixture_id=case_id,
status=STATUS_DEFERRED,
message="dry-run",
)
# Determine if this is a negative fixture by looking up the diagnostics table.
expected_diag = NEGATIVE_DIAGNOSTICS.get(case_id)
is_negative = expected_diag is not None
# Ensure build dir exists.
fixture_build = build_root / case_id
fixture_build.mkdir(parents=True, exist_ok=True)
# Check for cmake tool.
if plan.required_tool is not None:
try:
_ = common.require_tool(plan.required_tool)
except common.ScriptError as exc:
return FixtureResult(
fixture_id=case_id,
status=STATUS_DEFERRED,
message=f"tool missing: {exc}",
)
completed = subprocess.run(
list(plan.command),
cwd=str(plan.cwd),
capture_output=True,
text=True,
check=False,
)
if is_negative:
# Negative: cmake configure should FAIL, and stderr must contain the
# expected diagnostic substring.
if completed.returncode == 0:
msg = (
f"{STATUS_FAIL}: negative fixture {case_id} "
"expected configure failure but cmake returned 0"
)
print(msg)
return FixtureResult(fixture_id=case_id, status=STATUS_FAIL, message=msg)
combined_output = re.sub(r'\s+', ' ', completed.stderr + completed.stdout)
if expected_diag not in combined_output:
msg = (
f"{STATUS_FAIL}: negative fixture {case_id} "
f"configure failed as expected but diagnostic substring "
f"not found.\n expected: {expected_diag!r}\n stderr: {completed.stderr[:500]}"
)
print(msg)
return FixtureResult(fixture_id=case_id, status=STATUS_FAIL, message=msg)
msg = f"{STATUS_PASS}: negative fixture {case_id} — configure failed with expected diagnostic"
print(msg)
return FixtureResult(fixture_id=case_id, status=STATUS_PASS, message=msg)
# Positive: cmake configure should SUCCEED.
if completed.returncode != 0:
msg = (
f"{STATUS_FAIL}: positive fixture {case_id} "
f"configure failed (rc={completed.returncode})\n stderr: {completed.stderr[:500]}"
)
print(msg)
return FixtureResult(fixture_id=case_id, status=STATUS_FAIL, message=msg)
msg = f"{STATUS_PASS}: positive fixture {case_id} — configure succeeded"
print(msg)
return FixtureResult(fixture_id=case_id, status=STATUS_PASS, message=msg)
def run_all_fixtures(
project_root: pathlib.Path,
*,
dry_run: bool = False,
subset: Sequence[str] | None = None,
include_positive: bool = True,
include_negative: bool = True,
include_subproject: bool = True,
include_install_export: bool = True,
) -> list[FixtureResult]:
"""Discover, plan, and run all selected fixtures. Returns per-fixture results."""
plans = build_cmake_plans(
project_root,
include_positive=include_positive,
include_negative=include_negative,
include_subproject=include_subproject,
include_install_export=include_install_export,
subset=subset,
)
results: list[FixtureResult] = []
for plan in plans:
result = run_fixture(plan, dry_run=dry_run)
results.append(result)
return results
def summarize_results(results: Sequence[FixtureResult]) -> int:
"""Print summary and return exit code (0 = all pass, 1 = any fail)."""
passed = sum(1 for r in results if r.status == STATUS_PASS)
failed = sum(1 for r in results if r.status == STATUS_FAIL)
deferred = sum(1 for r in results if r.status == STATUS_DEFERRED)
total = len(results)
print(f"\ncmake-helper-fixtures summary: {total} fixtures, {passed} passed, {failed} failed, {deferred} deferred")
if failed > 0:
print(f"{STATUS_FAIL}: {failed} fixture(s) failed")
return 1
if total == 0:
print(f"{STATUS_DEFERRED}: no fixtures discovered")
return 2
if deferred > 0 and passed == 0:
print(f"{STATUS_DEFERRED}: {deferred} fixture(s) planned (dry-run)")
return 0
if deferred > 0:
print(f"{STATUS_PASS}: {passed} executed, {deferred} deferred (dry-run)")
return 0
print(f"{STATUS_PASS}: all {total} fixture(s) passed")
return 0