governance: harden fail-fast checks for traceability and task-test pairing (#372) #379
@@ -1,9 +1,9 @@
|
||||
<!--
|
||||
Doc-ID: DOC-REQ-001
|
||||
Version: 1.0.1
|
||||
Version: 1.0.2
|
||||
Status: active
|
||||
Owner: strategy
|
||||
Updated: 2026-03-01
|
||||
Updated: 2026-03-02
|
||||
-->
|
||||
|
||||
# 요구사항 원장 (Single Source of Truth)
|
||||
|
||||
@@ -16,42 +16,42 @@ Updated: 2026-02-26
|
||||
|
||||
## 구현 단위 A: 상태기계/청산
|
||||
|
||||
- `TASK-CODE-001` (`REQ-V2-001`,`REQ-V2-002`,`REQ-V2-003`): `src/strategy/`에 상태기계 모듈 추가
|
||||
- `TASK-CODE-002` (`REQ-V2-004`): ATR/BE/Hard Stop 결합 청산 함수 추가
|
||||
- `TASK-CODE-003` (`REQ-V2-008`): Kill Switch 오케스트레이터를 `src/core/kill_switch.py`에 추가
|
||||
- `TASK-CODE-001` (`REQ-V2-001`,`REQ-V2-002`,`REQ-V2-003`,`TEST-CODE-001`,`TEST-CODE-002`): `src/strategy/`에 상태기계 모듈 추가
|
||||
- `TASK-CODE-002` (`REQ-V2-004`,`TEST-ACC-011`): ATR/BE/Hard Stop 결합 청산 함수 추가
|
||||
- `TASK-CODE-003` (`REQ-V2-008`,`TEST-ACC-002`): Kill Switch 오케스트레이터를 `src/core/kill_switch.py`에 추가
|
||||
- `TEST-CODE-001`: 갭 점프 시 최고상태 승격 테스트
|
||||
- `TEST-CODE-002`: EXIT 우선순위 테스트
|
||||
|
||||
## 구현 단위 B: 라벨링/검증
|
||||
|
||||
- `TASK-CODE-004` (`REQ-V2-005`): Triple Barrier 라벨러 모듈 추가(`src/analysis/` 또는 `src/strategy/`)
|
||||
- `TASK-CODE-005` (`REQ-V2-006`): Walk-forward + Purge/Embargo 분할 유틸 추가
|
||||
- `TASK-CODE-006` (`REQ-V2-007`): 백테스트 실행기에서 비용/슬리피지 옵션 필수화
|
||||
- `TASK-CODE-004` (`REQ-V2-005`,`TEST-CODE-003`,`TEST-ACC-012`): Triple Barrier 라벨러 모듈 추가(`src/analysis/` 또는 `src/strategy/`)
|
||||
- `TASK-CODE-005` (`REQ-V2-006`,`TEST-CODE-004`,`TEST-ACC-013`): Walk-forward + Purge/Embargo 분할 유틸 추가
|
||||
- `TASK-CODE-006` (`REQ-V2-007`,`TEST-ACC-014`): 백테스트 실행기에서 비용/슬리피지 옵션 필수화
|
||||
- `TEST-CODE-003`: 라벨 선터치 우선 테스트
|
||||
- `TEST-CODE-004`: 누수 차단 테스트
|
||||
|
||||
## 구현 단위 C: 세션/주문 정책
|
||||
|
||||
- `TASK-CODE-007` (`REQ-V3-001`,`REQ-V3-002`): 세션 분류/전환 훅을 `src/markets/schedule.py` 연동
|
||||
- `TASK-CODE-008` (`REQ-V3-003`,`REQ-V3-004`): 블랙아웃 큐 처리기를 `src/broker/`에 추가
|
||||
- `TASK-CODE-009` (`REQ-V3-005`): 세션별 주문 타입 검증기 추가
|
||||
- `TASK-CODE-007` (`REQ-V3-001`,`REQ-V3-002`,`TEST-ACC-015`,`TEST-ACC-016`): 세션 분류/전환 훅을 `src/markets/schedule.py` 연동
|
||||
- `TASK-CODE-008` (`REQ-V3-003`,`REQ-V3-004`,`TEST-CODE-005`,`TEST-ACC-017`): 블랙아웃 큐 처리기를 `src/broker/`에 추가
|
||||
- `TASK-CODE-009` (`REQ-V3-005`,`TEST-CODE-006`,`TEST-ACC-004`): 세션별 주문 타입 검증기 추가
|
||||
- `TEST-CODE-005`: 블랙아웃 신규주문 차단 테스트
|
||||
- `TEST-CODE-006`: 저유동 세션 시장가 거부 테스트
|
||||
|
||||
## 구현 단위 D: 체결/환율/오버나잇
|
||||
|
||||
- `TASK-CODE-010` (`REQ-V3-006`): 불리한 체결가 모델을 백테스트 체결기로 구현
|
||||
- `TASK-CODE-011` (`REQ-V3-007`): FX PnL 분리 회계 테이블/컬럼 추가
|
||||
- `TASK-CODE-012` (`REQ-V3-008`): 오버나잇 예외와 Kill Switch 충돌 해소 로직 구현
|
||||
- `TASK-CODE-010` (`REQ-V3-006`,`TEST-CODE-007`,`TEST-ACC-005`): 불리한 체결가 모델을 백테스트 체결기로 구현
|
||||
- `TASK-CODE-011` (`REQ-V3-007`,`TEST-CODE-008`,`TEST-ACC-006`): FX PnL 분리 회계 테이블/컬럼 추가
|
||||
- `TASK-CODE-012` (`REQ-V3-008`,`TEST-ACC-018`): 오버나잇 예외와 Kill Switch 충돌 해소 로직 구현
|
||||
- `TEST-CODE-007`: 불리한 체결가 모델 테스트
|
||||
- `TEST-CODE-008`: FX 버퍼 위반 시 신규진입 제한 테스트
|
||||
|
||||
## 구현 단위 E: 운영/문서 거버넌스
|
||||
|
||||
- `TASK-OPS-001` (`REQ-OPS-001`): 시간 필드/로그 스키마의 타임존 표기 강제 규칙 구현
|
||||
- `TASK-OPS-002` (`REQ-OPS-002`): 정책 수치 변경 시 `01_requirements_registry.md` 선수정 CI 체크 추가
|
||||
- `TASK-OPS-003` (`REQ-OPS-003`): `TASK-*` 없는 `REQ-*` 또는 `TEST-*` 없는 `REQ-*`를 차단하는 문서 검증 게이트 유지
|
||||
- `TASK-OPS-004` (`REQ-OPS-004`): v2/v3 원본 계획 문서 위치를 `docs/ouroboros/source/`로 표준화하고 링크 일관성 검증
|
||||
- `TASK-OPS-001` (`REQ-OPS-001`,`TEST-ACC-007`): 시간 필드/로그 스키마의 타임존(KST/UTC) 표기 강제 규칙 구현
|
||||
- `TASK-OPS-002` (`REQ-OPS-002`,`TEST-ACC-008`): 정책 수치 변경 시 `01_requirements_registry.md` 선수정 CI 체크 추가
|
||||
- `TASK-OPS-003` (`REQ-OPS-003`,`TEST-ACC-009`): `TASK-*` 없는 `REQ-*` 또는 `TEST-*` 없는 `REQ-*`를 차단하는 문서 검증 게이트 유지
|
||||
- `TASK-OPS-004` (`REQ-OPS-004`,`TEST-ACC-019`): v2/v3 원본 계획 문서 위치를 `docs/ouroboros/source/`로 표준화하고 링크 일관성 검증
|
||||
|
||||
## 커밋 규칙
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ TASK_ID_IN_TEXT = re.compile(r"\bTASK-[A-Z0-9-]+-\d{3}\b")
|
||||
TEST_ID_IN_TEXT = re.compile(r"\bTEST-[A-Z0-9-]+-\d{3}\b")
|
||||
READ_ONLY_FILES = {"src/core/risk_manager.py"}
|
||||
PLACEHOLDER_VALUES = {"", "tbd", "n/a", "na", "none", "<link>", "<required>"}
|
||||
TIMEZONE_TOKEN_PATTERN = re.compile(r"\b(?:KST|UTC)\b")
|
||||
|
||||
|
||||
def must_contain(path: Path, required: list[str], errors: list[str]) -> None:
|
||||
@@ -105,7 +106,43 @@ def validate_task_req_mapping(errors: list[str], *, task_doc: Path | None = None
|
||||
errors.append(f"{path}: no TASK definitions found")
|
||||
|
||||
|
||||
def validate_pr_traceability(warnings: list[str]) -> None:
|
||||
def validate_task_test_pairing(errors: list[str], *, task_doc: Path | None = None) -> None:
|
||||
"""Fail when TASK definitions are not linked to at least one TEST id."""
|
||||
path = task_doc or Path(TASK_WORK_ORDERS_DOC)
|
||||
if not path.exists():
|
||||
errors.append(f"missing file: {path}")
|
||||
return
|
||||
|
||||
text = path.read_text(encoding="utf-8")
|
||||
found_task = False
|
||||
for line in text.splitlines():
|
||||
m = TASK_DEF_LINE.match(line.strip())
|
||||
if not m:
|
||||
continue
|
||||
found_task = True
|
||||
if not TEST_ID_IN_TEXT.search(m.group("body")):
|
||||
errors.append(f"{path}: TASK without TEST mapping -> {m.group('task_id')}")
|
||||
if not found_task:
|
||||
errors.append(f"{path}: no TASK definitions found")
|
||||
|
||||
|
||||
def validate_timezone_policy_tokens(errors: list[str]) -> None:
|
||||
"""Fail-fast check for REQ-OPS-001 governance tokens."""
|
||||
required_docs = [
|
||||
Path("docs/ouroboros/01_requirements_registry.md"),
|
||||
Path("docs/ouroboros/30_code_level_work_orders.md"),
|
||||
Path("docs/workflow.md"),
|
||||
]
|
||||
for path in required_docs:
|
||||
if not path.exists():
|
||||
errors.append(f"missing file: {path}")
|
||||
continue
|
||||
text = path.read_text(encoding="utf-8")
|
||||
if not TIMEZONE_TOKEN_PATTERN.search(text):
|
||||
errors.append(f"{path}: missing timezone policy token (KST/UTC)")
|
||||
|
||||
|
||||
def validate_pr_traceability(errors: list[str]) -> None:
|
||||
title = os.getenv("GOVERNANCE_PR_TITLE", "").strip()
|
||||
body = os.getenv("GOVERNANCE_PR_BODY", "").strip()
|
||||
if not title and not body:
|
||||
@@ -113,11 +150,11 @@ def validate_pr_traceability(warnings: list[str]) -> None:
|
||||
|
||||
text = f"{title}\n{body}"
|
||||
if not REQ_ID_IN_LINE.search(text):
|
||||
warnings.append("PR text missing REQ-ID reference")
|
||||
errors.append("PR text missing REQ-ID reference")
|
||||
if not TASK_ID_IN_TEXT.search(text):
|
||||
warnings.append("PR text missing TASK-ID reference")
|
||||
errors.append("PR text missing TASK-ID reference")
|
||||
if not TEST_ID_IN_TEXT.search(text):
|
||||
warnings.append("PR text missing TEST-ID reference")
|
||||
errors.append("PR text missing TEST-ID reference")
|
||||
|
||||
|
||||
def _parse_pr_evidence_line(text: str, field: str) -> str | None:
|
||||
@@ -145,8 +182,8 @@ def validate_read_only_approval(
|
||||
|
||||
body = os.getenv("GOVERNANCE_PR_BODY", "").strip()
|
||||
if not body:
|
||||
warnings.append(
|
||||
"READ-ONLY file changed but PR body is unavailable; approval evidence check skipped"
|
||||
errors.append(
|
||||
"READ-ONLY file changed but PR body is unavailable; approval evidence is required"
|
||||
)
|
||||
return
|
||||
|
||||
@@ -245,7 +282,9 @@ def main() -> int:
|
||||
|
||||
validate_registry_sync(changed_files, errors)
|
||||
validate_task_req_mapping(errors)
|
||||
validate_pr_traceability(warnings)
|
||||
validate_task_test_pairing(errors)
|
||||
validate_timezone_policy_tokens(errors)
|
||||
validate_pr_traceability(errors)
|
||||
validate_read_only_approval(changed_files, errors, warnings)
|
||||
|
||||
if errors:
|
||||
|
||||
@@ -108,14 +108,14 @@ def test_validate_task_req_mapping_passes_when_req_present(tmp_path) -> None:
|
||||
assert errors == []
|
||||
|
||||
|
||||
def test_validate_pr_traceability_warns_when_req_missing(monkeypatch) -> None:
|
||||
def test_validate_pr_traceability_fails_when_req_missing(monkeypatch) -> None:
|
||||
module = _load_module()
|
||||
monkeypatch.setenv("GOVERNANCE_PR_TITLE", "feat: update policy checker")
|
||||
monkeypatch.setenv("GOVERNANCE_PR_BODY", "Refs: TASK-OPS-001 TEST-ACC-007")
|
||||
warnings: list[str] = []
|
||||
module.validate_pr_traceability(warnings)
|
||||
assert warnings
|
||||
assert "PR text missing REQ-ID reference" in warnings
|
||||
errors: list[str] = []
|
||||
module.validate_pr_traceability(errors)
|
||||
assert errors
|
||||
assert "PR text missing REQ-ID reference" in errors
|
||||
|
||||
|
||||
def test_validate_read_only_approval_requires_evidence(monkeypatch) -> None:
|
||||
@@ -165,7 +165,7 @@ def test_validate_read_only_approval_passes_with_complete_evidence(monkeypatch)
|
||||
assert warnings == []
|
||||
|
||||
|
||||
def test_validate_read_only_approval_warns_without_pr_body(monkeypatch) -> None:
|
||||
def test_validate_read_only_approval_fails_without_pr_body(monkeypatch) -> None:
|
||||
module = _load_module()
|
||||
changed_files = ["src/core/risk_manager.py"]
|
||||
errors: list[str] = []
|
||||
@@ -173,9 +173,9 @@ def test_validate_read_only_approval_warns_without_pr_body(monkeypatch) -> None:
|
||||
monkeypatch.delenv("GOVERNANCE_PR_BODY", raising=False)
|
||||
|
||||
module.validate_read_only_approval(changed_files, errors, warnings)
|
||||
assert errors == []
|
||||
assert warnings
|
||||
assert "approval evidence check skipped" in warnings[0]
|
||||
assert warnings == []
|
||||
assert errors
|
||||
assert "approval evidence is required" in errors[0]
|
||||
|
||||
|
||||
def test_validate_read_only_approval_skips_when_no_readonly_file_changed() -> None:
|
||||
@@ -284,3 +284,54 @@ def test_must_contain_fails_when_commands_missing_newline_section_token(tmp_path
|
||||
errors,
|
||||
)
|
||||
assert any("Comment Newline Escaping" in err for err in errors)
|
||||
|
||||
|
||||
def test_validate_task_test_pairing_reports_missing_test_reference(tmp_path) -> None:
|
||||
module = _load_module()
|
||||
doc = tmp_path / "work_orders.md"
|
||||
doc.write_text(
|
||||
"- `TASK-OPS-999` (`REQ-OPS-001`): enforce timezone labels only\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
errors: list[str] = []
|
||||
module.validate_task_test_pairing(errors, task_doc=doc)
|
||||
assert errors
|
||||
assert "TASK without TEST mapping" in errors[0]
|
||||
|
||||
|
||||
def test_validate_task_test_pairing_passes_when_test_present(tmp_path) -> None:
|
||||
module = _load_module()
|
||||
doc = tmp_path / "work_orders.md"
|
||||
doc.write_text(
|
||||
"- `TASK-OPS-999` (`REQ-OPS-001`,`TEST-ACC-007`): enforce timezone labels\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
errors: list[str] = []
|
||||
module.validate_task_test_pairing(errors, task_doc=doc)
|
||||
assert errors == []
|
||||
|
||||
|
||||
def test_validate_timezone_policy_tokens_requires_kst_or_utc(tmp_path, monkeypatch) -> None:
|
||||
module = _load_module()
|
||||
docs = tmp_path / "docs"
|
||||
ouroboros = docs / "ouroboros"
|
||||
docs.mkdir(parents=True)
|
||||
ouroboros.mkdir(parents=True)
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
(ouroboros / "01_requirements_registry.md").write_text("REQ-OPS-001\nUTC\n", encoding="utf-8")
|
||||
(ouroboros / "30_code_level_work_orders.md").write_text(
|
||||
"TASK-OPS-001 (`REQ-OPS-001`,`TEST-ACC-007`)\nKST\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(docs / "workflow.md").write_text("timezone policy: KST and UTC\n", encoding="utf-8")
|
||||
|
||||
errors: list[str] = []
|
||||
module.validate_timezone_policy_tokens(errors)
|
||||
assert errors == []
|
||||
|
||||
(docs / "workflow.md").write_text("timezone policy missing labels\n", encoding="utf-8")
|
||||
errors = []
|
||||
module.validate_timezone_policy_tokens(errors)
|
||||
assert errors
|
||||
assert any("missing timezone policy token" in err for err in errors)
|
||||
|
||||
Reference in New Issue
Block a user