add uncommitted files

This commit is contained in:
KOKO\Mimi
2026-07-29 23:32:26 +09:00
parent fb0f8f39a0
commit f5379472ce
80 changed files with 7461 additions and 1 deletions
+481
View File
@@ -0,0 +1,481 @@
#!/usr/bin/env python3
"""
Harness Step Executor — phase 내 step을 순차 실행하고 자가 교정한다.
Usage:
python scripts/execute.py <phase-dir> [--push]
"""
import argparse
import contextlib
import json
import os
import subprocess
import sys
import threading
import time
import types
from datetime import datetime, timezone, timedelta
from pathlib import Path
from typing import Optional
ROOT = Path(__file__).resolve().parent.parent
class CodexEnvironmentError(RuntimeError):
"""재시도로 해결할 수 없는 Codex CLI 환경 오류."""
@contextlib.contextmanager
def progress_indicator(label: str):
"""터미널 진행 표시기. with 문으로 사용하며 .elapsed 로 경과 시간을 읽는다."""
frames = "◐◓◑◒"
stop = threading.Event()
t0 = time.monotonic()
def _animate():
idx = 0
while not stop.wait(0.12):
sec = int(time.monotonic() - t0)
sys.stderr.write(f"\r{frames[idx % len(frames)]} {label} [{sec}s]")
sys.stderr.flush()
idx += 1
sys.stderr.write("\r" + " " * (len(label) + 20) + "\r")
sys.stderr.flush()
th = threading.Thread(target=_animate, daemon=True)
th.start()
info = types.SimpleNamespace(elapsed=0.0)
try:
yield info
finally:
stop.set()
th.join()
info.elapsed = time.monotonic() - t0
class StepExecutor:
"""Phase 디렉토리 안의 step들을 순차 실행하는 하네스."""
MAX_RETRIES = 3
FEAT_MSG = "feat({phase}): step {num}{name}"
CHORE_MSG = "chore({phase}): step {num} output"
TZ = timezone(timedelta(hours=9))
def __init__(self, phase_dir_name: str, *, auto_push: bool = False):
self._root = str(ROOT)
self._phases_dir = ROOT / "phases"
self._phase_dir = self._phases_dir / phase_dir_name
self._phase_dir_name = phase_dir_name
self._top_index_file = self._phases_dir / "index.json"
self._auto_push = auto_push
if not self._phase_dir.is_dir():
print(f"ERROR: {self._phase_dir} not found")
sys.exit(1)
self._index_file = self._phase_dir / "index.json"
if not self._index_file.exists():
print(f"ERROR: {self._index_file} not found")
sys.exit(1)
idx = self._read_json(self._index_file)
self._project = idx.get("project", "project")
self._phase_name = idx.get("phase", phase_dir_name)
self._total = len(idx["steps"])
def run(self):
self._print_header()
self._check_blockers()
self._checkout_branch()
guardrails = self._load_guardrails()
self._ensure_created_at()
self._execute_all_steps(guardrails)
self._finalize()
# --- timestamps ---
def _stamp(self) -> str:
return datetime.now(self.TZ).strftime("%Y-%m-%dT%H:%M:%S%z")
# --- JSON I/O ---
@staticmethod
def _read_json(p: Path) -> dict:
return json.loads(p.read_text(encoding="utf-8"))
@staticmethod
def _write_json(p: Path, data: dict):
p.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
# --- git ---
def _run_git(self, *args) -> subprocess.CompletedProcess:
cmd = ["git"] + list(args)
return subprocess.run(cmd, cwd=self._root, capture_output=True, text=True)
def _checkout_branch(self):
branch = f"feat-{self._phase_name}"
r = self._run_git("rev-parse", "--abbrev-ref", "HEAD")
if r.returncode != 0:
print(f" ERROR: git을 사용할 수 없거나 git repo가 아닙니다.")
print(f" {r.stderr.strip()}")
sys.exit(1)
if r.stdout.strip() == branch:
return
r = self._run_git("rev-parse", "--verify", branch)
r = self._run_git("checkout", branch) if r.returncode == 0 else self._run_git("checkout", "-b", branch)
if r.returncode != 0:
print(f" ERROR: 브랜치 '{branch}' checkout 실패.")
print(f" {r.stderr.strip()}")
print(f" Hint: 변경사항을 stash하거나 commit한 후 다시 시도하세요.")
sys.exit(1)
print(f" Branch: {branch}")
def _commit_step(self, step_num: int, step_name: str):
output_rel = f"phases/{self._phase_dir_name}/step{step_num}-output.json"
index_rel = f"phases/{self._phase_dir_name}/index.json"
self._run_git("add", "-A")
self._run_git("reset", "HEAD", "--", output_rel)
self._run_git("reset", "HEAD", "--", index_rel)
if self._run_git("diff", "--cached", "--quiet").returncode != 0:
msg = self.FEAT_MSG.format(phase=self._phase_name, num=step_num, name=step_name)
r = self._run_git("commit", "-m", msg)
if r.returncode == 0:
print(f" Commit: {msg}")
else:
print(f" WARN: 코드 커밋 실패: {r.stderr.strip()}")
self._run_git("add", "-A")
if self._run_git("diff", "--cached", "--quiet").returncode != 0:
msg = self.CHORE_MSG.format(phase=self._phase_name, num=step_num)
r = self._run_git("commit", "-m", msg)
if r.returncode != 0:
print(f" WARN: housekeeping 커밋 실패: {r.stderr.strip()}")
# --- top-level index ---
def _update_top_index(self, status: str):
if not self._top_index_file.exists():
return
top = self._read_json(self._top_index_file)
ts = self._stamp()
for phase in top.get("phases", []):
if phase.get("dir") == self._phase_dir_name:
phase["status"] = status
ts_key = {"completed": "completed_at", "error": "failed_at", "blocked": "blocked_at"}.get(status)
if ts_key:
phase[ts_key] = ts
break
self._write_json(self._top_index_file, top)
# --- guardrails & context ---
def _load_guardrails(self) -> str:
sections = []
agents_md = ROOT / "AGENTS.md"
if agents_md.exists():
sections.append(
"## 프로젝트 규칙 (AGENTS.md)\n\n"
f"{agents_md.read_text(encoding='utf-8')}"
)
docs_dir = ROOT / "docs"
if docs_dir.is_dir():
for doc in sorted(docs_dir.glob("*.md")):
sections.append(
f"## {doc.stem}\n\n{doc.read_text(encoding='utf-8')}"
)
return "\n\n---\n\n".join(sections) if sections else ""
@staticmethod
def _build_step_context(index: dict) -> str:
lines = [
f"- Step {s['step']} ({s['name']}): {s['summary']}"
for s in index["steps"]
if s["status"] == "completed" and s.get("summary")
]
if not lines:
return ""
return "## 이전 Step 산출물\n\n" + "\n".join(lines) + "\n\n"
def _build_preamble(self, guardrails: str, step_context: str,
prev_error: Optional[str] = None) -> str:
retry_section = ""
if prev_error:
retry_section = (
f"\n## ⚠ 이전 시도 실패 — 아래 에러를 반드시 참고하여 수정하라\n\n"
f"{prev_error}\n\n---\n\n"
)
return (
f"당신은 {self._project} 프로젝트의 개발자입니다. 아래 step을 수행하세요.\n\n"
f"{guardrails}\n\n---\n\n"
f"{step_context}{retry_section}"
f"## 작업 규칙\n\n"
f"1. 이전 step에서 작성된 코드를 확인하고 일관성을 유지하라.\n"
f"2. 이 step에 명시된 작업만 수행하라. 추가 기능이나 파일을 만들지 마라.\n"
f"3. 기존 테스트를 깨뜨리지 마라.\n"
f"4. AC(Acceptance Criteria) 검증을 직접 실행하라.\n"
f"5. /phases/{self._phase_dir_name}/index.json의 해당 step status를 업데이트하라:\n"
f" - AC 통과 → \"completed\" + \"summary\" 필드에 이 step의 산출물을 한 줄로 요약\n"
f" - {self.MAX_RETRIES}회 수정 시도 후에도 실패 → \"error\" + \"error_message\" 기록\n"
f" - 사용자 개입이 필요한 경우 (API 키, 인증, 수동 설정 등) → \"blocked\" + \"blocked_reason\" 기록 후 즉시 중단\n"
f"6. 변경사항을 직접 커밋하지 마라. Git 커밋과 timestamp는 실행기가 처리한다.\n\n"
f"---\n\n"
)
# --- Codex 호출 ---
def _invoke_codex(self, step: dict, preamble: str) -> dict:
step_num, step_name = step["step"], step["name"]
step_file = self._phase_dir / f"step{step_num}.md"
if not step_file.exists():
print(f" ERROR: {step_file} not found")
sys.exit(1)
prompt = preamble + step_file.read_text(encoding="utf-8")
command = [
"codex",
"exec",
"--json",
"--sandbox",
"workspace-write",
"--dangerously-bypass-hook-trust",
"--cd",
self._root,
"-",
]
try:
result = subprocess.run(
command,
cwd=self._root,
input=prompt,
capture_output=True,
text=True,
timeout=1800,
)
except FileNotFoundError as exc:
raise CodexEnvironmentError(
"Codex CLI를 찾을 수 없습니다. codex를 설치하고 PATH를 확인하세요."
) from exc
if result.returncode != 0:
print(f"\n WARN: Codex가 비정상 종료됨 (code {result.returncode})")
if result.stderr:
print(f" stderr: {result.stderr[:500]}")
output = {
"step": step_num, "name": step_name,
"exitCode": result.returncode,
"stdout": result.stdout, "stderr": result.stderr,
}
out_path = self._phase_dir / f"step{step_num}-output.json"
self._write_json(out_path, output)
return output
@staticmethod
def _codex_environment_failure(output: dict) -> Optional[str]:
if output.get("exitCode", 0) == 0:
return None
diagnostic = (
f"{output.get('stderr', '')}\n{output.get('stdout', '')}"
).lower()
markers = {
"not logged in": "Codex 인증이 필요합니다.",
"authentication": "Codex 인증에 실패했습니다.",
"unexpected argument": "현재 Codex CLI가 필요한 옵션을 지원하지 않습니다.",
"unrecognized option": "현재 Codex CLI가 필요한 옵션을 지원하지 않습니다.",
}
for marker, message in markers.items():
if marker in diagnostic:
return message
return None
# --- 헤더 & 검증 ---
def _print_header(self):
print(f"\n{'='*60}")
print(f" Harness Step Executor")
print(f" Phase: {self._phase_name} | Steps: {self._total}")
if self._auto_push:
print(f" Auto-push: enabled")
print(f"{'='*60}")
def _check_blockers(self):
index = self._read_json(self._index_file)
for s in reversed(index["steps"]):
if s["status"] == "error":
print(f"\n ✗ Step {s['step']} ({s['name']}) failed.")
print(f" Error: {s.get('error_message', 'unknown')}")
print(f" Fix and reset status to 'pending' to retry.")
sys.exit(1)
if s["status"] == "blocked":
print(f"\n ⏸ Step {s['step']} ({s['name']}) blocked.")
print(f" Reason: {s.get('blocked_reason', 'unknown')}")
print(f" Resolve and reset status to 'pending' to retry.")
sys.exit(2)
if s["status"] != "pending":
break
def _ensure_created_at(self):
index = self._read_json(self._index_file)
if "created_at" not in index:
index["created_at"] = self._stamp()
self._write_json(self._index_file, index)
# --- 실행 루프 ---
def _execute_single_step(self, step: dict, guardrails: str) -> bool:
"""단일 step 실행 (재시도 포함). 완료되면 True, 실패/차단이면 False."""
step_num, step_name = step["step"], step["name"]
done = sum(1 for s in self._read_json(self._index_file)["steps"] if s["status"] == "completed")
prev_error = None
for attempt in range(1, self.MAX_RETRIES + 1):
index = self._read_json(self._index_file)
step_context = self._build_step_context(index)
preamble = self._build_preamble(guardrails, step_context, prev_error)
tag = f"Step {step_num}/{self._total - 1} ({done} done): {step_name}"
if attempt > 1:
tag += f" [retry {attempt}/{self.MAX_RETRIES}]"
with progress_indicator(tag) as pi:
output = self._invoke_codex(step, preamble)
elapsed = int(pi.elapsed)
environment_failure = self._codex_environment_failure(output)
if environment_failure:
raise CodexEnvironmentError(environment_failure)
index = self._read_json(self._index_file)
status = next((s.get("status", "pending") for s in index["steps"] if s["step"] == step_num), "pending")
ts = self._stamp()
if status == "completed":
for s in index["steps"]:
if s["step"] == step_num:
s["completed_at"] = ts
self._write_json(self._index_file, index)
self._commit_step(step_num, step_name)
print(f" ✓ Step {step_num}: {step_name} [{elapsed}s]")
return True
if status == "blocked":
for s in index["steps"]:
if s["step"] == step_num:
s["blocked_at"] = ts
self._write_json(self._index_file, index)
reason = next((s.get("blocked_reason", "") for s in index["steps"] if s["step"] == step_num), "")
print(f" ⏸ Step {step_num}: {step_name} blocked [{elapsed}s]")
print(f" Reason: {reason}")
self._update_top_index("blocked")
sys.exit(2)
runtime_error = ""
if output["exitCode"] != 0:
runtime_error = (
output.get("stderr", "").strip()
or output.get("stdout", "").strip()
)
err_msg = next(
(
s.get(
"error_message",
runtime_error or "Step did not update status",
)
for s in index["steps"]
if s["step"] == step_num
),
runtime_error or "Step did not update status",
)
if attempt < self.MAX_RETRIES:
for s in index["steps"]:
if s["step"] == step_num:
s["status"] = "pending"
s.pop("error_message", None)
self._write_json(self._index_file, index)
prev_error = err_msg
print(f" ↻ Step {step_num}: retry {attempt}/{self.MAX_RETRIES}{err_msg}")
else:
for s in index["steps"]:
if s["step"] == step_num:
s["status"] = "error"
s["error_message"] = f"[{self.MAX_RETRIES}회 시도 후 실패] {err_msg}"
s["failed_at"] = ts
self._write_json(self._index_file, index)
self._commit_step(step_num, step_name)
print(f" ✗ Step {step_num}: {step_name} failed after {self.MAX_RETRIES} attempts [{elapsed}s]")
print(f" Error: {err_msg}")
self._update_top_index("error")
sys.exit(1)
return False # unreachable
def _execute_all_steps(self, guardrails: str):
while True:
index = self._read_json(self._index_file)
pending = next((s for s in index["steps"] if s["status"] == "pending"), None)
if pending is None:
print("\n All steps completed!")
return
step_num = pending["step"]
for s in index["steps"]:
if s["step"] == step_num and "started_at" not in s:
s["started_at"] = self._stamp()
self._write_json(self._index_file, index)
break
self._execute_single_step(pending, guardrails)
def _finalize(self):
index = self._read_json(self._index_file)
index["completed_at"] = self._stamp()
self._write_json(self._index_file, index)
self._update_top_index("completed")
self._run_git("add", "-A")
if self._run_git("diff", "--cached", "--quiet").returncode != 0:
msg = f"chore({self._phase_name}): mark phase completed"
r = self._run_git("commit", "-m", msg)
if r.returncode == 0:
print(f"{msg}")
if self._auto_push:
branch = f"feat-{self._phase_name}"
r = self._run_git("push", "-u", "origin", branch)
if r.returncode != 0:
print(f"\n ERROR: git push 실패: {r.stderr.strip()}")
sys.exit(1)
print(f" ✓ Pushed to origin/{branch}")
print(f"\n{'='*60}")
print(f" Phase '{self._phase_name}' completed!")
print(f"{'='*60}")
def main():
parser = argparse.ArgumentParser(description="Harness Step Executor")
parser.add_argument("phase_dir", help="Phase directory name (e.g. 0-mvp)")
parser.add_argument("--push", action="store_true", help="Push branch after completion")
args = parser.parse_args()
try:
StepExecutor(args.phase_dir, auto_push=args.push).run()
except CodexEnvironmentError as exc:
print(f"ERROR: {exc}")
sys.exit(1)
if __name__ == "__main__":
main()
+118
View File
@@ -0,0 +1,118 @@
#!/usr/bin/env python3
"""Codex PreToolUse 정책: 위험 명령과 테스트 없는 구현 파일 수정을 차단한다."""
import json
import re
import sys
from pathlib import Path
from typing import Any
SCRIPTS_DIR = Path(__file__).resolve().parents[1]
if str(SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPTS_DIR))
from msvc_harness.config import ConfigError, load_config
from msvc_harness.tdd_policy import CPP_SUFFIXES, evaluate_paths
DANGEROUS_PATTERNS = (
re.compile(r"\bgit\s+reset\s+--hard\b", re.IGNORECASE),
re.compile(r"\bgit\s+push\b[^\n]*--force(?:-with-lease)?\b", re.IGNORECASE),
re.compile(r"\brm\s+-rf\b", re.IGNORECASE),
re.compile(
r"\bRemove-Item\b(?=[^\n]*-Recurse\b)(?=[^\n]*-Force\b)",
re.IGNORECASE,
),
re.compile(
r"\b(?:rmdir|rd)\b(?=[^\n]*/s\b)(?=[^\n]*/q\b)",
re.IGNORECASE,
),
re.compile(r"\bDROP\s+TABLE\b", re.IGNORECASE),
)
PATCH_PATH = re.compile(
r"^\*\*\* (?:(?:Add|Update|Delete) File:|Move to:) (?P<path>.+?)\s*$",
re.MULTILINE,
)
def _tool_input(payload: dict[str, Any]) -> dict[str, Any]:
value = payload.get("tool_input", {})
return value if isinstance(value, dict) else {}
def _candidate_paths(payload: dict[str, Any], root: Path) -> list[Path]:
tool_input = _tool_input(payload)
raw_paths: list[str] = []
for key in ("path", "file_path"):
value = tool_input.get(key)
if isinstance(value, str) and value.strip():
raw_paths.append(value.strip())
edits = tool_input.get("edits")
if isinstance(edits, list):
for edit in edits:
if not isinstance(edit, dict):
continue
value = edit.get("path")
if isinstance(value, str) and value.strip():
raw_paths.append(value.strip())
for key in ("patch", "input"):
value = tool_input.get(key)
if isinstance(value, str):
raw_paths.extend(match.group("path") for match in PATCH_PATH.finditer(value))
paths: list[Path] = []
for raw_path in raw_paths:
path = Path(raw_path)
paths.append(path.resolve() if path.is_absolute() else (root / path).resolve())
return list(dict.fromkeys(paths))
def evaluate(payload: dict[str, Any], root: Path) -> str | None:
"""Return a blocking reason, or None when the tool call is allowed."""
tool_name = str(payload.get("tool_name", ""))
tool_input = _tool_input(payload)
if tool_name in {"Bash", "shell_command", "PowerShell"}:
command = tool_input.get("command", "")
if isinstance(command, str) and any(
pattern.search(command) for pattern in DANGEROUS_PATTERNS
):
return "위험한 명령어가 감지되어 실행을 차단했습니다."
if tool_name not in {"apply_patch", "Edit", "MultiEdit", "Write"}:
return None
paths = _candidate_paths(payload, root)
try:
config = load_config(root)
except ConfigError as exc:
if any(path.suffix.lower() in CPP_SUFFIXES for path in paths):
return f"TDD GUARD: .harness/config.json must be repaired: {exc}"
return None
return evaluate_paths(paths, root, config.tdd)
def main() -> int:
try:
payload = json.load(sys.stdin)
if not isinstance(payload, dict):
raise TypeError("hook input must be a JSON object")
cwd = payload.get("cwd")
if cwd is not None and not isinstance(cwd, str):
raise TypeError("hook cwd must be a string")
except (json.JSONDecodeError, TypeError) as exc:
print(f"TDD GUARD: hook 입력을 해석하지 못해 검사를 건너뜁니다: {exc}", file=sys.stderr)
return 0
root = Path(payload.get("cwd") or Path.cwd()).resolve()
reason = evaluate(payload, root)
if reason:
print(reason, file=sys.stderr)
return 2
return 0
if __name__ == "__main__":
raise SystemExit(main())
+148
View File
@@ -0,0 +1,148 @@
#!/usr/bin/env python3
"""Codex Stop hook: validate discovered C/C++ projects with MSVC."""
import json
import os
import subprocess
import sys
import time
from pathlib import Path
SCRIPTS = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(SCRIPTS))
from msvc_harness.adapters.base import AdapterError
from msvc_harness.adapters.cmake import CMakeAdapter
from msvc_harness.adapters.msbuild import MSBuildAdapter
from msvc_harness.config import ConfigError, load_config
from msvc_harness.discovery import DiscoveryError, discover_project
from msvc_harness.models import ProjectKind
from msvc_harness.process import ValidationFailure, execute_plan
from msvc_harness.toolchain import ToolchainError, discover_toolchain
REENTRY_ENV = "CODEX_STOP_VALIDATION_ACTIVE"
TOTAL_TIMEOUT_SECONDS = 1800
def _remaining(deadline, clock, stage):
remaining = deadline - clock()
if remaining <= 0:
raise ValidationFailure(f"{stage} timed out before it could start")
return remaining
def _project_root(
cwd: Path,
*,
deadline: float,
run=subprocess.run,
clock=time.monotonic,
) -> Path:
argv = ["git", "rev-parse", "--show-toplevel"]
resolved_cwd = cwd.resolve()
try:
result = run(
argv,
cwd=resolved_cwd,
capture_output=True,
text=True,
shell=False,
timeout=_remaining(deadline, clock, "repository discovery"),
)
except subprocess.TimeoutExpired as exc:
raise ValidationFailure(
f"repository discovery timed out; argv={argv!r}; "
f"cwd={str(resolved_cwd)!r}"
) from exc
if result.returncode == 0 and result.stdout.strip():
return Path(result.stdout.strip()).resolve()
return resolved_cwd
def run_validations(
root: Path,
*,
deadline: float | None = None,
clock=time.monotonic,
) -> tuple[bool, str]:
"""Build and test a discovered C/C++ project, when one exists."""
if deadline is None:
deadline = clock() + TOTAL_TIMEOUT_SECONDS
try:
config = load_config(root)
discovery = discover_project(root, config)
if discovery.selection is None:
return True, ""
selection = discovery.selection
tools = discover_toolchain(
selection.kind,
deadline=deadline,
clock=clock,
)
adapter = (
CMakeAdapter()
if selection.kind is ProjectKind.CMAKE
else MSBuildAdapter()
)
plan = adapter.create_plan(root, selection, config, tools)
child_env = os.environ.copy()
child_env[REENTRY_ENV] = "1"
execute_plan(
plan,
root,
env=child_env,
deadline=deadline,
clock=clock,
)
return True, ""
except (
ConfigError,
DiscoveryError,
ToolchainError,
AdapterError,
ValidationFailure,
OSError,
) as exc:
return False, str(exc)
def _emit_stop_response(message: str) -> None:
print(
json.dumps(
{
"continue": False,
"stopReason": message,
"systemMessage": message,
},
ensure_ascii=False,
)
)
def main(*, run=subprocess.run, clock=time.monotonic) -> int:
if os.environ.get(REENTRY_ENV) == "1":
return 0
deadline = clock() + TOTAL_TIMEOUT_SECONDS
try:
root = _project_root(
Path.cwd(),
deadline=deadline,
run=run,
clock=clock,
)
ok, message = run_validations(root, deadline=deadline, clock=clock)
except (OSError, ValidationFailure) as exc:
_emit_stop_response(f"validation hook failed: {exc}")
return 0
if ok:
return 0
_emit_stop_response(message)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+3
View File
@@ -0,0 +1,3 @@
from .config import ConfigError, load_config
__all__ = ["ConfigError", "load_config"]
@@ -0,0 +1,5 @@
from .base import AdapterError, ValidationAdapter
from .cmake import CMakeAdapter
from .msbuild import MSBuildAdapter
__all__ = ["AdapterError", "CMakeAdapter", "MSBuildAdapter", "ValidationAdapter"]
+19
View File
@@ -0,0 +1,19 @@
from pathlib import Path
from typing import Protocol
from ..models import HarnessConfig, ProjectSelection, Toolchain, ValidationPlan
class AdapterError(RuntimeError):
pass
class ValidationAdapter(Protocol):
def create_plan(
self,
root: Path,
selection: ProjectSelection,
config: HarnessConfig,
tools: Toolchain,
) -> ValidationPlan:
raise NotImplementedError
+99
View File
@@ -0,0 +1,99 @@
from pathlib import Path
from ..models import (
CommandSpec,
HarnessConfig,
ProjectKind,
ProjectSelection,
ResultCheck,
ResultCheckKind,
Toolchain,
ValidationPlan,
ValidationStep,
)
from .base import AdapterError
class CMakeAdapter:
def create_plan(
self,
root: Path,
selection: ProjectSelection,
config: HarnessConfig,
tools: Toolchain,
) -> ValidationPlan:
if selection.kind is not ProjectKind.CMAKE:
raise AdapterError("CMakeAdapter requires a CMake project selection")
if tools.cmake is None:
raise AdapterError("CMake tool is required")
if tools.ctest is None:
raise AdapterError("CTest tool is required")
if config.cmake.configure_preset is None:
command_cwd = root
binary = root / ".harness/build"
configure = (
str(tools.cmake),
"-S",
str(config.cmake.source_dir),
"-B",
str(binary),
"-A",
"x64",
)
build = (str(tools.cmake), "--build", str(binary), "--config", "Debug")
discover = (
str(tools.ctest),
"--test-dir",
str(binary),
"-C",
"Debug",
"--show-only=json-v1",
)
test = (
str(tools.ctest),
"--test-dir",
str(binary),
"-C",
"Debug",
"--output-on-failure",
)
else:
command_cwd = config.cmake.source_dir
binary = config.cmake.binary_dir
if (
binary is None
or config.cmake.build_preset is None
or config.cmake.test_preset is None
):
raise AdapterError("CMake presets require binary, build, and test settings")
configure = (str(tools.cmake), "--preset", config.cmake.configure_preset)
build = (str(tools.cmake), "--build", "--preset", config.cmake.build_preset)
discover = (
str(tools.ctest),
"--preset",
config.cmake.test_preset,
"--show-only=json-v1",
)
test = (
str(tools.ctest),
"--preset",
config.cmake.test_preset,
"--output-on-failure",
)
return ValidationPlan(
ProjectKind.CMAKE,
(
ValidationStep(
CommandSpec(configure, command_cwd, "configure"),
(ResultCheck(ResultCheckKind.CMAKE_COMPILER_IS_MSVC, binary),),
),
ValidationStep(CommandSpec(build, command_cwd, "build")),
ValidationStep(
CommandSpec(discover, command_cwd, "test-discovery"),
(ResultCheck(ResultCheckKind.CTEST_HAS_TESTS),),
),
ValidationStep(CommandSpec(test, command_cwd, "test")),
),
)
+60
View File
@@ -0,0 +1,60 @@
from pathlib import Path
from ..models import (
CommandSpec,
HarnessConfig,
ProjectKind,
ProjectSelection,
Toolchain,
ValidationPlan,
ValidationStep,
)
from .base import AdapterError
def _test_argv(root: Path, command: tuple[str, ...]) -> tuple[str, ...]:
if "/" not in command[0] and "\\" not in command[0]:
return command
first = Path(command[0].replace("\\", "/"))
executable = first if first.is_absolute() else (root / first).resolve()
try:
executable.relative_to(root)
except ValueError as exc:
raise AdapterError(
"msbuild.testCommand resolves outside the repository"
) from exc
return (str(executable), *command[1:])
class MSBuildAdapter:
def create_plan(
self,
root: Path,
selection: ProjectSelection,
config: HarnessConfig,
tools: Toolchain,
) -> ValidationPlan:
if selection.kind is not ProjectKind.MSBUILD:
raise AdapterError("MSBuildAdapter received a non-MSBuild project")
if not config.msbuild.test_command:
raise AdapterError(
"msbuild.testCommand is required for .sln/.vcxproj validation"
)
build = ValidationStep(
CommandSpec(
(
str(tools.msbuild),
str(selection.project_file),
"/m",
"/nologo",
f"/p:Configuration={config.msbuild.configuration}",
f"/p:Platform={config.msbuild.platform}",
),
root,
"build",
)
)
test = ValidationStep(
CommandSpec(_test_argv(root, config.msbuild.test_command), root, "test")
)
return ValidationPlan(ProjectKind.MSBUILD, (build, test))
+146
View File
@@ -0,0 +1,146 @@
import json
from pathlib import Path
from typing import Any
from .models import CMakeConfig, HarnessConfig, MSBuildConfig, TDDConfig
DEFAULT_TEST_PATTERNS = (
"{stem}_test.cpp",
"{stem}_tests.cpp",
"test_{stem}.cpp",
"{stem}.test.cpp",
)
class ConfigError(ValueError):
pass
def _reject_unknown(data: dict[str, Any], allowed: set[str], label: str) -> None:
unknown = sorted(set(data) - allowed)
if unknown:
raise ConfigError(f"{label} contains unexpected field: {unknown[0]}")
def _repo_path(root: Path, raw: str, label: str) -> Path:
candidate = Path(raw)
if candidate.is_absolute():
raise ConfigError(f"{label} must be repository-relative")
resolved = (root / candidate).resolve()
try:
resolved.relative_to(root)
except ValueError as exc:
raise ConfigError(f"{label} resolves outside the repository") from exc
return resolved
def _mapping(value: Any, label: str) -> dict[str, Any]:
if not isinstance(value, dict):
raise ConfigError(f"{label} must be an object")
return value
def _nonempty_string(value: Any, label: str) -> str:
if not isinstance(value, str) or not value:
raise ConfigError(f"{label} must be a non-empty string")
return value
def _string_array(value: Any, label: str) -> tuple[str, ...]:
if not isinstance(value, list):
raise ConfigError(f"{label} must be an array")
if not value or any(not isinstance(item, str) or not item for item in value):
raise ConfigError(f"{label} must be a non-empty string array")
return tuple(value)
def load_config(root: Path) -> HarnessConfig:
root = root.resolve()
config_path = root / ".harness" / "config.json"
if config_path.is_file():
try:
data = json.loads(config_path.read_text(encoding="utf-8"))
except UnicodeDecodeError as exc:
raise ConfigError(
f"{config_path}: configuration must be valid UTF-8: {exc}"
) from exc
except json.JSONDecodeError as exc:
raise ConfigError(f"{config_path}: {exc}") from exc
else:
data = {"version": 1}
data = _mapping(data, "config")
_reject_unknown(data, {"version", "projectType", "cmake", "msbuild", "tdd"}, "config")
if data.get("version") != 1 or isinstance(data.get("version"), bool):
raise ConfigError("version must be 1")
project_type = data.get("projectType", "auto")
if project_type not in {"auto", "cmake", "msbuild"}:
raise ConfigError("projectType must be auto, cmake, or msbuild")
cmake_data = _mapping(data.get("cmake", {}), "cmake")
_reject_unknown(
cmake_data,
{"sourceDir", "binaryDir", "configurePreset", "buildPreset", "testPreset"},
"cmake",
)
source_dir = _repo_path(
root, _nonempty_string(cmake_data.get("sourceDir", "."), "cmake.sourceDir"), "cmake.sourceDir"
)
binary_dir = None
if "binaryDir" in cmake_data:
binary_dir = _repo_path(
root, _nonempty_string(cmake_data["binaryDir"], "cmake.binaryDir"), "cmake.binaryDir"
)
preset_keys = ("configurePreset", "buildPreset", "testPreset", "binaryDir")
if any(key in cmake_data for key in preset_keys) and not all(key in cmake_data for key in preset_keys):
raise ConfigError("cmake configurePreset, buildPreset, testPreset, and binaryDir must be specified together")
configure_preset = (
_nonempty_string(cmake_data["configurePreset"], "cmake.configurePreset")
if "configurePreset" in cmake_data
else None
)
build_preset = (
_nonempty_string(cmake_data["buildPreset"], "cmake.buildPreset")
if "buildPreset" in cmake_data
else None
)
test_preset = (
_nonempty_string(cmake_data["testPreset"], "cmake.testPreset")
if "testPreset" in cmake_data
else None
)
msbuild_data = _mapping(data.get("msbuild", {}), "msbuild")
_reject_unknown(msbuild_data, {"solution", "configuration", "platform", "testCommand"}, "msbuild")
solution = None
if "solution" in msbuild_data:
solution = _repo_path(
root, _nonempty_string(msbuild_data["solution"], "msbuild.solution"), "msbuild.solution"
)
configuration = _nonempty_string(msbuild_data.get("configuration", "Debug"), "msbuild.configuration")
platform = _nonempty_string(msbuild_data.get("platform", "x64"), "msbuild.platform")
test_command = (
_string_array(msbuild_data["testCommand"], "msbuild.testCommand")
if "testCommand" in msbuild_data
else None
)
tdd_data = _mapping(data.get("tdd", {}), "tdd")
_reject_unknown(tdd_data, {"testRoots", "testPatterns", "exclude"}, "tdd")
raw_test_roots = _string_array(tdd_data["testRoots"], "tdd.testRoots") if "testRoots" in tdd_data else ("tests", "test")
test_roots = tuple(_repo_path(root, item, "tdd.testRoots") for item in raw_test_roots)
test_patterns = _string_array(tdd_data["testPatterns"], "tdd.testPatterns") if "testPatterns" in tdd_data else DEFAULT_TEST_PATTERNS
if any("{stem}" not in pattern for pattern in test_patterns):
raise ConfigError("every tdd.testPatterns entry must contain {stem}")
exclude = _string_array(tdd_data["exclude"], "tdd.exclude") if "exclude" in tdd_data else ()
for item in exclude:
_repo_path(root, item, "tdd.exclude")
return HarnessConfig(
version=1,
project_type=project_type,
cmake=CMakeConfig(source_dir, binary_dir, configure_preset, build_preset, test_preset),
msbuild=MSBuildConfig(solution, configuration, platform, test_command),
tdd=TDDConfig(test_roots, test_patterns, exclude),
)
+77
View File
@@ -0,0 +1,77 @@
from pathlib import Path
from .models import DiscoveryResult, HarnessConfig, ProjectKind, ProjectSelection
from .tdd_policy import find_cpp_files
class DiscoveryError(RuntimeError):
pass
def _cmake_entry(source: Path) -> Path | None:
for name in ("CMakePresets.json", "CMakeUserPresets.json", "CMakeLists.txt"):
candidate = source / name
if candidate.is_file():
return candidate
return None
def _single_msbuild(root: Path, explicit: Path | None) -> Path:
if explicit is not None:
if explicit.is_file() and explicit.suffix.lower() in {".sln", ".vcxproj"}:
return explicit
raise DiscoveryError(f"configured MSBuild project is invalid: {explicit}")
solutions = sorted(root.glob("*.sln"))
if len(solutions) > 1:
raise DiscoveryError("multiple .sln files found; set msbuild.solution")
if solutions:
return solutions[0]
projects = sorted(root.glob("*.vcxproj"))
if len(projects) > 1:
raise DiscoveryError("multiple .vcxproj files found; set msbuild.solution")
if projects:
return projects[0]
raise DiscoveryError("no root .sln or .vcxproj was found")
def discover_project(root: Path, config: HarnessConfig) -> DiscoveryResult:
root = root.resolve()
if config.project_type == "cmake":
entry = _cmake_entry(config.cmake.source_dir)
if entry is None:
raise DiscoveryError(
f"no CMake project found in {config.cmake.source_dir}"
)
return DiscoveryResult(ProjectSelection(ProjectKind.CMAKE, entry))
if config.project_type == "msbuild":
project = _single_msbuild(root, config.msbuild.solution)
return DiscoveryResult(ProjectSelection(ProjectKind.MSBUILD, project))
entry = _cmake_entry(root)
if entry is not None:
return DiscoveryResult(ProjectSelection(ProjectKind.CMAKE, entry))
solutions = sorted(root.glob("*.sln"))
if len(solutions) > 1:
raise DiscoveryError("multiple .sln files found; set msbuild.solution")
if solutions:
return DiscoveryResult(
ProjectSelection(ProjectKind.MSBUILD, solutions[0])
)
projects = sorted(root.glob("*.vcxproj"))
if len(projects) > 1:
raise DiscoveryError("multiple .vcxproj files found; set msbuild.solution")
if projects:
return DiscoveryResult(
ProjectSelection(ProjectKind.MSBUILD, projects[0])
)
cpp_files = find_cpp_files(root)
if cpp_files:
raise DiscoveryError(
"C/C++ files exist but no CMakeLists.txt, preset, .sln, or .vcxproj "
"was found; configure projectType and project path"
)
return DiscoveryResult(None, ())
+100
View File
@@ -0,0 +1,100 @@
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
class ProjectKind(Enum):
CMAKE = "cmake"
MSBUILD = "msbuild"
class ResultCheckKind(Enum):
CMAKE_COMPILER_IS_MSVC = "cmake_compiler_is_msvc"
CTEST_HAS_TESTS = "ctest_has_tests"
@dataclass(frozen=True)
class CMakeConfig:
source_dir: Path
binary_dir: Path | None = None
configure_preset: str | None = None
build_preset: str | None = None
test_preset: str | None = None
@dataclass(frozen=True)
class MSBuildConfig:
solution: Path | None = None
configuration: str = "Debug"
platform: str = "x64"
test_command: tuple[str, ...] | None = None
@dataclass(frozen=True)
class TDDConfig:
test_roots: tuple[Path, ...]
test_patterns: tuple[str, ...]
exclude: tuple[str, ...] = ()
@dataclass(frozen=True)
class HarnessConfig:
version: int
project_type: str
cmake: CMakeConfig
msbuild: MSBuildConfig
tdd: TDDConfig
@dataclass(frozen=True)
class ProjectSelection:
kind: ProjectKind
project_file: Path
@dataclass(frozen=True)
class DiscoveryResult:
selection: ProjectSelection | None
cpp_files: tuple[Path, ...] = ()
@dataclass(frozen=True)
class Toolchain:
installation: Path
msbuild: Path
cmake: Path | None = None
ctest: Path | None = None
@dataclass(frozen=True)
class CommandSpec:
argv: tuple[str, ...]
cwd: Path
stage: str
timeout_seconds: int = 1800
@dataclass(frozen=True)
class ResultCheck:
kind: ResultCheckKind
path: Path | None = None
@dataclass(frozen=True)
class ValidationStep:
command: CommandSpec
checks: tuple[ResultCheck, ...] = ()
@dataclass(frozen=True)
class ValidationPlan:
project_kind: ProjectKind
steps: tuple[ValidationStep, ...]
@dataclass(frozen=True)
class CommandResult:
command: CommandSpec
returncode: int
stdout: str
stderr: str
+140
View File
@@ -0,0 +1,140 @@
import json
import locale
import re
import subprocess
import time
from .models import CommandResult, ResultCheckKind
DIAGNOSTIC_LIMIT = 8000
class ValidationFailure(RuntimeError):
pass
def _decode(value):
if value is None:
return ""
if isinstance(value, str):
return value
for encoding in ("utf-8", locale.getpreferredencoding(False)):
try:
return value.decode(encoding)
except (UnicodeDecodeError, LookupError):
continue
return value.decode("utf-8", errors="replace")
def _check_result(check, result):
if check.kind is ResultCheckKind.CMAKE_COMPILER_IS_MSVC:
if check.path is None:
raise ValidationFailure("MSVC check is missing binaryDir")
files = sorted(check.path.glob("CMakeFiles/*/CMakeCXXCompiler.cmake"))
if not files:
raise ValidationFailure("CMake did not generate compiler metadata")
text = files[-1].read_text(encoding="utf-8", errors="replace")
if not re.search(
r'set\s*\(\s*CMAKE_CXX_COMPILER_ID\s+"MSVC"\s*\)', text
):
raise ValidationFailure("CMake selected a compiler other than MSVC")
elif check.kind is ResultCheckKind.CTEST_HAS_TESTS:
try:
payload = json.loads(result.stdout)
except json.JSONDecodeError as exc:
raise ValidationFailure("CTest discovery did not return JSON") from exc
if not isinstance(payload, dict):
raise ValidationFailure("CTest discovery JSON must be an object")
if not isinstance(payload.get("tests"), list) or not payload["tests"]:
raise ValidationFailure("CTest discovered no tests")
def _diagnostic(stdout, stderr):
diagnostic = (stdout + "\n" + stderr).rstrip()[-DIAGNOSTIC_LIMIT:]
return diagnostic.replace("\r\n", " | ").replace("\n", " | ")
def _command_context(argv, cwd):
return f"argv={list(argv)!r}; cwd={str(cwd)!r}"
def execute_plan(
plan,
root,
*,
env=None,
total_timeout_seconds=1800,
deadline=None,
run=subprocess.run,
clock=time.monotonic,
):
root = root.resolve()
if deadline is None:
deadline = clock() + total_timeout_seconds
results = []
for step in plan.steps:
cwd = step.command.cwd.resolve()
context = _command_context(step.command.argv, cwd)
try:
cwd.relative_to(root)
except ValueError as exc:
raise ValidationFailure(
f"{step.command.stage} working directory is outside the repository; "
f"{context}"
) from exc
remaining = deadline - clock()
if remaining <= 0:
raise ValidationFailure(
f"{step.command.stage} timed out before it could start; {context}"
)
timeout = min(step.command.timeout_seconds, remaining)
try:
completed = run(
list(step.command.argv),
cwd=cwd,
env=env,
capture_output=True,
shell=False,
timeout=timeout,
)
except subprocess.TimeoutExpired as exc:
stdout = _decode(exc.output)
stderr = _decode(exc.stderr)
diagnostic = _diagnostic(stdout, stderr)
message = (
f"{step.command.stage} timed out after {timeout} seconds; {context}"
)
if diagnostic:
message = f"{message}; output tail: {diagnostic}"
raise ValidationFailure(message) from exc
stdout = _decode(completed.stdout)
stderr = _decode(completed.stderr)
result = CommandResult(step.command, completed.returncode, stdout, stderr)
if completed.returncode != 0:
diagnostic = _diagnostic(stdout, stderr)
message = (
f"{step.command.stage} failed with exit code {completed.returncode}; "
f"{context}"
)
if diagnostic:
message = f"{message}; output tail: {diagnostic}"
raise ValidationFailure(message)
try:
for check in step.checks:
_check_result(check, result)
except ValidationFailure as exc:
diagnostic = _diagnostic(stdout, stderr)
message = (
f"{step.command.stage} result check failed: {exc}; {context}"
)
if diagnostic:
message = f"{message}; output tail: {diagnostic}"
raise ValidationFailure(message) from exc
results.append(result)
return tuple(results)
+88
View File
@@ -0,0 +1,88 @@
from fnmatch import fnmatch
from pathlib import Path
CPP_SUFFIXES = frozenset({".c", ".cc", ".cpp", ".cxx", ".h", ".hpp", ".hxx"})
DEFAULT_EXCLUDES = (
".harness/build/**",
"build/**",
"out/**",
"cmake-build-*/**",
"third_party/**",
"external/**",
"vendor/**",
"generated/**",
)
def is_excluded_path(path: Path, root: Path, extra: tuple[str, ...] = ()) -> bool:
try:
relative = path.resolve().relative_to(root.resolve()).as_posix()
except ValueError:
return False
return any(fnmatch(relative, pattern) for pattern in DEFAULT_EXCLUDES + extra)
def find_cpp_files(
root: Path,
extra_excludes: tuple[str, ...] = (),
) -> tuple[Path, ...]:
found = (
path
for path in root.resolve().rglob("*")
if path.is_file()
and path.suffix.lower() in CPP_SUFFIXES
and not is_excluded_path(path, root, extra_excludes)
)
return tuple(sorted(found))
def is_test_file(path: Path, root: Path, config) -> bool:
resolved = path.resolve()
if any(root == resolved or root in resolved.parents for root in config.test_roots):
return True
relative = resolved.relative_to(root.resolve())
if {"test", "tests"} & {part.lower() for part in relative.parts}:
return True
stem = resolved.stem.lower()
return stem.startswith("test_") or stem.endswith(("_test", "_tests", ".test"))
def matching_test_exists(path: Path, config) -> bool:
names = tuple(pattern.format(stem=path.stem) for pattern in config.test_patterns)
roots = (
*config.test_roots,
path.parent / "tests",
path.parent / "test",
)
for root in roots:
if not root.is_dir():
continue
for name in names:
if any(candidate.is_file() for candidate in root.rglob(name)):
return True
return False
def evaluate_paths(paths, root: Path, config) -> str | None:
root = root.resolve()
for raw in paths:
path = raw.resolve()
try:
path.relative_to(root)
except ValueError:
return f"TDD GUARD: '{path}' is outside the repository"
if path.suffix.lower() not in CPP_SUFFIXES:
continue
if (
path.name.lower() == "main.cpp"
or is_excluded_path(path, root, config.exclude)
or is_test_file(path, root, config)
):
continue
if not matching_test_exists(path, config):
expected = config.test_patterns[0].format(stem=path.stem)
return (
f"TDD GUARD: '{path.name}' requires an existing test such as "
f"'{expected}'. Add the test in a configured test root first."
)
return None
+169
View File
@@ -0,0 +1,169 @@
import os
import shutil
import subprocess
import time
from pathlib import Path
from .models import ProjectKind, Toolchain
VC_WORKLOAD = "Microsoft.VisualStudio.Component.VC.Tools.x86.x64"
VS_CMAKE_RELATIVE = Path(
"Common7/IDE/CommonExtensions/Microsoft/CMake/CMake/bin"
)
class ToolchainError(RuntimeError):
pass
def _decode(value):
if isinstance(value, str):
return value
return (value or b"").decode("utf-8", errors="replace")
def _first_path(result, label, argv, cwd):
text = _decode(result.stdout).strip()
if result.returncode != 0:
diagnostic = (
_decode(result.stdout) + "\n" + _decode(result.stderr)
).rstrip()[-8000:]
diagnostic = diagnostic.replace("\r\n", " | ").replace("\n", " | ")
message = (
f"{label} probe failed with exit code {result.returncode}; "
f"argv={argv!r}; cwd={str(cwd)!r}"
)
if diagnostic:
message = f"{message}; output tail: {diagnostic}"
raise ToolchainError(
f"{message}; install Visual Studio Desktop development with C++"
)
if not text:
raise ToolchainError(
f"{label} probe returned no path; argv={argv!r}; cwd={str(cwd)!r}; "
"install Visual Studio Desktop development with C++"
)
path = Path(text.splitlines()[0]).resolve()
if not path.exists():
diagnostic = (
_decode(result.stdout) + "\n" + _decode(result.stderr)
).rstrip()[-8000:]
diagnostic = diagnostic.replace("\r\n", " | ").replace("\n", " | ")
message = (
f"{label} probe returned nonexistent path with exit code 0; "
f"argv={argv!r}; cwd={str(cwd)!r}"
)
if diagnostic:
message = f"{message}; output tail: {diagnostic}"
raise ToolchainError(
f"{message}; install or repair Visual Studio Desktop development with C++"
)
return path
def _require_path(path, label):
if path is None or not path.exists():
raise ToolchainError(f"{label} not found")
return path.resolve()
def _remaining(deadline, clock, label):
if deadline is None:
return None
remaining = deadline - clock()
if remaining <= 0:
raise ToolchainError(f"{label} probe timed out before it could start")
return remaining
def _probe(run, argv, label, *, deadline, clock):
cwd = Path.cwd().resolve()
kwargs = {
"cwd": cwd,
"capture_output": True,
"check": False,
"shell": False,
}
remaining = _remaining(deadline, clock, label)
if remaining is not None:
kwargs["timeout"] = remaining
try:
return run(argv, **kwargs)
except subprocess.TimeoutExpired as exc:
raise ToolchainError(
f"{label} probe timed out; argv={argv!r}; cwd={str(cwd)!r}; "
"install or repair Visual Studio Desktop development with C++"
) from exc
def discover_toolchain(
kind: ProjectKind,
*,
env=None,
which=shutil.which,
run=subprocess.run,
deadline=None,
clock=time.monotonic,
) -> Toolchain:
environment = os.environ if env is None else env
vswhere_path = which("vswhere.exe")
if vswhere_path is None:
program_files = environment.get("ProgramFiles(x86)")
if program_files:
candidate = Path(program_files) / "Microsoft Visual Studio/Installer/vswhere.exe"
vswhere_path = str(candidate) if candidate.exists() else None
vswhere = _require_path(
Path(vswhere_path) if vswhere_path else None,
"vswhere.exe; install Visual Studio Desktop development with C++",
)
install_argv = [
str(vswhere), "-latest", "-products", "*", "-requires", VC_WORKLOAD,
"-property", "installationPath",
]
install_result = _probe(
run,
install_argv,
"Visual Studio installation",
deadline=deadline,
clock=clock,
)
probe_cwd = Path.cwd().resolve()
installation = _require_path(
_first_path(
install_result,
"Visual Studio installation",
install_argv,
probe_cwd,
),
"Visual Studio installation",
)
msbuild_argv = [
str(vswhere), "-latest", "-products", "*", "-requires", VC_WORKLOAD,
"-find", r"MSBuild\**\Bin\MSBuild.exe",
]
msbuild_result = _probe(
run,
msbuild_argv,
"MSBuild",
deadline=deadline,
clock=clock,
)
msbuild = _require_path(
_first_path(msbuild_result, "MSBuild", msbuild_argv, probe_cwd),
"MSBuild.exe",
)
if kind is not ProjectKind.CMAKE:
return Toolchain(installation=installation, msbuild=msbuild)
bundled_cmake_dir = installation / VS_CMAKE_RELATIVE
cmake = _require_path(
Path(which("cmake.exe")) if which("cmake.exe") else bundled_cmake_dir / "cmake.exe",
"cmake.exe",
)
ctest = _require_path(
Path(which("ctest.exe")) if which("ctest.exe") else bundled_cmake_dir / "ctest.exe",
"ctest.exe",
)
return Toolchain(installation=installation, msbuild=msbuild, cmake=cmake, ctest=ctest)