#!/usr/bin/env python3 """Codex Stop hook: validate discovered .NET 8 C# projects.""" 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 dotnet_harness.adapters.base import AdapterError from dotnet_harness.adapters.dotnet import DotnetAdapter from dotnet_harness.config import ConfigError, load_config from dotnet_harness.discovery import DiscoveryError, discover_project from dotnet_harness.metadata import validate_project_graph from dotnet_harness.process import ValidationFailure, execute_plan from dotnet_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 product validation.""" 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 .NET 8 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, "No C# product found; product validation was not run." selection = discovery.selection tools = discover_toolchain( root, deadline=deadline, clock=clock, ) validate_project_graph(root, selection, config, tools, deadline=deadline, clock=clock) adapter = DotnetAdapter() 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: if message: print(message, file=sys.stderr) return 0 _emit_stop_response(message) return 0 if __name__ == "__main__": raise SystemExit(main())