362 lines
15 KiB
Python
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())
|