Files
llm-wiki/harness/runtime/contract_projection.py
T

288 lines
11 KiB
Python

#!/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"<!-- GENERATED: {marker}:start -->", f"<!-- GENERATED: {marker}:end -->"
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())