Files
document-haness/scripts/check_release_sync.py
T

362 lines
15 KiB
Python

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