Files
kjh2064 ee3e799de1 feat: 리밸런싱 엔진 V1 + GAS 버그 수정 (2026-06-13)
주요 변경:
- tools/build_rebalance_engine_v1.py: REBALANCE_ENGINE_V1 신규
  * account_snapshot 직접 합산(_build_snap_position_map) → 소수주 분리 행 병합
  * 레짐 소스 macro.REGIME_PRELIM 최우선 (GAS 와 동일)
- src/gas_adapter_parts/gdf_06_rebalance.gs: runRebalanceSheet_() 신규
  * Logger.log / getSpreadsheet_() 로 run_all 연동 수정
- src/gas_adapter_parts/gdc_01_fetch_fundamentals.gs
  * _mergePositionRecord_(): 소수주 중복 행 합산 신규
  * parseInt → parseFloat (qty, availQty)
- src/gas_adapter_parts/gdf_01_price_metrics.gs
  * 미보유 종목 SELL_READY → WATCH_EXIT_SIGNAL
- spec/41_release_dag.yaml: build_rebalance_sheet 노드 추가 (step_count 63)
- spec/51_formula_lifecycle_registry.yaml: REBALANCE_ENGINE_V1 등록

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 13:20:14 +09:00

385 lines
15 KiB
Python

"""lint_repo_hygiene.py — 미사용·중복·상충 파일 감사 도구
사용법:
python tools/lint_repo_hygiene.py [--json out.json] [--delete-safe]
종료 코드:
0 = 경고 없음 (또는 warn-only 항목만)
1 = 삭제 권장 파일 존재
"""
from __future__ import annotations
import argparse
import json
import os
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
# ──────────────────────────────────────────────────────────
# 1. 참조 수집 헬퍼
# ──────────────────────────────────────────────────────────
def _load_pkg_refs() -> set[str]:
"""package.json scripts에서 tools/*.py 파일 stem 수집."""
pkg_path = ROOT / "package.json"
if not pkg_path.exists():
return set()
text = pkg_path.read_text(encoding="utf-8")
return set(m.group(1) for m in re.finditer(r'tools/([a-z_A-Z0-9]+)\.py', text))
def _load_spec_refs() -> set[str]:
"""spec/*.yaml 에서 python_tool: 참조 수집."""
refs: set[str] = set()
for y in ROOT.rglob("spec/**/*.yaml"):
for m in re.finditer(r'python_tool:\s*tools/([a-zA-Z0-9_]+)\.py', y.read_text(encoding="utf-8", errors="ignore")):
refs.add(m.group(1))
return refs
def _load_py_refs() -> set[str]:
"""tools/*.py 내부에서 tools/*.py 문자열 참조 수집 (self-reference 제외)."""
refs: set[str] = set()
for py in (ROOT / "tools").glob("*.py"):
try:
content = py.read_text(encoding="utf-8", errors="ignore")
for m in re.finditer(r'tools/([a-zA-Z0-9_]+)\.py', content):
found = m.group(1)
if found != py.stem: # self-reference 제외
refs.add(found)
except Exception:
pass
return refs
def _load_import_refs() -> set[str]:
"""tools/*.py 및 root/*.py 에서 Python import 기반 참조 수집."""
tool_stems = {p.stem for p in (ROOT / "tools").glob("*.py")}
refs: set[str] = set()
for py in list((ROOT / "tools").glob("*.py")) + list(ROOT.glob("*.py")):
try:
content = py.read_text(encoding="utf-8", errors="ignore")
for m in re.finditer(r'^from\s+([a-zA-Z0-9_]+)\s+import', content, re.MULTILINE):
if m.group(1) in tool_stems:
refs.add(m.group(1))
for m in re.finditer(r'^import\s+([a-zA-Z0-9_]+)', content, re.MULTILINE):
if m.group(1) in tool_stems:
refs.add(m.group(1))
except Exception:
pass
return refs
def _load_path_refs() -> set[str]:
"""tools/*.py 내에서 "filename.py" (Path 방식) 참조 수집."""
tool_stems = {p.stem for p in (ROOT / "tools").glob("*.py")}
refs: set[str] = set()
for py in list((ROOT / "tools").glob("*.py")) + list(ROOT.glob("*.py")):
try:
content = py.read_text(encoding="utf-8", errors="ignore")
for m in re.finditer(r'"([a-zA-Z0-9_]+)\.py"', content):
stem = m.group(1)
if stem in tool_stems and stem != py.stem:
refs.add(stem)
except Exception:
pass
return refs
def _load_ps1_refs() -> set[str]:
"""*.ps1 에서 tools/*.py 참조 수집."""
refs: set[str] = set()
for ps1 in ROOT.rglob("*.ps1"):
try:
for m in re.finditer(r'tools/([a-zA-Z0-9_]+)\.py', ps1.read_text(encoding="utf-8", errors="ignore")):
refs.add(m.group(1))
except Exception:
pass
return refs
def _all_py_stems() -> list[str]:
stems = []
for py in sorted((ROOT / "tools").glob("*.py")):
stems.append(py.stem)
for py in sorted((ROOT / "Temp").glob("*.py")):
stems.append("__Temp__/" + py.stem)
return stems
# ──────────────────────────────────────────────────────────
# 2. Python 파일 분류
# ──────────────────────────────────────────────────────────
# 패턴별 분류
_ONETIME_PREFIXES = ("fix_", "update_formula_registry", "append_golden_cases", "rename_data_files")
_APPLY_PREFIX = "apply_"
# package.json에서 명시적으로 쓰이는 apply_* 는 유지
_APPLY_KEEP = {
"apply_engine_upgrade_v4", # validate-engine-v4
"apply_strategy_execution_locks", # apply-strategy-execution-locks
"apply_perf_recovery_overrides_v1",
"apply_request_result_adoption_v1",
}
# 자동 탐지에서 누락되지만 명시적으로 유지해야 하는 파일
_MANUAL_KEEP = {
"__init__", # Python package init
"backfill_eod_replay_history", # data management tool
"run_formula_golden_cases_v3", # test runner (v2 + v3 coexist for different coverage)
"sync_replay_sheet_to_history", # data management tool
"validate_harness_json", # called from harness_coverage_auditor via Path ref
"lint_repo_hygiene", # this tool itself
}
def _categorize_py(stem: str, all_refs: set[str]) -> str:
"""KEEP / SAFE_DELETE / REVIEW 반환."""
if stem in all_refs:
return "KEEP"
raw = stem.replace("__Temp__/", "")
if raw in _APPLY_KEEP or raw in _MANUAL_KEEP:
return "KEEP"
if raw.startswith(_APPLY_PREFIX) or any(raw.startswith(p) for p in _ONETIME_PREFIXES):
return "SAFE_DELETE"
return "REVIEW"
# ──────────────────────────────────────────────────────────
# 3. YAML 중복·버전 충돌 감사
# ──────────────────────────────────────────────────────────
def _audit_yaml() -> list[dict]:
issues: list[dict] = []
spec = ROOT / "spec"
# 같은 번호 prefix 파일 검출 (ex: 35_foo_v2, 35_foo_v3)
numbered: dict[str, list[Path]] = {}
for y in sorted(spec.rglob("*.yaml")):
m = re.match(r'(\d+[a-z]?)_', y.name)
if m:
numbered.setdefault(m.group(1), []).append(y)
for num, files in numbered.items():
if len(files) > 1:
# 같은 숫자 번호를 가진 복수 파일 → 충돌 가능
names = [f.relative_to(ROOT).as_posix() for f in files]
issues.append({
"type": "YAML_NUMBER_CONFLICT",
"severity": "WARN",
"files": names,
"note": f"spec prefix '{num}' shared by {len(files)} files - number conflict",
})
# 버전 쌍 감지 (foo_v2.yaml + foo_v3.yaml → v2 리뷰 필요)
def _yaml_has_refs(yaml_path: Path) -> list[str]:
"""Python tools 또는 spec YAML에서 이 파일명을 참조하는지 확인."""
name = yaml_path.name
found = []
for py in (ROOT / "tools").glob("*.py"):
try:
if name in py.read_text(encoding="utf-8", errors="ignore"):
found.append(py.name)
except Exception:
pass
# YAML-to-YAML cross-reference (spec files, main YAML)
for y in list(ROOT.glob("*.yaml")) + list((ROOT / "spec").rglob("*.yaml")):
try:
if y != yaml_path and name in y.read_text(encoding="utf-8", errors="ignore"):
found.append(y.name)
except Exception:
pass
return found
# Both versions intentionally maintained with different test scopes
_COEXIST_BASES = {"formula_golden_cases"}
versioned: dict[str, list[tuple[int, Path]]] = {}
for y in sorted(spec.rglob("*.yaml")):
m = re.match(r'(.+?)_v(\d+)\.yaml$', y.name)
if m:
if m.group(1) in _COEXIST_BASES:
continue
base = (y.parent / m.group(1)).as_posix()
versioned.setdefault(base, []).append((int(m.group(2)), y))
for base, vers in versioned.items():
if len(vers) > 1:
vers.sort()
latest_ver, latest_path = vers[-1]
for ver, path in vers[:-1]:
py_refs = _yaml_has_refs(path)
if py_refs:
# 여전히 Python 도구에서 참조 → INFO만
issues.append({
"type": "YAML_SUPERSEDED_VERSION",
"severity": "INFO",
"file": path.relative_to(ROOT).as_posix(),
"superseded_by": latest_path.relative_to(ROOT).as_posix(),
"note": f"v{ver} superseded by v{latest_ver} but still referenced by {py_refs[:2]}",
})
else:
issues.append({
"type": "YAML_SUPERSEDED_VERSION",
"severity": "WARN",
"file": path.relative_to(ROOT).as_posix(),
"superseded_by": latest_path.relative_to(ROOT).as_posix(),
"note": f"v{ver} superseded by v{latest_ver} - no Python refs found, review then delete",
})
# 원본 + v1 공존 (예: horizon_allocation.yaml + horizon_allocation_v1.yaml)
for y in sorted(spec.rglob("*.yaml")):
m = re.match(r'(.+?)_v1\.yaml$', y.name)
if m:
base_name = m.group(1) + ".yaml"
base_path = y.parent / base_name
if base_path.exists():
issues.append({
"type": "YAML_ORIGINAL_AND_V1",
"severity": "INFO",
"file": base_path.relative_to(ROOT).as_posix(),
"superseded_by": y.relative_to(ROOT).as_posix(),
"note": "base + _v1 coexist - review if base is still needed",
})
return issues
# ──────────────────────────────────────────────────────────
# 4. MD 감사
# ──────────────────────────────────────────────────────────
def _audit_md() -> list[dict]:
issues: list[dict] = []
# spec/ 내 README.md는 전략/리스크 구조 설명용 — 내용 확인 권장
for md in (ROOT / "spec").rglob("README.md"):
issues.append({
"type": "MD_REVIEW",
"severity": "INFO",
"file": md.relative_to(ROOT).as_posix(),
"note": "spec README - verify alignment with current YAML structure",
})
# prompts/ — 버전 관리 누락 여부
for md in (ROOT / "prompts").glob("*.md"):
issues.append({
"type": "MD_PROMPT",
"severity": "INFO",
"file": md.relative_to(ROOT).as_posix(),
"note": "prompt file - verify sync with latest spec",
})
return issues
# ──────────────────────────────────────────────────────────
# 5. 메인
# ──────────────────────────────────────────────────────────
def main() -> int:
ap = argparse.ArgumentParser(description="Repo hygiene lint")
ap.add_argument("--json", default=None, help="결과를 JSON으로 저장")
ap.add_argument("--delete-safe", action="store_true", help="SAFE_DELETE 파일을 실제로 삭제")
args = ap.parse_args()
# 참조 집합
all_refs = (
_load_pkg_refs()
| _load_spec_refs()
| _load_py_refs()
| _load_ps1_refs()
| _load_import_refs()
| _load_path_refs()
)
# Python 파일 분류
py_stems = _all_py_stems()
keep, safe_delete, review = [], [], []
for stem in py_stems:
raw = stem.replace("__Temp__/", "")
cat = _categorize_py(raw, all_refs)
if cat == "KEEP":
keep.append(stem)
elif cat == "SAFE_DELETE":
safe_delete.append(stem)
else:
review.append(stem)
# YAML 감사
yaml_issues = _audit_yaml()
# MD 감사
md_issues = _audit_md()
# ── 출력 ──
print(f"\n{'='*60}")
print(f" REPO HYGIENE REPORT - {ROOT.name}")
print(f"{'='*60}")
print(f"\n[Python] KEEP={len(keep)} SAFE_DELETE={len(safe_delete)} REVIEW={len(review)}")
if safe_delete:
print("\n>> SAFE_DELETE (1-time fix/apply - delete recommended):")
for f in safe_delete:
print(f" {f}")
if review:
print("\n>> REVIEW (unreferenced - verify then delete):")
for f in review:
print(f" {f}")
if yaml_issues:
print(f"\n[YAML] {len(yaml_issues)} issues found:")
for iss in yaml_issues:
icon = "!" if iss["severity"] == "WARN" else "i"
print(f" {icon} [{iss['type']}] {iss.get('file', iss.get('files', ''))}")
print(f" -> {iss['note']}")
if md_issues:
print(f"\n[MD] {len(md_issues)} files for review:")
for iss in md_issues:
print(f" i {iss['file']} - {iss['note']}")
# delete safe files
deleted = []
if args.delete_safe and safe_delete:
print("\n[--delete-safe] Deleting safe files...")
for stem in safe_delete:
if stem.startswith("__Temp__/"):
path = ROOT / "Temp" / (stem.replace("__Temp__/", "") + ".py")
else:
path = ROOT / "tools" / (stem + ".py")
if path.exists():
path.unlink()
deleted.append(path.relative_to(ROOT).as_posix())
print(f" deleted: {path.relative_to(ROOT).as_posix()}")
# JSON output
result = {
"python_keep_count": len(keep),
"python_safe_delete_count": len(safe_delete),
"python_review_count": len(review),
"python_safe_delete": safe_delete,
"python_review": review,
"yaml_issues": yaml_issues,
"md_issues": md_issues,
"deleted": deleted,
"gate": "PASS" if not safe_delete and not review else "WARN",
}
if args.json:
out = Path(args.json)
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"\nSaved: {args.json}")
print(f"\n{'='*60}")
print(f"gate={result['gate']} py_delete={len(safe_delete)} py_review={len(review)} yaml_issues={len(yaml_issues)}")
return 0 if result["gate"] == "PASS" else 1
if __name__ == "__main__":
raise SystemExit(main())