Files
qt6-migration-pipeline/app/migration_pipeline/build_runner.py
T

186 lines
5.7 KiB
Python

"""Isolated cmake / ctest subprocess helpers.
Each track gets its own worktree + build directory, so workers never
share a build state and async concurrency is safe.
"""
from __future__ import annotations
import asyncio
import json
import re
import subprocess
from pathlib import Path
from typing import Tuple
# ---------------------------------------------------------------------------
# Type alias
# ---------------------------------------------------------------------------
RunResult = Tuple[bool, str, str] # (success, stdout, stderr)
# ---------------------------------------------------------------------------
# Low-level sync helpers (run in thread-pool via run_in_executor)
# ---------------------------------------------------------------------------
def _run(cmd: list[str], cwd: Path | None = None) -> Tuple[int, str, str]:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
cwd=str(cwd) if cwd else None,
)
return result.returncode, result.stdout, result.stderr
# ---------------------------------------------------------------------------
# Async wrappers
# ---------------------------------------------------------------------------
async def cmake_configure(source_dir: Path, build_dir: Path) -> RunResult:
"""Run cmake configure, enabling compile_commands.json export."""
build_dir.mkdir(parents=True, exist_ok=True)
loop = asyncio.get_running_loop()
rc, out, err = await loop.run_in_executor(
None,
lambda: _run(
[
"cmake",
"-B", str(build_dir),
str(source_dir),
"-DCMAKE_EXPORT_COMPILE_COMMANDS=ON",
"-DCMAKE_BUILD_TYPE=Debug",
]
),
)
return rc == 0, out, err
async def cmake_build(build_dir: Path, jobs: int = 4) -> RunResult:
"""Full (incremental) build of the tree in *build_dir*."""
loop = asyncio.get_running_loop()
rc, out, err = await loop.run_in_executor(
None,
lambda: _run(
["cmake", "--build", str(build_dir), "--", f"-j{jobs}"]
),
)
return rc == 0, out, err
async def syntax_check_file(file_path: Path, build_dir: Path) -> RunResult:
"""Fast per-file syntax check using the compile command from
compile_commands.json. Falls back to a plain clang++ call when the
JSON is not available.
"""
loop = asyncio.get_running_loop()
cmd = _compile_cmd_for_file(file_path, build_dir)
if cmd is None:
# Fallback: bare syntax-only compile with C++20
cmd = [
"clang++",
"-std=c++20",
"-fsyntax-only",
"-w", # suppress all warnings for the fast check
str(file_path),
]
rc, out, err = await loop.run_in_executor(None, lambda: _run(cmd))
return rc == 0, out, err
async def run_ctest(
build_dir: Path,
test_filter: str | None = None,
) -> RunResult:
"""Execute CTest in *build_dir*.
Pass *test_filter* to run only a subset (regex forwarded to ``-R``).
"""
cmd = ["ctest", "--test-dir", str(build_dir), "--output-on-failure"]
if test_filter:
cmd += ["-R", test_filter]
loop = asyncio.get_running_loop()
rc, out, err = await loop.run_in_executor(None, lambda: _run(cmd))
return rc == 0, out, err
# ---------------------------------------------------------------------------
# Warning parsing
# ---------------------------------------------------------------------------
_WARNING_RE = re.compile(r":\s*warning\s*:", re.IGNORECASE)
def parse_warnings(text: str) -> list[str]:
"""Return all lines that contain a compiler/linker warning."""
return [ln.strip() for ln in text.splitlines() if _WARNING_RE.search(ln)]
# ---------------------------------------------------------------------------
# compile_commands.json helpers
# ---------------------------------------------------------------------------
def _load_compile_commands(build_dir: Path) -> dict[str, dict]:
"""Return {absolute_file_path: entry} from compile_commands.json."""
cc_path = build_dir / "compile_commands.json"
if not cc_path.exists():
return {}
try:
with cc_path.open() as fh:
entries = json.load(fh)
return {e["file"]: e for e in entries if "file" in e}
except Exception:
return {}
def _compile_cmd_for_file(
file_path: Path,
build_dir: Path,
) -> list[str] | None:
"""Build a ``-fsyntax-only`` command for *file_path* from
``compile_commands.json``. Returns None if no entry is found.
"""
db = _load_compile_commands(build_dir)
if not db:
return None
# Try both the resolved absolute path and as-is string.
key = str(file_path.resolve())
entry = db.get(key) or db.get(str(file_path))
if entry is None:
return None
raw_cmd: str = entry.get("command", "")
if not raw_cmd:
# Some generators use "arguments" (list) instead of "command" (string).
args: list[str] = entry.get("arguments", [])
if not args:
return None
raw_cmd = " ".join(args)
parts = raw_cmd.split()
result: list[str] = []
skip_next = False
for part in parts:
if skip_next:
skip_next = False
continue
if part == "-c":
result.append("-fsyntax-only")
elif part == "-o":
skip_next = True # drop the output-file argument
else:
result.append(part)
# Ensure the entry's source file is replaced with the migrated file path.
orig_src = entry.get("file", "")
if orig_src and result and result[-1] == orig_src:
result[-1] = str(file_path)
return result