modify harness framework
This commit is contained in:
@@ -0,0 +1,257 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user