init: llm-wiki-haness 하네스 설계
This commit is contained in:
@@ -0,0 +1,287 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user