#!/usr/bin/env python3 """Fail when release, path, command, and runtime contracts drift apart.""" from __future__ import annotations import argparse import ast import json import re import subprocess import sys from pathlib import Path from typing import Any SEMVER_RE = re.compile( r"^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)" r"(?:-((?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)" r"(?:\.(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?" r"(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$" ) RUNTIME_TOOLS = ( "init_run.py", "lint_document.py", "update_run.py", "verify_run.py", "split_document.py", "reassemble_document.py", "build_quick_rules.py", ) SCHEMA_BY_ARTIFACT = { "00_run.json": "run.schema.json", "01_sources.json": "sources.schema.json", "02_reader_contract.json": "reader-contract.schema.json", "03_evidence_map.json": "evidence-map.schema.json", "04_logic_map.json": "logic-map.schema.json", "05_term_ledger.json": "term-ledger.schema.json", "08_logic_review.json": "review.schema.json", "08_reader_review.json": "review.schema.json", "08_lint.json": "lint-report.schema.json", "09_final_report.json": "final-report.schema.json", } def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="릴리스와 정본 경로 계약의 동기화를 검사합니다.") parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parent.parent) return parser.parse_args() def load_json(path: Path, errors: list[str]) -> dict[str, Any]: try: value = json.loads(path.read_text(encoding="utf-8")) except (OSError, UnicodeError, json.JSONDecodeError) as exc: errors.append(f"JSON을 읽을 수 없음: {path}: {exc}") return {} if not isinstance(value, dict): errors.append(f"JSON 최상위 값이 객체가 아님: {path}") return {} return value def require_file(root: Path, relative: str, errors: list[str]) -> Path: path = root / relative if not path.is_file(): errors.append(f"필수 파일 누락: {relative}") return path def nested(value: dict[str, Any], *keys: str) -> Any: current: Any = value for key in keys: if not isinstance(current, dict): return None current = current.get(key) return current def validate_version(root: Path, errors: list[str]) -> None: version_path = require_file(root, "VERSION", errors) try: version = version_path.read_text(encoding="utf-8").strip() except (OSError, UnicodeError): return if not SEMVER_RE.fullmatch(version): errors.append(f"VERSION이 SemVer가 아님: {version!r}") manifests = { ".claude-plugin/plugin.json": [("version",)], ".claude-plugin/marketplace.json": [("metadata", "version")], "gemini-extension.json": [("version",)], } for relative, paths in manifests.items(): value = load_json(require_file(root, relative, errors), errors) for path in paths: current: Any = value for key in path: if isinstance(current, list) and key.isdigit(): index = int(key) current = current[index] if index < len(current) else None elif isinstance(current, dict): current = current.get(key) else: current = None if current != version: errors.append(f"버전 불일치: {relative}:{'.'.join(path)}={current!r}, VERSION={version!r}") marketplace_path = require_file(root, ".claude-plugin/marketplace.json", errors) marketplace = load_json(marketplace_path, errors) plugins = marketplace.get("plugins") matches = [ item for item in plugins if isinstance(item, dict) and item.get("name") == "technical-doc-flow" ] if isinstance(plugins, list) else [] if len(matches) != 1: errors.append("marketplace에는 technical-doc-flow plugin 항목이 정확히 하나여야 함") elif matches[0].get("version") != version: errors.append( "버전 불일치: .claude-plugin/marketplace.json의 technical-doc-flow plugin=" f"{matches[0].get('version')!r}, VERSION={version!r}" ) def validate_manifest_paths(root: Path, errors: list[str]) -> None: harness = load_json(require_file(root, "harness.json", errors), errors) expected = { "version_file": "VERSION", "canonical_skill": "skills/technical-doc-flow/SKILL.md", "quality_rules": "skills/technical-doc-flow/config/quality-rules.json", "runtime_contract": "skills/technical-doc-flow/config/runtime-contract.json", "schemas": "skills/technical-doc-flow/schemas", "runtime_scripts": "skills/technical-doc-flow/scripts", "quick_rules": "skills/technical-doc-flow/references/quick-rules.md", } for key, relative in expected.items(): if harness.get(key) != relative: errors.append(f"harness.json {key} 불일치: {harness.get(key)!r}") if not (root / relative).exists(): errors.append(f"harness.json 대상 누락: {relative}") plugin = load_json(require_file(root, ".claude-plugin/plugin.json", errors), errors) if plugin.get("skills") != ["./skills/"]: errors.append("Claude plugin skills 경로는 ['./skills/']여야 함") gemini = load_json(require_file(root, "gemini-extension.json", errors), errors) if gemini.get("contextFileName") != "GEMINI.md": errors.append("Gemini manifest contextFileName은 GEMINI.md여야 함") def validate_runtime(root: Path, errors: list[str]) -> None: skill = root / "skills" / "technical-doc-flow" helper = skill / "scripts" / "harness_common.py" if not helper.is_file(): errors.append("canonical runtime helper 누락: skills/technical-doc-flow/scripts/harness_common.py") root_entry = root / "scripts" / "_runtime_entry.py" if not root_entry.is_file(): errors.append("root runtime entry helper 누락: scripts/_runtime_entry.py") for name in RUNTIME_TOOLS: canonical = skill / "scripts" / name wrapper = root / "scripts" / name if not canonical.is_file(): errors.append(f"canonical runtime 누락: {canonical.relative_to(root)}") if not wrapper.is_file(): errors.append(f"root wrapper 누락: {wrapper.relative_to(root)}") else: try: tree = ast.parse(wrapper.read_text(encoding="utf-8"), filename=str(wrapper)) except (OSError, UnicodeError, SyntaxError) as exc: errors.append(f"root wrapper를 파싱할 수 없음: {wrapper.relative_to(root)}: {exc}") continue body = tree.body import_ok = ( len(body) == 2 and isinstance(body[0], ast.ImportFrom) and body[0].module == "_runtime_entry" and body[0].level == 0 and len(body[0].names) == 1 and body[0].names[0].name == "execute" and body[0].names[0].asname is None ) call = body[1].value if len(body) == 2 and isinstance(body[1], ast.Expr) else None call_ok = ( isinstance(call, ast.Call) and isinstance(call.func, ast.Name) and call.func.id == "execute" and len(call.args) == 1 and not call.keywords and isinstance(call.args[0], ast.Constant) and call.args[0].value == name ) if not import_ok or not call_ok: errors.append(f"root wrapper가 exact canonical execute template이 아님: {wrapper.relative_to(root)}") try: smoke = subprocess.run( [sys.executable, str(wrapper), "--help"], cwd=root, stdin=subprocess.DEVNULL, capture_output=True, text=True, timeout=10, check=False, ) except (OSError, subprocess.TimeoutExpired) as exc: errors.append(f"root wrapper smoke 실행 실패: {wrapper.relative_to(root)}: {exc}") else: if smoke.returncode != 0 or "usage:" not in smoke.stdout.casefold(): errors.append( f"root wrapper --help smoke 실패: {wrapper.relative_to(root)} " f"(exit={smoke.returncode})" ) contract = load_json(skill / "config" / "runtime-contract.json", errors) try: script_path = str(skill / "scripts") if script_path not in sys.path: sys.path.insert(0, script_path) from harness_common import validate_with_schema validate_with_schema( contract, "runtime-contract.schema.json", "runtime contract", skill / "schemas", ) except (ImportError, OSError, RuntimeError, ValueError, KeyError) as exc: errors.append(f"runtime-contract.schema.json 검증 실패: {exc}") agents = contract.get("agents") actual_agents = sorted(path.stem for path in (root / "agents").glob("doc-*.md")) if not isinstance(agents, list) or sorted(agents) != actual_agents: errors.append(f"runtime agent 목록 불일치: contract={agents!r}, files={actual_agents!r}") artifacts = contract.get("artifacts") if not isinstance(artifacts, dict): errors.append("runtime contract artifacts 객체 누락") return names: set[str] = set() for key in ("always", "light", "standard", "deep", "review_mode"): values = artifacts.get(key) if not isinstance(values, list): errors.append(f"runtime contract artifacts.{key} 배열 누락") continue names.update(item for item in values if isinstance(item, str)) schema_dir = skill / "schemas" allowed_artifacts = set(SCHEMA_BY_ARTIFACT) | {"01_input.md", "07_draft.md", "final.md"} unknown_artifacts = names - allowed_artifacts for artifact in sorted(unknown_artifacts): errors.append(f"schema/verifier 매핑이 없는 runtime artifact: {artifact}") json_artifacts: set[str] = set() for artifact in sorted(names): schema = SCHEMA_BY_ARTIFACT.get(artifact) if schema: json_artifacts.add(artifact) if not (schema_dir / schema).is_file(): errors.append(f"artifact schema 누락: {artifact} -> {schema}") verifier_path = skill / "scripts" / "verify_run.py" if verifier_path.is_file(): try: verifier_tree = ast.parse(verifier_path.read_text(encoding="utf-8")) declared: set[str] | None = None declared_schemas: dict[str, str] | None = None for node in verifier_tree.body: if not isinstance(node, ast.Assign): continue names_in_assignment = { target.id for target in node.targets if isinstance(target, ast.Name) } if "JSON_ARTIFACTS" in names_in_assignment: literal = ast.literal_eval(node.value) declared = set(literal) if isinstance(literal, (set, list, tuple)) else None if "ARTIFACT_SCHEMAS" in names_in_assignment: literal = ast.literal_eval(node.value) declared_schemas = literal if isinstance(literal, dict) else None if declared is None: errors.append("verify_run.py JSON_ARTIFACTS 선언을 정적으로 확인할 수 없음") elif not json_artifacts <= declared: errors.append( "verifier JSON artifact 목록 누락: " + ", ".join(sorted(json_artifacts - declared)) ) expected_schemas = { artifact: SCHEMA_BY_ARTIFACT[artifact] for artifact in json_artifacts } if declared_schemas is None: errors.append("verify_run.py ARTIFACT_SCHEMAS 선언을 정적으로 확인할 수 없음") elif declared_schemas != expected_schemas: errors.append( "verifier artifact schema mapping 불일치: " f"expected={expected_schemas!r}, actual={declared_schemas!r}" ) except (OSError, UnicodeError, SyntaxError, ValueError) as exc: errors.append(f"verify_run.py JSON_ARTIFACTS를 검사할 수 없음: {exc}") def validate_skill_references(root: Path, errors: list[str]) -> None: skill_dir = root / "skills" / "technical-doc-flow" skill_path = require_file(root, "skills/technical-doc-flow/SKILL.md", errors) try: text = skill_path.read_text(encoding="utf-8") except (OSError, UnicodeError): return for relative in ( "references/quick-rules.md", "references/artifact-contracts.md", "references/logic-flow.md", "references/reader-contract.md", "references/terminology-policy.md", "references/section-playbook.md", "references/evidence-policy.md", "references/quality-rubric.md", ): if relative not in text and Path(relative).name not in text: errors.append(f"SKILL.md가 reference를 선언하지 않음: {relative}") if not (skill_dir / relative).is_file(): errors.append(f"SKILL reference 누락: {relative}") for name in ("init_run.py", "lint_document.py", "verify_run.py", "split_document.py", "reassemble_document.py"): if name not in text: errors.append(f"SKILL.md가 runtime 도구를 참조하지 않음: {name}") def validate_gemini_commands(root: Path, errors: list[str]) -> None: for relative in ("commands/technical-doc.toml", "commands/technical-doc-review.toml"): path = require_file(root, relative, errors) try: text = path.read_text(encoding="utf-8") except (OSError, UnicodeError): continue if "technical-doc-flow" not in text or "활성화" not in text: errors.append(f"Gemini command가 등록 skill 활성화를 요구하지 않음: {relative}") if "skills/technical-doc-flow/SKILL.md" in text or "${extensionPath}" in text: errors.append(f"Gemini command에 cwd 의존 경로가 있음: {relative}") if "{{args}}" not in text: errors.append(f"Gemini command가 사용자 args를 전달하지 않음: {relative}") def main() -> int: args = parse_args() root = args.root.expanduser().resolve() errors: list[str] = [] if not root.is_dir(): print(f"input error: 저장소 루트가 없습니다: {root}", file=sys.stderr) return 2 validate_version(root, errors) validate_manifest_paths(root, errors) validate_runtime(root, errors) validate_skill_references(root, errors) validate_gemini_commands(root, errors) for relative in ("install.sh", "uninstall.sh", "update.sh"): path = require_file(root, relative, errors) if path.exists() and not path.stat().st_mode & 0o111: errors.append(f"실행 권한 누락: {relative}") if errors: for error in errors: print(f"FAIL: {error}", file=sys.stderr) return 1 print("release and path contracts are in sync") return 0 if __name__ == "__main__": raise SystemExit(main())