modify harness framework

This commit is contained in:
김경종
2026-09-08 10:30:07 +09:00
parent 1239d5b9f6
commit 1a0f3c9aaf
68 changed files with 6729 additions and 957 deletions
@@ -0,0 +1 @@
"""Windows .NET 8 C# product validation support."""
@@ -0,0 +1 @@
""".NET validation command construction."""
@@ -0,0 +1,2 @@
class AdapterError(RuntimeError):
pass
@@ -0,0 +1,36 @@
from uuid import uuid4
from .base import AdapterError
from ..models import (CommandSpec, ProjectKind, ResultCheck, ResultCheckKind,
ValidationPlan, ValidationStep)
class DotnetAdapter:
def create_plan(self, root, selection, config, tools):
root = root.resolve()
if not config.dotnet.test_projects:
raise AdapterError("dotnet.testProjects must specify the product tests")
tests = tuple(dict.fromkeys(config.dotnet.test_projects))
targets = tuple(dict.fromkeys((selection.project_file, *tests)))
configuration = config.dotnet.configuration
steps = []
for action in ("restore", "build"):
for target in targets:
argv = (str(tools.dotnet), action, str(target))
# Restore and build must evaluate the same conditional references.
if action == "restore":
argv += (f"-p:Configuration={configuration}",)
else:
argv += ("-c", configuration, "--no-restore", "-warnaserror")
steps.append(ValidationStep(CommandSpec(argv, root, f"{action}: {target.name}")))
results = root / ".harness/test-results" / uuid4().hex
for index, target in enumerate(tests):
directory = results / str(index)
argv = (str(tools.dotnet), "test", str(target), "-c", configuration,
"--no-build", "--no-restore", "--logger", "trx",
"--results-directory", str(directory))
steps.append(ValidationStep(
CommandSpec(argv, root, f"test: {target.name}"),
(ResultCheck(ResultCheckKind.TRX_HAS_TESTS, directory),),
))
return ValidationPlan(ProjectKind.DOTNET, tuple(steps))
@@ -0,0 +1,81 @@
import json
from pathlib import Path, PureWindowsPath
from typing import Any
from .discovery import DiscoveryError, validate_csproj
from .models import DotnetConfig, HarnessConfig, TDDConfig
DEFAULT_TEST_PATTERNS = ("{stem}Tests.cs", "{stem}Test.cs")
class ConfigError(ValueError):
pass
def _mapping(value: Any, allowed: set[str], label: str) -> dict:
if not isinstance(value, dict):
raise ConfigError(f"{label} must be an object")
unknown = sorted(set(value) - allowed)
if unknown:
raise ConfigError(f"{label} contains unexpected field: {unknown[0]}; use version 2 .NET configuration")
return value
def _string(value: Any, label: str) -> str:
if not isinstance(value, str) or not value.strip():
raise ConfigError(f"{label} must be a non-empty string")
return value
def _array(value: Any, label: str, *, allow_empty=False) -> tuple[str, ...]:
if not isinstance(value, list) or (not value and not allow_empty):
raise ConfigError(f"{label} must be a {'non-empty ' if not allow_empty else ''}string array")
return tuple(_string(item, label) for item in value)
def _repo_path(root: Path, raw: str, label: str) -> Path:
raw = _string(raw, label)
windows = PureWindowsPath(raw)
if windows.drive or windows.root or Path(raw).is_absolute():
raise ConfigError(f"{label} must be repository-relative")
resolved = (root / raw.replace("\\", "/")).resolve()
if not resolved.is_relative_to(root):
raise ConfigError(f"{label} resolves outside the repository")
return resolved
def load_config(root: Path) -> HarnessConfig:
root = root.resolve()
path = root / ".harness/config.json"
try:
data = json.loads(path.read_text(encoding="utf-8")) if path.exists() else {"version": 2}
except (OSError, ValueError) as exc:
raise ConfigError(f"cannot read configuration {path}: {exc}") from exc
data = _mapping(data, {"version", "projectType", "dotnet", "tdd"}, "config")
if type(data.get("version")) is not int or data["version"] != 2:
raise ConfigError("version must be 2; rewrite legacy configuration using the .NET config example")
project_type = data.get("projectType", "auto")
if project_type not in ("auto", "dotnet"):
raise ConfigError("projectType must be auto or dotnet")
dotnet = _mapping(data.get("dotnet", {}), {"target", "configuration", "testProjects"}, "dotnet")
target = _repo_path(root, dotnet["target"], "dotnet.target") if "target" in dotnet else None
if target is not None and (target.suffix.lower() not in {".sln", ".csproj"} or not target.is_file()):
raise ConfigError("dotnet.target must be an existing .sln or SDK-style .csproj")
configuration = _string(dotnet.get("configuration", "Debug"), "dotnet.configuration")
tests = tuple(_repo_path(root, item, "dotnet.testProjects") for item in _array(dotnet["testProjects"], "dotnet.testProjects")) if "testProjects" in dotnet else ()
for test in tests:
try:
validate_csproj(test)
except DiscoveryError as exc:
raise ConfigError(f"dotnet.testProjects: {exc}") from exc
tdd = _mapping(data.get("tdd", {}), {"testRoots", "testPatterns", "exclude"}, "tdd")
roots = _array(tdd["testRoots"], "tdd.testRoots") if "testRoots" in tdd else ("tests",)
test_roots = tuple(_repo_path(root, item, "tdd.testRoots") for item in roots)
patterns = _array(tdd["testPatterns"], "tdd.testPatterns") if "testPatterns" in tdd else DEFAULT_TEST_PATTERNS
for pattern in patterns:
if "{stem}" not in pattern or not pattern.endswith(".cs") or any(char in pattern.replace("{stem}", "") for char in "{}/\\"):
raise ConfigError("tdd.testPatterns must be C# filenames containing only the {stem} placeholder")
exclude = _array(tdd["exclude"], "tdd.exclude", allow_empty=True) if "exclude" in tdd else ()
for item in exclude:
_repo_path(root, item, "tdd.exclude")
return HarnessConfig(2, project_type, DotnetConfig(target, configuration, tests), TDDConfig(test_roots, patterns, exclude))
@@ -0,0 +1,106 @@
import re
import xml.etree.ElementTree as ET
from pathlib import Path, PureWindowsPath
from .models import DiscoveryResult, HarnessConfig, ProjectKind, ProjectSelection
from .paths import iter_source_files
class DiscoveryError(RuntimeError):
pass
def validate_csproj(path: Path) -> None:
if path.suffix.lower() != ".csproj" or not path.is_file():
raise DiscoveryError(f"expected an existing SDK-style .csproj: {path}")
try:
project = ET.parse(path).getroot()
except (ET.ParseError, OSError) as exc:
raise DiscoveryError(f"cannot read project {path}: {exc}") from exc
name = lambda element: element.tag.rsplit("}", 1)[-1]
if name(project) != "Project" or not (
project.get("Sdk", "").strip()
or any(name(child) == "Sdk" and child.get("Name", "").strip() for child in project)
):
raise DiscoveryError(f"project must use SDK style; .NET Framework is unsupported: {path}")
for element in project.iter():
tag = name(element)
value = (element.text or "").strip()
if tag == "TargetFrameworkVersion" and value:
raise DiscoveryError(f".NET Framework metadata is unsupported: {path}")
if tag in {"TargetFramework", "TargetFrameworks"}:
# Build evaluates inherited properties and expressions. This preflight
# rejects explicit incompatible values rather than guessing expansion.
for target in value.split(";"):
target = target.strip()
if target and "$(" not in target and not re.fullmatch(r"net8\.0(?:-[A-Za-z0-9.]+)?", target):
raise DiscoveryError(f"only net8.0 targets are supported; found {target!r}: {path}")
def _validate_solution(path: Path, root: Path) -> tuple[Path, ...]:
try:
content = path.read_text(encoding="utf-8-sig")
except (OSError, UnicodeError) as exc:
raise DiscoveryError(f"cannot read solution {path}: {exc}") from exc
if "Microsoft Visual Studio Solution File" not in content:
raise DiscoveryError(f"invalid .sln file: {path}")
projects = []
folder_types = {"66A26720-8FB5-11D2-AA7E-00C04F688DDE", "2150E333-8FDC-42A3-9474-1A3956D46DE8"}
csharp_types = {"FAE04EC0-301F-11D3-BF4B-00C04F79EFBC", "9A19103F-16F7-4668-BE54-9A1E7A4F7556"}
for line in content.splitlines():
if not line.lstrip().startswith("Project("):
continue
match = re.match(r'\s*Project\("\{([^}]+)\}"\)\s*=\s*"[^"]*",\s*"([^"]+)"', line)
if not match:
raise DiscoveryError(f"invalid project entry in solution: {path}")
kind, reference = match.groups()
if kind.upper() in folder_types:
continue
if kind.upper() not in csharp_types:
raise DiscoveryError(f"unsupported project type in solution: {reference}")
windows_path = PureWindowsPath(reference)
if windows_path.drive or windows_path.root or Path(reference).is_absolute():
raise DiscoveryError(f"solution project must be repository-relative: {reference}")
candidate = (path.parent / reference.replace("\\", "/")).resolve()
if not candidate.is_relative_to(root):
raise DiscoveryError(f"solution project resolves outside repository: {reference}")
validate_csproj(candidate)
projects.append(candidate)
if not projects:
raise DiscoveryError(f"solution has no supported C# projects: {path}")
return tuple(projects)
def discover_project(root: Path, config: HarnessConfig) -> DiscoveryResult:
root = root.resolve()
target = config.dotnet.target
if target is None:
files = tuple(iter_source_files(root))
solutions = [path for path in files if path.parent == root and path.suffix.lower() == ".sln"]
candidates = solutions or [path for path in files if path.suffix.lower() == ".csproj"]
if len(candidates) > 1:
raise DiscoveryError("multiple project candidates; set dotnet.target")
if candidates:
target = candidates[0]
elif config.project_type == "dotnet" or any(
path.suffix.lower() in {".cs", ".sln", ".slnx", ".vcxproj", ".fsproj", ".vbproj"}
for path in files
):
raise DiscoveryError("no supported C# metadata found; set dotnet.target to a .sln or SDK-style .csproj")
else:
return DiscoveryResult(None)
target = target.resolve()
if not target.is_relative_to(root) or not target.is_file():
raise DiscoveryError(f"project target must be an existing repository file: {target}")
if target.suffix.lower() == ".sln":
projects = _validate_solution(target, root)
else:
validate_csproj(target)
projects = (target,)
if not config.dotnet.test_projects:
raise DiscoveryError("product validation requires an explicit non-empty dotnet.testProjects list")
for test_project in config.dotnet.test_projects:
if not test_project.resolve().is_relative_to(root):
raise DiscoveryError(f"test project resolves outside repository: {test_project}")
validate_csproj(test_project)
return DiscoveryResult(ProjectSelection(ProjectKind.DOTNET, target, projects))
@@ -0,0 +1,78 @@
"""Validate the SDK-evaluated project-reference graph before restore."""
import json
import subprocess
import time
from collections import deque
from pathlib import Path
from .discovery import DiscoveryError, validate_csproj
from .models import CommandSpec, ResultCheck, ResultCheckKind, ValidationPlan, ValidationStep
from .process import ValidationFailure, execute_plan
def _project_path(root, path):
try:
path = path.resolve()
if not path.is_relative_to(root):
raise ValidationFailure(f"ProjectReference resolves outside the repository: {path}")
validate_csproj(path)
except (DiscoveryError, OSError, ValueError) as exc:
raise ValidationFailure(f"invalid ProjectReference {path}: {exc}") from exc
return path
def validate_project_graph(
root, selection, config, tools, *, deadline, clock=time.monotonic, run=subprocess.run,
):
root = root.resolve()
pending = deque((path, None) for path in
(*selection.projects, *config.dotnet.test_projects))
visited = set()
while pending:
project, framework = pending.popleft()
project = _project_path(root, project)
key = (project, framework)
if key in visited:
continue
visited.add(key)
argv = (
str(tools.dotnet), "msbuild", str(project),
"-getProperty:TargetFramework,TargetFrameworks",
"-getItem:ProjectReference",
f"-p:Configuration={config.dotnet.configuration}",
)
if framework is not None:
argv += (f"-p:TargetFramework={framework}",)
plan = ValidationPlan(selection.kind, (ValidationStep(
CommandSpec(argv, root, f"metadata: {project.name}"),
(ResultCheck(ResultCheckKind.PROJECT_IS_NET8),),
),))
result = execute_plan(plan, root, deadline=deadline, clock=clock, run=run)[0]
try:
data = json.loads(result.stdout)
references = data["Items"]["ProjectReference"]
if not isinstance(references, list):
raise ValueError("expected an array")
for reference in references:
fields = {key.casefold(): value for key, value in reference.items()}
if any(fields.get(name) for name in (
"additionalproperties", "globalpropertiestoremove",
"setconfiguration", "setplatform", "settargetframework",
)):
raise ValueError("custom reference properties are unsupported; use the shared Configuration and project TFM")
raw = fields.get("fullpath") or fields.get("identity")
if not isinstance(raw, str) or not raw.strip():
raise ValueError("reference must have FullPath or Identity")
child = _project_path(root, project.parent / Path(raw))
pending.append((child, None))
# Outer builds omit references conditioned on an individual TFM.
if framework is None:
targets = data["Properties"].get("TargetFrameworks", "")
for target in targets.split(";"):
if target.strip():
pending.append((project, target.strip()))
except (ValueError, KeyError, TypeError, AttributeError) as exc:
raise ValidationFailure(
f"invalid evaluated ProjectReference metadata for {project}: {exc}"
) from exc
@@ -0,0 +1,86 @@
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
class ProjectKind(Enum):
DOTNET = "dotnet"
class ResultCheckKind(Enum):
TRX_HAS_TESTS = "trx_has_tests"
PROJECT_IS_NET8 = "project_is_net8"
@dataclass(frozen=True)
class DotnetConfig:
target: Path | None = None
configuration: str = "Debug"
test_projects: tuple[Path, ...] = ()
@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
dotnet: DotnetConfig
tdd: TDDConfig
@dataclass(frozen=True)
class ProjectSelection:
kind: ProjectKind
project_file: Path
projects: tuple[Path, ...] = ()
@dataclass(frozen=True)
class DiscoveryResult:
selection: ProjectSelection | None
@dataclass(frozen=True)
class Toolchain:
dotnet: Path
sdk_version: str
@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
@@ -0,0 +1,58 @@
import fnmatch
import os
from pathlib import Path
EXCLUDED_PARTS = {
"bin", "obj", ".git", ".vs", ".harness", "testresults",
"generated", "vendor", "third_party", "external",
}
def is_excluded_path(path: Path, root: Path, extra=()) -> bool:
root = root.resolve()
candidate = path if path.is_absolute() else root / path
try:
relative = candidate.relative_to(root)
resolved_relative = candidate.resolve().relative_to(root)
except ValueError:
return True
parts = tuple(part.lower() for part in relative.parts)
if any(part in EXCLUDED_PARTS for part in parts):
return True
if parts[:2] == ("tests", "fixtures"):
return True
resolved_parts = tuple(part.lower() for part in resolved_relative.parts)
if any(part in EXCLUDED_PARTS for part in resolved_parts) or resolved_parts[:2] == ("tests", "fixtures"):
return True
name = relative.as_posix().lower()
return any(
fnmatch.fnmatchcase(name, pattern.replace("\\", "/").lower())
or name.startswith(pattern.replace("\\", "/").lower().rstrip("/") + "/")
for pattern in extra
)
def iter_source_files(root: Path):
"""Walk repository inputs, pruning artifacts and external/cyclic links."""
root = root.resolve()
seen = set()
def fail(error):
raise error
for directory, directories, files in os.walk(root, followlinks=False, onerror=fail):
current = Path(directory)
resolved = current.resolve()
if resolved in seen or is_excluded_path(current, root):
directories[:] = []
continue
seen.add(resolved)
directories[:] = sorted(
name for name in directories
if not is_excluded_path(current / name, root)
and (current / name).resolve() not in seen
)
for name in sorted(files):
path = current / name
if not is_excluded_path(path, root):
yield path
@@ -0,0 +1,170 @@
import json
import locale
import re
import xml.etree.ElementTree as ET
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.PROJECT_IS_NET8:
try:
properties = json.loads(result.stdout)["Properties"]
target = properties.get("TargetFrameworks") or properties["TargetFramework"]
frameworks = target.split(";")
if not all(re.fullmatch(r"net8\.0(?:-[A-Za-z0-9.]+)?", item.strip()) for item in frameworks):
raise ValueError(f"expected net8.0 targets, got {target!r}")
except (ValueError, KeyError, TypeError, AttributeError) as exc:
raise ValidationFailure(f"cannot verify evaluated net8.0 target: {exc}") from exc
return
if check.kind is not ResultCheckKind.TRX_HAS_TESTS:
raise ValidationFailure("unsupported result check")
files = sorted(check.path.glob("*.trx"))
if not files:
raise ValidationFailure("test run produced no TRX results")
for path in files:
try:
if path.resolve().parent != check.path.resolve():
raise ValueError("TRX result is outside its result directory")
tree = ET.parse(path)
summary = tree.getroot().find("{*}ResultSummary")
if summary is None or summary.get("outcome") not in {"Completed", "Passed"}:
raise ValueError("test run did not complete successfully")
counters = summary.find("{*}Counters")
if counters is None:
raise ValueError("missing test counters")
counts = {key: int(value) for key, value in counters.attrib.items()}
if not (0 < counts["passed"] <= counts["executed"] <= counts["total"]):
raise ValueError("no executed passing tests or inconsistent counters")
if counts["failed"] or any(counts.get(key, 0) for key in
("error", "timeout", "aborted", "inconclusive", "passedButRunAborted", "notRunnable", "disconnected")):
raise ValueError("unsuccessful tests in TRX")
if any(value < 0 for value in counts.values()):
raise ValueError("negative test counters")
except (ET.ParseError, OSError, ValueError, KeyError) as exc:
raise ValidationFailure(f"invalid TRX {path.name}: {exc}") from exc
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
for check in step.checks:
if check.kind is not ResultCheckKind.TRX_HAS_TESTS:
continue
if check.path is None:
raise ValidationFailure(f"{step.command.stage} missing results directory; {context}")
result_path = check.path.absolute()
if not result_path.resolve().is_relative_to(root) or result_path.resolve() != result_path:
raise ValidationFailure(f"{step.command.stage} results directory is outside repository or linked; {context}")
try:
result_path.mkdir(parents=True, exist_ok=False)
except OSError as exc:
raise ValidationFailure(f"{step.command.stage} requires a fresh results directory: {exc}; {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)
@@ -0,0 +1,36 @@
from fnmatch import fnmatch
from .paths import is_excluded_path, iter_source_files
CS_SUFFIXES = frozenset({".cs"})
def is_test_file(path, config):
return any(path.is_relative_to(root) for root in config.test_roots)
def evaluate_paths(paths, root, config):
root = root.resolve()
candidates = None
for raw in paths:
path = raw.resolve()
if not path.is_relative_to(root):
return f"TDD GUARD: '{path}' is outside the repository"
if path.suffix.lower() not in CS_SUFFIXES:
continue
if is_excluded_path(path, root, config.exclude) or is_test_file(path, config):
continue
if candidates is None:
candidates = tuple(p for p in iter_source_files(root) if p.suffix.lower() == ".cs")
roots = (*config.test_roots, path.parent / "tests", path.parent / "test")
names = tuple(pattern.format(stem=path.stem) for pattern in config.test_patterns)
found = any(
candidate.is_relative_to(test_root)
and any(fnmatch(candidate.name if "/" not in pattern else candidate.relative_to(test_root).as_posix(), pattern)
for pattern in names)
for test_root in roots for candidate in candidates
)
if not found:
return (f"TDD GUARD: '{path.name}' requires an existing test such as '{names[0]}'. "
"Add the test in a configured test root first.")
return None
@@ -0,0 +1,41 @@
import re
import shutil
import subprocess
import time
from pathlib import Path
from .models import Toolchain
class ToolchainError(RuntimeError):
pass
def _decode(value):
return value if isinstance(value, str) else (value or b"").decode("utf-8", errors="replace")
def discover_toolchain(root: Path, *, which=shutil.which, run=subprocess.run, deadline=None, clock=time.monotonic) -> Toolchain:
root = root.resolve()
executable = which("dotnet")
if executable is None or not Path(executable).is_file():
raise ToolchainError("dotnet not found; install .NET SDK 8 and check PATH and global.json")
executable = Path(executable).resolve()
argv = [str(executable), "--version"]
context = f"argv={argv!r}; cwd={str(root)!r}; check .NET SDK 8 and global.json"
timeout = 1800 if deadline is None else min(1800, deadline - clock())
if timeout <= 0:
raise ToolchainError(f"SDK probe timed out before it could start; {context}")
try:
result = run(argv, cwd=root, capture_output=True, check=False, shell=False, timeout=timeout)
except subprocess.TimeoutExpired as exc:
raise ToolchainError(f"SDK probe timed out; {context}") from exc
except OSError as exc:
raise ToolchainError(f"SDK probe could not start: {exc}; {context}") from exc
if result.returncode != 0:
diagnostic = (_decode(result.stdout) + "\n" + _decode(result.stderr)).strip()[-8000:]
raise ToolchainError(f"SDK probe failed with exit code {result.returncode}; {context}; output tail: {diagnostic}")
version = _decode(result.stdout).strip()
if not re.fullmatch(r"8\.0\.\d+", version):
raise ToolchainError(f"expected stable .NET SDK 8, got {version!r}; {context}")
return Toolchain(executable, version)
@@ -0,0 +1,592 @@
#!/usr/bin/env python3
"""
Harness Step Executor — phase 내 step을 순차 실행하고 자가 교정한다.
Usage:
python scripts/execute.py <phase-dir> [--push | --dry-run]
"""
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, PureWindowsPath
from typing import Optional
ROOT = Path(__file__).resolve().parent.parent
class CodexEnvironmentError(RuntimeError):
"""재시도로 해결할 수 없는 Codex CLI 환경 오류."""
class ContextError(ValueError):
"""Step 실행 전에 수정해야 하는 문서/설정 오류."""
@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):
if (not phase_dir_name or phase_dir_name in {".", ".."}
or any(c in phase_dir_name for c in "/\\:")):
raise ContextError("phase must be a directory name under phases/")
self._root = str(ROOT)
self._phases_dir = ROOT / "phases"
self._phase_dir = self._phases_dir / phase_dir_name
if self._phase_dir.resolve().parent != self._phases_dir.resolve():
raise ContextError("phase resolves outside phases/")
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, *, dry_run: bool = False):
if dry_run and self._auto_push:
raise ContextError("--dry-run and --push cannot be combined")
self._print_header()
self._check_blockers()
pending = [s for s in self._read_json(self._index_file)["steps"]
if s["status"] == "pending"]
if dry_run:
if not pending:
print("No pending steps.")
return
sections = self._prompt_sections(pending[0])
for label, body in sections:
print(f" {label}: {len(body)} characters")
print(f" Total prompt: {len(self._render_prompt(sections))} characters")
return
for step in pending:
self._build_prompt(step)
self._checkout_branch()
self._ensure_created_at()
self._execute_all_steps()
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, encoding="utf-8", errors="replace")
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 _document_path(self, raw: str) -> Path:
if (not isinstance(raw, str) or not raw.strip()
or PureWindowsPath(raw).drive or PureWindowsPath(raw).root
or any(c in raw for c in "*?[]:")):
raise ContextError(f"context file must be a repository-relative file: {raw!r}")
root = Path(self._root).resolve()
path = (root / raw.replace("\\", "/")).resolve()
if not path.is_relative_to(root):
raise ContextError(f"context file resolves outside repository: {raw}")
return path
def _read_document(self, path: Path) -> tuple[str, str]:
label = path.relative_to(Path(self._root).resolve()).as_posix()
try:
return label, path.read_text(encoding="utf-8")
except (OSError, UnicodeError) as exc:
raise ContextError(f"cannot read UTF-8 document {label}: {exc}") from exc
def _prompt_sections(self, step: dict, prev_error: Optional[str] = None) -> list:
self._validation_mode(step)
paths = step.get("context_files", [])
if not isinstance(paths, list):
raise ContextError("context_files must be a string array")
step_path = self._document_path(f"phases/{self._phase_dir_name}/step{step['step']}.md")
agents = self._document_path("AGENTS.md")
handoff = self._document_path(f"docs/phase/{self._phase_dir_name}/handoff.md")
seen = {step_path, agents, handoff}
sections = [self._read_document(agents)] if agents.exists() else []
sections.append(("작업 규칙", self._work_rules(step)))
for raw in paths:
path = self._document_path(raw)
if not path.is_file():
raise ContextError(f"context file not found: {raw}")
if path not in seen:
sections.append(self._read_document(path))
seen.add(path)
if handoff.exists():
sections.append(self._read_document(handoff))
else:
summary = self._build_step_context(self._read_json(self._index_file), step["step"])
if summary:
sections.append((f"이전 step 요약 (phases/{self._phase_dir_name}/index.json)", summary))
if prev_error:
sections.append(("이전 시도 실패", self._bounded_error(prev_error)))
sections.append(("직전 원본 로그",
f"phases/{self._phase_dir_name}/step{step['step']}-output.json"))
sections.append(self._read_document(step_path))
return sections
@staticmethod
def _render_prompt(sections: list) -> str:
return "\n\n---\n\n".join(f"## {label}\n\n{body}" for label, body in sections)
def _build_prompt(self, step: dict, prev_error: Optional[str] = None) -> str:
return self._render_prompt(self._prompt_sections(step, prev_error))
@staticmethod
def _validation_mode(step: dict) -> str:
mode = step.get("validation", "full")
if mode not in ("full", "docs-only"):
raise ContextError("validation must be full or docs-only")
return mode
@staticmethod
def _bounded_error(error: str) -> str:
if len(error) <= 4000:
return error
marker = "\n[중간 생략; 원본 로그 참조]\n"
return error[:1500] + marker + error[-(2500 - len(marker)):]
@staticmethod
def _build_step_context(index: dict, step_num: int) -> str:
prior = [s for s in index["steps"]
if s["step"] < step_num and s["status"] == "completed"]
if not prior:
return ""
step = max(prior, key=lambda s: s["step"])
summary = step.get("summary", "")
if not summary:
return ""
if len(summary) > 1000:
summary = summary[:980] + "\n[이후 생략; index 참조]"
return f"Step {step['step']} ({step['name']}): {summary}"
def _work_rules(self, step: dict) -> str:
notes = f"docs/phase/{self._phase_dir_name}"
return (
f"당신은 {self._project} 프로젝트의 개발자입니다. 아래 step을 수행하세요.\n\n"
f"1. 지정한 문서와 관련 코드를 읽고, 추가 자료는 필요할 때만 읽어라.\n"
f"2. 현재 step 범위만 수행하라. 아래 인계·step 기록은 허용된 산출물이다.\n"
f"3. 기존 테스트를 깨뜨리지 마라.\n"
f"4. AC(Acceptance Criteria) 검증을 직접 실행하라.\n"
f"5. 성공·실패·차단을 보고하기 전에 {notes}/handoff.md와 {notes}/step{step['step']}.md를 갱신하라.\n"
f" handoff는 현재 상황, 유효한 결정과 이유, 미해결 문제, 검증 명령·결과, 다음 시작점과 상세 기록 링크를 약 60줄로 유지하라.\n"
f" step 기록에는 변경 파일, 결정, 검증, 실패한 접근과 이유를 적고 원본 로그를 복사하지 마라.\n"
f"6. phases/{self._phase_dir_name}/index.json의 해당 step status를 업데이트하라:\n"
f" - AC 통과 → \"completed\" + \"summary\" 필드에 이 step의 산출물을 한 줄로 요약\n"
f" - 이번 세션에서 해결하지 못한 실패 → \"error\" + \"error_message\"에 원인·시도한 수정 기록\n"
f" - 사용자 개입이 필요한 경우 (API 키, 인증, 수동 설정 등) → \"blocked\" + \"blocked_reason\" 기록 후 즉시 중단\n"
f" 새 세션 재시도는 실행기가 최초 호출 포함 최대 {self.MAX_RETRIES}회 관리한다.\n"
f" 상태가 인계와 다르면 index와 실제 변경·로그를 확인하라.\n"
f"7. 변경사항을 직접 커밋하지 마라. Git 커밋과 timestamp는 실행기가 처리한다."
)
# --- Codex 호출 ---
def _invoke_codex(self, step: dict, prompt: str) -> dict:
step_num, step_name = step["step"], step["name"]
child_env = os.environ.copy()
child_env.pop("HARNESS_DOCS_ONLY_PHASE", None)
if self._validation_mode(step) == "docs-only":
child_env["HARNESS_DOCS_ONLY_PHASE"] = f"phases/{self._phase_dir_name}"
command = [
"codex",
"exec",
"--json",
"--sandbox",
"workspace-write",
"--dangerously-bypass-hook-trust",
"--cd",
self._root,
"-",
]
try:
result = subprocess.run(
command,
cwd=self._root,
input=prompt,
env=child_env,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=1800,
)
except FileNotFoundError as exc:
raise CodexEnvironmentError(
"Codex CLI를 찾을 수 없습니다. codex를 설치하고 PATH를 확인하세요."
) from exc
except subprocess.TimeoutExpired as exc:
stdout = exc.stdout or ""
stderr = exc.stderr or ""
if isinstance(stdout, bytes):
stdout = stdout.decode("utf-8", errors="replace")
if isinstance(stderr, bytes):
stderr = stderr.decode("utf-8", errors="replace")
result = subprocess.CompletedProcess(
command, 124, stdout, f"Codex timed out after 1800 seconds\n{stderr}",
)
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 _validate_completed_step(self, step: dict) -> tuple[bool, str]:
# Stop can halt a Codex turn with exit 0 even when its build failed.
# Independently verify before accepting the agent's completed state.
from hooks.stop_validation import _documentation_only_changes, run_validations
root = Path(self._root)
deadline = time.monotonic() + 1800
if self._validation_mode(step) == "docs-only" and _documentation_only_changes(
root, f"phases/{self._phase_dir_name}", deadline=deadline,
):
return True, ""
return run_validations(root, deadline=deadline)
def _execute_single_step(self, step: dict) -> 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 = next(s for s in index["steps"] if s["step"] == step_num)
prompt = self._build_prompt(step, 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, prompt)
elapsed = int(pi.elapsed)
index = self._read_json(self._index_file)
if output["exitCode"] != 0:
for s in index["steps"]:
if s["step"] == step_num and s["status"] == "completed":
s["status"] = "error"
s.pop("completed_at", None)
s.pop("summary", None)
self._write_json(self._index_file, index)
environment_failure = self._codex_environment_failure(output)
if environment_failure:
raise CodexEnvironmentError(environment_failure)
status = next((s.get("status", "pending") for s in index["steps"] if s["step"] == step_num), "pending")
if status == "completed" and output["exitCode"] == 0:
with progress_indicator("Verifying completed step"):
valid, validation_error = self._validate_completed_step(step)
if not valid:
status = "error"
current = next(s for s in index["steps"] if s["step"] == step_num)
current.update(status="error", error_message=validation_error)
current.pop("completed_at", None)
current.pop("summary", None)
self._write_json(self._index_file, index)
ts = self._stamp()
if status == "completed" and output["exitCode"] == 0:
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)
current = next(s for s in index["steps"] if s["step"] == step_num)
reason = current.get("error_message") or (
f"Codex exited with code {output['exitCode']}"
if output["exitCode"] else "Step did not update status"
)
diagnostics = [reason]
for stream in ("stderr", "stdout"):
if output.get(stream, "").strip():
diagnostics.append(f"{stream}:\n{output[stream].strip()}")
err_msg = self._bounded_error("\n\n".join(diagnostics))
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):
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)
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)")
mode = parser.add_mutually_exclusive_group()
mode.add_argument("--push", action="store_true", help="Push branch after completion")
mode.add_argument("--dry-run", action="store_true", help="Preview first pending step context without execution")
args = parser.parse_args()
try:
StepExecutor(args.phase_dir, auto_push=args.push).run(dry_run=args.dry_run)
except (CodexEnvironmentError, ContextError) as exc:
print(f"ERROR: {exc}")
sys.exit(1)
if __name__ == "__main__":
main()
@@ -0,0 +1,128 @@
#!/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 dotnet_harness.config import ConfigError, load_config
from dotnet_harness.tdd_policy import CS_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", {})
if isinstance(value, str):
return {"input": value}
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", "command"):
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", "")).removeprefix("functions.")
tool_input = _tool_input(payload)
if tool_name in {"Bash", "shell_command", "PowerShell", "exec_command"}:
command = tool_input.get("command", tool_input.get("cmd", ""))
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 CS_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()
try:
reason = evaluate(payload, root)
except OSError as exc:
reason = f"TDD GUARD: repository inspection failed: {exc}"
if reason:
# PowerShell can translate a native exit code 2 into 1. JSON preserves
# the deny decision across the Windows command-hook shell boundary.
print(json.dumps({"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": reason,
}}, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -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())