From a292238675a2f7e5773b9b91d6eccc2d915a30b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B2=BD=EC=A2=85?= Date: Tue, 2 Jun 2026 09:51:30 +0900 Subject: [PATCH] modify framework --- .codex/hooks/pre_commit_checks.py | 54 +++------ .codex/hooks/tdd-guard.py | 118 ++++++++++-------- .codex/skills/harness-review/SKILL.md | 18 +-- .codex/skills/harness-workflow/SKILL.md | 27 +++-- .gitignore | 22 +++- AGENTS.md | 49 ++++++-- docs/ADR.md | 39 ++++-- docs/ARCHITECTURE.md | 68 ++++++++--- docs/PRD.md | 29 ++--- scripts/test_pre_commit_checks.py | 41 +++++++ scripts/test_tdd_guard.py | 61 ++++++++++ scripts/test_validate_workspace.py | 94 +++++++++++++++ scripts/validate_workspace.py | 151 ++++++++++++++++++++++++ 13 files changed, 602 insertions(+), 169 deletions(-) create mode 100644 scripts/test_pre_commit_checks.py create mode 100644 scripts/test_tdd_guard.py create mode 100644 scripts/test_validate_workspace.py create mode 100644 scripts/validate_workspace.py diff --git a/.codex/hooks/pre_commit_checks.py b/.codex/hooks/pre_commit_checks.py index 1cca8d8..92dd51d 100644 --- a/.codex/hooks/pre_commit_checks.py +++ b/.codex/hooks/pre_commit_checks.py @@ -1,14 +1,10 @@ import json import re -import shutil import subprocess import sys from pathlib import Path -CHECKS = ("lint", "build", "test") - - def _repo_root(cwd: Path) -> Path: try: root = subprocess.check_output( @@ -22,25 +18,11 @@ def _repo_root(cwd: Path) -> Path: return Path(root) -def _load_scripts(root: Path) -> dict[str, str]: - package_json = root / "package.json" - if not package_json.exists(): - return {} - - try: - package = json.loads(package_json.read_text(encoding="utf-8")) - except json.JSONDecodeError as exc: - _deny(f"Invalid package.json: {exc}") - raise SystemExit(0) from exc - - scripts = package.get("scripts", {}) - if not isinstance(scripts, dict): - return {} - return {str(name): str(command) for name, command in scripts.items()} - - def _is_git_commit(command: str) -> bool: - return re.search(r"\bgit(?:\s+(?:-[A-Za-z]\s+\S+|--[A-Za-z0-9-]+(?:=\S+)?))*\s+commit\b", command) is not None + return re.search( + r"^\s*git(?:\s+(?:-[A-Za-z]\s+\S+|--[A-Za-z0-9-]+(?:=\S+)?))*\s+commit\b", + command, + ) is not None def _deny(reason: str) -> None: @@ -64,26 +46,22 @@ def _tail(text: str, limit: int = 1200) -> str: return text[-limit:] -def _run_checks(root: Path, scripts: dict[str, str]) -> str | None: - npm = shutil.which("npm") or shutil.which("npm.cmd") - if npm is None: - return "npm was not found, so pre-commit checks could not run." +def _build_pre_commit_commands(root: Path) -> list[list[str]]: + return [ + [sys.executable, "-m", "unittest", "discover", "-s", "scripts", "-p", "test_*.py"], + [sys.executable, "scripts/validate_workspace.py"], + ] - for check in CHECKS: - if check not in scripts: - continue - result = subprocess.run( - [npm, "run", check], - cwd=root, - capture_output=True, - text=True, - ) +def _run_checks(root: Path) -> str | None: + for command in _build_pre_commit_commands(root): + result = subprocess.run(command, cwd=root, capture_output=True, text=True) if result.returncode != 0: details = _tail(result.stdout + "\n" + result.stderr) + label = " ".join(command) if details: - return f"npm run {check} failed:\n{details}" - return f"npm run {check} failed with exit code {result.returncode}." + return f"{label} failed:\n{details}" + return f"{label} failed with exit code {result.returncode}." return None @@ -100,7 +78,7 @@ def main() -> int: cwd = Path(payload.get("cwd") or Path.cwd()) root = _repo_root(cwd) - failure = _run_checks(root, _load_scripts(root)) + failure = _run_checks(root) if failure: _deny(f"PRE-COMMIT CHECKS: {failure}") diff --git a/.codex/hooks/tdd-guard.py b/.codex/hooks/tdd-guard.py index 8f7e23c..0e4cba7 100644 --- a/.codex/hooks/tdd-guard.py +++ b/.codex/hooks/tdd-guard.py @@ -4,19 +4,9 @@ import sys from pathlib import Path -SOURCE_SUFFIXES = {".ts", ".tsx", ".js", ".jsx"} -TEST_SUFFIXES = ("ts", "tsx", "js", "jsx") -CONFIG_SUFFIXES = {".json", ".css", ".scss", ".md", ".yml", ".yaml"} -NEXT_SPECIAL_FILES = { - "layout.ts", - "layout.tsx", - "page.ts", - "page.tsx", - "loading.tsx", - "error.tsx", - "not-found.tsx", - "globals.css", -} +SOURCE_SUFFIXES = {".h", ".hpp", ".hh", ".hxx", ".c", ".cc", ".cpp", ".cxx", ".ixx"} +TEST_SUFFIXES = {".h", ".hpp", ".hh", ".hxx", ".c", ".cc", ".cpp", ".cxx", ".ixx"} +CONFIG_SUFFIXES = {".json", ".md", ".yml", ".yaml", ".txt", ".cmake"} def _repo_root(cwd: Path) -> Path: @@ -72,13 +62,66 @@ def _normalize(path_text: str) -> str: def _is_test_path(path_text: str) -> bool: normalized = _normalize(path_text) name = normalized.rsplit("/", 1)[-1] + path = Path(path_text) return ( - "__tests__/" in normalized + "/tests/" in f"/{normalized}" + or "/test/" in f"/{normalized}" + or name.endswith("_test.cpp") + or name.startswith("test_") or ".test." in name or ".spec." in name - or "test" in name - or "spec" in name - ) + ) and path.suffix.lower() in TEST_SUFFIXES + + +def _token(text: str) -> str: + return "".join(ch for ch in text.lower() if ch.isalnum()) + + +def _module_token(path: Path) -> str: + parts = [part.lower() for part in path.parts] + for marker in ("include", "src"): + if marker not in parts: + continue + idx = parts.index(marker) + if marker == "include" and idx + 2 < len(parts) and parts[idx + 1] == "fesa": + return _token(parts[idx + 2]) + if marker == "src" and idx + 1 < len(parts): + return _token(parts[idx + 1]) + return "" + + +def _related_tokens(path: Path) -> set[str]: + tokens = {_token(_base_name(path))} + module = _module_token(path) + if module: + tokens.add(module) + return {token for token in tokens if token} + + +def _candidate_test_paths(paths: list[str], cwd: Path, root: Path) -> list[Path]: + candidates: list[Path] = [] + for path_text in paths: + resolved = _resolve_path(path_text, cwd) + if _is_test_path(str(resolved)): + candidates.append(resolved) + + for test_root_name in ("tests", "test"): + test_root = root / test_root_name + if not test_root.is_dir(): + continue + for suffix in TEST_SUFFIXES: + candidates.extend(test_root.rglob(f"*{suffix}")) + + return candidates + + +def _has_related_test(path: Path, candidate_tests: list[Path]) -> bool: + tokens = _related_tokens(path) + for test_path in candidate_tests: + test_token = _token(test_path.stem) + if any(token and token in test_token for token in tokens): + return True + return False def _is_exempt(path_text: str) -> bool: @@ -86,17 +129,13 @@ def _is_exempt(path_text: str) -> bool: path = Path(path_text) name = path.name.lower() - if _is_test_path(path_text): + if name == "cmakelists.txt": return True - if name in NEXT_SPECIAL_FILES: + if _is_test_path(path_text): return True if path.suffix.lower() in CONFIG_SUFFIXES: return True - if ".env" in name or ".config." in name: - return True - if any(token in name for token in ("tailwind", "postcss", "next.config", "tsconfig")): - return True - if "/types/" in normalized or name in {"types.ts", "types.d.ts"}: + if "/cmake/" in normalized: return True return False @@ -110,38 +149,15 @@ def _resolve_path(path_text: str, cwd: Path) -> Path: def _base_name(path: Path) -> str: - for suffix in (".tsx", ".ts", ".jsx", ".js"): - if path.name.endswith(suffix): + for suffix in sorted(SOURCE_SUFFIXES, key=len, reverse=True): + if path.name.lower().endswith(suffix): return path.name[: -len(suffix)] return path.stem -def _has_existing_test(path: Path, root: Path) -> bool: - directory = path.parent - parent = directory.parent - base = _base_name(path) - - for ext in TEST_SUFFIXES: - if (directory / f"{base}.test.{ext}").exists(): - return True - if (directory / f"{base}.spec.{ext}").exists(): - return True - - for ext in TEST_SUFFIXES: - if (parent / "__tests__" / f"{base}.test.{ext}").exists(): - return True - if (directory / "__tests__" / f"{base}.test.{ext}").exists(): - return True - - for ext in TEST_SUFFIXES: - if (root / "src" / "__tests__" / f"{base}.test.{ext}").exists(): - return True - - return False - - def _guarded_paths(paths: list[str], cwd: Path, root: Path) -> list[str]: missing_tests: list[str] = [] + candidate_tests = _candidate_test_paths(paths, cwd, root) for path_text in paths: if _is_exempt(path_text): continue @@ -149,7 +165,7 @@ def _guarded_paths(paths: list[str], cwd: Path, root: Path) -> list[str]: path = _resolve_path(path_text, cwd) if path.suffix.lower() not in SOURCE_SUFFIXES: continue - if not _has_existing_test(path, root): + if not _has_related_test(path, candidate_tests): missing_tests.append(_base_name(path)) return missing_tests diff --git a/.codex/skills/harness-review/SKILL.md b/.codex/skills/harness-review/SKILL.md index 75d026e..498d394 100644 --- a/.codex/skills/harness-review/SKILL.md +++ b/.codex/skills/harness-review/SKILL.md @@ -1,19 +1,19 @@ --- name: harness-review -description: Use when reviewing this Harness repository: local changes, generated phase files, step outputs, implementation diffs, missing tests, build readiness, or compliance with AGENTS.md, docs/ARCHITECTURE.md, docs/ADR.md, and Harness acceptance criteria. +description: Use when reviewing this C++/MSVC Harness repository: local changes, generated phase files, step outputs, implementation diffs, missing tests, MSVC build readiness, or compliance with AGENTS.md, docs/ARCHITECTURE.md, docs/ADR.md, and Harness acceptance criteria. --- # Harness Review ## Overview -Use this skill to review Harness work against the repository's persistent rules, architecture docs, and executable verification requirements. Prioritize bugs, regressions, missing tests, and rule violations. +Use this skill to review Harness work against the repository's persistent rules, architecture docs, C++/MSVC constraints, TDD guard policy, and executable verification requirements. Prioritize bugs, regressions, missing tests, and rule violations. ## Review Process 1. Read `/AGENTS.md`, `/docs/ARCHITECTURE.md`, and `/docs/ADR.md`. 2. Inspect the changed files with `git status --short` and `git diff`. -3. Check architecture, stack choices, tests, critical rules, and build readiness. +3. Check architecture, stack choices, C++ test coverage, critical rules, and MSVC/CMake readiness. 4. Run relevant verification commands when feasible. If a command cannot be run, report that as residual risk. 5. Lead with actionable findings. Keep summaries secondary. @@ -21,11 +21,12 @@ Use this skill to review Harness work against the repository's persistent rules, | Item | Question | | --- | --- | -| Architecture | Does the change follow `docs/ARCHITECTURE.md` directory and module boundaries? | -| Stack | Does the change stay within choices documented in `docs/ADR.md`? | -| Tests | Are new or changed behaviors covered by tests? | +| Architecture | Does the change follow `docs/ARCHITECTURE.md` ownership boundaries? | +| Stack | Does the change stay within C++/MSVC/CMake decisions documented in `docs/ADR.md`? | +| Tests | Are new or changed behaviors covered by Python Harness tests or C++ tests? | +| TDD Guard | Would C++ production edits be blocked without related tests? | | Critical Rules | Does the change violate any `AGENTS.md` CRITICAL rule? | -| Build | Do relevant build/test/lint commands pass? | +| Build | Do `python -m unittest discover -s scripts -p "test_*.py"` and `python scripts/validate_workspace.py` pass or provide an expected no-CMake message? | ## Output Format @@ -36,7 +37,8 @@ If there are findings, list them first in severity order with file and line refe | 아키텍처 준수 | PASS/FAIL | {상세} | | 기술 스택 준수 | PASS/FAIL | {상세} | | 테스트 존재 | PASS/FAIL | {상세} | +| TDD Guard | PASS/FAIL | {상세} | | CRITICAL 규칙 | PASS/FAIL | {상세} | -| 빌드 가능 | PASS/FAIL | {상세} | +| 빌드/검증 가능 | PASS/FAIL | {상세} | When there are no findings, say that clearly, then mention any commands not run or remaining risk. diff --git a/.codex/skills/harness-workflow/SKILL.md b/.codex/skills/harness-workflow/SKILL.md index 872ddb1..a499e94 100644 --- a/.codex/skills/harness-workflow/SKILL.md +++ b/.codex/skills/harness-workflow/SKILL.md @@ -1,13 +1,13 @@ --- name: harness-workflow -description: Use when planning or running this Harness framework: reading AGENTS.md and docs/*.md, discussing implementation scope, creating or updating phases/index.json, phases/{task}/index.json, phases/{task}/stepN.md, or invoking scripts/execute.py for staged Codex execution. +description: Use when planning or running this C++/MSVC Harness framework: reading AGENTS.md and docs/*.md, discussing implementation scope, creating or updating phases/index.json, phases/{task}/index.json, phases/{task}/stepN.md, or invoking scripts/execute.py for staged Codex execution. --- # Harness Workflow ## Overview -Use this skill to turn a user-approved task into small, self-contained Harness steps that another Codex session can execute reliably. Keep the workflow grounded in repository docs and executable acceptance criteria. +Use this skill to turn a user-approved task into small, self-contained Harness steps that another Codex session can execute reliably. Keep every step grounded in repository docs, C++/MSVC constraints, TDD, and executable acceptance criteria. ## Workflow @@ -23,10 +23,10 @@ Use this skill to turn a user-approved task into small, self-contained Harness s - Make every step self-contained. Do not rely on prior conversation; include all required context and file paths. - Force context gathering. Each step must tell Codex which docs and previous outputs to read before editing. - Specify interfaces and signatures, not full implementations, unless exact code is required for a constraint. -- Put core invariants directly in the step: idempotency, security, data integrity, API contracts, or other non-negotiables. -- Use executable acceptance criteria such as `npm run build && npm test`, not abstract statements. -- Write cautions concretely: "Do not do X. Reason: Y." -- Name steps with kebab-case slugs such as `project-setup`, `api-layer`, or `auth-flow`. +- Put core invariants directly in the step: idempotency, numerical conventions, data integrity, API contracts, or other non-negotiables. +- Use executable acceptance criteria such as `python scripts/validate_workspace.py`, not abstract statements. +- For C++ behavior changes, require tests first and name the expected test file or test executable. +- Name steps with kebab-case slugs such as `project-setup`, `core-types`, or `solver-validation`. ## Phase Files @@ -47,12 +47,12 @@ Create `phases/{task-name}/index.json`: ```json { - "project": "", + "project": "FESA Harness", "phase": "", "steps": [ { "step": 0, "name": "project-setup", "status": "pending" }, { "step": 1, "name": "core-types", "status": "pending" }, - { "step": 2, "name": "api-layer", "status": "pending" } + { "step": 2, "name": "validation-path", "status": "pending" } ] } ``` @@ -85,11 +85,15 @@ Rules: {Concrete instructions with file paths, interfaces, signatures, and rules.} +## Tests To Write First + +- {Exact C++ or Python test file and behavior to add before implementation.} + ## Acceptance Criteria ```bash -npm run build -npm test +python -m unittest discover -s scripts -p "test_*.py" +python scripts/validate_workspace.py ``` ## 검증 절차 @@ -99,6 +103,7 @@ npm test - ARCHITECTURE.md 디렉토리 구조를 따르는가? - ADR 기술 스택을 벗어나지 않았는가? - AGENTS.md CRITICAL 규칙을 위반하지 않았는가? + - C++ 변경에는 관련 테스트가 존재하는가? 3. 결과에 따라 `phases/{task-name}/index.json`의 해당 step을 업데이트한다: - 성공: `"status": "completed"`, `"summary": "산출물 한 줄 요약"` - 3회 수정 시도 후 실패: `"status": "error"`, `"error_message": "구체적 에러 내용"` @@ -106,7 +111,7 @@ npm test ## 금지사항 -- {Do not do X. Reason: Y.} +- JavaScript/TypeScript/npm fallback을 추가하지 마라. Reason: 이 Harness는 C++/MSVC 전용이다. - 기존 테스트를 깨뜨리지 마라. ``` diff --git a/.gitignore b/.gitignore index 19f6219..ab001a0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,22 @@ -node_modules/ -.next/ +.vs/ +build/ out/ -next-env.d.ts -tsconfig.tsbuildinfo +CMakeFiles/ +CMakeCache.txt +cmake_install.cmake +CTestTestfile.cmake +Testing/ +*.vcxproj.user +*.obj +*.pdb +*.ilk +*.exe +*.dll +*.lib +*.exp +*.log +__pycache__/ +*.pyc # phase execution outputs phases/**/phase*-output.json diff --git a/AGENTS.md b/AGENTS.md index cd56f06..2abc792 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,21 +1,44 @@ -# 프로젝트: {프로젝트명} +# Project: FESA Harness ## 기술 스택 -- {프레임워크 (예: Next.js 15)} -- {언어 (예: TypeScript strict mode)} -- {스타일링 (예: Tailwind CSS)} +- C++17 이상 +- MSVC on Windows +- CMake + CTest +- Harness scripts in Python 3 ## 아키텍처 규칙 -- CRITICAL: {절대 지켜야 할 규칙 1 (예: 모든 API 로직은 app/api/ 라우트 핸들러에서만 처리)} -- CRITICAL: {절대 지켜야 할 규칙 2 (예: 클라이언트 컴포넌트에서 직접 외부 API를 호출하지 말 것)} -- {일반 규칙 (예: 컴포넌트는 components/ 폴더에, 타입은 types/ 폴더에 분리)} +- CRITICAL: 기본 검증 경로는 `python scripts/validate_workspace.py`이다. +- CRITICAL: C++ 빌드는 CMake/MSVC/x64/Debug 기준으로 검증한다. +- CRITICAL: 새 기능 또는 동작 변경은 테스트를 먼저 작성하고 실패를 확인한 뒤 구현한다. +- CRITICAL: Abaqus reference artifact나 solver 코드 복원은 명시적으로 요청된 phase에서만 수행한다. +- Harness runner는 `scripts/execute.py`에 둔다. +- Codex hook 정책은 `.codex/hooks/`에 둔다. +- Harness planning/review instructions are stored in `.codex/skills/`. +- Generated phase execution outputs remain ignored under `phases/**/step*-output.json`. ## 개발 프로세스 -- CRITICAL: 새 기능 구현 시 반드시 테스트를 먼저 작성하고, 테스트가 통과하는 구현을 작성할 것 (TDD) -- 커밋 메시지는 conventional commits 형식을 따를 것 (feat:, fix:, docs:, refactor:) +- TDD를 기본으로 한다. C++ production file을 바꿀 때는 관련 C++ test file이 있어야 한다. +- 커밋 전 hook은 Harness Python self-test와 workspace validation을 실행해야 한다. +- 커밋 메시지는 conventional commits 형식을 따른다: `feat:`, `fix:`, `docs:`, `refactor:`, `test:`. +- 계획이 필요한 장기 작업은 Harness phase로 나누고, 각 step은 독립 실행 가능해야 한다. ## 명령어 -npm run dev # 개발 서버 -npm run build # 프로덕션 빌드 -npm run lint # ESLint -npm run test # 테스트 +```bash +python -m unittest discover -s scripts -p "test_*.py" +python scripts/validate_workspace.py +python scripts/execute.py +python scripts/execute.py --push +``` + +## MSVC 검증 기본값 +- Generator: `Visual Studio 17 2022` +- Platform: `x64` +- Config: `Debug` +- Build directory: `build/msvc-debug` + +Override variables: +- `HARNESS_VALIDATION_COMMANDS` +- `HARNESS_CMAKE_GENERATOR` +- `HARNESS_CMAKE_PLATFORM` +- `HARNESS_CMAKE_CONFIG` +- `HARNESS_BUILD_DIR` diff --git a/docs/ADR.md b/docs/ADR.md index 216ad6d..1ba9166 100644 --- a/docs/ADR.md +++ b/docs/ADR.md @@ -1,21 +1,34 @@ # Architecture Decision Records ## 철학 -{프로젝트의 핵심 가치관 (예: MVP 속도 최우선. 외부 의존성 최소화. 작동하는 최소 구현을 선택.)} +Harness는 현재 프로젝트의 실제 기술 스택을 반영해야 한다. C++/MSVC 프로젝트에서 npm, Next.js, TypeScript test naming을 기본값으로 두면 agent prompt와 hook policy가 잘못된 구현을 유도한다. --- -### ADR-001: {결정 사항 (예: Next.js App Router 선택)} -**결정**: {뭘 선택했는지} -**이유**: {왜 선택했는지} -**트레이드오프**: {뭘 포기했는지} +### ADR-001: C++ 전용 Harness +**결정**: Harness scaffold는 C++/MSVC 전용으로 운영한다. JavaScript/TypeScript fallback은 유지하지 않는다. -### ADR-002: {결정 사항} -**결정**: {뭘 선택했는지} -**이유**: {왜 선택했는지} -**트레이드오프**: {뭘 포기했는지} +**이유**: FESA 개발 환경은 MSVC 기반 C++이다. 언어별 fallback을 남기면 validation, TDD guard, acceptance criteria가 흐려진다. -### ADR-003: {결정 사항} -**결정**: {뭘 선택했는지} -**이유**: {왜 선택했는지} -**트레이드오프**: {뭘 포기했는지} +**트레이드오프**: 같은 Harness scaffold를 JS/TS 프로젝트에 재사용할 수 없다. 필요하면 별도 template이나 language registry를 새 ADR로 설계한다. + +### ADR-002: CMake/MSVC/x64/Debug 기본 검증 +**결정**: 기본 workspace validation은 CMake, Visual Studio 17 2022 generator, x64 platform, Debug config, CTest로 수행한다. + +**이유**: MSVC 환경에서 CMake/CTest는 source tree가 복원되거나 새 C++ project가 추가될 때 가장 일관된 build/test entry point다. + +**트레이드오프**: Visual Studio solution-only project는 기본 지원하지 않는다. 명시적으로 필요하면 `HARNESS_VALIDATION_COMMANDS`로 override한다. + +### ADR-003: 엄격한 C++ TDD Guard +**결정**: C++ production file 변경은 관련 C++ test file이 없으면 차단한다. + +**이유**: Harness의 핵심 목적은 agent가 검증 없는 C++ 변경을 만들지 않도록 하는 것이다. Header 중심 C++ 구조에서도 module 또는 basename 기반 테스트 존재를 확인한다. + +**트레이드오프**: 초기 scaffolding 작업에서 guard가 엄격하게 느껴질 수 있다. 문서, CMake 설정, Harness metadata는 guard 대상에서 제외한다. + +### ADR-004: Harness 자체 테스트 우선 +**결정**: commit hook은 먼저 Python Harness self-test를 실행한 뒤 workspace validation을 실행한다. + +**이유**: 현재 저장소에는 C++ source tree가 없을 수 있다. Harness가 스스로 검증 가능해야 이후 phase generation과 source restoration을 안전하게 진행할 수 있다. + +**트레이드오프**: commit 시간이 조금 늘어난다. 대신 hook/validation regressions를 빠르게 잡는다. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 2ff9891..1747640 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,24 +1,58 @@ # 아키텍처 -## 디렉토리 구조 -``` -src/ -├── app/ # 페이지 + API 라우트 -├── components/ # UI 컴포넌트 -├── types/ # TypeScript 타입 정의 -├── lib/ # 유틸리티 + 헬퍼 -└── services/ # 외부 API 래퍼 -``` +## 목표 +이 저장소의 현재 책임은 C++/MSVC 프로젝트를 위한 Codex Harness scaffold를 제공하는 것이다. Harness는 phase execution, edit guard, commit validation, workspace validation을 분리해서 관리한다. -## 패턴 -{사용하는 디자인 패턴 (예: Server Components 기본, 인터랙션이 필요한 곳만 Client Component)} +## 디렉토리 구조 +```text +.codex/ +├── hooks/ # Codex hook scripts +└── skills/ # Harness planning/review instructions +docs/ # Project and Harness guidance +scripts/ +├── execute.py # Phase step executor +├── validate_workspace.py +└── test_*.py # Harness self-tests +phases/ # Optional generated phase plans +``` ## 데이터 흐름 -``` -{데이터가 어떻게 흐르는지 (예: -사용자 입력 → Client Component → API Route → 외부 API → 응답 → UI 업데이트 -)} +```text +User-approved task +-> Harness phase files under phases/ +-> scripts/execute.py injects AGENTS.md and docs/*.md +-> Codex executes one step at a time +-> step updates phases/{phase}/index.json +-> validation runs through scripts/validate_workspace.py ``` -## 상태 관리 -{상태 관리 방식 (예: 서버 상태는 Server Components, 클라이언트 상태는 useState/useReducer)} +## Hook 흐름 +```text +apply_patch/Edit/Write +-> .codex/hooks/tdd-guard.py +-> C++ production changes require related tests + +git commit command +-> .codex/hooks/pre_commit_checks.py +-> Python Harness self-tests +-> scripts/validate_workspace.py +``` + +## Validation 흐름 +```text +HARNESS_VALIDATION_COMMANDS set +-> run exact commands + +CMakePresets.json has msvc-debug configure preset +-> cmake --preset msvc-debug +-> cmake --build preset binary dir --config Debug +-> ctest --test-dir preset binary dir -C Debug + +CMakeLists.txt exists +-> cmake -S . -B build/msvc-debug -G "Visual Studio 17 2022" -A x64 +-> cmake --build build/msvc-debug --config Debug +-> ctest --test-dir build/msvc-debug --output-on-failure -C Debug + +No CMake project +-> print guidance and exit successfully +``` diff --git a/docs/PRD.md b/docs/PRD.md index b1950bb..a6630fb 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -1,21 +1,22 @@ -# PRD: {프로젝트명} +# PRD: C++/MSVC Harness ## 목표 -{이 프로젝트가 해결하려는 문제를 한 줄로 요약} +Codex Harness가 C++/MSVC 프로젝트에서 phase planning, TDD guard, commit validation, workspace validation을 일관되게 수행하게 한다. ## 사용자 -{누가 이 제품을 쓰는지} +- Windows/MSVC 기반 C++ 개발자 +- Harness phase를 작성하고 실행하는 Codex agent +- Harness 결과를 검토하는 reviewer ## 핵심 기능 -1. {기능 1} -2. {기능 2} -3. {기능 3} +1. CMake/MSVC/x64/Debug 기반 workspace validation +2. C++ source/header 변경에 대한 엄격한 TDD guard +3. npm 없이 Python self-test와 CMake/CTest 검증을 수행하는 pre-commit hook +4. C++ 프로젝트에 맞는 Harness workflow/review prompt +5. CMake project가 아직 없어도 Harness 자체 테스트가 가능한 no-op validation path -## MVP 제외 사항 -- {안 만들 것 1} -- {안 만들 것 2} -- {안 만들 것 3} - -## 디자인 -- {디자인 방향 (예: 다크모드 고정, 미니멀)} -- {색상 (예: 무채색 + 포인트 1가지)} +## 제외 사항 +- 이전 FESA solver source tree 복원 +- JavaScript/TypeScript fallback 유지 +- Abaqus reference artifact 생성 또는 solver reference 비교 구현 +- Visual Studio `.sln`/`.vcxproj` 전용 MSBuild workflow diff --git a/scripts/test_pre_commit_checks.py b/scripts/test_pre_commit_checks.py new file mode 100644 index 0000000..8553716 --- /dev/null +++ b/scripts/test_pre_commit_checks.py @@ -0,0 +1,41 @@ +import importlib.util +import sys +import tempfile +import unittest +from pathlib import Path + + +def load_pre_commit_checks(): + module_path = Path(__file__).resolve().parent.parent / ".codex" / "hooks" / "pre_commit_checks.py" + spec = importlib.util.spec_from_file_location("pre_commit_checks", module_path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +class PreCommitChecksTests(unittest.TestCase): + def test_git_commit_runs_python_self_tests_and_workspace_validation(self): + pre_commit_checks = load_pre_commit_checks() + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + commands = pre_commit_checks._build_pre_commit_commands(root) + + self.assertEqual( + commands, + [ + [sys.executable, "-m", "unittest", "discover", "-s", "scripts", "-p", "test_*.py"], + [sys.executable, "scripts/validate_workspace.py"], + ], + ) + self.assertFalse(any("npm" in part.lower() for command in commands for part in command)) + + def test_only_git_commit_commands_trigger_checks(self): + pre_commit_checks = load_pre_commit_checks() + self.assertTrue(pre_commit_checks._is_git_commit('git commit -m "change"')) + self.assertTrue(pre_commit_checks._is_git_commit('git -c core.editor=true commit -m "change"')) + self.assertFalse(pre_commit_checks._is_git_commit("git status --short")) + self.assertFalse(pre_commit_checks._is_git_commit("echo git commit")) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test_tdd_guard.py b/scripts/test_tdd_guard.py new file mode 100644 index 0000000..67436df --- /dev/null +++ b/scripts/test_tdd_guard.py @@ -0,0 +1,61 @@ +import importlib.util +import tempfile +import unittest +from pathlib import Path + + +def load_tdd_guard(): + module_path = Path(__file__).resolve().parent.parent / ".codex" / "hooks" / "tdd-guard.py" + spec = importlib.util.spec_from_file_location("tdd_guard", module_path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +class CppTddGuardTests(unittest.TestCase): + def test_cpp_production_file_without_related_test_is_blocked(self): + tdd_guard = load_tdd_guard() + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "include" / "fesa" / "Core" / "DofManager.hpp" + source.parent.mkdir(parents=True) + source.write_text("#pragma once\n", encoding="utf-8") + + self.assertEqual(tdd_guard._guarded_paths([str(source)], root, root), ["DofManager"]) + + def test_cpp_production_file_with_module_test_is_allowed(self): + tdd_guard = load_tdd_guard() + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "include" / "fesa" / "Core" / "DofManager.hpp" + source.parent.mkdir(parents=True) + source.write_text("#pragma once\n", encoding="utf-8") + tests_dir = root / "tests" + tests_dir.mkdir() + (tests_dir / "test_core_module_includes.cpp").write_text("int main() { return 0; }\n", encoding="utf-8") + + self.assertEqual(tdd_guard._guarded_paths([str(source)], root, root), []) + + def test_cpp_production_file_with_basename_test_in_same_patch_is_allowed(self): + tdd_guard = load_tdd_guard() + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "src" / "Math" / "DenseMatrix.cpp" + source.parent.mkdir(parents=True) + source.write_text("void f() {}\n", encoding="utf-8") + test_path = root / "tests" / "test_dense_matrix.cpp" + + self.assertEqual(tdd_guard._guarded_paths([str(source), str(test_path)], root, root), []) + + def test_cmake_and_docs_are_exempt(self): + tdd_guard = load_tdd_guard() + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self.assertEqual( + tdd_guard._guarded_paths(["CMakeLists.txt", "docs/ARCHITECTURE.md", "cmake/toolchain.cmake"], root, root), + [], + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test_validate_workspace.py b/scripts/test_validate_workspace.py new file mode 100644 index 0000000..71ba75a --- /dev/null +++ b/scripts/test_validate_workspace.py @@ -0,0 +1,94 @@ +import importlib.util +import os +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + + +def load_validate_workspace(): + module_path = Path(__file__).resolve().parent / "validate_workspace.py" + spec = importlib.util.spec_from_file_location("validate_workspace", module_path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +class ValidateWorkspaceTests(unittest.TestCase): + def test_env_commands_override_cmake_detection(self): + validate_workspace = load_validate_workspace() + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / "CMakeLists.txt").write_text("cmake_minimum_required(VERSION 3.20)\n", encoding="utf-8") + with patch.dict(os.environ, {"HARNESS_VALIDATION_COMMANDS": "echo first\n echo second \n"}, clear=True): + self.assertEqual(validate_workspace.discover_commands(root), ["echo first", "echo second"]) + + def test_msvc_debug_cmake_commands_are_default_for_cmake_project(self): + validate_workspace = load_validate_workspace() + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / "CMakeLists.txt").write_text("cmake_minimum_required(VERSION 3.20)\n", encoding="utf-8") + build_dir = root / "build" / "msvc-debug" + with patch.dict(os.environ, {}, clear=True): + self.assertEqual( + validate_workspace.discover_commands(root), + [ + f'cmake -S "{root}" -B "{build_dir}" -G "Visual Studio 17 2022" -A x64', + f'cmake --build "{build_dir}" --config Debug', + f'ctest --test-dir "{build_dir}" --output-on-failure -C Debug', + ], + ) + + def test_msvc_debug_configure_preset_is_preferred_when_present(self): + validate_workspace = load_validate_workspace() + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / "CMakeLists.txt").write_text("cmake_minimum_required(VERSION 3.20)\n", encoding="utf-8") + (root / "CMakePresets.json").write_text( + """ +{ + "version": 3, + "configurePresets": [ + { + "name": "msvc-debug", + "generator": "Visual Studio 17 2022", + "binaryDir": "${sourceDir}/out/msvc-debug" + } + ] +} +""", + encoding="utf-8", + ) + with patch.dict(os.environ, {}, clear=True): + self.assertEqual( + validate_workspace.discover_commands(root), + [ + "cmake --preset msvc-debug", + f'cmake --build "{root / "out" / "msvc-debug"}" --config Debug', + f'ctest --test-dir "{root / "out" / "msvc-debug"}" --output-on-failure -C Debug', + ], + ) + + def test_no_cmake_project_has_no_validation_commands(self): + validate_workspace = load_validate_workspace() + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + with patch.dict(os.environ, {}, clear=True): + self.assertEqual(validate_workspace.discover_commands(root), []) + + def test_common_cmake_install_path_is_prepended_when_cmake_is_not_on_path(self): + validate_workspace = load_validate_workspace() + with tempfile.TemporaryDirectory() as tmp: + common_bin = Path(tmp) / "CMake" / "bin" + common_bin.mkdir(parents=True) + (common_bin / "cmake.exe").write_text("", encoding="utf-8") + (common_bin / "ctest.exe").write_text("", encoding="utf-8") + with patch.object(validate_workspace, "COMMON_CMAKE_BIN", common_bin): + with patch.object(validate_workspace.shutil, "which", return_value=None): + env = validate_workspace.validation_environment({"PATH": "C:\\Windows\\System32"}) + + self.assertTrue(env["PATH"].startswith(str(common_bin))) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/validate_workspace.py b/scripts/validate_workspace.py new file mode 100644 index 0000000..45b4530 --- /dev/null +++ b/scripts/validate_workspace.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python3 +"""Run C++/MSVC Harness validation commands.""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import sys +from pathlib import Path + + +DEFAULT_GENERATOR = "Visual Studio 17 2022" +DEFAULT_PLATFORM = "x64" +DEFAULT_CONFIG = "Debug" +DEFAULT_BUILD_DIR = "build/msvc-debug" +PRESET_NAME = "msvc-debug" +COMMON_CMAKE_BIN = Path(r"C:\Program Files\CMake\bin") + + +def load_env_commands() -> list[str]: + raw = os.environ.get("HARNESS_VALIDATION_COMMANDS", "") + return [line.strip() for line in raw.splitlines() if line.strip()] + + +def _cmake_config() -> tuple[str, str, str, Path]: + generator = os.environ.get("HARNESS_CMAKE_GENERATOR", DEFAULT_GENERATOR) + platform = os.environ.get("HARNESS_CMAKE_PLATFORM", DEFAULT_PLATFORM) + config = os.environ.get("HARNESS_CMAKE_CONFIG", DEFAULT_CONFIG) + build_dir = Path(os.environ.get("HARNESS_BUILD_DIR", DEFAULT_BUILD_DIR)) + return generator, platform, config, build_dir + + +def _read_presets(root: Path) -> dict: + presets_file = root / "CMakePresets.json" + if not presets_file.exists(): + return {} + try: + return json.loads(presets_file.read_text(encoding="utf-8")) + except json.JSONDecodeError: + return {} + + +def _preset_binary_dir(root: Path, preset: dict) -> Path: + binary_dir = str(preset.get("binaryDir") or DEFAULT_BUILD_DIR) + binary_dir = binary_dir.replace("${sourceDir}", str(root)) + binary_dir = binary_dir.replace("$sourceDir", str(root)) + path = Path(binary_dir) + return path if path.is_absolute() else root / path + + +def load_preset_commands(root: Path) -> list[str]: + payload = _read_presets(root) + config = os.environ.get("HARNESS_CMAKE_CONFIG", DEFAULT_CONFIG) + for preset in payload.get("configurePresets", []): + if isinstance(preset, dict) and preset.get("name") == PRESET_NAME: + build_dir = _preset_binary_dir(root, preset) + return [ + f"cmake --preset {PRESET_NAME}", + f'cmake --build "{build_dir}" --config {config}', + f'ctest --test-dir "{build_dir}" --output-on-failure -C {config}', + ] + return [] + + +def load_cmake_commands(root: Path) -> list[str]: + if not (root / "CMakeLists.txt").exists(): + return [] + + generator, platform, config, build_dir = _cmake_config() + if not build_dir.is_absolute(): + build_dir = root / build_dir + return [ + f'cmake -S "{root}" -B "{build_dir}" -G "{generator}" -A {platform}', + f'cmake --build "{build_dir}" --config {config}', + f'ctest --test-dir "{build_dir}" --output-on-failure -C {config}', + ] + + +def discover_commands(root: Path) -> list[str]: + env_commands = load_env_commands() + if env_commands: + return env_commands + preset_commands = load_preset_commands(root) + if preset_commands: + return preset_commands + return load_cmake_commands(root) + + +def run_command(command: str, root: Path) -> subprocess.CompletedProcess: + return subprocess.run( + command, + cwd=root, + shell=True, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + env=validation_environment(os.environ), + ) + + +def validation_environment(base_env: os._Environ | dict[str, str]) -> dict[str, str]: + env = dict(base_env) + if shutil.which("cmake") is not None: + return env + cmake_exe = COMMON_CMAKE_BIN / "cmake.exe" + if not cmake_exe.exists(): + return env + + current_path = env.get("PATH", "") + paths = [part for part in current_path.split(os.pathsep) if part] + common_bin_text = str(COMMON_CMAKE_BIN) + if not any(part.lower() == common_bin_text.lower() for part in paths): + env["PATH"] = common_bin_text + (os.pathsep + current_path if current_path else "") + return env + + +def emit_stream(prefix: str, content: str, *, stream) -> None: + text = (content or "").strip() + if not text: + return + print(prefix, file=stream) + print(text, file=stream) + + +def main() -> int: + root = Path(__file__).resolve().parent.parent + commands = discover_commands(root) + + if not commands: + print("No C++ validation commands configured.") + print("Add CMakeLists.txt or set HARNESS_VALIDATION_COMMANDS.") + return 0 + + for command in commands: + print(f"$ {command}") + result = run_command(command, root) + emit_stream("[stdout]", result.stdout, stream=sys.stdout) + emit_stream("[stderr]", result.stderr, stream=sys.stderr) + if result.returncode != 0: + print(f"Validation failed: {command}", file=sys.stderr) + return result.returncode + + print("Validation succeeded.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())