260 lines
8.2 KiB
Python
260 lines
8.2 KiB
Python
#!/usr/bin/env python3
|
|
"""Codex Stop hook: validate discovered C/C++ projects with MSVC."""
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from pathlib import Path, PurePosixPath, PureWindowsPath
|
|
|
|
|
|
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,
|
|
encoding="utf-8",
|
|
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 _changed_path_kind(root: Path, phase: Path, raw: str) -> str | None:
|
|
if (not raw or raw.endswith("/") or "\\" in raw
|
|
or PureWindowsPath(raw).drive or PureWindowsPath(raw).root
|
|
or ".." in raw.split("/")):
|
|
return None
|
|
path = root / raw
|
|
if path.exists() and not path.is_file():
|
|
return None
|
|
resolved = path.resolve()
|
|
# Symlinks/junctions can make an apparent document modify a build input.
|
|
if not resolved.is_relative_to(root) or resolved != path:
|
|
return None
|
|
relative = resolved.relative_to(root)
|
|
if relative == Path("AGENTS.md"):
|
|
return "doc"
|
|
if relative.parts and relative.parts[0] == "docs" and relative.suffix.lower() == ".md":
|
|
return "doc"
|
|
if relative.parent == phase and (relative.name == "index.json"
|
|
or re.fullmatch(r"step\d+-output\.json", relative.name)):
|
|
return "metadata"
|
|
return None
|
|
|
|
|
|
def _was_regular_file(root: Path, raw: str, *, deadline: float, run, clock) -> bool:
|
|
"""Missing paths may be deleted gitlinks rather than deleted Markdown files."""
|
|
for args in (("ls-files", "--stage", "-z", "--", raw),
|
|
("ls-tree", "-z", "HEAD", "--", raw)):
|
|
result = run(
|
|
["git", *args], cwd=root, capture_output=True, text=True,
|
|
encoding="utf-8", errors="strict", shell=False,
|
|
timeout=_remaining(deadline, clock, "deleted document type detection"),
|
|
)
|
|
if result.returncode != 0:
|
|
return False
|
|
if not result.stdout:
|
|
continue
|
|
records = result.stdout.split("\0")
|
|
if len(records) != 2 or records[1] or "\t" not in records[0]:
|
|
return False
|
|
info, name = records[0].split("\t", 1)
|
|
fields = info.split()
|
|
return name == raw and len(fields) == 3 and fields[0] in {"100644", "100755"}
|
|
return False
|
|
|
|
|
|
def _documentation_only_changes(
|
|
root: Path,
|
|
phase_dir: str,
|
|
*,
|
|
deadline: float,
|
|
run=subprocess.run,
|
|
clock=time.monotonic,
|
|
) -> bool:
|
|
"""Opt-in plus an unambiguous, document-only Git diff permits skipping MSVC."""
|
|
if not phase_dir:
|
|
return False
|
|
parts = PurePosixPath(phase_dir).parts
|
|
if (len(parts) != 2 or parts[0] != "phases" or parts[1] in {".", ".."}
|
|
or "\\" in phase_dir or ":" in phase_dir):
|
|
return False
|
|
root = root.resolve()
|
|
phase = Path(*parts)
|
|
try:
|
|
if (root / phase).resolve() != root / phase:
|
|
return False
|
|
result = run(
|
|
["git", "status", "--porcelain=v1", "-z", "--untracked-files=all"],
|
|
cwd=root,
|
|
capture_output=True,
|
|
text=True,
|
|
encoding="utf-8",
|
|
errors="strict",
|
|
shell=False,
|
|
timeout=_remaining(deadline, clock, "documentation change detection"),
|
|
)
|
|
if result.returncode != 0 or not result.stdout or not result.stdout.endswith("\0"):
|
|
return False
|
|
records = iter(result.stdout[:-1].split("\0"))
|
|
has_document = False
|
|
for record in records:
|
|
if len(record) < 4 or record[2] != " ":
|
|
return False
|
|
status = record[:2]
|
|
if status != "??" and (any(c not in " MARCD" for c in status)
|
|
or status in {" ", "AA", "DD"}):
|
|
return False
|
|
paths = [record[3:]]
|
|
if "R" in status or "C" in status:
|
|
paths.append(next(records)) # -z uses destination, then original path.
|
|
for raw in paths:
|
|
kind = _changed_path_kind(root, phase, raw)
|
|
if kind is None:
|
|
return False
|
|
if not (root / raw).exists() and not _was_regular_file(
|
|
root, raw, deadline=deadline, run=run, clock=clock,
|
|
):
|
|
return False
|
|
has_document |= kind == "doc"
|
|
return has_document
|
|
except (OSError, UnicodeError, ValueError, StopIteration,
|
|
subprocess.TimeoutExpired, ValidationFailure):
|
|
return False
|
|
|
|
|
|
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,
|
|
)
|
|
if _documentation_only_changes(
|
|
root, os.environ.get("HARNESS_DOCS_ONLY_PHASE", ""),
|
|
deadline=deadline, run=run, clock=clock,
|
|
):
|
|
return 0
|
|
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())
|