From d610ecb57c3fa0d7f20ca476003ee568d2c696f6 Mon Sep 17 00:00:00 2001 From: kjh2064 Date: Mon, 13 Jul 2026 00:41:30 +0900 Subject: [PATCH] feat(dotnet): add read model contract harness --- .gitea/workflows/ci.yml | 3 + AGENTS.md | 2 + docs/ROADMAP_WBS.md | 1 + docs/WBS_10_DOTNET_READ_MODEL_CONTRACT.yaml | 54 +++++++++++++ spec/41_release_dag.yaml | 16 ++++ ..._validate_dotnet_read_model_contract_v1.py | 38 +++++++++ .../validate_dotnet_read_model_contract_v1.py | 79 +++++++++++++++++++ 7 files changed, 193 insertions(+) create mode 100644 docs/WBS_10_DOTNET_READ_MODEL_CONTRACT.yaml create mode 100644 tests/unit/test_validate_dotnet_read_model_contract_v1.py create mode 100644 tools/validate_dotnet_read_model_contract_v1.py diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index c57b6bbd..955fade5 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -193,6 +193,9 @@ jobs: - name: Validate Dotnet Domain Parity Backlog run: python3 tools/validate_dotnet_domain_parity_backlog_v1.py + - name: Validate Dotnet Read Model Contract + run: python3 tools/validate_dotnet_read_model_contract_v1.py + - name: Build Calibration Priority Backlog diff --git a/AGENTS.md b/AGENTS.md index 44e48d6a..927ec4b8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -106,6 +106,7 @@ - `docs/WBS_10_DOTNET_IDEMPOTENCY_CONTRACT.yaml`: WBS-10 idempotency/lock 계약. - `docs/WBS_10_DOTNET_CICD_CHAIN_CONTRACT.yaml`: WBS-10 CI/CD 순차 게이트 계약. - `docs/WBS_10_DOTNET_DOMAIN_PARITY_BACKLOG.yaml`: WBS-10 domain parity backlog contract. +- `docs/WBS_10_DOTNET_READ_MODEL_CONTRACT.yaml`: WBS-10 read model contract. - `tools/validate_dotnet_migration_roadmap_v1.py`: WBS-10 상세 로드맵 YAML validator. - `tools/validate_dotnet_migration_execution_plan_v1.py`: WBS-10 실행 분해 계획 validator. - `tools/validate_dotnet_parity_contract_v1.py`: WBS-10 parity 계약 validator. @@ -115,6 +116,7 @@ - `tools/validate_dotnet_idempotency_contract_v1.py`: WBS-10 idempotency 계약 validator. - `tools/validate_dotnet_cicd_chain_contract_v1.py`: WBS-10 CI/CD chain 계약 validator. - `tools/validate_dotnet_domain_parity_backlog_v1.py`: WBS-10 domain parity backlog validator. +- `tools/validate_dotnet_read_model_contract_v1.py`: WBS-10 read model validator. - `Temp/snapshot_admin_approval_packet_v1.json`: snapshot admin approval packet export. - `Temp/snapshot_admin_approval_packet_v1.md`: snapshot admin approval packet summary. - `Temp/`: 실행 결과와 캐시. 라우팅 대상은 아니며 runtime consumer만 읽는다. diff --git a/docs/ROADMAP_WBS.md b/docs/ROADMAP_WBS.md index 2740c1bf..ea119639 100644 --- a/docs/ROADMAP_WBS.md +++ b/docs/ROADMAP_WBS.md @@ -1475,6 +1475,7 @@ WBS-8.8 (KIS 리팩터) — 독립적 (원격 병행) > idempotency contract: [WBS_10_DOTNET_IDEMPOTENCY_CONTRACT.yaml](./WBS_10_DOTNET_IDEMPOTENCY_CONTRACT.yaml) > ci/cd chain contract: [WBS_10_DOTNET_CICD_CHAIN_CONTRACT.yaml](./WBS_10_DOTNET_CICD_CHAIN_CONTRACT.yaml) > domain parity backlog: [WBS_10_DOTNET_DOMAIN_PARITY_BACKLOG.yaml](./WBS_10_DOTNET_DOMAIN_PARITY_BACKLOG.yaml) +> read model contract: [WBS_10_DOTNET_READ_MODEL_CONTRACT.yaml](./WBS_10_DOTNET_READ_MODEL_CONTRACT.yaml) > 현황 진단(2026-06-26): .NET 프로젝트는 Python 엔진(41 모듈, 14,500 LOC) 대비 5~10%(~1,400 LOC) 수준. > Domain 계산기 6개·데이터 모델 8개·KIS/Naver/Yahoo 클라이언트·PostgreSQL 마이그레이션·Razor Pages 어드민 대시보드 기본 구현 완료. diff --git a/docs/WBS_10_DOTNET_READ_MODEL_CONTRACT.yaml b/docs/WBS_10_DOTNET_READ_MODEL_CONTRACT.yaml new file mode 100644 index 00000000..10632850 --- /dev/null +++ b/docs/WBS_10_DOTNET_READ_MODEL_CONTRACT.yaml @@ -0,0 +1,54 @@ +formula_id: WBS_10_DOTNET_READ_MODEL_CONTRACT_V1 +owner: QuantEngine +status: draft +goal: "운영 화면과 조회 API의 read model 경계를 분리한다." + +read_models: + - model_id: dashboard_summary + purpose: "운영 대시보드 상태" + source: QuantEngine.Infrastructure.Repositories.CollectionRepository + consumers: + - src/dotnet/QuantEngine.Web/Pages/Admin/Dashboard/Index.cshtml.cs + - src/dotnet/QuantEngine.Web/Endpoints/CollectionEndpoints.cs + fields: + - LastRunId + - LastRunAt + - SuccessCount + - ErrorCount + - SnapshotCount + staleness_budget: "5m" + - model_id: collection_runs + purpose: "최근 수집 실행 이력" + source: QuantEngine.Infrastructure.Repositories.CollectionRepository + consumers: + - src/dotnet/QuantEngine.Web/Pages/Admin/Collection/Index.cshtml.cs + - src/dotnet/QuantEngine.Web/Endpoints/CollectionEndpoints.cs + fields: + - RunId + - State + - StartedAt + - FinishedAt + - SuccessCount + - ErrorCount + staleness_budget: "5m" + - model_id: price_history_summary + purpose: "가격 히스토리 요약" + source: QuantEngine.Infrastructure.Repositories.CollectionRepository + consumers: + - src/dotnet/QuantEngine.Web/Pages/Admin/Collection/Index.cshtml.cs + - src/dotnet/QuantEngine.Web/Endpoints/CollectionEndpoints.cs + fields: + - Ticker + - Count + - FirstDate + - LastDate + staleness_budget: "15m" + +rules: + - "read model은 조회 전용이어야 한다." + - "운영 화면은 직접 원장 테이블을 조립하지 않는다." + - "쓰기 로직은 read model에 의존하지 않는다." + - "staleness_budget이 명시되지 않은 조회는 금지한다." + +notes: + - "의도된 역정규화는 허용하되, 원장과 동일 테이블로 재사용하지 않는다." diff --git a/spec/41_release_dag.yaml b/spec/41_release_dag.yaml index 268e8d16..f568be27 100644 --- a/spec/41_release_dag.yaml +++ b/spec/41_release_dag.yaml @@ -2425,6 +2425,22 @@ dag: - Temp/wbs_10_dotnet_domain_parity_backlog_v1.json strict: true timeout_sec: 60 + validate_dotnet_read_model_contract: + artifact_policy: keep + cache_key: validate_dotnet_read_model_contract_v1 + command: + - python + - tools/validate_dotnet_read_model_contract_v1.py + depends_on: [] + id: validate_dotnet_read_model_contract + inputs: + - tools/validate_dotnet_read_model_contract_v1.py + - docs/WBS_10_DOTNET_READ_MODEL_CONTRACT.yaml + note: WBS-10 운영 조회용 read model 경계를 검증한다. + outputs: + - Temp/wbs_10_dotnet_read_model_contract_v1.json + strict: true + timeout_sec: 60 validate_specs: artifact_policy: keep cache_key: validate_specs_v1 diff --git a/tests/unit/test_validate_dotnet_read_model_contract_v1.py b/tests/unit/test_validate_dotnet_read_model_contract_v1.py new file mode 100644 index 00000000..39d662b1 --- /dev/null +++ b/tests/unit/test_validate_dotnet_read_model_contract_v1.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + + +def test_validate_dotnet_read_model_contract_passes() -> None: + root = Path(__file__).resolve().parents[2] + proc = subprocess.run( + [sys.executable, str(root / "tools" / "validate_dotnet_read_model_contract_v1.py")], + cwd=root, + capture_output=True, + text=True, + ) + assert proc.returncode == 0, proc.stdout + proc.stderr + payload = json.loads(proc.stdout) + assert payload["gate"] == "PASS" + + +def test_validate_dotnet_read_model_contract_reports_missing_budget() -> None: + root = Path(__file__).resolve().parents[2] + temp = root / "Temp" / "test_wbs_10_read_model_contract_bad.yaml" + temp.write_text( + "formula_id: WBS_10_DOTNET_READ_MODEL_CONTRACT_V1\ngoal: 운영 화면과 조회 API의 read model 경계를 분리한다.\nread_models:\n - model_id: dashboard_summary\n purpose: x\n source: y\n consumers: [a]\n fields: [b]\nrules: [x,y,z,w]\n", + encoding="utf-8", + ) + proc = subprocess.run( + [sys.executable, str(root / "tools" / "validate_dotnet_read_model_contract_v1.py"), "--contract", str(temp)], + cwd=root, + capture_output=True, + text=True, + ) + assert proc.returncode != 0 + payload = json.loads(proc.stdout) + assert payload["gate"] == "FAIL" + assert "dashboard_summary.staleness_budget" in payload["missing"] diff --git a/tools/validate_dotnet_read_model_contract_v1.py b/tools/validate_dotnet_read_model_contract_v1.py new file mode 100644 index 00000000..30269eba --- /dev/null +++ b/tools/validate_dotnet_read_model_contract_v1.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +from pathlib import Path +from typing import Any + +import yaml + + +def load_yaml(path: Path) -> dict[str, Any]: + if not path.exists(): + raise FileNotFoundError(path) + return yaml.safe_load(path.read_text(encoding="utf-8")) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Validate WBS-10 dotnet read model contract") + parser.add_argument("--contract", default="docs/WBS_10_DOTNET_READ_MODEL_CONTRACT.yaml") + args = parser.parse_args(argv) + + contract_path = Path(args.contract).resolve() + payload: dict[str, Any] = { + "formula_id": "WBS_10_DOTNET_READ_MODEL_CONTRACT_V1", + "gate": "FAIL", + "missing": [], + "evidence": {"contract": str(contract_path)}, + } + + try: + data = load_yaml(contract_path) + except FileNotFoundError: + payload["missing"].append("contract missing") + print(json.dumps(payload, ensure_ascii=False, indent=2)) + return 1 + + if data.get("formula_id") != "WBS_10_DOTNET_READ_MODEL_CONTRACT_V1": + payload["missing"].append("formula_id") + if data.get("goal") != "운영 화면과 조회 API의 read model 경계를 분리한다.": + payload["missing"].append("goal") + + models = data.get("read_models") or [] + if len(models) < 3: + payload["missing"].append("read_models") + for model in models: + if not model.get("model_id"): + payload["missing"].append("model_id") + if not model.get("purpose"): + payload["missing"].append(f"{model.get('model_id', 'unknown')}.purpose") + if not model.get("source"): + payload["missing"].append(f"{model.get('model_id', 'unknown')}.source") + if not model.get("consumers"): + payload["missing"].append(f"{model.get('model_id', 'unknown')}.consumers") + if not model.get("fields"): + payload["missing"].append(f"{model.get('model_id', 'unknown')}.fields") + if not model.get("staleness_budget"): + payload["missing"].append(f"{model.get('model_id', 'unknown')}.staleness_budget") + + rules = data.get("rules") or [] + if len(rules) < 4: + payload["missing"].append("rules") + + payload["gate"] = "PASS" if not payload["missing"] else "FAIL" + payload["message"] = ( + "WBS-10 dotnet read model contract validation passed." + if payload["gate"] == "PASS" + else "WBS-10 dotnet read model contract validation failed." + ) + + out_path = contract_path.parent.parent / "Temp" / "wbs_10_dotnet_read_model_contract_v1.json" + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + print(json.dumps(payload, ensure_ascii=False, indent=2)) + return 0 if payload["gate"] == "PASS" else 1 + + +if __name__ == "__main__": + raise SystemExit(main())