#!/usr/bin/env python3 """Generate deterministic typed-contract projections for consumer documents.""" from __future__ import annotations import argparse import json from pathlib import Path import re import sys from typing import Any, Iterable, Mapping from fs_transaction import replace_many import typed_contract_check from typed_contract_check import Graph, Record DEFAULT_ROOT = Path(__file__).resolve().parents[2] DEFAULT_SCHEMA = Path("harness/source/typed-contracts.json") RESULT_SCHEMA = "contract-projection-result/v1" CLUSTER_HEADING = re.compile(r"^##\s+.*(?:Cluster|묶음).*$", re.MULTILINE | re.IGNORECASE) class ProjectionError(ValueError): def __init__(self, code: str, message: str, path: str = "") -> None: self.code = code self.path = path super().__init__(message) def _finding(code: str, path: str, message: str, line: int = 0) -> dict[str, Any]: return {"code": code, "path": path, "line": line, "message": message} def _marker_pair(marker: str) -> tuple[str, str]: return f"", f"" def _escape(value: str) -> str: return value.replace("|", "\\|").replace("\n", " ") def _link(graph: Graph, slug: str) -> str: matches = graph.by_slug.get(slug, ()) return f"[[{matches[0].relative[:-3]}]]" if len(matches) == 1 else slug def _records_by_id(graph: Graph) -> dict[str, Record]: grouped: dict[str, list[Record]] = {} for records in graph.records.values(): for record in records: grouped.setdefault(record.identifier, []).append(record) return {identifier: records[0] for identifier, records in grouped.items() if len(records) == 1} def _artifact_block(graph: Graph, marker: str, records: Iterable[Record]) -> str: rows = [ f"| `{item.identifier}@{item.revision}` | {_link(graph, item.owner)} | " f"{_link(graph, typed_contract_check._owner_slug(item.values.get('producer', '')))} | " f"`{_escape(item.values.get('schemaref', ''))}` |" for item in sorted(records, key=lambda value: value.identifier) ] start, end = _marker_pair(marker) return "\n".join( [ start, "### 가져온 artifact 계약", "", "| Artifact Ref | Owner | Producer | Schema Ref |", "|---|---|---|---|", *rows, end, ] ) def _contract_block(graph: Graph, marker: str, records: Iterable[Record]) -> str: rows = [ f"| `{item.identifier}@{item.revision}` | {_link(graph, item.owner)} | " f"{_escape(item.values.get('requiredeffect', ''))} | import 참조로 적용 |" for item in sorted(records, key=lambda value: value.identifier) ] start, end = _marker_pair(marker) return "\n".join( [ start, "## 가져온 프로젝트 계약", "", "| Ref | Owner | 요약 | Branch 적용 |", "|---|---|---|---|", *rows, end, ] ) def _delegation_block(graph: Graph, marker: str, records: Iterable[Record]) -> str: rows = [ f"| `{item.identifier}@{item.revision}` | {_link(graph, typed_contract_check._owner_slug(item.values.get('delegator', '')))} | " f"`{_escape(item.values.get('concernkey', ''))}` | {_escape(item.values.get('status', ''))} |" for item in sorted(records, key=lambda value: value.identifier) ] start, end = _marker_pair(marker) return "\n".join( [ start, "### 수신한 위임", "", "| Delegation Ref | From | Concern | Status |", "|---|---|---|---|", *rows, end, ] ) def _flow_block(graph: Graph, marker: str, records: Iterable[Record]) -> str: rows = [ f"| `{item.identifier}@{item.revision}` | {item.values.get('order', '')} | {_link(graph, item.owner)} | " f"{_escape(item.values.get('input', ''))} | {_escape(item.values.get('action', ''))} | " f"{_escape(item.values.get('output', ''))} |" for item in sorted(records, key=lambda value: (int(value.values.get("order", "0") or 0), value.identifier)) ] start, end = _marker_pair(marker) return "\n".join( [ start, "### 가져온 흐름 단계", "", "| Stage Ref | Order | Owner | Input | Action | Output |", "|---|---:|---|---|---|---|", *rows, end, ] ) def _replace_or_insert(text: str, marker: str, block: str, relative: str) -> tuple[str, bool]: start, end = _marker_pair(marker) starts = [item.start() for item in re.finditer(re.escape(start), text)] ends = [item.start() for item in re.finditer(re.escape(end), text)] if starts or ends: if len(starts) != 1 or len(ends) != 1 or starts[0] >= ends[0]: raise ProjectionError("INVALID_CONTRACT_PROJECTION_MARKERS", f"{marker} markers must be one ordered pair", relative) end_at = ends[0] + len(end) updated = text[: starts[0]] + block + text[end_at:] return updated, updated != text heading = CLUSTER_HEADING.search(text) if heading is not None: insert_at = text.find("\n", heading.end()) if insert_at < 0: return text.rstrip() + "\n\n" + block + "\n", True return text[: insert_at + 1] + "\n" + block + "\n" + text[insert_at + 1 :], True return text.rstrip() + "\n\n" + block + "\n", True def _drift_codes(kind: str) -> tuple[str, ...]: return { "artifacts": ("ARTIFACT_PROJECTION_DRIFT",), "contracts": ("GENERATED_CONTRACT_PROJECTION_DRIFT", "CONTRACT_PROJECTION_DRIFT"), "delegations": ("MISSING_RECEIVED_DELEGATION_PROJECTION",), "flow_stages": ("CONTRACT_PROJECTION_DRIFT",), }[kind] def plan(graph: Graph) -> dict[str, Any]: by_id = _records_by_id(graph) markers = graph.config["projection_markers"] updates: dict[Path, str] = {} findings: list[dict[str, Any]] = [] projection_count = 0 for document in graph.documents: imported: dict[str, list[Record]] = { "artifacts": [], "contracts": [], "flow_stages": [], } for identifier, revision in graph.imports[document.relative]: record = by_id.get(identifier) if record is not None and record.revision == revision and record.kind in imported: imported[record.kind].append(record) delegations = [ record for record in graph.records["delegations"] if record.values.get("status") == "accepted" and typed_contract_check._owner_slug(record.values.get("delegate", "")) == document.slug and (record.identifier, record.revision) in set(graph.accepts[document.relative]) ] desired: dict[str, list[Record]] = {**imported, "delegations": delegations} text = document.text for kind in ("artifacts", "contracts", "delegations", "flow_stages"): marker = markers[kind] start, end = _marker_pair(marker) has_marker = start in text or end in text rows = desired[kind] if not rows and not has_marker: continue projection_count += len(rows) if kind == "artifacts": block = _artifact_block(graph, marker, rows) elif kind == "contracts": block = _contract_block(graph, marker, rows) elif kind == "delegations": block = _delegation_block(graph, marker, rows) else: block = _flow_block(graph, marker, rows) try: updated, changed = _replace_or_insert(text, marker, block, document.relative) except ProjectionError as exc: findings.append(_finding(exc.code, exc.path, str(exc))) continue if changed: for code in _drift_codes(kind): findings.append(_finding(code, document.relative, f"{marker} projection differs from registry authority")) text = updated if text != document.text: updates[document.path] = text findings.sort(key=lambda item: (item["path"], item["code"], item["message"])) return { "updates": updates, "projection_count": projection_count, "findings": findings, } def build_updates( root: Path, schema_path: Path = DEFAULT_SCHEMA, ) -> tuple[dict[Path, str], dict[str, Any]]: graph, schema_findings = typed_contract_check.build_graph(root, schema_path) base = typed_contract_check.check(root, schema_path, include_projection=False) if base["status"] != "PASS": return {}, { "status": "FAIL", "findings": base["findings"], "projection_count": 0, "changed_paths": [], } result = plan(graph) return result["updates"], { "status": "DRIFT" if result["updates"] or result["findings"] else "CURRENT", "findings": [*schema_findings, *result["findings"]], "projection_count": result["projection_count"], "changed_paths": [path.relative_to(graph.root).as_posix() for path in sorted(result["updates"])], } def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--root", type=Path, default=DEFAULT_ROOT) parser.add_argument("--schema", type=Path, default=DEFAULT_SCHEMA) mode = parser.add_mutually_exclusive_group(required=True) mode.add_argument("--check", action="store_true") mode.add_argument("--write", action="store_true") args = parser.parse_args(argv) try: root = args.root.resolve(strict=True) updates, result = build_updates(root, args.schema) if args.write and result["status"] != "FAIL" and updates: # 문서 스캔은 legacy 경로(raw/…)로 하지만 쓰기는 정본으로 해야 한다. # resolve() 없이 쓰면 atomic replace 가 심링크를 실파일로 갈아치워 # canonical/legacy 사본이 갈라진다(layout_check INVALID_COMPATIBILITY_STUB). replace_many({path.resolve(): text.encode("utf-8") for path, text in updates.items()}) result["status"] = "UPDATED" result["findings"] = [] payload = {"schema_version": RESULT_SCHEMA, **result} exit_code = 0 if payload["status"] in {"CURRENT", "UPDATED"} else 1 except (typed_contract_check.TypedContractError, ProjectionError, OSError, UnicodeError, json.JSONDecodeError) as exc: payload = { "schema_version": RESULT_SCHEMA, "status": "ERROR", "errors": [ { "code": getattr(exc, "code", "CONTRACT_PROJECTION_ERROR"), "path": getattr(exc, "path", ""), "message": str(exc), } ], } exit_code = 2 json.dump(payload, sys.stdout, ensure_ascii=False, indent=2, sort_keys=True) sys.stdout.write("\n") return exit_code if __name__ == "__main__": raise SystemExit(main())