#!/usr/bin/env python3 """명령을 실제로 돌려 원문과 실행 메타를 함께 적립한다. `final/evidence/raw/` 는 정본이고 `final/evidence/meta/` 는 그 실행의 command·cwd·executedAt·exitCode·revision 이다. 둘을 사람이 따로 적으면 갈라진다 — `verify-project-layout.py` 가 「raw 는 있는데 meta 가 없다」로 세는 자리가 그것이다. 이 도구는 **종료 코드를 손으로 적을 수 없게 만든다.** 명령을 여기서 돌리고, 그 프로세스의 반환값을 그대로 meta 에 적는다. 돌리지 않은 검증을 완료로 적는 경로가 없어야 한다. 기존 경로를 지우지 않는다 — 손으로 만든 raw/meta 도 그대로 유효하고, 이 도구는 선택적으로 쓴다. python3 scripts/capture-evidence.py <프로젝트> <증거 id> -- <명령...> python3 scripts/capture-evidence.py <프로젝트> <증거 id> --cwd <경로> \ --proves "<이 출력이 뒷받침하는 것>" --does-not-prove "<뒷받침하지 못하는 것>" -- <명령...> """ from __future__ import annotations import argparse import datetime import hashlib import json import os import subprocess import sys ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) def _revision(cwd: str) -> str | None: """그 작업 디렉터리 저장소의 HEAD. 저장소가 아니면 None 이다.""" try: out = subprocess.run(["git", "rev-parse", "HEAD"], cwd=cwd, capture_output=True, text=True, timeout=15) except (OSError, subprocess.SubprocessError): return None return out.stdout.strip() if out.returncode == 0 else None def _dirty(cwd: str) -> bool | None: """작업 트리에 커밋 안 된 변경이 있나. 있으면 revision 이 출력을 설명하지 못한다.""" try: out = subprocess.run(["git", "status", "--porcelain"], cwd=cwd, capture_output=True, text=True, timeout=15) except (OSError, subprocess.SubprocessError): return None return bool(out.stdout.strip()) if out.returncode == 0 else None def capture(project: str, eid: str, command: list[str], cwd: str, proves: str, does_not_prove: str, kind: str, timeout: int, subdir: str) -> int: base = os.path.join(ROOT, "docs", project, "final", "evidence") raw_dir = os.path.join(base, "raw", subdir) if subdir else os.path.join(base, "raw") meta_dir = os.path.join(base, "meta") os.makedirs(raw_dir, exist_ok=True) os.makedirs(meta_dir, exist_ok=True) started = datetime.datetime.now().astimezone() try: proc = subprocess.run(command, cwd=cwd, capture_output=True, text=True, timeout=timeout) exit_code, out = proc.returncode, proc.stdout + proc.stderr except subprocess.TimeoutExpired as e: exit_code = 124 out = (e.stdout or "") + (e.stderr or "") + f"\n[timeout {timeout}s]\n" except OSError as e: print(f"명령을 실행하지 못했다: {e}", file=sys.stderr) return 2 raw_rel = os.path.join("raw", subdir, f"{eid}.txt") if subdir else os.path.join("raw", f"{eid}.txt") raw_path = os.path.join(base, raw_rel) with open(raw_path, "w", encoding="utf-8") as fh: fh.write(out) meta = { "id": eid, "kind": kind, "sourceRevision": _revision(cwd), "sourceDirty": _dirty(cwd), "executedAt": started.isoformat(timespec="seconds"), "executedAtSource": "이 도구가 명령을 실행한 시각", "command": " ".join(command), "cwd": os.path.relpath(cwd, ROOT) if cwd.startswith(ROOT) else cwd, "exitCode": exit_code, "exitCodeSource": "실행한 프로세스의 반환값. 손으로 적지 않는다", "rawPath": f"evidence/{raw_rel}", "presentationPath": None, "proves": proves, "doesNotProve": does_not_prove, "sha256": hashlib.sha256(out.encode("utf-8")).hexdigest(), "bytes": len(out.encode("utf-8")), } meta_path = os.path.join(meta_dir, f"{eid}.json") tmp = meta_path + ".tmp" with open(tmp, "w", encoding="utf-8") as fh: json.dump(meta, fh, ensure_ascii=False, indent=2) fh.write("\n") os.replace(tmp, meta_path) print(f"{os.path.relpath(raw_path, ROOT)} exit={exit_code} {meta['bytes']}B") print(f"{os.path.relpath(meta_path, ROOT)}") return 0 def main() -> int: ap = argparse.ArgumentParser( description="명령을 돌려 raw 원문과 실행 메타를 함께 적립한다.") ap.add_argument("project") ap.add_argument("evidence_id") ap.add_argument("--cwd", default=ROOT) ap.add_argument("--subdir", default="", help="raw/ 아래 하위 폴더") ap.add_argument("--kind", default="terminal", choices=["terminal", "browser", "query-plan", "benchmark", "other"]) ap.add_argument("--proves", default="", help="이 출력이 뒷받침하는 것 (경계까지)") ap.add_argument("--does-not-prove", default="", help="이 출력이 뒷받침하지 못하는 것") ap.add_argument("--timeout", type=int, default=600) # `--` 앞뒤를 먼저 가른다. argparse.REMAINDER 에 맡기면 옵션이 명령으로 딸려 간다 argv = sys.argv[1:] if "-h" in argv or "--help" in argv: ap.parse_args(["--help"]) if "--" not in argv: ap.error("돌릴 명령이 없다. `-- <명령...>` 으로 준다") cut = argv.index("--") args = ap.parse_args(argv[:cut]) command = argv[cut + 1:] if not command: ap.error("돌릴 명령이 없다. `-- <명령...>` 으로 준다") return capture(args.project, args.evidence_id, command, os.path.abspath(args.cwd), args.proves, args.does_not_prove, args.kind, args.timeout, args.subdir) if __name__ == "__main__": raise SystemExit(main())