modify harness framework
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
---
|
||||
name: harness
|
||||
description: Use when planning agentic implementation phases, creating phases/index.json and self-contained step files, or running the Harness step executor.
|
||||
---
|
||||
|
||||
# Harness Workflow
|
||||
|
||||
Harness는 Windows에서 C#·.NET 8 제품 개발을 지원하는 Python 계획·실행 도구다.
|
||||
제품 요구사항과 기술 결정을 Harness 자체 요구사항과 섞지 않는다. 제품 유형과 계층을
|
||||
가정하지 않으며 신규 테스트 기본값은 xUnit v2 + VSTest다.
|
||||
|
||||
## 탐색과 논의
|
||||
|
||||
1. `AGENTS.md`를 읽는다.
|
||||
2. 변경 범위와 직접 관련된 제품 문서, 코드, 테스트와 `.harness/config.json`만 읽는다.
|
||||
3. 미정인 제품 요구사항이나 공개 계약이 구현 결과를 바꾸면 사용자와 논의한다.
|
||||
|
||||
전체 `docs/`를 일괄 읽거나 그 본문을 step에 복사하지 않는다. 이전 대화 없이도 실행할
|
||||
수 있도록 필요한 기존 파일을 명시적으로 참조한다.
|
||||
|
||||
## Step 설계
|
||||
|
||||
하나의 step에는 다음 요소만 필수로 둔다.
|
||||
|
||||
- 목표와 범위
|
||||
- 관련 파일과 `context_files`
|
||||
- 핵심 제약
|
||||
- 실행 가능한 Acceptance Criteria
|
||||
|
||||
공개 API 계약이 중요한 경우에만 시그니처와 nullability를 적는다. 새 기능과 버그 수정은 한
|
||||
step 안에서 실패 테스트 확인, 최소 구현, 성공 확인까지 끝낸다. 실제 설정과 기존
|
||||
테스트 명령을 우선하며 확인되지 않은 예시 명령을 AC로 쓰지 않는다.
|
||||
|
||||
제품 검증은 저장소 루트에서 `global.json`에 따른 SDK 선택을 확인한다. version 2
|
||||
설정의 target에 포함된 각 프로젝트와 모든 `testProjects`에서 SDK로 평가한 참조를
|
||||
끝까지 따라가 실제 TFM이 .NET 8이고 프로젝트가 저장소 내부인지 확인한다.
|
||||
이후 target과 모든 `testProjects`를 restore/build한 뒤 각 테스트를
|
||||
실행한다. 경고는 오류로 처리하고 현재 TRX의 누락·실행 0건·skip-only를 성공으로
|
||||
기록하지 않는다. 제품에서 테스트 프로젝트로 향하는 참조를 만들지 않는다.
|
||||
세부 명령은 [환경 구축 가이드](../../../docs/ENVIRONMENT_SETUP.md)를 따른다.
|
||||
|
||||
`Foo.cs`에는 기본적으로 `FooTests.cs` 또는 `FooTest.cs`가 필요하다. 파일 존재 검사는
|
||||
실제 실패 테스트와 성공 실행을 증명하지 않으므로 명령·결과를 step 기록에 남긴다.
|
||||
`Program.cs`도 자동 면제가 아니다. 필요한 면제는 정확한 `tdd.exclude` 경로와 이유를
|
||||
기록하며 제품 검증 대상에서 파일을 숨기는 용도로 사용하지 않는다.
|
||||
|
||||
## Index 형식
|
||||
|
||||
`phases/index.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"phases": [{"dir": "add-parser", "status": "pending"}]
|
||||
}
|
||||
```
|
||||
|
||||
`phases/{phase}/index.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"project": "<제품명 또는 미정>",
|
||||
"phase": "add-parser",
|
||||
"steps": [
|
||||
{
|
||||
"step": 0,
|
||||
"name": "implement-parser",
|
||||
"status": "pending",
|
||||
"context_files": ["docs/ARCHITECTURE.md"],
|
||||
"validation": "full"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- `name`은 kebab-case slug다.
|
||||
- `context_files`는 선택 사항인 저장소 상대 파일 경로 배열이다. glob과 URL은 쓰지
|
||||
않는다. 생략하거나 빈 배열이면 추가 문서를 주입하지 않는다.
|
||||
- `validation`은 `full` 또는 `docs-only`이며 생략 시 `full`이다. `docs-only`는 실제
|
||||
변경이 허용된 문서 경로에만 있을 때만 제품 검증 생략을 요청한다.
|
||||
- 생성 시 status는 `pending`이다. timestamp는 executor가 기록한다.
|
||||
|
||||
상태 기록:
|
||||
|
||||
| 상태 | Codex 기록 | Executor 기록 |
|
||||
|---|---|---|
|
||||
| `completed` | 한 줄 `summary` | `completed_at` |
|
||||
| `error` | `error_message` | `failed_at` |
|
||||
| `blocked` | `blocked_reason` | `blocked_at` |
|
||||
|
||||
## Step 파일
|
||||
|
||||
````markdown
|
||||
# Step N: <name>
|
||||
|
||||
## 목표와 범위
|
||||
<한 가지 책임과 범위 밖 항목>
|
||||
|
||||
## 관련 파일
|
||||
- `<path>`
|
||||
|
||||
## 핵심 제약
|
||||
- <유지할 계약과 이유>
|
||||
|
||||
## Acceptance Criteria
|
||||
```powershell
|
||||
<실제 build/test 명령>
|
||||
```
|
||||
````
|
||||
|
||||
현재 step보다 앞선 작업이 만든 파일은 관련 파일에 적고 실행 중 직접 읽는다. 이미
|
||||
존재하는 제품 문서만 `context_files`에 넣는다.
|
||||
|
||||
## Phase 기록
|
||||
|
||||
`docs/phase/{phase}/handoff.md`는 최신 상태, 결정, 미해결 문제, 마지막 검증과 다음
|
||||
시작점을 약 60줄 이내로 유지한다. `stepN.md`에는 변경 파일, 결정, 검증 결과와 유효한
|
||||
시행착오를 기록한다. 성공·실패·차단을 보고하기 전에 둘을 갱신한다. 형식은
|
||||
`docs/phase/README.md`를 따른다.
|
||||
|
||||
## 실행
|
||||
|
||||
```powershell
|
||||
python scripts/execute.py <phase>
|
||||
python scripts/execute.py <phase> --dry-run
|
||||
python scripts/execute.py <phase> --push
|
||||
```
|
||||
|
||||
`--dry-run`은 첫 pending step의 주입 경로와 문자 수, 전체 프롬프트 문자 수만
|
||||
출력하며 파일, Git, 상태와 Codex를 변경하지 않는다. `--push`와 함께 쓸 수 없다.
|
||||
|
||||
프롬프트는 `AGENTS → 공통 규칙 → context_files → 최신 handoff 또는 호환 summary →
|
||||
직전 실패 → 현재 step` 순서다. handoff가 없을 때만 현재 step보다 앞선 완료 step 중
|
||||
가장 최근 하나의 summary를 최대 1,000자로 전달한다.
|
||||
|
||||
Executor는 최초 호출을 포함해 Codex를 최대 3회 호출한다. 재시도 오류 본문은 총
|
||||
4,000자 안에서 앞 1,500자, 중간 생략 marker와 끝부분 최대 2,500자를 보존한다. 원본 출력은
|
||||
`phases/{phase}/stepN-output.json`에 온전히 쓰지만 다음 호출에서 덮어쓴다. 지속할
|
||||
시행착오는 `docs/phase/{phase}/stepN.md`에 남긴다.
|
||||
|
||||
`completed`는 Codex 프로세스 exit code가 0이고 executor의 독립 제품 검증까지
|
||||
통과해야 성공이다. Stop이 실패 응답으로 턴을 멈춰도 CLI는 exit 0일 수 있으므로
|
||||
모델이 쓴 상태만 신뢰하지 않는다. 독립 검증 실패도 같은 3회 호출 예산에서 재시도한다.
|
||||
`docs-only`는 이 검사에서도 실제 문서 변경을 확인하며 `blocked`는 즉시 멈춘다.
|
||||
`error`나 `blocked`를 재개하려면 원인을 해결하고 status를 `pending`으로 바꾼 뒤 해당
|
||||
오류 필드와 timestamp를 제거한다.
|
||||
@@ -0,0 +1,38 @@
|
||||
---
|
||||
name: review
|
||||
description: Use when reviewing repository changes against AGENTS.md, architecture decisions, tests, and build requirements.
|
||||
---
|
||||
|
||||
# Repository Review
|
||||
|
||||
`AGENTS.md`를 먼저 읽고 변경 범위와 직접 관련된 제품 문서, 코드와 테스트만 추가로
|
||||
읽는다. `context_files`가 있으면 해당 목록을 우선한다. 전체 `docs/`를 일괄 읽도록
|
||||
요구하지 않는다.
|
||||
|
||||
사용자가 범위를 지정하지 않으면 현재 작업 트리의 변경을 리뷰한다. 실제 결함을
|
||||
심각도순으로 제시하고 각 finding에 파일과 줄 번호, 영향, 근거와 수정 방안을 적는다.
|
||||
|
||||
다음을 해당 변경에 적용할 수 있는 범위에서 확인한다.
|
||||
|
||||
- 공개 C# API의 접근성·호환성·nullability 계약, 프로젝트 참조 방향과 관련 ADR
|
||||
- 제품 프로젝트가 테스트 프로젝트를 참조하지 않는지 확인
|
||||
- 새 C# 동작을 먼저 재현한 실패 테스트의 명령·결과와 구현 후 회귀 검증
|
||||
- 실제 `global.json`, `.sln` / SDK 스타일 `.csproj`, version 2 `.harness/config.json`에
|
||||
따른 .NET 8 SDK 선택, 각 제품·테스트 프로젝트의 실제 TFM 평가와 restore/build/test 결과;
|
||||
상속된 props와 속성 참조를 포함한 `TargetFramework`/`TargetFrameworks`도 확인
|
||||
- `testProjects`가 전체 제품 테스트를 포함하고 각 프로젝트의 현재 TRX에 실행된
|
||||
테스트가 있는지 확인; 테스트 0건·skip-only·누락·과거 결과는 성공 근거가 아니다
|
||||
- nullable 진단, 경고의 오류 처리와 범위 밖 변경
|
||||
- async 코드 변경 시 취소·예외 전파, `IDisposable` / `IAsyncDisposable` 사용 변경 시
|
||||
자원 해제 확인; 모든 제품에 async나 DI를 요구하지 않는다
|
||||
- C# 대응 테스트 파일 존재를 red-green 증거로 혼동하지 않는지, `Program.cs` 등의
|
||||
면제가 정확한 경로와 이유로 한정되고 제품 검증을 우회하지 않는지 확인
|
||||
- Harness 변경이면 관련 Python 테스트와 운영 문서의 필드·동작 일치
|
||||
|
||||
명시적으로 `validation: docs-only`인 문서 step은 실제 변경이 허용된 문서 경로에만
|
||||
있을 때 제품 build를 생략할 수 있다. 그 외에는 기존 전체 검증을 사용한다. 실행하지
|
||||
못한 검사는 성공으로 추정하지 않고 `미검증`과 이유를 기록한다.
|
||||
|
||||
finding 뒤에는 아키텍처, 기술 스택, 테스트, 필수 규칙, build 가능 여부를 각각
|
||||
`통과`, `실패`, `미검증`으로 요약한다. finding이 없으면 발견된 문제가 없다고 쓰고
|
||||
남은 검증 공백을 밝힌다.
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"description": "Harness TDD, command safety, and .NET 8 C# validation hooks on Windows.",
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Bash|shell_command|PowerShell|exec_command|functions.exec_command|apply_patch|functions.apply_patch|Edit|MultiEdit|Write",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 -X utf8 \"$(git rev-parse --show-toplevel)/scripts/hooks/pre_tool_use.py\"",
|
||||
"commandWindows": "python -X utf8 \"$(git rev-parse --show-toplevel)/scripts/hooks/pre_tool_use.py\"",
|
||||
"timeout": 30,
|
||||
"statusMessage": "Checking Harness policies"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 -X utf8 \"$(git rev-parse --show-toplevel)/scripts/hooks/stop_validation.py\"",
|
||||
"commandWindows": "python -X utf8 \"$(git rev-parse --show-toplevel)/scripts/hooks/stop_validation.py\"",
|
||||
"timeout": 1800,
|
||||
"statusMessage": "Running .NET 8 build and tests"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
*.py[cod]
|
||||
.harness/build/
|
||||
bin/
|
||||
obj/
|
||||
.vs/
|
||||
TestResults/
|
||||
.harness/test-results/
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"version": 2,
|
||||
"projectType": "dotnet",
|
||||
"dotnet": {
|
||||
"target": "Example.sln",
|
||||
"configuration": "Debug",
|
||||
"testProjects": ["tests/Example.Tests/Example.Tests.csproj"]
|
||||
},
|
||||
"tdd": {
|
||||
"testRoots": [
|
||||
"tests"
|
||||
],
|
||||
"testPatterns": [
|
||||
"{stem}Tests.cs",
|
||||
"{stem}Test.cs"
|
||||
],
|
||||
"exclude": []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
# C# .NET 8 제품 개발 프로젝트
|
||||
|
||||
이 저장소는 **C#·.NET 8 제품 개발용 범용 템플릿**이다. Harness와 `scripts/`의 Python
|
||||
코드는 제품 개발을 지원하는 도구이며 제품의 목표나 기능이 아니다.
|
||||
|
||||
## 현재 확정 상태
|
||||
|
||||
- 제품명·사용자·업무 기능·UI·배포 방식: 미정
|
||||
- 제품: C#, 기본 TFM `net8.0`, SDK 선택은 루트 `global.json`
|
||||
- Harness 실행·검증 환경: Windows, Python 3.10 이상
|
||||
- 신규 제품 테스트 기본값: xUnit v2 + VSTest
|
||||
- 빌드 진입점과 전체 테스트 목록: 실제 `.sln` / SDK 스타일 `.csproj`와
|
||||
`.harness/config.json` version 2로 확인
|
||||
- 권장 배치: 제품 `src/<ProjectName>/`, 테스트 `tests/<ProjectName>.Tests/`
|
||||
- `tests/fixtures/`는 Harness 검증용 예제이며 실제 제품이 아니다.
|
||||
|
||||
미정 값을 예시로 채우거나 검증된 사실처럼 표현하지 않는다. 특정 UI, 계층, DI나
|
||||
async 구조를 필요 없이 강제하지 않는다.
|
||||
|
||||
## 필수 규칙
|
||||
|
||||
- CRITICAL: 공개 API 계약과 프로젝트 참조 방향을 유지한다. 제품 프로젝트에서
|
||||
테스트 프로젝트를 참조하지 않는다.
|
||||
- CRITICAL: 새 기능과 버그 수정은 실패하는 테스트를 먼저 확인한 뒤 구현한다.
|
||||
편집 훅의 대응 테스트 파일 존재 검사는 실제 red-green 증거를 대신하지 않는다.
|
||||
- CRITICAL: 새 빌드 경고를 추가하지 않는다. 빌드는 경고를 오류로 처리한다.
|
||||
- 신규 프로젝트는 nullable 검사와 implicit usings를 활성화한다. 언어 버전은
|
||||
SDK 기본값을 사용하며 `latest`를 지정하지 않는다.
|
||||
- 변경 범위와 관련된 문서·코드·테스트만 읽고 수정한다. step의 `context_files`가
|
||||
있으면 그 목록을 우선한다.
|
||||
- 실행 상태는 `phases/{phase}/index.json`을 기준으로 판단한다. 인계 문서는 상태를
|
||||
대신하지 않는다.
|
||||
- phase 작업 시 `docs/phase/{phase}/handoff.md`를 확인하고, 종료 전에 최신 인계와
|
||||
해당 `stepN.md` 기록을 갱신한다. 상세 양식은 [docs/phase/README.md](docs/phase/README.md)를 따른다.
|
||||
- 커밋 메시지는 conventional commits 형식을 따른다.
|
||||
|
||||
## 검증
|
||||
|
||||
실제 설정과 프로젝트 문서를 우선한다. 아래 제품 경로는 예시이므로 실제 target과
|
||||
모든 `testProjects`로 바꾼다. 저장소 루트에서 SDK 확인 → 실제 TFM 평가 → restore →
|
||||
build → test를 실행한다. target에 포함된 각 프로젝트와 모든 test project에서 평가된
|
||||
참조를 끝까지 따라가 `TargetFramework`/`TargetFrameworks`가 .NET 8이고 프로젝트가
|
||||
저장소 내부인지 확인한다. 각 test project의 현재 TRX
|
||||
결과와 실행 건수를 확인한다. 테스트 0건,
|
||||
skip-only, 결과 누락은 성공이 아니다. 대상 없는 템플릿도 제품 빌드 성공이 아니다.
|
||||
|
||||
```powershell
|
||||
dotnet --version
|
||||
dotnet msbuild <each-project.csproj> -getProperty:TargetFramework,TargetFrameworks -getItem:ProjectReference -p:Configuration=Debug
|
||||
dotnet restore <target.sln-or-csproj> -p:Configuration=Debug
|
||||
dotnet restore <test-project.csproj> -p:Configuration=Debug
|
||||
dotnet build <target.sln-or-csproj> -c Debug --no-restore -warnaserror
|
||||
dotnet build <test-project.csproj> -c Debug --no-restore -warnaserror
|
||||
dotnet test <test-project.csproj> -c Debug --no-build --no-restore --logger trx
|
||||
```
|
||||
|
||||
Harness 자체 변경은 별도로 검증한다.
|
||||
|
||||
```powershell
|
||||
uv run --with pytest python -m pytest tests/harness -v -rs
|
||||
uv run --with pytest python -m compileall -q scripts tests/harness
|
||||
```
|
||||
|
||||
설정·결과 판정은 [Harness 운영 가이드](docs/HARNESS.md), Windows 설치와 첫 실행은
|
||||
[환경 구축 가이드](docs/ENVIRONMENT_SETUP.md)를 따른다.
|
||||
@@ -0,0 +1,56 @@
|
||||
# Architecture Decision Records
|
||||
|
||||
제품명, 사용자, 업무 기능, UI와 배포 구조는 미정이다. 아래 결정은 제품 기술 경계와
|
||||
신규 프로젝트 기본값을 정하며 구체적인 도메인 설계를 대신하지 않는다.
|
||||
|
||||
## ADR-001: MSVC 기반 C++ 제품
|
||||
|
||||
- **상태:** 대체됨 (superseded by ADR-003)
|
||||
- **이전 결정:** 대상 제품을 MSVC 기반 C++로 개발한다.
|
||||
- **대체 이유:** 사용자가 C# 전용 .NET 8 템플릿 전환을 승인했다.
|
||||
- **결과:** 이전 C++·MSVC 지원과 호환 계층을 유지하지 않는다.
|
||||
|
||||
## ADR-002: public header 경계
|
||||
|
||||
- **상태:** 대체됨 (superseded by ADR-004)
|
||||
- **이전 결정:** 공개 header와 implementation의 의존성 방향을 유지하고
|
||||
`src/`, `include/`, `tests/`로 배치한다.
|
||||
- **대체 이유:** C#의 공개 API 접근성과 프로젝트 참조로 경계를 표현한다.
|
||||
|
||||
## ADR-003: C#·.NET 8 범용 제품 템플릿
|
||||
|
||||
- **상태:** 승인
|
||||
- **결정:** 제품 언어는 C#, 신규 제품 기본 TFM은 `net8.0`이다.
|
||||
`global.json`은 SDK `8.0.424`, `rollForward=latestPatch`,
|
||||
`allowPrerelease=false`를 기준으로 한다.
|
||||
- **이유:** 승인된 기술 전환 방향과 설치가 확인된 SDK 기준을 명시한다. 해당 SDK를
|
||||
최신 버전이라고 주장하지 않는다.
|
||||
- **대안·trade-off:** 다중 언어 호환 계층과 .NET Framework 4.x 지원은 제외한다.
|
||||
SDK 패치 갱신과 향후 런타임 전환은 별도로 검증한다.
|
||||
- **영향 경로:** `global.json`, 신규 제품 `.csproj`, `AGENTS.md`, 제품 문서.
|
||||
|
||||
## ADR-004: 공개 API와 프로젝트 참조 경계
|
||||
|
||||
- **상태:** 승인
|
||||
- **결정:** 공개 API의 접근성·nullability 계약과 제품이 정한 참조 방향을 유지한다.
|
||||
제품 프로젝트는 테스트 프로젝트를 참조하지 않는다. 권장 배치는
|
||||
`src/<ProjectName>/`와 `tests/<ProjectName>.Tests/`이다.
|
||||
- **이유:** 제품 소비자가 구현 세부사항이나 테스트 의존성을 부담하지 않도록 한다.
|
||||
- **대안·trade-off:** 별도 Contracts, DI와 계층을 템플릿에 미리 만들지 않는다.
|
||||
공개 계약 분리가 필요해질 때 제품 요구에 따라 결정한다.
|
||||
- **영향 경로:** 신규 제품·테스트 `.csproj`, 공개 C# API, `docs/ARCHITECTURE.md`.
|
||||
|
||||
## ADR-005: 신규 프로젝트의 테스트와 컴파일 기본값
|
||||
|
||||
- **상태:** 승인
|
||||
- **결정:** 신규 테스트는 xUnit v2 + VSTest를 기본으로 하고, nullable와 implicit
|
||||
usings를 활성화한다. C# 언어 버전은 SDK 기본값을 사용하고 빌드 경고는 오류로 처리한다.
|
||||
- **이유:** 실제 실행된 테스트와 컴파일 진단을 기본 검증 근거로 삼는다.
|
||||
- **대안·trade-off:** 기존 테스트 프레임워크는 VSTest/TRX 계약을 만족하면 사용할 수
|
||||
있다. MTP 전용 어댑터, 대규모 스타일 규칙과 커버리지 임계값은 도입하지 않는다.
|
||||
- **영향 경로:** 신규 제품·테스트 `.csproj`, `AGENTS.md`.
|
||||
|
||||
## 새 결정 형식
|
||||
|
||||
새 ADR에는 상태, 결정, 이유, 대안 또는 trade-off, 영향을 받는 경로를 기록한다.
|
||||
Harness 사용법과 실행 상태는 제품 ADR 대신 Harness 문서와 phase index에 기록한다.
|
||||
@@ -0,0 +1,25 @@
|
||||
# 제품 아키텍처
|
||||
|
||||
## 확정된 기술 경계
|
||||
|
||||
제품은 C#·.NET 8을 사용한다. 실제 업무 프로젝트, entry point, 런타임 데이터 흐름과
|
||||
배포 구조는 아직 미정이다. 특정 UI 프레임워크, 계층, DI나 async 구조를 강제하지 않는다.
|
||||
|
||||
다음은 제품 생성 시의 **권장 배치**이며 현재 구현된 제품 구조를 뜻하지 않는다.
|
||||
|
||||
```text
|
||||
src/<ProjectName>/ 제품 C# 코드와 SDK 스타일 .csproj
|
||||
tests/<ProjectName>.Tests/ 제품 테스트와 테스트 .csproj
|
||||
```
|
||||
|
||||
- 공개 API의 접근성, 타입과 nullability 계약을 유지한다.
|
||||
- 프로젝트 참조는 제품에서 정한 방향을 따른다. 테스트가 제품을 참조하며 제품은
|
||||
테스트 프로젝트를 참조하지 않는다.
|
||||
- 별도 Contracts 프로젝트나 추가 계층은 실제 필요가 확인될 때만 도입한다.
|
||||
- 신규 프로젝트는 `TargetFramework=net8.0`, `Nullable=enable`,
|
||||
`ImplicitUsings=enable`을 기본으로 한다. C# 언어 버전은 SDK 기본값을 따른다.
|
||||
- 루트 `global.json`은 빌드에 사용할 SDK 선택을 제어하며 제품 TFM을 대신하지 않는다.
|
||||
|
||||
실제 제품과 metadata가 추가되면 확인된 구조와 참조 방향만 이 문서에 기록한다.
|
||||
`tests/fixtures/dotnet/Smoke/`는 개발 도구 검증용이며 제품이 아니다. Harness의 Python
|
||||
모듈 구조와 phase 운영 절차는 [Harness 문서](HARNESS.md)에 둔다.
|
||||
@@ -0,0 +1,354 @@
|
||||
# C# .NET 8 Harness 환경 구축 가이드
|
||||
|
||||
이 문서는 Windows에서 Harness를 설치하고 C#·.NET 8 제품의 빌드·테스트와 Codex
|
||||
step 실행을 준비하는 절차를 설명한다. 명령은 별도 표시가 없으면 **대상 저장소
|
||||
루트의 PowerShell**에서 실행한다.
|
||||
|
||||
이 저장소는 범용 템플릿이며 제품명·업무 기능·UI·배포 방식은 미정이다.
|
||||
`tests/fixtures/dotnet/Smoke/`는 도구 검증용 프로젝트다. 아래 `Example` 경로는
|
||||
제품 설정 예시이며 현재 제품이나 검증 성공을 뜻하지 않는다. 실제 검증 결과는
|
||||
해당 [phase 기록](phase/README.md)에 남긴다.
|
||||
|
||||
## 1. 필요한 환경
|
||||
|
||||
| 구분 | 요구 사항 | 용도 및 확인 기준 |
|
||||
|---|---|---|
|
||||
| 운영체제 | Windows | Harness 실행·검증 환경 고정. 제품 배포 OS와 별개다. |
|
||||
| 셸 | PowerShell | 아래 UTF-8 파일 작성 예제는 PowerShell 7 기준이다. |
|
||||
| Git | Windows에서 실행 가능한 Git | 저장소 탐색, branch 전환, 자동 commit. `--push` 시 원격 인증이 필요하다. |
|
||||
| Python | 3.10 이상, 실제 `python` 명령 실행 가능 | 실행기·훅은 표준 라이브러리를 사용한다. 자식 훅 환경의 PATH에서도 보여야 한다. |
|
||||
| Codex CLI | 아래 옵션·훅 호환성 점검을 통과하는 버전 | `codex exec`, JSON 출력, stdin 프롬프트와 프로젝트 훅을 사용한다. |
|
||||
| .NET | 루트 `global.json`을 만족하는 .NET 8 SDK | runtime만 설치해서는 restore/build/test를 실행할 수 없다. |
|
||||
| 테스트 | 기본 xUnit v2 + VSTest, NuGet 접근 | 각 명시 test project의 실제 실행과 TRX 결과가 필요하다. |
|
||||
| 선택 도구 | IDE, Node.js/npm, pytest/uv | IDE는 선택이다. npm은 해당 Codex 설치 방식에, pytest/uv는 Harness 테스트에 사용한다. |
|
||||
|
||||
제품 아키텍처나 CPU 대상을 일괄 지정하지 않는다. SDK와 제품 TFM의 역할은
|
||||
[Microsoft global.json 문서](https://learn.microsoft.com/en-us/dotnet/core/tools/global-json)를 참고한다.
|
||||
|
||||
## 2. 기본 도구 설치
|
||||
|
||||
### Git과 Python
|
||||
|
||||
1. [Git for Windows 공식 설치 페이지](https://git-scm.com/install/windows)에서 설치하고
|
||||
PowerShell과 외부 프로그램에서 `git`을 실행할 수 있도록 PATH를 설정한다.
|
||||
2. [Python Windows 공식 배포 페이지](https://www.python.org/downloads/windows/)에서
|
||||
Python 3.10 이상을 설치한다. 실행기와 자식 훅이 실제 설치 경로를 찾도록 설정한다.
|
||||
3. 새 PowerShell 창에서 확인한다.
|
||||
|
||||
```powershell
|
||||
git --version
|
||||
python --version
|
||||
python -c "import sys; print(sys.executable); assert sys.version_info >= (3, 10)"
|
||||
Get-Command git, python | Select-Object Name, Source
|
||||
```
|
||||
|
||||
`python`이 Microsoft Store를 열거나 WindowsApps 별칭으로 실행에 실패하면 앱 실행
|
||||
별칭과 PATH 순서를 확인한다. `py`나 `uv run python`만 동작하는 상태는 충분하지
|
||||
않다. 현재 Windows 훅은 `python`을 호출한다. 가상환경을 쓰면 활성화한 동일 터미널에서
|
||||
Harness와 Codex를 실행한다. uv로 Python 테스트가 통과해도 훅 PATH 점검을 대신하지 않는다.
|
||||
|
||||
### .NET 8 SDK
|
||||
|
||||
[Microsoft .NET 8 다운로드](https://dotnet.microsoft.com/en-us/download/dotnet/8.0)에서
|
||||
환경에 맞는 **SDK**를 설치하고 새 터미널을 연다. 루트의 `global.json` 기준은 다음과 같다.
|
||||
|
||||
```json
|
||||
{
|
||||
"sdk": {
|
||||
"version": "8.0.424",
|
||||
"rollForward": "latestPatch",
|
||||
"allowPrerelease": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
이 SDK 번호는 초기 검증 기준이며 최신 버전이라는 뜻이 아니다. `latestPatch`는 같은
|
||||
feature band의 적합한 패치를 선택하며 설치된 SDK 9로 넘어가는 정책이 아니다.
|
||||
설치 목록과 실제 선택 결과를 구분해 **저장소 루트에서** 확인한다.
|
||||
|
||||
```powershell
|
||||
Get-Command dotnet | Select-Object Name, Source
|
||||
dotnet --list-sdks
|
||||
dotnet --version
|
||||
```
|
||||
|
||||
선택 결과는 위 정책을 만족하는 `8.0.4xx`여야 한다. SDK가 없거나 조건에 맞지 않으면
|
||||
설치를 복구하거나 검토 후 SDK 기준을 변경한다. Harness는 SDK를 자동 설치하지 않는다.
|
||||
`global.json`은 도구 선택이고 `.csproj`의 `TargetFramework`는 제품 대상이다.
|
||||
신규 프로젝트는 `net8.0`, `Nullable=enable`, `ImplicitUsings=enable`을 기본으로 하고
|
||||
언어 버전은 SDK 기본값을 사용한다.
|
||||
|
||||
## 3. Codex CLI 설치와 호환성 확인
|
||||
|
||||
[공식 Codex CLI 설치 안내](https://learn.chatgpt.com/docs/codex/cli)를 따라 설치하고
|
||||
새 터미널을 연다. npm 방식을 선택하면
|
||||
[Node.js 공식 배포 페이지](https://nodejs.org/en/download)의 Node.js/npm이 필요하다.
|
||||
Harness는 Codex 앱 설치 여부와 별개로 `codex` 명령을 직접 호출한다.
|
||||
|
||||
```powershell
|
||||
codex --version
|
||||
codex login
|
||||
codex login status
|
||||
codex exec --help
|
||||
```
|
||||
|
||||
`codex login`의 안내에 따라 인증한다. Harness 설정에 계정이나 API 키를 넣지 않는다.
|
||||
현재 실행기가 사용하는 명령 형태는 다음과 같다.
|
||||
|
||||
```text
|
||||
codex exec --json --sandbox workspace-write --dangerously-bypass-hook-trust --cd <repository-root> -
|
||||
```
|
||||
|
||||
저장소는 최소 Codex 버전을 선언하지 않으므로 `exec --help`로 해당 옵션을 확인한다.
|
||||
2026-09-08 로컬 CLI `0.153.4`에서 옵션을 확인했지만 이것은 전체 모델 실행이나
|
||||
자동 훅 차단 검증을 뜻하지 않는다.
|
||||
|
||||
실행기는 셸 alias나 PowerShell 함수 대신 Python 자식 프로세스로 CLI를 호출한다.
|
||||
아래 점검에는 모델 호출이 없다.
|
||||
|
||||
```powershell
|
||||
python -X utf8 -c "import subprocess; subprocess.run(['codex', '--version'], check=True)"
|
||||
python -X utf8 -c "import subprocess; subprocess.run(['codex', 'exec', '--help'], check=True)"
|
||||
```
|
||||
|
||||
터미널의 `codex`는 되는데 위 명령이 실패하면 `Get-Command codex -All`과
|
||||
`where.exe codex`로 PATH를 확인한다. alias나 npm shim이 자식 프로세스에서 실행되지
|
||||
않으면 공식 Windows 설치 방식과 실제 실행 파일 경로를 확인한 뒤 다시 점검한다.
|
||||
|
||||
## 4. 저장소 준비와 훅 확인
|
||||
|
||||
Git으로 clone하거나 기존 제품 저장소에 다음 구성요소를 가져온다. 기존 `AGENTS.md`와
|
||||
훅 설정이 있으면 제품 규칙을 유지하면서 병합한다.
|
||||
|
||||
```text
|
||||
AGENTS.md
|
||||
global.json
|
||||
.gitignore
|
||||
.agents/skills/harness/SKILL.md
|
||||
.agents/skills/review/SKILL.md
|
||||
.codex/hooks.json
|
||||
.harness/config.example.json
|
||||
scripts/execute.py
|
||||
scripts/hooks/
|
||||
scripts/dotnet_harness/
|
||||
docs/HARNESS.md
|
||||
docs/HARNESS_WORKFLOW.md
|
||||
docs/ENVIRONMENT_SETUP.md
|
||||
docs/phase/README.md
|
||||
```
|
||||
|
||||
실행기와 훅이 경로를 기준으로 저장소를 찾으므로 배치를 유지한다. 제품 권장 배치는
|
||||
`src/<ProjectName>/`와 `tests/<ProjectName>.Tests/`이며 제품을 미리 만들어 놓았다는
|
||||
뜻은 아니다. Harness 자체를 개발하려면 `tests/harness/`, `tests/fixtures/`와
|
||||
`pytest.ini`도 유지한다.
|
||||
|
||||
```powershell
|
||||
git rev-parse --show-toplevel
|
||||
git status --short
|
||||
git config user.name
|
||||
git config user.email
|
||||
python -X utf8 scripts/execute.py --help
|
||||
```
|
||||
|
||||
Git 작성자 정보가 없으면 대상 저장소에서 아래 예시를 자신의 정보로 바꾸어 설정한다.
|
||||
실제 실행 전 준비한 설정·phase 파일을 검토해 기준 commit을 만들고, 다른 작업의
|
||||
변경이 없는 전용 checkout/worktree를 사용한다. 실행기는 `git add -A`와 자동 commit을
|
||||
수행하므로 초기 HEAD가 없는 상태에서 실제 phase 실행을 시작하지 않는다.
|
||||
|
||||
```powershell
|
||||
git config --local user.name 'YOUR_NAME'
|
||||
git config --local user.email 'YOUR_EMAIL'
|
||||
```
|
||||
|
||||
저장소 루트에서 `codex`를 실행하고 `/hooks`로 `.codex/hooks.json`의 `PreToolUse`와
|
||||
`Stop`이 로드되는지 확인한다. 프로젝트와 변경된 훅의 신뢰, 사용자·관리자 설정을
|
||||
확인한다. `commandWindows`는 Windows용 명령 재정의이며 현재 실행기의
|
||||
`--dangerously-bypass-hook-trust`는 검토된 자동화에서 활성 훅의 지속 신뢰 요구를
|
||||
건너뛰는 옵션이다. 비활성 훅을 켜거나 빌드 설정을 대신하지 않는다.
|
||||
[공식 Codex 훅 문서](https://learn.chatgpt.com/docs/hooks)
|
||||
|
||||
Windows 훅은 `python -X utf8`과 Git 루트 탐색을 사용한다. 아래 직접 호출은 스크립트의
|
||||
입력 처리만 확인하며 Codex가 해당 훅을 자동 호출하는지는 검증하지 않는다.
|
||||
|
||||
```powershell
|
||||
'{"tool_name":"Edit","tool_input":{"file_path":"docs/ENVIRONMENT_SETUP.md"}}' | python -X utf8 scripts/hooks/pre_tool_use.py
|
||||
$LASTEXITCODE
|
||||
```
|
||||
|
||||
문서 편집 허용 기준은 종료 코드 `0`, 거부 JSON 없음, 입력 해석 오류 없음이다.
|
||||
PreToolUse 거부는 stdout의 `hookSpecificOutput.permissionDecision: "deny"`와
|
||||
`permissionDecisionReason`으로 판정한다. Windows PowerShell이 자식 프로세스의 종료
|
||||
코드 `2`를 `1`로 바꿀 수 있어 훅은 JSON 응답과 종료 코드 `0`을 사용한다.
|
||||
C# `Foo.cs` 편집에는 기본 루트 `tests/` 또는 편집 파일 옆 `tests/`, `test/`의
|
||||
`FooTests.cs`나 `FooTest.cs`가 필요하다. 실제 실패 테스트 확인은 별도다. `Program.cs`는 자동 면제가
|
||||
아니며 필요한 경우 정확한 `tdd.exclude` 경로와 이유를 기록한다.
|
||||
|
||||
실제 자동 훅 확인은 격리 저장소에서 테스트 없는 C# 편집 요청이 차단되는지,
|
||||
대응 테스트 작성 후 허용되는지, 잘못된 빌드가 Stop에서 거부되는지를 관측한다.
|
||||
등록 확인·직접 스크립트 실행·실제 CLI 이벤트 검증 결과를 구분해서 기록한다.
|
||||
|
||||
## 5. 제품 빌드 설정
|
||||
|
||||
`.harness/config.example.json`을 참고해 실제 제품에 맞는 version 2 설정을 작성한다.
|
||||
JSON은 **UTF-8 BOM 없이**, 주석 없이 저장한다. PowerShell 7에서는
|
||||
`Set-Content -Encoding utf8NoBOM`을 사용할 수 있다. 아래 `Example.sln`과 경로는
|
||||
설명용이며 그대로 복사해 완성된 제품 설정으로 사용하지 않는다.
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 2,
|
||||
"projectType": "dotnet",
|
||||
"dotnet": {
|
||||
"target": "Example.sln",
|
||||
"configuration": "Debug",
|
||||
"testProjects": ["tests/Example.Tests/Example.Tests.csproj"]
|
||||
},
|
||||
"tdd": {
|
||||
"testRoots": ["tests"],
|
||||
"testPatterns": ["{stem}Tests.cs", "{stem}Test.cs"],
|
||||
"exclude": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
target은 저장소 내부 `.sln` 또는 SDK 스타일 `.csproj`다. `projectType`는
|
||||
`auto`(기본값) 또는 `dotnet`이다. target 생략 시 루트 단일 solution, 다음으로
|
||||
제외 경로 밖 단일 project를 선택한다. 여러 후보면 target을 지정한다.
|
||||
`.slnx`, 기존 .NET Framework project와 다른 언어 project는 지원하지 않는다.
|
||||
|
||||
제품이 있으면 `testProjects`에 **전체 제품 테스트**를 비어 있지 않은 목록으로 명시한다.
|
||||
테스트가 solution에 포함되어 있어도 이 목록이 필요하다. 모든 설정 경로는 저장소
|
||||
상대 경로이며 외부로 향하는 링크·junction, 없는 target과 알 수 없는 필드는 거부한다.
|
||||
version 1은 새 설정으로 다시 작성한다. `exclude: []`는 유효하지만 명시된 test root,
|
||||
test pattern과 제품의 test project 목록은 비워 둘 수 없다.
|
||||
|
||||
신규 테스트 기본값은 xUnit v2 + VSTest다. 테스트 project는 제품 project를 참조하고
|
||||
`xunit`, `xunit.runner.visualstudio`, `Microsoft.NET.Test.Sdk`의 호환되는 안정 버전을
|
||||
명시적으로 고정한다. fixture의 실제 버전은
|
||||
[Smoke.Tests.csproj](../tests/fixtures/dotnet/Smoke/tests/Smoke.Tests/Smoke.Tests.csproj)를 참고한다.
|
||||
fixture의 `NuGet.Config`는 도구 검증용 feed를 한정한다. 실제 제품에서는 제품의 feed와
|
||||
인증 설정을 유지하고 restore 오류를 개별적으로 해결한다.
|
||||
제품에서 테스트 project를 참조하지 않는다. 다른 기존 프레임워크도 VSTest/TRX 계약을
|
||||
만족해야 한다. [Microsoft의 xUnit 구성 예제](https://learn.microsoft.com/en-us/dotnet/core/testing/unit-testing-csharp-with-xunit)
|
||||
|
||||
아래는 설정 확인 명령이다. JSON 구문 검사와 설정 load는 제품 빌드가 아니다.
|
||||
|
||||
```powershell
|
||||
python -X utf8 -m json.tool .harness/config.json
|
||||
python -X utf8 -c "import sys; from pathlib import Path; sys.path.insert(0, 'scripts'); from dotnet_harness.config import load_config; print(load_config(Path.cwd()))"
|
||||
```
|
||||
|
||||
제품 경로를 실제 값으로 바꾸고 각 명령의 종료 코드가 0인지 확인한다. 실패한 단계가
|
||||
있으면 다음 단계로 진행하지 않는다. SDK 확인 다음에는 target의 실제 각 프로젝트와
|
||||
모든 test project와 SDK로 평가한 ProjectReference의 전체 참조 그래프에서 TFM·저장소 경계를 확인한다. 아래 `src/Example/Example.csproj`도 예시이며
|
||||
solution에 포함된 실제 프로젝트마다 반복한다. 모든 test project에 restore/build/test를 반복한다.
|
||||
|
||||
```powershell
|
||||
dotnet --version
|
||||
dotnet msbuild src/Example/Example.csproj -getProperty:TargetFramework,TargetFrameworks -getItem:ProjectReference -p:Configuration=Debug
|
||||
dotnet msbuild tests/Example.Tests/Example.Tests.csproj -getProperty:TargetFramework,TargetFrameworks -getItem:ProjectReference -p:Configuration=Debug
|
||||
dotnet restore Example.sln -p:Configuration=Debug
|
||||
dotnet restore tests/Example.Tests/Example.Tests.csproj -p:Configuration=Debug
|
||||
dotnet build Example.sln -c Debug --no-restore -warnaserror
|
||||
dotnet build tests/Example.Tests/Example.Tests.csproj -c Debug --no-restore -warnaserror
|
||||
$manualResults = Join-Path '.harness/test-results' ([guid]::NewGuid().ToString('N'))
|
||||
dotnet test tests/Example.Tests/Example.Tests.csproj -c Debug --no-build --no-restore --logger trx --results-directory $manualResults
|
||||
```
|
||||
|
||||
TFM 평가와 restore의 `Configuration`, build/test의 `-c`는 설정의 같은 값을 사용한다.
|
||||
평가 결과의 `TargetFrameworks` 또는 `TargetFramework`는 모두 .NET 8이어야 한다.
|
||||
`net8.0`과 .NET 8의 플랫폼별 TFM을 허용하며 빈 값·비지원 TFM은 restore 전에 거부한다.
|
||||
상속·조건부 참조도 따라가며 저장소 밖 프로젝트는 거부한다. 참조별 AdditionalProperties나
|
||||
SetConfiguration 등 별도 속성 전달은 현재 지원하지 않는다. 상세 계약은 [운영 가이드](HARNESS.md)를 따른다.
|
||||
이 검사는 상속된 `Directory.Build.props`와 속성 참조를 평가하므로 `.csproj`의 문자만
|
||||
확인한 결과로 대체하지 않는다.
|
||||
Harness도 target과 test project별 restore/build를 수행하므로 solution에서 빠진 테스트를
|
||||
누락하지 않는다. 현재 호출의 고유 결과 경로에서 **각 project의 모든 TRX**를 확인하며
|
||||
실행된 테스트가 있어야 한다. exit code 0만으로는 충분하지 않다. 실패·손상·누락,
|
||||
0건과 skip-only는 실패이며 과거 TRX를 재사용하지 않는다.
|
||||
[VSTest dotnet test 문서](https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-test-vstest)
|
||||
|
||||
`.gitignore`의 중첩 `bin/`, `obj/`, `.vs/`, `TestResults/`와 Harness 결과 제외를
|
||||
확인한다. 공유할 `.harness/config.json`과 로컬 파일·산출물 정책을 자동 staging 전에
|
||||
정한다. `tdd.exclude`는 테스트 요구 면제만 제어하고 제품 존재 탐색에는 적용되지 않는다.
|
||||
`tests/fixtures/`는 도구 fixture로 예약하므로 실제 제품을 그 안에 두지 않는다.
|
||||
|
||||
## 6. 구축 완료 확인과 최초 실행
|
||||
|
||||
제품 metadata와 설정을 준비한 뒤 일반 터미널에서 Stop 훅을 직접 실행한다.
|
||||
SDK 선택과 실제 TFM 평가에 이어 restore·build·test와 산출물 작성이 발생한다.
|
||||
|
||||
```powershell
|
||||
python -X utf8 scripts/hooks/stop_validation.py
|
||||
```
|
||||
|
||||
스크립트는 **실패해도 프로세스 종료 코드가 0일 수 있다**. 표준 출력의
|
||||
`"continue": false`와 `stopReason`은 검증 실패다. 제품과 metadata가 없는 템플릿의
|
||||
`제품 검증 대상 없음`이나 docs-only 생략을 제품 build 성공으로 기록하지 않는다.
|
||||
`CODEX_STOP_VALIDATION_ACTIVE=1`인 재진입 환경에서도 생략될 수 있으므로 일반
|
||||
터미널의 새 검증 결과와 실제 테스트 실행 건수를 확인한다.
|
||||
|
||||
실제 CLI에서도 Stop 실패가 종료 코드 `0`과 모델의 `completed` 상태를 남길 수 있다.
|
||||
따라서 executor는 완료 보고 후 제품 검증을 직접 다시 실행하고, 성공해야만 step을
|
||||
승인한다. 문서 전용 변경 판정도 이 단계에서 다시 확인하며 실패는 기존 재시도 예산을 사용한다.
|
||||
|
||||
[실행 흐름](HARNESS_WORKFLOW.md)과
|
||||
[Harness 스킬](../.agents/skills/harness/SKILL.md)에 맞춰 실제 작업의
|
||||
`phases/index.json`, `phases/<phase>/index.json`, `stepN.md`를 준비한다.
|
||||
`context_files`에는 존재하는 파일만 넣는다. [phase 기록 형식](phase/README.md)에 따라
|
||||
handoff는 약 60줄, step 기록에는 변경·결정·실제 검증·유효한 실패 접근을 남긴다.
|
||||
실행 상태의 기준은 항상 phase index다.
|
||||
|
||||
`<phase>`를 실제 디렉터리 이름으로 바꾼다.
|
||||
|
||||
```powershell
|
||||
python -X utf8 scripts/execute.py <phase> --dry-run
|
||||
python -X utf8 scripts/execute.py <phase>
|
||||
```
|
||||
|
||||
`--dry-run`은 첫 pending step의 문맥 경로와 프롬프트 크기만 확인하며 Codex, Git 변경,
|
||||
인증과 제품 검증을 실행하지 않는다. 실제 실행은 branch 전환, 파일 수정과 자동 commit을
|
||||
수행한다. 원격 전송이 필요할 때만 `--push`를 사용한다.
|
||||
|
||||
구축 완료 기준은 다음과 같다.
|
||||
|
||||
- Python 자식 프로세스가 Codex를 직접 실행하고 필요한 옵션·인증을 사용할 수 있다.
|
||||
- 실제 CLI 이벤트에서 편집 훅과 Stop 검증이 동작한다.
|
||||
- 실제 제품 또는 격리 fixture에서 .NET SDK 8 선택, 실제 TFM 평가, restore/build와 테스트가 성공하고
|
||||
경고·차단 응답이 없으며 현재 TRX에서 실행 건수가 확인된다.
|
||||
- 실제 phase의 dry-run이 파일·Git·상태를 변경하지 않고, 첫 step 실행 결과와 index가 일치한다.
|
||||
|
||||
Harness 자체 검증은 제품 검증과 별도로 실행한다.
|
||||
|
||||
```powershell
|
||||
uv run --with pytest python -m pytest tests/harness -v -rs
|
||||
uv run --with pytest python -m compileall -q scripts tests/harness
|
||||
```
|
||||
|
||||
SDK/NuGet/CLI 문제로 실제 통합 검증이 skip되면 미검증으로 기록한다. subprocess 대역
|
||||
테스트 통과와 실제 .NET·CLI 통과, 설치된 도구 확인과 새 환경의 설치 검증을 구분한다.
|
||||
|
||||
## 7. 자주 발생하는 문제
|
||||
|
||||
| 증상 | 확인 및 조치 |
|
||||
|---|---|
|
||||
| `python` 또는 `codex`를 찾지 못함 | 새 터미널의 PATH와 앱 실행 별칭을 확인한다. Python 자식 프로세스 점검까지 통과해야 한다. |
|
||||
| `unexpected argument` / `unrecognized option` | `codex exec --help`로 실행기의 옵션 지원을 확인한다. |
|
||||
| 인증 실패 | Harness를 실행하는 동일 Windows 계정과 Codex 환경에서 로그인 상태를 확인한다. |
|
||||
| 훅이 로드되지만 차단되지 않음 | `/hooks`, 프로젝트·훅 신뢰, 사용자·관리자 설정, `commandWindows`, 자식 PATH와 실제 이벤트·matcher를 확인한다. |
|
||||
| `dotnet` 누락 / SDK 없음 | runtime만 설치했는지 확인하고 SDK 설치와 PATH를 복구한다. |
|
||||
| SDK 선택 오류 | 루트 `global.json`, `dotnet --list-sdks`와 루트 `dotnet --version`을 비교한다. 임의 디렉터리의 SDK 선택으로 대체하지 않는다. |
|
||||
| 실제 TFM 평가 실패 | 같은 Configuration으로 각 `.csproj`의 `TargetFramework`/`TargetFrameworks`를 조회한다. 상속된 props·속성 참조·조건부 값을 확인하고 빈 값이나 .NET 8 이외의 대상을 수정한다. |
|
||||
| NuGet restore 실패 | 오류 단계의 feed URL·네트워크·인증·패키지 버전을 확인한다. 성공한 테스트로 기록하지 않는다. |
|
||||
| 여러 target 후보 | `dotnet.target`에 실제 `.sln` 또는 SDK 스타일 `.csproj`를 명시한다. |
|
||||
| C# orphan / metadata 없음 | C# 소스에 맞는 지원 metadata와 target을 준비한다. exclude로 제품을 숨기지 않는다. |
|
||||
| 테스트 0건 / skip-only / TRX 누락 | `testProjects`, 테스트 SDK·어댑터, 테스트 발견과 실행 조건을 확인한다. 현재 project별 결과를 확인한다. |
|
||||
| build 경고로 실패 | nullable·분석기 등 실제 진단을 수정한다. 경고를 숨겨 통과시키지 않는다. |
|
||||
| `TDD GUARD` 차단 | 대응 테스트를 먼저 추가하고 red 단계 확인 후 구현한다. `Program.cs`도 기본 검사 대상이다. |
|
||||
| `dubious ownership` | 현재 계정 소유 checkout을 우선한다. 신뢰하는 공유 경로라면 해당 경로만 Git `safe.directory`에 등록한다. |
|
||||
| 소스 탐색 중 `PermissionError` / 접근 거부 | Windows sandbox에서 생성한 폴더의 ACL과 훅 실행 계정을 확인한다. 일반 실행 계정이 소스·테스트 폴더를 읽을 수 있어야 한다. 필요하면 해당 계정으로 프로젝트 폴더를 먼저 준비한다. 읽기 실패는 대상 없는 템플릿으로 처리하지 않는다. |
|
||||
| phase 또는 context 파일 누락 | 실제 phase/index/step과 존재하는 참조 파일을 준비한다. 인계 문서를 상태 파일로 사용하지 않는다. |
|
||||
| build·Codex 시간 초과 | Stop 전체와 각 Codex 호출 제한은 1,800초다. 로그의 실패 단계·argv·cwd를 확인한다. |
|
||||
|
||||
재시도, `error`/`blocked` 복구는 [Harness 운영 가이드](HARNESS.md)와
|
||||
[실행 흐름](HARNESS_WORKFLOW.md)을 따른다.
|
||||
@@ -0,0 +1,165 @@
|
||||
# Harness 운영 가이드
|
||||
|
||||
Harness는 Windows에서 C#·.NET 8 제품 개발의 step 실행, 편집 정책과 build/test를
|
||||
지원하는 Python 도구다. 제품 목표와 기능은 [PRD](PRD.md)에 별도로 둔다.
|
||||
상세 실행 흐름은 [HARNESS_WORKFLOW.md](HARNESS_WORKFLOW.md)를 참고한다.
|
||||
|
||||
## 요구 환경과 설정
|
||||
|
||||
설치, PATH·인증·훅 점검과 첫 검증은 [환경 구축 가이드](ENVIRONMENT_SETUP.md)를 따른다.
|
||||
|
||||
- Windows, Git, 실제 `python` 명령으로 실행 가능한 Python 3.10 이상, 호환 Codex CLI
|
||||
- 루트 `global.json`을 만족하는 .NET 8 **SDK**와 NuGet restore 접근
|
||||
- 신규 테스트 기본값 xUnit v2 + VSTest; IDE는 선택 사항
|
||||
- Harness 자체 테스트에는 pytest/uv 사용
|
||||
|
||||
`global.json`의 기준은 SDK `8.0.424`, `latestPatch`, `allowPrerelease=false`다.
|
||||
SDK 선택과 `.csproj`의 제품 대상 TFM은 별개이며 신규 프로젝트 기본 TFM은 `net8.0`이다.
|
||||
|
||||
다음 경로는 실제 제품이 아닌 설정 예시다. `.harness/config.example.json`을 참고해
|
||||
실제 제품에 맞는 `.harness/config.json`을 작성한다. 제품이 없는 템플릿에는 가상의
|
||||
target을 넣은 설정 파일을 만들지 않는다.
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 2,
|
||||
"projectType": "dotnet",
|
||||
"dotnet": {
|
||||
"target": "Example.sln",
|
||||
"configuration": "Debug",
|
||||
"testProjects": ["tests/Example.Tests/Example.Tests.csproj"]
|
||||
},
|
||||
"tdd": {
|
||||
"testRoots": ["tests"],
|
||||
"testPatterns": ["{stem}Tests.cs", "{stem}Test.cs"],
|
||||
"exclude": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`projectType`는 `auto`(기본값) 또는 `dotnet`이다. `dotnet.target`은 선택 사항이며,
|
||||
명시한 저장소 내부 `.sln` 또는 SDK 스타일 `.csproj`를 우선한다. 생략하면 루트의
|
||||
단일 solution, 다음으로 제외 경로 밖의 단일 C# project를 찾는다. 후보가 여러 개면
|
||||
target을 명시한다. C# 소스가 있지만 지원 metadata가 없으면 오류다.
|
||||
|
||||
제품이 있으면 `dotnet.testProjects`는 전체 제품 테스트를 담은 비어 있지 않은 명시
|
||||
목록이어야 한다. `configuration`의 기본값은 `Debug`다. 모든 설정 경로는 저장소
|
||||
상대 경로이며 외부 symlink/junction 대상, 없는 target, 알 수 없는 필드와 version 1은
|
||||
거부한다. `tdd.testRoots`와 `testPatterns`를 명시하면 빈 배열을 허용하지 않는다.
|
||||
`tdd.exclude: []`는 유효하다. 테스트 패턴은 `{stem}`을 포함하는 `.cs` 파일명이며
|
||||
경로 구분자를 넣지 않는다. SDK·TFM·패키지 버전을 Harness 설정에 중복하지 않는다.
|
||||
|
||||
## Phase 실행
|
||||
|
||||
```powershell
|
||||
python -X utf8 scripts/execute.py <phase-name> --dry-run
|
||||
python -X utf8 scripts/execute.py <phase-name>
|
||||
python -X utf8 scripts/execute.py <phase-name> --push
|
||||
```
|
||||
|
||||
먼저 `--dry-run`으로 첫 pending step이 읽을 파일과 프롬프트 문자 수를 확인한다. 이
|
||||
모드는 Codex 호출, branch 전환, timestamp, 파일 쓰기, commit과 push를 하지 않는다.
|
||||
`--push`와 함께 사용할 수 없다. 실제 실행은 자동 staging·commit을 수행하므로
|
||||
검토한 기준 commit이 있는 전용 checkout/worktree에서 시작한다.
|
||||
|
||||
## 선택 문맥과 호환성
|
||||
|
||||
step index의 `context_files`에 필요한 기존 문서를 저장소 상대 경로로 적는다.
|
||||
|
||||
```json
|
||||
{
|
||||
"step": 1,
|
||||
"name": "implement-parser",
|
||||
"status": "pending",
|
||||
"context_files": ["docs/ARCHITECTURE.md"]
|
||||
}
|
||||
```
|
||||
|
||||
필드를 생략하거나 빈 배열로 두면 `AGENTS.md`, 자동 인계와 step 이외의 문서는
|
||||
추가되지 않는다. glob, URL, 절대 경로와 저장소 밖 경로는 지원하지 않는다.
|
||||
이전에 모든 문서를 자동 주입하던 동작에 의존하는 phase는 필요한 문서를 명시한다.
|
||||
|
||||
현재 phase의 `docs/phase/{phase}/handoff.md`가 있으면 매 호출 다시 읽는다. 없을 때만
|
||||
현재 step보다 앞선 완료 step 중 가장 최근 하나의 summary를 최대 1,000자로 전달한다.
|
||||
상세 기록 형식은 [phase README](phase/README.md)를 따른다.
|
||||
|
||||
## 검증 모드
|
||||
|
||||
```json
|
||||
{"step": 2, "name": "update-docs", "status": "pending", "validation": "docs-only"}
|
||||
```
|
||||
|
||||
`validation` 생략 또는 `full`은 전체 제품 검증을 수행한다. `docs-only`도 실제 Git
|
||||
변경이 루트 `AGENTS.md`, `docs/**/*.md`, 현재 phase의 `index.json`과
|
||||
`stepN-output.json`에만 있고 실제 문서 변경이 있을 때만 제품 검증을 생략한다.
|
||||
Git 조회 실패, 빈 변경, bookkeeping만 있는 변경, 다른 코드·설정·스킬 파일이 섞인
|
||||
변경은 전체 검증으로 돌아간다. rename은 이전·새 경로를 모두 확인하며 symlink나
|
||||
junction 경로는 문서 전용으로 인정하지 않는다. 제품 build가 Markdown을 입력으로
|
||||
사용하면 `full`을 선택한다.
|
||||
|
||||
## .NET 검증
|
||||
|
||||
Stop 훅은 `scripts/dotnet_harness/`를 통해 저장소 루트에서 다음 순서로 검증한다.
|
||||
|
||||
1. SDK 선택 확인.
|
||||
2. target·test project와 평가된 `ProjectReference`로 연결되는 모든 프로젝트의 저장소 내부 경로·C# SDK 형식·실제 .NET 8 TFM 확인.
|
||||
3. target과 모든 test project의 중복 경로를 제거한 뒤 같은 Configuration으로 각각 restore.
|
||||
4. 각 대상을 `--no-restore -warnaserror`로 build.
|
||||
5. 각 test project를 `--no-build --no-restore --logger trx`로 실행.
|
||||
6. 현재 호출의 project별 고유 결과 디렉터리에 생성된 모든 TRX 검사.
|
||||
|
||||
TFM과 참조 확인은 각 `.csproj`에 `dotnet msbuild -getProperty:TargetFramework,TargetFrameworks
|
||||
-getItem:ProjectReference -p:Configuration=<configuration>`을 실행한다. 다중 TFM은
|
||||
각 `TargetFramework`의 조건부 참조도 평가한다. 프로젝트 XML의 문자만 확인하지 않고
|
||||
상속된 props와 속성 참조를 평가한 결과를 검사한다. 값이 없거나 .NET 8 이외의 TFM이
|
||||
포함되면 restore 전에 실패한다. `net8.0`과 .NET 8의 플랫폼별 TFM을 허용한다.
|
||||
|
||||
참조별 `AdditionalProperties`, `GlobalPropertiesToRemove`, `SetConfiguration`,
|
||||
`SetPlatform`, `SetTargetFramework`로 평가 문맥을 바꾸는 구성은 현재 지원하지 않으며
|
||||
명확한 오류로 거부한다. 프로젝트는 공유 Configuration과 각 프로젝트의 TFM을 사용한다.
|
||||
|
||||
solution에 빠진 test project도 직접 restore/build한다. 한 단계라도 실패하면 이후
|
||||
단계를 멈춘다. 전체 deadline은 1,800초이며 명령은 shell 확장 없이 argv 배열로
|
||||
실행한다. NuGet 오류는 restore 실패로 보고한다.
|
||||
|
||||
실패·손상·누락된 TRX, 실행된 테스트 0건, skip-only 결과는 검증 실패다. 여러 결과
|
||||
중 하나라도 실패하면 성공 결과로 덮지 않는다. 오래된 TRX는 현재 실행의 근거가
|
||||
아니다. 기본 xUnit 이외의 기존 테스트도 VSTest/TRX 계약을 만족해야 한다.
|
||||
|
||||
C# 제품과 지원 metadata가 모두 없는 템플릿은 `제품 검증 대상 없음`으로 구분한다.
|
||||
`tests/fixtures/`는 도구 검증용이므로 제품 자동 탐색에서 제외한다. 대상 없음과
|
||||
docs-only 생략을 제품 빌드 성공으로 기록하지 않는다.
|
||||
|
||||
## TDD와 복구
|
||||
|
||||
`Foo.cs` 편집에는 기본 test root인 저장소 `tests/`와 편집 파일 옆 `tests/`, `test/`에서
|
||||
`FooTests.cs` 또는 `FooTest.cs`가 먼저 있어야 한다. 설정으로 테스트 루트·패턴을
|
||||
조정할 수 있다. 테스트 루트의 보조 코드, 중첩된 `bin/`, `obj/`, `.git/`, `.vs/`,
|
||||
`.harness/`, `TestResults/`, 외부 의존성·명시 generated 경로는 정책에서 제외한다.
|
||||
|
||||
`Program.cs`, DTO, interface, `.Designer.cs`는 파일명만으로 면제하지 않는다.
|
||||
필요하면 예를 들어 `src/Example/Program.cs`처럼 정확한 `tdd.exclude` 경로와
|
||||
composition root 등 실제 이유를 기록한다. 이 설정은 편집 전 테스트 요구만 면제하며
|
||||
제품 존재 탐색을 숨기지 않는다. 수동 작성 partial 파일은 각 stem에 대응 테스트가
|
||||
필요하다. 파일 존재 검사는 테스트 품질이나 실제 red-green 실행을 대신하지 않는다.
|
||||
|
||||
Executor는 Codex를 최초 호출 포함 최대 3회 호출한다. 재시도 입력은 marker를 포함해
|
||||
4,000자로 제한하며 앞 1,500자와 끝부분 최대 2,500자를 보존한다. 원본
|
||||
`stepN-output.json`은 온전히 저장되지만 다음 시도에서 덮어쓴다. exit code 0과
|
||||
`completed` 상태와 executor의 독립 제품 검증 성공이 모두 있어야 성공한다.
|
||||
Stop 실패도 CLI exit 0으로 끝날 수 있어 executor가 완료 보고 후 검증을 다시 실행한다.
|
||||
독립 검증은 별도의 1,800초 제한을 공유하며 실패하면 같은 호출 예산 안에서 재시도한다.
|
||||
`docs-only` 판정도 실제 변경을 다시 확인한다. `blocked`는 즉시 멈춘다.
|
||||
|
||||
`error` 또는 `blocked`를 재개할 때는 원인을 해결하고 step을 `pending`으로 바꾼 뒤
|
||||
오류 이유와 해당 timestamp를 제거한다. 인증이나 CLI 환경 오류는 즉시 중단한다.
|
||||
|
||||
Harness 자체 Python 검증:
|
||||
|
||||
```powershell
|
||||
uv run --with pytest python -m pytest tests/harness -v -rs
|
||||
uv run --with pytest python -m compileall -q scripts tests/harness
|
||||
```
|
||||
|
||||
실제 SDK 통합 테스트의 skip과 subprocess 대역 테스트 성공을 구분한다. 검증한
|
||||
OS·SDK·패키지 버전, 실행 건수, 경고와 남은 미검증 항목은 phase 기록에 남긴다.
|
||||
@@ -0,0 +1,171 @@
|
||||
# Harness 실행 흐름
|
||||
|
||||
Harness는 승인된 phase를 독립 Codex 세션으로 실행하고 C#·.NET 8 제품 규칙을
|
||||
검증한다. 설정 예시는 [HARNESS.md](HARNESS.md), phase 기록 형식은
|
||||
[phase README](phase/README.md)를 참고한다.
|
||||
|
||||
## 1. 계획
|
||||
|
||||
`AGENTS.md`와 변경에 관련된 제품 문서·코드·테스트를 확인하고 작업을 한 가지 책임의
|
||||
step으로 나눈다. 각 step은 목표와 범위, 관련 파일, 핵심 제약, 실제 Acceptance
|
||||
Criteria를 가진다. 공개 계약이 필요한 경우에만 시그니처를 적는다. 이전 대화에
|
||||
의존하지 않으며 문서 전체를 step 본문에 복사하지 않는다.
|
||||
|
||||
제품 기능의 red-green 작업은 한 step 안에서 끝낸다.
|
||||
|
||||
```text
|
||||
실패 테스트 작성·확인 → 최소 구현 → 전체 관련 테스트 성공 → step 종료
|
||||
```
|
||||
|
||||
## 2. Phase 파일
|
||||
|
||||
```text
|
||||
phases/
|
||||
index.json
|
||||
<phase>/
|
||||
index.json
|
||||
step0.md
|
||||
docs/phase/<phase>/
|
||||
handoff.md
|
||||
step0.md
|
||||
```
|
||||
|
||||
`phases/{phase}/index.json`이 실행 상태의 기준이다. `context_files`는 기존 참고 문서,
|
||||
`validation`은 `full` 또는 `docs-only`를 선택한다.
|
||||
|
||||
```json
|
||||
{
|
||||
"project": "<제품명 또는 미정>",
|
||||
"phase": "add-parser",
|
||||
"steps": [
|
||||
{
|
||||
"step": 0,
|
||||
"name": "implement-parser",
|
||||
"status": "pending",
|
||||
"context_files": ["docs/ARCHITECTURE.md"],
|
||||
"validation": "full"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 3. 읽기 전용 확인
|
||||
|
||||
```powershell
|
||||
python scripts/execute.py <phase> --dry-run
|
||||
```
|
||||
|
||||
첫 pending step의 현재 주입 경로, 경로별 문자 수와 전체 프롬프트 문자 수를
|
||||
출력한다. Codex, Git, timestamp와 파일을 변경하지 않는다. `--push`와 동시 사용은
|
||||
오류다.
|
||||
|
||||
## 4. 프롬프트 구성
|
||||
|
||||
각 호출 직전에 index, `context_files`와 handoff를 다시 읽고 다음 순서로 한 번만
|
||||
결합한다.
|
||||
|
||||
```text
|
||||
AGENTS.md
|
||||
→ 공통 작업 규칙
|
||||
→ context_files
|
||||
→ 현재 phase handoff 또는 호환 summary
|
||||
→ 직전 실패 요약
|
||||
→ 현재 step
|
||||
```
|
||||
|
||||
`context_files` 생략은 추가 문서 없음이다. 잘못된 배열, 없는 파일, UTF-8로 읽을 수
|
||||
없는 파일, 절대 경로, 저장소 밖 경로와 외부를 가리키는 symlink/junction은 Codex나 Git 변경 전에
|
||||
거부한다. 경로 표기와 대소문자만 다른 중복도 한 번만 넣는다.
|
||||
|
||||
handoff가 없을 때만 현재 step보다 앞선 완료 step 중 가장 최근 하나의 summary를
|
||||
최대 1,000자로 전달한다. 이후 step이나 모든 과거 summary를 누적하지 않는다.
|
||||
|
||||
## 5. 실행과 상태
|
||||
|
||||
Executor는 `feat-{phase}` branch에서 각 pending step을 독립 `codex exec` 프로세스로
|
||||
실행한다. Codex는 종료 전에 현재 `docs/phase/{phase}/stepN.md`, handoff와 phase index를
|
||||
갱신한다.
|
||||
|
||||
| 상태 | 의미 |
|
||||
|---|---|
|
||||
| `completed` | exit code 0과 executor 독립 검증 통과 후 timestamp와 commit 처리 |
|
||||
| `error` | 자동 시도 예산 소진, 이유와 timestamp 기록 후 중단 |
|
||||
| `blocked` | 사용자 또는 외부 환경 조치 필요, 추가 호출 없이 중단 |
|
||||
|
||||
프로세스가 nonzero로 끝나면 index에 `completed`가 있어도 성공으로 처리하지 않는다.
|
||||
Stop 실패가 exit code 0으로 끝나는 경우도 있으므로 완료 보고 직후 executor가 제품을
|
||||
다시 검증한다. 실패하면 성공 상태를 취소하고 같은 최대 3회 호출 예산에서 재시도한다.
|
||||
독립 검증에도 실제 docs-only 판정과 별도의 총 1,800초 검증 제한을 적용한다.
|
||||
|
||||
## 6. 재시도와 로그
|
||||
|
||||
Executor의 호출 예산은 최초 호출을 포함해 총 3회다. 한 Codex 세션 안의 일반적인
|
||||
red-green 수정 횟수를 별도로 3회로 제한하지 않는다.
|
||||
|
||||
재시도 입력의 오류 본문은 총 4,000자로 제한한다. 앞 1,500자와 끝부분 최대 2,500자를
|
||||
남기고 중간 생략 marker를 제한 안에 포함한다. 전체 stdout/stderr 결과는
|
||||
`phases/{phase}/stepN-output.json`에 저장하지만 다음 시도에서 같은 파일을 덮어쓴다.
|
||||
장기 보관할 시행착오는 step 기록에 요약한다. timeout도 같은 호출 예산의 실패이며
|
||||
인증·CLI 옵션 같은 환경 오류는 즉시 중단한다.
|
||||
|
||||
## 7. 편집 전 정책
|
||||
|
||||
PreToolUse 훅은 위험한 삭제, 강제 push, hard reset과 SQL 파괴 명령을 차단한다.
|
||||
Windows 셸의 종료 코드 변환에 의존하지 않도록 JSON `permissionDecision: "deny"`로
|
||||
거부한다. 실제 Codex 편집 payload의 `tool_input.command`에서 patch 경로를 읽는다.
|
||||
C# 제품 `.cs` 편집에는 대응 테스트 파일 존재 여부를 검사한다. 기본 루트는
|
||||
저장소 `tests/`이며 편집 파일 옆 `tests/`, `test/`도 대응 테스트를 찾는다.
|
||||
`Foo.cs`의 대응 이름은 `FooTests.cs` 또는 `FooTest.cs`다.
|
||||
테스트 루트의 보조 코드, 중첩 `bin/`·`obj/` 등 산출물, 외부 의존성·명시 generated
|
||||
경로는 제외한다. `Program.cs`나 `.Designer.cs`라는 이름만으로 면제하지 않는다.
|
||||
필요한 면제는 정확한 `tdd.exclude` 경로와 이유를 기록한다. 이 설정은 제품 존재
|
||||
탐색을 우회하지 않는다. 테스트 파일 존재와 실제 red-green 증거를 구분한다.
|
||||
|
||||
`tdd.testPatterns`는 `{stem}`을 포함하는 C# 파일명 패턴이다. 경로 구분자는 허용하지
|
||||
않으며 테스트 위치는 `testRoots`로 지정한다. 한 검사 호출 안에서 후보 목록을 재사용하고
|
||||
다음 호출에는 파일 추가·삭제를 반영한다. 제품 탐색에서 제외한 산출물·fixture 경로의
|
||||
파일을 대응 테스트 증거로 사용하지 않는다.
|
||||
|
||||
## 8. 종료 전 검증
|
||||
|
||||
`validation` 생략 또는 `full`이면 Stop 훅이 발견한 C# 제품 전체를 검증한다.
|
||||
|
||||
- 저장소 루트 `global.json`에 따른 .NET 8 SDK 선택 확인
|
||||
- target의 실제 각 프로젝트와 모든 test project의 `TargetFramework`/`TargetFrameworks`를
|
||||
SDK로 평가해 .NET 8인지 확인; 상속된 props·속성 및 ProjectReference 그래프도 평가하며 빈 값·비지원 TFM·저장소 밖 참조는 거부
|
||||
- version 2 설정의 target과 명시된 모든 `testProjects`를 restore/build;
|
||||
build 경고는 오류로 처리하고 solution에 빠진 test project도 직접 build
|
||||
- 각 test project를 VSTest로 실행하고 현재 호출의 고유 디렉터리에 생성된 모든 TRX 검사
|
||||
- 실행 0건·skip-only·누락·손상·실패 TRX는 실패; 과거 결과를 성공 근거로 사용하지 않음
|
||||
- 지원 metadata 없음: 제외되지 않은 C# 소스가 있으면 orphan 오류
|
||||
- 제품과 metadata 모두 없음: 제품 검증 대상 없음; 제품 build 성공으로 기록하지 않음
|
||||
|
||||
전체 검증은 1,800초 deadline을 공유하며 SDK·TFM 평가·restore·build·test 중 실패한 단계에서
|
||||
중단한다. `tests/fixtures/`는 제품 자동 탐색에서 제외하는 도구 검증용이다.
|
||||
Stop 프로세스 종료 코드만으로 통과를 판정하지 않는다. `continue: false`와
|
||||
`stopReason` 응답은 검증 실패이며 실제 자동 훅 호출은 별도로 확인한다.
|
||||
|
||||
`docs-only`이면 executor가 현재 phase를 자식 프로세스 환경으로 전달하고 Stop 훅이
|
||||
실제 Git 변경을 다시 검사한다. 루트 `AGENTS.md`, `docs/**/*.md`, 현재 phase의
|
||||
`index.json`과 `stepN-output.json`만 허용하며 실제 문서 변경이 있어야 생략한다.
|
||||
rename은 원래 경로와 새 경로를 모두 검사한다. Git 실패, 빈 변경, bookkeeping만 있는
|
||||
변경, untracked C# 또는 다른 파일이 하나라도 있으면 전체 검증으로 돌아간다.
|
||||
symlink나 junction으로 해석되는 변경 경로도 보수적으로 전체 검증한다.
|
||||
|
||||
## 9. Commit, 완료와 재개
|
||||
|
||||
step 성공 시 executor는 제품 변경과 metadata를 분리해 commit한다. 모든 step이
|
||||
성공하면 phase와 top-level 상태를 `completed`로 바꾸고 `--push`가 있으면
|
||||
`origin/feat-{phase}`에 push한다. 실행기는 광범위한 staging을 할 수 있으므로 깨끗한
|
||||
전용 worktree에서 실행한다.
|
||||
|
||||
재개 절차:
|
||||
|
||||
1. `error` 또는 `blocked` 원인을 해결한다.
|
||||
2. 해당 step status를 `pending`으로 바꾼다.
|
||||
3. `error_message`/`failed_at` 또는 `blocked_reason`/`blocked_at`을 제거한다.
|
||||
4. handoff, 직전 원본 출력과 index를 확인한 뒤 다시 실행한다.
|
||||
|
||||
강제 종료로 handoff가 낡았을 수 있으므로 index 상태를 우선한다. 이 저장소의 fixture
|
||||
검증은 제품 요구사항이나 실제 제품 build 완료를 의미하지 않는다. 실행한 명령과
|
||||
결과, SDK·테스트 건수와 남은 미검증 항목은 해당 phase 기록에서 확인한다.
|
||||
@@ -0,0 +1,37 @@
|
||||
# C# .NET 8 제품 PRD
|
||||
|
||||
Harness 운영 요구사항은 이 문서의 제품 요구사항에 포함하지 않는다.
|
||||
|
||||
## 목표
|
||||
|
||||
미정 — 제품 요구사항 정의 시 확정.
|
||||
|
||||
## 사용자
|
||||
|
||||
미정 — 제품 요구사항 정의 시 확정.
|
||||
|
||||
## 핵심 기능
|
||||
|
||||
미정 — 제품 요구사항 정의 시 확정.
|
||||
|
||||
## 제약
|
||||
|
||||
- C#·.NET 8 제품으로 개발하며 신규 프로젝트 기본 TFM은 `net8.0`이다.
|
||||
- 제품의 UI, 배포 OS·방식, 성능·보안·호환성 조건은 미정이다.
|
||||
Harness의 Windows 실행 제약으로 제품 배포 OS를 확정하지 않는다.
|
||||
- 공개 API 계약과 프로젝트 참조 방향을 유지하며 제품이 테스트를 참조하지 않는다.
|
||||
- 신규 프로젝트의 nullable 검사를 활성화하고 빌드 경고는 오류로 처리한다.
|
||||
- 새 기능과 버그 수정은 실패하는 테스트를 먼저 확인한다.
|
||||
- 신규 테스트 기본값은 xUnit v2 + VSTest다. 제품의 전체 테스트 범위는 요구사항과
|
||||
함께 정하고 명시적인 `testProjects`에 반영한다.
|
||||
|
||||
## 완료 조건
|
||||
|
||||
미정 — 제품 기능별 실행 가능한 build/test 명령과 수용 조건을 정의할 때 확정.
|
||||
|
||||
## 제외 범위
|
||||
|
||||
- Harness 실행기, 훅, phase 관리와 문맥 경량화는 개발 도구의 책임이며 제품 기능이
|
||||
아니다.
|
||||
- 구체적인 제품 기능, API, UI, 계층과 배포 범위는 요구사항이 정해질 때까지 확정하지 않는다.
|
||||
- .NET Framework 4.x와 Microsoft.Testing.Platform 전용 실행은 이번 템플릿 범위 밖이다.
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"sdk": {
|
||||
"version": "8.0.424",
|
||||
"rollForward": "latestPatch",
|
||||
"allowPrerelease": false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
[pytest]
|
||||
testpaths = tests/harness
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user