generated from coulomb/repo-seed
distribute/base.py: Artifact dataclass + Distributor protocol + idempotent BEGIN/END snippet markers (upsert_block replaces a pattern's block in place so re-distribution doesn't duplicate) + agnostic markdown body rendering from SolutionPattern fields. BaseDistributor honours per-flavor body/target hints. 8 new tests; suite 110/110. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
116 lines
4.2 KiB
Python
116 lines
4.2 KiB
Python
"""Distributor base — Artifact, the Distributor protocol, and idempotent markers
|
|
(PRD §6.4 FR-X1; T01).
|
|
|
|
A **distributor** turns one agnostic :class:`SolutionPattern` into a per-flavor
|
|
:class:`Artifact` (a target path + a snippet of content). Everything flavor-neutral
|
|
lives here; each flavor adapter (T02/T03) only supplies its target filename and may
|
|
override the rendered body using the pattern's ``rendering_hints``.
|
|
|
|
Snippets carry stable ``BEGIN/END`` markers keyed on the pattern id, so
|
|
re-distributing a pattern **updates its block in place** instead of duplicating it
|
|
— the property that lets Distribute run repeatedly (HITL) without drift.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from dataclasses import dataclass
|
|
from typing import Any, Optional, Protocol, runtime_checkable
|
|
|
|
from ..curate.schema import SolutionPattern
|
|
|
|
|
|
@dataclass
|
|
class Artifact:
|
|
"""A proposed per-flavor rendering of a pattern (FR-X1/FR-X3 — proposed, not applied)."""
|
|
|
|
flavor: str
|
|
target_path: str # repo-relative file the snippet belongs in (e.g. "CLAUDE.md")
|
|
pattern_id: str
|
|
content: str # the marker-wrapped snippet block
|
|
|
|
|
|
@runtime_checkable
|
|
class Distributor(Protocol):
|
|
flavor: str
|
|
target_path: str
|
|
|
|
def render(self, pattern: SolutionPattern) -> Artifact: ...
|
|
|
|
|
|
# --- idempotent snippet markers ---------------------------------------------
|
|
|
|
_MARK = "helix-forge pattern"
|
|
|
|
|
|
def begin_marker(pattern_id: str) -> str:
|
|
return f"<!-- BEGIN {_MARK}:{pattern_id} -->"
|
|
|
|
|
|
def end_marker(pattern_id: str) -> str:
|
|
return f"<!-- END {_MARK}:{pattern_id} -->"
|
|
|
|
|
|
def wrap_block(pattern_id: str, body: str, version: str = "") -> str:
|
|
"""Wrap a rendered body in stable BEGIN/END markers."""
|
|
ver = f" v{version}" if version else ""
|
|
return f"{begin_marker(pattern_id)}{ver}\n{body.strip()}\n{end_marker(pattern_id)}"
|
|
|
|
|
|
def upsert_block(doc_text: str, pattern_id: str, block: str) -> str:
|
|
"""Insert or replace a pattern's marked block within a document (idempotent)."""
|
|
pat = re.compile(
|
|
re.escape(begin_marker(pattern_id)) + r".*?" + re.escape(end_marker(pattern_id)),
|
|
re.DOTALL,
|
|
)
|
|
if pat.search(doc_text):
|
|
return pat.sub(block, doc_text)
|
|
sep = "" if doc_text.endswith("\n\n") or not doc_text else "\n\n"
|
|
return f"{doc_text}{sep}{block}\n"
|
|
|
|
|
|
# --- agnostic body rendering ------------------------------------------------
|
|
|
|
def render_markdown_body(pattern: SolutionPattern) -> str:
|
|
"""Default flavor-neutral snippet body from the agnostic pattern fields."""
|
|
label = "Avoid" if pattern.polarity == "problem" else "Prefer"
|
|
lines = [f"### {pattern.name}", "", pattern.problem.strip(), ""]
|
|
if pattern.resolutions:
|
|
lines.append(f"**{label}:**")
|
|
for r in pattern.resolutions:
|
|
detail = f" — {r.detail}" if r.detail else ""
|
|
lines.append(f"- {r.summary}{detail}")
|
|
for step in r.steps:
|
|
lines.append(f" - {step}")
|
|
return "\n".join(lines).strip()
|
|
|
|
|
|
def hint(pattern: SolutionPattern, flavor: str, key: str, default: Any = None) -> Any:
|
|
"""Read a per-flavor rendering hint, falling back to ``default``."""
|
|
return (pattern.rendering_hints.get(flavor) or {}).get(key, default)
|
|
|
|
|
|
class BaseDistributor:
|
|
"""Shared distributor: renders the agnostic body, honouring a ``body`` hint
|
|
override and a ``target`` hint, then wraps it in idempotent markers."""
|
|
|
|
flavor: str = ""
|
|
target_path: str = ""
|
|
|
|
def __init__(self, flavor: Optional[str] = None, target_path: Optional[str] = None) -> None:
|
|
if flavor is not None:
|
|
self.flavor = flavor
|
|
if target_path is not None:
|
|
self.target_path = target_path
|
|
|
|
def body(self, pattern: SolutionPattern) -> str:
|
|
return hint(pattern, self.flavor, "body") or render_markdown_body(pattern)
|
|
|
|
def target(self, pattern: SolutionPattern) -> str:
|
|
return hint(pattern, self.flavor, "target") or self.target_path
|
|
|
|
def render(self, pattern: SolutionPattern) -> Artifact:
|
|
block = wrap_block(pattern.id, self.body(pattern), pattern.version)
|
|
return Artifact(flavor=self.flavor, target_path=self.target(pattern),
|
|
pattern_id=pattern.id, content=block)
|