modify harness framework

This commit is contained in:
김경종
2026-09-08 10:30:07 +09:00
parent 1239d5b9f6
commit 1a0f3c9aaf
68 changed files with 6729 additions and 957 deletions
@@ -3,7 +3,7 @@
Harness Step Executor — phase 내 step을 순차 실행하고 자가 교정한다.
Usage:
python scripts/execute.py <phase-dir> [--push]
python scripts/execute.py <phase-dir> [--push | --dry-run]
"""
import argparse
@@ -16,7 +16,7 @@ import threading
import time
import types
from datetime import datetime, timezone, timedelta
from pathlib import Path
from pathlib import Path, PureWindowsPath
from typing import Optional
ROOT = Path(__file__).resolve().parent.parent
@@ -26,6 +26,10 @@ class CodexEnvironmentError(RuntimeError):
"""재시도로 해결할 수 없는 Codex CLI 환경 오류."""
class ContextError(ValueError):
"""Step 실행 전에 수정해야 하는 문서/설정 오류."""
@contextlib.contextmanager
def progress_indicator(label: str):
"""터미널 진행 표시기. with 문으로 사용하며 .elapsed 로 경과 시간을 읽는다."""
@@ -63,9 +67,14 @@ class StepExecutor:
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
@@ -84,13 +93,27 @@ class StepExecutor:
self._phase_name = idx.get("phase", phase_dir_name)
self._total = len(idx["steps"])
def run(self):
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()
guardrails = self._load_guardrails()
self._ensure_created_at()
self._execute_all_steps(guardrails)
self._execute_all_steps()
self._finalize()
# --- timestamps ---
@@ -112,7 +135,8 @@ class StepExecutor:
def _run_git(self, *args) -> subprocess.CompletedProcess:
cmd = ["git"] + list(args)
return subprocess.run(cmd, cwd=self._root, capture_output=True, text=True)
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}"
@@ -178,69 +202,118 @@ class StepExecutor:
# --- guardrails & context ---
def _load_guardrails(self) -> str:
sections = []
agents_md = ROOT / "AGENTS.md"
if agents_md.exists():
sections.append(
"## 프로젝트 규칙 (AGENTS.md)\n\n"
f"{agents_md.read_text(encoding='utf-8')}"
)
docs_dir = ROOT / "docs"
if docs_dir.is_dir():
for doc in sorted(docs_dir.glob("*.md")):
sections.append(
f"## {doc.stem}\n\n{doc.read_text(encoding='utf-8')}"
)
return "\n\n---\n\n".join(sections) if sections else ""
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 _build_step_context(index: dict) -> str:
lines = [
f"- Step {s['step']} ({s['name']}): {s['summary']}"
for s in index["steps"]
if s["status"] == "completed" and s.get("summary")
]
if not lines:
return ""
return "## 이전 Step 산출물\n\n" + "\n".join(lines) + "\n\n"
def _render_prompt(sections: list) -> str:
return "\n\n---\n\n".join(f"## {label}\n\n{body}" for label, body in sections)
def _build_preamble(self, guardrails: str, step_context: str,
prev_error: Optional[str] = None) -> str:
retry_section = ""
if prev_error:
retry_section = (
f"\n## ⚠ 이전 시도 실패 — 아래 에러를 반드시 참고하여 수정하라\n\n"
f"{prev_error}\n\n---\n\n"
)
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"{guardrails}\n\n---\n\n"
f"{step_context}{retry_section}"
f"## 작업 규칙\n\n"
f"1. 이전 step에서 작성된 코드를 확인하고 일관성을 유지하라.\n"
f"2. 이 step에 명시된 작업만 수행하라. 추가 기능이나 파일을 만들지 마라.\n"
f"1. 지정한 문서와 관련 코드를 읽고, 추가 자료는 필요할 때만 읽어라.\n"
f"2. 현재 step 범위만 수행하라. 아래 인계·step 기록은 허용된 산출물이다.\n"
f"3. 기존 테스트를 깨뜨리지 마라.\n"
f"4. AC(Acceptance Criteria) 검증을 직접 실행하라.\n"
f"5. /phases/{self._phase_dir_name}/index.json의 해당 step status를 업데이트하라:\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" - {self.MAX_RETRIES}회 수정 시도 후에도 실패 → \"error\" + \"error_message\" 기록\n"
f" - 이번 세션에서 해결하지 못한 실패 → \"error\" + \"error_message\"에 원인·시도한 수정 기록\n"
f" - 사용자 개입이 필요한 경우 (API 키, 인증, 수동 설정 등) → \"blocked\" + \"blocked_reason\" 기록 후 즉시 중단\n"
f"6. 변경사항을 직접 커밋하지 마라. Git 커밋과 timestamp는 실행기가 처리한다.\n\n"
f"---\n\n"
f" 새 세션 재시도는 실행기가 최초 호출 포함 최대 {self.MAX_RETRIES}회 관리한다.\n"
f" 상태가 인계와 다르면 index와 실제 변경·로그를 확인하라.\n"
f"7. 변경사항을 직접 커밋하지 마라. Git 커밋과 timestamp는 실행기가 처리한다."
)
# --- Codex 호출 ---
def _invoke_codex(self, step: dict, preamble: str) -> dict:
def _invoke_codex(self, step: dict, prompt: str) -> dict:
step_num, step_name = step["step"], step["name"]
step_file = self._phase_dir / f"step{step_num}.md"
if not step_file.exists():
print(f" ERROR: {step_file} not found")
sys.exit(1)
prompt = preamble + step_file.read_text(encoding="utf-8")
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",
@@ -257,14 +330,27 @@ class StepExecutor:
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})")
@@ -333,7 +419,7 @@ class StepExecutor:
# --- 실행 루프 ---
def _execute_single_step(self, step: dict, guardrails: str) -> bool:
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")
@@ -341,26 +427,33 @@ class StepExecutor:
for attempt in range(1, self.MAX_RETRIES + 1):
index = self._read_json(self._index_file)
step_context = self._build_step_context(index)
preamble = self._build_preamble(guardrails, step_context, prev_error)
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, preamble)
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)
index = self._read_json(self._index_file)
status = next((s.get("status", "pending") for s in index["steps"] if s["step"] == step_num), "pending")
ts = self._stamp()
if status == "completed":
if status == "completed" and output["exitCode"] == 0:
for s in index["steps"]:
if s["step"] == step_num:
s["completed_at"] = ts
@@ -380,23 +473,16 @@ class StepExecutor:
self._update_top_index("blocked")
sys.exit(2)
runtime_error = ""
if output["exitCode"] != 0:
runtime_error = (
output.get("stderr", "").strip()
or output.get("stdout", "").strip()
)
err_msg = next(
(
s.get(
"error_message",
runtime_error or "Step did not update status",
)
for s in index["steps"]
if s["step"] == step_num
),
runtime_error or "Step did not update status",
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"]:
@@ -421,7 +507,7 @@ class StepExecutor:
return False # unreachable
def _execute_all_steps(self, guardrails: str):
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)
@@ -436,7 +522,7 @@ class StepExecutor:
self._write_json(self._index_file, index)
break
self._execute_single_step(pending, guardrails)
self._execute_single_step(pending)
def _finalize(self):
index = self._read_json(self._index_file)
@@ -467,12 +553,14 @@ class StepExecutor:
def main():
parser = argparse.ArgumentParser(description="Harness Step Executor")
parser.add_argument("phase_dir", help="Phase directory name (e.g. 0-mvp)")
parser.add_argument("--push", action="store_true", help="Push branch after completion")
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()
except CodexEnvironmentError as exc:
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)
@@ -3,10 +3,11 @@
import json
import os
import re
import subprocess
import sys
import time
from pathlib import Path
from pathlib import Path, PurePosixPath, PureWindowsPath
SCRIPTS = Path(__file__).resolve().parents[1]
@@ -48,6 +49,7 @@ def _project_root(
cwd=resolved_cwd,
capture_output=True,
text=True,
encoding="utf-8",
shell=False,
timeout=_remaining(deadline, clock, "repository discovery"),
)
@@ -61,6 +63,110 @@ def _project_root(
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 MSVC."""
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,
*,
@@ -133,6 +239,11 @@ def main(*, run=subprocess.run, clock=time.monotonic) -> int:
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}")
@@ -1,7 +1,7 @@
from pathlib import Path
from .models import DiscoveryResult, HarnessConfig, ProjectKind, ProjectSelection
from .tdd_policy import find_cpp_files
from .tdd_policy import has_cpp_files
class DiscoveryError(RuntimeError):
@@ -68,8 +68,7 @@ def discover_project(root: Path, config: HarnessConfig) -> DiscoveryResult:
ProjectSelection(ProjectKind.MSBUILD, projects[0])
)
cpp_files = find_cpp_files(root)
if cpp_files:
if has_cpp_files(root):
raise DiscoveryError(
"C/C++ files exist but no CMakeLists.txt, preset, .sln, or .vcxproj "
"was found; configure projectType and project path"
@@ -1,5 +1,7 @@
import os
from fnmatch import fnmatch
from pathlib import Path
from typing import Iterator
CPP_SUFFIXES = frozenset({".c", ".cc", ".cpp", ".cxx", ".h", ".hpp", ".hxx"})
DEFAULT_EXCLUDES = (
@@ -22,18 +24,70 @@ def is_excluded_path(path: Path, root: Path, extra: tuple[str, ...] = ()) -> boo
return any(fnmatch(relative, pattern) for pattern in DEFAULT_EXCLUDES + extra)
def _fully_excluded_directory(
path: Path,
root: Path,
patterns: tuple[str, ...],
) -> bool:
try:
relative = path.resolve().relative_to(root).as_posix()
except ValueError:
return False
for pattern in patterns:
for suffix in ("/**", "\\**"):
if pattern.endswith(suffix) and fnmatch(relative, pattern[:-3]):
return True
return False
def _iter_files(
root: Path,
pruned_patterns: tuple[str, ...] = (),
) -> Iterator[Path]:
for current, directories, files in os.walk(root, followlinks=False):
current_path = Path(current)
if pruned_patterns:
directories[:] = [
name
for name in directories
if not _fully_excluded_directory(
current_path / name,
root,
pruned_patterns,
)
]
for name in files:
path = current_path / name
if path.is_file():
yield path
def _iter_cpp_files(
root: Path,
extra_excludes: tuple[str, ...] = (),
) -> Iterator[Path]:
root = root.resolve()
excludes = DEFAULT_EXCLUDES + extra_excludes
for path in _iter_files(root, excludes):
if (
path.suffix.lower() in CPP_SUFFIXES
and not is_excluded_path(path, root, extra_excludes)
):
yield path
def find_cpp_files(
root: Path,
extra_excludes: tuple[str, ...] = (),
) -> tuple[Path, ...]:
found = (
path
for path in root.resolve().rglob("*")
if path.is_file()
and path.suffix.lower() in CPP_SUFFIXES
and not is_excluded_path(path, root, extra_excludes)
)
return tuple(sorted(found))
return tuple(sorted(_iter_cpp_files(root, extra_excludes)))
def has_cpp_files(
root: Path,
extra_excludes: tuple[str, ...] = (),
) -> bool:
return next(_iter_cpp_files(root, extra_excludes), None) is not None
def is_test_file(path: Path, root: Path, config) -> bool:
@@ -47,7 +101,60 @@ def is_test_file(path: Path, root: Path, config) -> bool:
return stem.startswith("test_") or stem.endswith(("_test", "_tests", ".test"))
def matching_test_exists(path: Path, config) -> bool:
def _is_simple_filename_pattern(pattern: str) -> bool:
pattern_path = Path(pattern)
return (
not pattern_path.is_absolute()
and len(pattern_path.parts) == 1
and pattern == pattern_path.name
and "**" not in pattern
and not pattern.endswith(("/", "\\"))
)
class _TestFileIndex:
def __init__(self) -> None:
self._roots: dict[Path, Path] = {}
self._files: dict[Path, list[Path]] = {}
self._iterators: dict[Path, Iterator[Path]] = {}
self._exhausted: set[Path] = set()
self._path_matches: dict[tuple[Path, str], bool] = {}
def _path_pattern_match(
self,
key: Path,
root: Path,
pattern: str,
) -> bool:
cache_key = (key, pattern)
if cache_key not in self._path_matches:
self._path_matches[cache_key] = any(
candidate.is_file() for candidate in root.rglob(pattern)
)
return self._path_matches[cache_key]
def matching_file_exists(self, root: Path, pattern: str) -> bool:
key = root.resolve()
traversal_root = self._roots.setdefault(key, root.absolute())
if not _is_simple_filename_pattern(pattern):
return self._path_pattern_match(key, traversal_root, pattern)
files = self._files.setdefault(key, [])
if any(fnmatch(path.name, pattern) for path in files):
return True
if key in self._exhausted:
return False
iterator = self._iterators.setdefault(key, _iter_files(traversal_root))
for path in iterator:
files.append(path)
if fnmatch(path.name, pattern):
return True
self._exhausted.add(key)
return False
def _matching_test_exists(path: Path, config, index: _TestFileIndex) -> bool:
names = tuple(pattern.format(stem=path.stem) for pattern in config.test_patterns)
roots = (
*config.test_roots,
@@ -58,13 +165,18 @@ def matching_test_exists(path: Path, config) -> bool:
if not root.is_dir():
continue
for name in names:
if any(candidate.is_file() for candidate in root.rglob(name)):
if index.matching_file_exists(root, name):
return True
return False
def matching_test_exists(path: Path, config) -> bool:
return _matching_test_exists(path, config, _TestFileIndex())
def evaluate_paths(paths, root: Path, config) -> str | None:
root = root.resolve()
test_files = _TestFileIndex()
for raw in paths:
path = raw.resolve()
try:
@@ -79,7 +191,7 @@ def evaluate_paths(paths, root: Path, config) -> str | None:
or is_test_file(path, root, config)
):
continue
if not matching_test_exists(path, config):
if not _matching_test_exists(path, config, test_files):
expected = config.test_patterns[0].format(stem=path.stem)
return (
f"TDD GUARD: '{path.name}' requires an existing test such as "