#!/usr/bin/env python3 """lint_company_context.py — company-context.yaml 내부 정합성 린터(§9.2). 구조·참조·권한·상태 = Hard Fail(exit 1). 의미상 오분류 가능성 = Warning(exit 0, stderr). 공식 파일: status ∈ {template, provisional, operating}, candidate-status 금지. candidate 파일: candidate-status: bootstrap 허용. CLI: lint_company_context.py [--candidate] [PATH] # 기본 PATH = org-os/01-company/company-context.yaml lint_company_context.py --migrate [PATH] # 구 스키마/어휘 1회 변환(Task 7) """ import os, sys ROOT = os.environ.get("CLAUDE_PROJECT_DIR") or os.path.dirname( os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) DEFAULT_PATH = os.path.join(ROOT, "org-os", "01-company", "company-context.yaml") OFFICIAL_STATUS = {"template", "provisional", "operating"} def _load(path): import yaml with open(path, encoding="utf-8") as fh: return yaml.safe_load(fh) or {} def lint_file(path, is_candidate=False): """(hard_fails, warnings) 반환. 예외 없이 파싱 실패도 hard_fail 로.""" hard, warn = [], [] try: doc = _load(path) except Exception as e: return ([f"파싱 실패: {e}"], []) if not isinstance(doc, dict): return ([f"'{path}' 최상위 구조가 매핑(dict)이 아님"], []) status = str(doc.get("status", "")).strip().lower() # --- 상태·candidate 정합(Hard Fail) --- if is_candidate: # candidate 는 최종 목표 status(3-상태) + candidate-status: bootstrap if status not in OFFICIAL_STATUS: hard.append(f"candidate status '{status}' 는 {sorted(OFFICIAL_STATUS)} 밖") if str(doc.get("candidate-status", "")).strip().lower() != "bootstrap": hard.append("candidate 파일은 candidate-status: bootstrap 필요") else: if status == "demo" or status == "populated": warn.append(f"구 어휘 status='{status}' — --migrate 로 변환 필요(deprecated)") elif status not in OFFICIAL_STATUS: hard.append(f"공식 status '{status}' 는 {sorted(OFFICIAL_STATUS)} 밖(특히 'bootstrap'은 공식 status 아님)") if "candidate-status" in doc: hard.append("공식 파일에 candidate-status 필드가 남아있음(commit 시 제거돼야 함)") # --- 항목 구조 정합(Hard Fail) --- company = doc.get("company") or {} facts = company.get("facts") or [] decs = company.get("strategic-decisions") or [] hyps = company.get("hypotheses") or [] # 항목 id 수집(중복·hypothesis-id-as-fact 검사) seen = {} def _reg(idv, block): if not idv: hard.append(f"{block} 항목 id 누락") return if idv in seen: hard.append(f"중복 id '{idv}' ({seen[idv]} 와 {block})") else: seen[idv] = block def _evidence_paths_exist(items, block): for it in items: for ev in (it.get("provenance") or it.get("supporting-evidence") or it.get("evidence") or []): src = str((ev or {}).get("source-uri", "")).strip() if not src or src.lower().startswith("http"): continue # glob(*) 은 검사 생략(경로 패턴). 구체 경로만 실존 확인. if "*" in src: continue ap = src if os.path.isabs(src) else os.path.join(ROOT, src) if not os.path.exists(ap): hard.append(f"{block} 항목 evidence 경로 미존재: {src}") for it in facts: _reg(it.get("id"), "fact") if not (it.get("provenance")): hard.append(f"fact '{it.get('id')}' provenance 누락") for it in decs: _reg(it.get("id"), "decision") for k in ("accepted-by", "accepted-at", "source-decision-id"): if not it.get(k): hard.append(f"decision '{it.get('id')}' {k} 누락") hyp_ids = set() for it in hyps: _reg(it.get("id"), "hypothesis") hyp_ids.add(it.get("id")) for k in ("validation-status", "confidence", "falsification-criteria"): if not it.get(k): hard.append(f"hypothesis '{it.get('id')}' {k} 누락") # hypothesis-id 를 fact/decision 근거(source-decision-id 또는 evidence source)에 쓰면 hard for it in decs: if it.get("source-decision-id") in hyp_ids and it.get("source-decision-id"): hard.append(f"decision '{it.get('id')}' 가 hypothesis id 를 source-decision-id 로 사용") _evidence_paths_exist(facts, "fact") _evidence_paths_exist(decs, "decision") _evidence_paths_exist(hyps, "hypothesis") # 상태-권한 정합 if status == "provisional" and not is_candidate: if not any(d.get("accepted-by") for d in decs): hard.append("status=provisional 인데 human 승인(accepted-by) strategic-decision 이 하나도 없음") if status == "operating" and not is_candidate: # operating 승격은 별도 승격 이벤트(acceptance) 를 요구 — validation-state.stage 로 근사 검사. if str((company.get("validation-state") or {}).get("stage", "")).lower() != "operating": hard.append("status=operating 인데 validation-state.stage != operating(승격 근거 부재)") # --- 의미 오분류 Warning 규칙 --- _ESTIMATIVE = ("예상", "일 것", "추정", "아마", "듯", "가능성이 높") for it in facts: s = str(it.get("statement", "")) if any(t in s for t in _ESTIMATIVE): warn.append(f"fact '{it.get('id')}' 문장이 추정 표현 포함 — hypothesis 여야 할 수 있음: {s[:40]}") if any(t in s for t in ("시장 규모", "WTP", "지불 의사", "market size")): warn.append(f"fact '{it.get('id')}' 가 시장/WTP 주장 — hypothesis 로 분류 검토") for it in hyps: if str(it.get("validation-status", "")).lower() == "untested" and not it.get("evidence"): warn.append(f"hypothesis '{it.get('id')}' 미검증+근거 없음 — 장기 방치 주의") return (hard, warn) def main(argv): args = list(argv) is_candidate = "--candidate" in args if is_candidate: args.remove("--candidate") if "--migrate" in args: args.remove("--migrate") path = args[0] if args else DEFAULT_PATH return migrate(path) path = args[0] if args else DEFAULT_PATH hard, warn = lint_file(path, is_candidate=is_candidate) for w in warn: sys.stderr.write(f"[lint_company_context] WARN: {w}\n") for h in hard: sys.stderr.write(f"[lint_company_context] FAIL: {h}\n") if hard: return 1 print(f"[lint_company_context] OK ({path}) — hard-fails 0, warnings {len(warn)}") return 0 def migrate(path): """구 스키마/어휘를 schema-version 2 + 3-상태로 1회 변환. 자유서술 company 는 보존.""" import yaml try: doc = _load(path) except Exception as e: sys.stderr.write(f"[lint_company_context] migrate 파싱 실패: {e}\n"); return 1 st = str(doc.get("status", "")).strip().lower() doc["status"] = {"demo": "template", "populated": "operating"}.get(st, st if st in OFFICIAL_STATUS else "template") doc["schema-version"] = 2 comp = doc.get("company") if not isinstance(comp, dict): comp = {} # 자유서술 키(name/mission/constraints 등)는 보존하고 골격 블록만 보강 comp.setdefault("facts", []) comp.setdefault("strategic-decisions", []) comp.setdefault("hypotheses", []) comp.setdefault("validation-state", {"stage": "pre-traction", "validated": [], "open": [], "refuted": []}) doc["company"] = comp doc.pop("candidate-status", None) with open(path, "w", encoding="utf-8") as fh: yaml.safe_dump(doc, fh, allow_unicode=True, sort_keys=False) print(f"[lint_company_context] migrated -> status={doc['status']}, schema-version=2") return 0 if __name__ == "__main__": sys.exit(main(sys.argv[1:]))