Files
company-haness/.claude/hooks/lint_company_context.py
T

173 lines
7.9 KiB
Python

#!/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:]))