288 lines
11 KiB
Python
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())
|