91 lines
3.6 KiB
Python
91 lines
3.6 KiB
Python
#!/usr/bin/env python3
|
|
"""Strict, dependency-free rendering for deterministic harness templates."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
from pathlib import Path
|
|
import re
|
|
from typing import Mapping
|
|
|
|
|
|
PLACEHOLDER_RE = re.compile(r"\{\{([a-z][a-z0-9_]*)\}\}")
|
|
REGION_START = "<!-- RUNTIME-TEMPLATE: branch-from-project:start -->"
|
|
REGION_END = "<!-- RUNTIME-TEMPLATE: branch-from-project:end -->"
|
|
GENERATED_START = "<!-- GENERATED: branch-contract:start -->"
|
|
GENERATED_END = "<!-- GENERATED: branch-contract:end -->"
|
|
|
|
|
|
class TemplateRenderError(ValueError):
|
|
def __init__(self, code: str, message: str) -> None:
|
|
self.code = code
|
|
super().__init__(message)
|
|
|
|
|
|
def extract_region(text: str, start: str, end: str) -> str:
|
|
"""Return one ordered marker region, excluding the marker lines."""
|
|
if text.count(start) != 1 or text.count(end) != 1:
|
|
raise TemplateRenderError("INVALID_TEMPLATE_REGION", f"expected one marker pair: {start} / {end}")
|
|
start_at = text.index(start) + len(start)
|
|
end_at = text.index(end, start_at)
|
|
if start_at >= end_at:
|
|
raise TemplateRenderError("INVALID_TEMPLATE_REGION", "template region markers are reversed")
|
|
return text[start_at:end_at].strip("\n") + "\n"
|
|
|
|
|
|
def generated_region(text: str) -> str:
|
|
"""Return the runtime-owned branch contract including its marker lines."""
|
|
if text.count(GENERATED_START) != 1 or text.count(GENERATED_END) != 1:
|
|
raise TemplateRenderError("GENERATED_REGION_DRIFT", "generated branch-contract markers must occur exactly once")
|
|
start_at = text.index(GENERATED_START)
|
|
end_at = text.index(GENERATED_END, start_at) + len(GENERATED_END)
|
|
if start_at >= end_at:
|
|
raise TemplateRenderError("GENERATED_REGION_DRIFT", "generated branch-contract markers are reversed")
|
|
return text[start_at:end_at]
|
|
|
|
|
|
def generated_sha256(text: str) -> str:
|
|
return hashlib.sha256(generated_region(text).encode("utf-8")).hexdigest()
|
|
|
|
|
|
def render(text: str, values: Mapping[str, str], *, allowed: set[str]) -> str:
|
|
"""Render only allowlisted placeholders and reject missing or extra input."""
|
|
discovered = set(PLACEHOLDER_RE.findall(text))
|
|
unknown = sorted(discovered - allowed)
|
|
if unknown:
|
|
raise TemplateRenderError("UNKNOWN_PLACEHOLDER", f"template contains non-allowlisted placeholders: {unknown}")
|
|
missing = sorted(discovered - set(values))
|
|
if missing:
|
|
raise TemplateRenderError("UNRESOLVED_PLACEHOLDER", f"placeholder values are missing: {missing}")
|
|
extra = sorted(set(values) - allowed)
|
|
if extra:
|
|
raise TemplateRenderError("UNEXPECTED_TEMPLATE_VALUE", f"values were supplied for unknown placeholders: {extra}")
|
|
|
|
rendered = PLACEHOLDER_RE.sub(lambda match: values[match.group(1)], text)
|
|
unresolved = sorted(set(PLACEHOLDER_RE.findall(rendered)))
|
|
if unresolved:
|
|
raise TemplateRenderError("UNRESOLVED_PLACEHOLDER", f"unresolved placeholders remain: {unresolved}")
|
|
return rendered
|
|
|
|
|
|
def render_branch_note(template_path: Path, values: Mapping[str, str]) -> str:
|
|
"""Render the deterministic branch-from-project region of the branch template."""
|
|
template = template_path.read_text(encoding="utf-8")
|
|
body = extract_region(template, REGION_START, REGION_END)
|
|
allowed = {
|
|
"branch_slug",
|
|
"branch_id",
|
|
"project",
|
|
"project_parent_link",
|
|
"work_item",
|
|
"inherits_yaml",
|
|
"depends_on_yaml",
|
|
"created",
|
|
"contract_packet_sha256",
|
|
"project_revision",
|
|
"completion",
|
|
"inherited_rows",
|
|
"dependency_display",
|
|
}
|
|
return render(body, values, allowed=allowed)
|