173 lines
7.9 KiB
Python
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:]))
|