feat(registry): the set builder per ADR-002/005/008

- TemplateSet: one strict handlebars registry per template set; strict
  mode forced at construction, unparameterizable; enforcement test pins
  missing-variable renders as Error::Render, never empty output
- EscapePolicy::{Html, NoOp}: explicit per-set escaper choice; Html
  leaves the substrate default html_escape, NoOp registers
  handlebars::no_escape; both postures pinned by golden render tests
- typed fallible registration of templates and partials; malformed
  text is Error::Registration, never a panic; failed registration
  leaves bookkeeping untouched
- set-level bookkeeping of template vs partial names (decision 1,
  flagged for the gate): the substrate's register_partial is identical
  to template registration, so the render-name rule cannot delegate to
  it; TemplateSet::is_template exposes the tracking for the
  w1-render-entry rejection
- per-set helper registration seam; zero custom helpers by default,
  pinned by test (unknown helper is a typed render error)
- naming conveventions enforced by tests only, not at registration
  (decision 2, flagged for the gate): consumer-facing engine API
  stays naming-neutral per ADR-001/008

Verification: cargo test (15 pass), clippy -D warnings, fmt --check,
wasm32-unknown-unknown release build

Verification: cargo test (15 pass), clippy --all-targets -D warnings, cargo fmt --check, wasm32-unknown-unknown --release
This commit is contained in:
glm-5.3-flash committed 2026-11-06 19:59:19 +00:00
1 parent 02037c4f10
commit 22f32e6fc5
4 files changed
+397 -8

No files matched your search

+30
View File
@@ -0,0 +1,30 @@
//! The escape postures a template set is built with (ADR-005).
/// The escape policy of a template set, chosen explicitly at
/// construction and applied registry-wide. It is a property of the set,
/// not of individual templates (ADR-005): the one-registry-per-set rule
/// (ADR-002 decision 6) is what makes the choice enforceable.
///
/// The substrate applies HTML entity escaping to `{{expr}}` output by
/// default; `{{& }}` and triple-brace output skip escaping in either
/// policy.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum EscapePolicy {
/// handlebars' built-in HTML escaper stays on (ADR-005 posture 1,
/// ssr-shaped sets). Interpolated user data is entity-escaped;
/// template authors opt out per expression with `{{& }}` only for
/// deliberately-trusted markup.
///
/// Mechanism: the substrate default (`html_escape`) is left
/// untouched.
Html,
/// The no-op identity escaper (ADR-005 posture 2, code target sets).
/// Interpolated text passes through verbatim; codegen output is
/// escaped at the source (literals properly quoted by the builder),
/// never by entity mangling.
///
/// Mechanism: the substrate's [`handlebars::no_escape`] is
/// registered as the registry-wide escape function.
NoOp,
}
+4
View File
@@ -1,5 +1,9 @@
//! alkgen: text-generation engine on handlebars-rust templates over serde-serializable contexts.
mod error;
mod escape;
mod registry;
pub use error::Error;
pub use escape::EscapePolicy;
pub use registry::TemplateSet;
+275
View File
@@ -0,0 +1,275 @@
//! Registry construction: one strict registry per template set
//! (core.md §Registry construction).
use std::collections::HashSet;
use handlebars::{no_escape, Handlebars, HelperDef};
use crate::error::Error;
use crate::escape::EscapePolicy;
/// A template set: the one handlebars registry scoped to that set
/// (ADR-002 decision 6), with strict mode forced on and the set's
/// escape policy applied.
///
/// The set tracks at set level which registered names are templates
/// (render targets) and which are partials (composition units), because
/// the substrate's registry is a single namespace where a registered
/// partial is indistinguishable from a template. The render entry point
/// is what resolves a name against this bookkeeping (core.md §Render
/// entry point: the name argument resolves to a registered template,
/// never a bare partial).
///
/// Externally supplied text registers through the same machinery as
/// engine-owned assets (ADR-008 machinery equality): every registration
/// method takes `&str`, so templates arrive as inline literals or
/// caller-owned text; there is no filesystem path by construction.
pub struct TemplateSet {
handlebars: Handlebars<'static>,
template_names: HashSet<String>,
partial_names: HashSet<String>,
}
impl TemplateSet {
/// Construct the registry for one template set.
///
/// Strict mode is forced on here and no API exposes it as a
/// parameter: a missing context variable renders as a typed error,
/// never silent empty output (ADR-002 decision 2, AGENTS.md
/// convention 6). The [`EscapePolicy`] is the caller's explicit
/// per-set choice (ADR-005); no escaper can be swapped afterward
/// through this type.
pub fn new(policy: EscapePolicy) -> Self {
let mut handlebars = Handlebars::new();
handlebars.set_strict_mode(true);
if policy == EscapePolicy::NoOp {
handlebars.register_escape_fn(no_escape);
}
Self {
handlebars,
template_names: HashSet::new(),
partial_names: HashSet::new(),
}
}
/// Register a template by name and source text.
///
/// Fallible and typed: malformed template text is rejected through
/// [`Error::Registration`] at registration time, never a panic
/// (ADR-002 decision 3). The parse happens under the hood at this
/// point, so a malformed template never survives into the registry.
/// Re-registering a name overwrites it and its kind.
pub fn register_template(&mut self, name: &str, text: &str) -> Result<(), Error> {
self.handlebars.register_template_string(name, text)?;
self.template_names.insert(name.to_owned());
self.partial_names.remove(name);
Ok(())
}
/// Register a partial by name and source text.
///
/// Partials are first-class from day one (ADR-002 decision 5) but
/// exist to be composed inside templates, not rendered directly;
/// the same fallible, typed registration contract applies. A name
/// moves out of template bookkeeping when registered as a partial.
pub fn register_partial(&mut self, name: &str, text: &str) -> Result<(), Error> {
self.handlebars.register_partial(name, text)?;
self.partial_names.insert(name.to_owned());
self.template_names.remove(name);
Ok(())
}
/// Registration seam for set-owned custom helpers.
///
/// The core registers no helpers of its own beyond handlebars'
/// built-ins (core.md §Helper policy). Helpers see text and must be
/// pure and deterministic: no environment reads, no time, no
/// randomness, no I/O. The purity statement is a documented
/// convention owned by each template set, not runtime enforcement.
pub fn register_helper(&mut self, name: &str, helper: Box<dyn HelperDef + Send + Sync>) {
self.handlebars.register_helper(name, helper);
}
/// Whether the name is registered as a template (a render target)
/// rather than a partial.
///
/// This is the set-level bookkeeping the render entry point resolves
/// template names against (core.md §Render entry point: the name
/// argument resolves to a registered template, never a bare
/// partial).
pub fn is_template(&self, name: &str) -> bool {
self.template_names.contains(name)
}
#[cfg(test)]
fn render<T: serde::Serialize>(&self, name: &str, data: &T) -> Result<String, Error> {
Ok(self.handlebars.render(name, data)?)
}
}
#[cfg(test)]
mod tests {
use super::TemplateSet;
use crate::error::Error;
use crate::escape::EscapePolicy;
use handlebars::{
Context, Handlebars, Helper, HelperDef, HelperResult, Output, RenderContext,
RenderErrorReason,
};
use serde_json::json;
#[test]
fn strict_mode_is_forced_missing_variable_is_a_typed_error() {
let mut set = TemplateSet::new(EscapePolicy::NoOp);
set.register_template("greeting", "hello {{name}}")
.expect("fixture template should be well-formed");
let err = set
.render("greeting", &json!({}))
.expect_err("missing variable must fail, never render as empty output");
assert!(matches!(err, Error::Render(_)));
let rendered = set
.render("greeting", &json!({ "name": "world" }))
.expect("present variable should render");
assert_eq!(rendered, "hello world");
}
#[test]
fn html_policy_escapes_interpolated_output() {
let mut set = TemplateSet::new(EscapePolicy::Html);
set.register_template("page", "{{content}} {{&trusted}}")
.expect("fixture template should be well-formed");
let rendered = set
.render(
"page",
&json!({ "content": "<b>&\"x\"</b>", "trusted": "<em>" }),
)
.expect("present variables should render");
assert_eq!(rendered, "&lt;b&gt;&amp;&quot;x&quot;&lt;/b&gt; <em>");
}
#[test]
fn noop_policy_preserves_interpolated_output_verbatim() {
let mut set = TemplateSet::new(EscapePolicy::NoOp);
set.register_template("code", "let s = {{literal}};")
.expect("fixture template should be well-formed");
let rendered = set
.render("code", &json!({ "literal": "\"<b>&&quot;\"" }))
.expect("present variables should render");
assert_eq!(rendered, "let s = \"<b>&&quot;\";");
}
#[test]
fn malformed_template_text_is_a_typed_registration_error() {
let mut set = TemplateSet::new(EscapePolicy::Html);
let err = set
.register_template("bad", "{{#if name")
.expect_err("malformed template must fail registration");
assert!(matches!(err, Error::Registration(_)));
let mut set = TemplateSet::new(EscapePolicy::NoOp);
let err = set
.register_partial("bad", "{{#each items")
.expect_err("malformed partial must fail registration");
assert!(matches!(err, Error::Registration(_)));
}
#[test]
fn failed_registration_leaves_bookkeeping_untouched() {
let mut set = TemplateSet::new(EscapePolicy::NoOp);
set.register_template("page", "ok")
.expect("fixture template should be well-formed");
let err = set
.register_template("page", "{{#if name")
.expect_err("malformed overwrite must fail");
assert!(matches!(err, Error::Registration(_)));
assert!(set.is_template("page"));
}
#[test]
fn externally_supplied_text_registers_through_the_same_machinery() {
let mut set = TemplateSet::new(EscapePolicy::Html);
let consumer_template = String::from("<p>{{title}}</p>");
let consumer_partial = String::from("<footer>{{year}}</footer>");
set.register_template("page", &consumer_template)
.expect("caller-owned text should register like engine assets");
set.register_partial("footer", &consumer_partial)
.expect("caller-owned partial should register like engine assets");
set.register_template("shell", "<main>{{title}}</main>{{> footer}}")
.expect("fixture template should be well-formed");
let rendered = set
.render("shell", &json!({ "title": "Q&amp;A", "year": "2026" }))
.expect("registered surface should render");
assert_eq!(rendered, "<main>Q&amp;amp;A</main><footer>2026</footer>");
}
#[test]
fn bookkeeping_tracks_kinds_and_survives_reregistration() {
let mut set = TemplateSet::new(EscapePolicy::NoOp);
set.register_template("page", "{{> nav}}")
.expect("fixture template should be well-formed");
set.register_partial("nav", "<nav/>")
.expect("fixture partial should be well-formed");
assert!(set.is_template("page"));
assert!(!set.is_template("nav"));
set.register_partial("page", "{{content}}")
.expect("re-registration should parse");
assert!(!set.is_template("page"));
}
struct ShoutHelper;
impl HelperDef for ShoutHelper {
fn call<'reg: 'rc, 'rc>(
&self,
h: &Helper<'rc>,
_r: &'reg Handlebars<'reg>,
_ctx: &'rc Context,
_rc: &mut RenderContext<'reg, 'rc>,
out: &mut dyn Output,
) -> HelperResult {
let word = h
.param(0)
.and_then(|p| p.value().as_str())
.ok_or_else(|| RenderErrorReason::ParamNotFoundForIndex("shout", 0))?;
out.write(word.to_uppercase().as_str())
.map_err(|e| RenderErrorReason::Other(e.to_string()).into())
}
}
#[test]
fn helper_seam_registers_set_owned_helpers() {
let mut set = TemplateSet::new(EscapePolicy::NoOp);
set.register_helper("shout", Box::new(ShoutHelper));
set.register_template("loud", "{{shout word}}")
.expect("fixture template should be well-formed");
let rendered = set
.render("loud", &json!({ "word": "echo" }))
.expect("registered helper should render");
assert_eq!(rendered, "ECHO");
}
#[test]
fn fresh_set_registers_zero_helpers_missing_helper_fails_typed() {
let mut set = TemplateSet::new(EscapePolicy::NoOp);
set.register_template("loud", "{{shout word}}")
.expect("fixture template should be well-formed");
let err = set
.render("loud", &json!({ "word": "echo" }))
.expect_err("unregistered helper must fail, never render silently");
assert!(matches!(err, Error::Render(_)));
}
}
@@ -1,7 +1,7 @@
---
id: w1-registry-construction
name: "Wave 1: registry construction (strict mode, escaper policy, typed registration)"
status: pending
status: completed
depends_on: [w1-error-model]
scope: narrow
risk: medium
@@ -56,19 +56,19 @@ the core (core.md §Registry construction; ADR-002 decision 6):
## Acceptance Criteria
- [ ] set builder with forced strict mode; an enforcement test proves
- [x] set builder with forced strict mode; an enforcement test proves
a missing variable renders as a typed error, never empty output
- [ ] escaper choice explicit per registry (HTML and no-op postures),
- [x] escaper choice explicit per registry (HTML and no-op postures),
each pinned by a rendering test
- [ ] template and partial registration fallible and typed; malformed
- [x] template and partial registration fallible and typed; malformed
template text yields the error enum, never a panic
- [ ] externally supplied text registers identically (the ADR-008
- [x] externally supplied text registers identically (the ADR-008
consumer path exercised)
- [ ] helper registration seam exists; zero helpers registered by
- [x] helper registration seam exists; zero helpers registered by
default
- [ ] the two decisions recorded in Notes; both flagged in the task
- [x] the two decisions recorded in Notes; both flagged in the task
Summary for the gate
- [ ] `cargo test`, `cargo clippy --all-targets -- -D warnings`,
- [x] `cargo test`, `cargo clippy --all-targets -- -D warnings`,
`cargo fmt --check` pass
## References
@@ -91,6 +91,86 @@ the core (core.md §Registry construction; ADR-002 decision 6):
> enforcement mechanism, naming validation posture) must be recorded
> here.
**Landed surface.** `src/registry.rs` (`TemplateSet`), `src/escape.rs`
(`EscapePolicy::{Html, NoOp}`), re-exported from `src/lib.rs`
alongside the wave-1 `Error`. The builder wraps one
`handlebars::Handlebars<'static>` per set; strict mode is set
unconditionally inside `TemplateSet::new` and no API exposes it, so it
is not parameterizable by construction (enforcement test: a missing
variable renders `Error::Render`, a present one renders the golden
`hello world`).
**Decision 1: render-name rule enforced by set-level bookkeeping.**
The substrate's `register_partial` is literally
`register_template_string` (handlebars 6.4, registry.rs:309-318,
vendored checkout: "Currently a registered partial is just identical
to a template"), so the substrate cannot distinguish them and the rule
cannot be delegated to it. Chosen mechanism: `TemplateSet` keeps
per-set bookkeeping (`template_names` / `partial_names`; registration
failure leaves it untouched, re-registering moves a name's kind),
exposed as `TemplateSet::is_template` (public, additive, pre-publish).
The behavioral enforcement (rejecting render-by-partial-name as a
typed error) lands in `w1-render-entry`, which consumes this surface
and pins the behavior; this task pins the bookkeeping itself by tests.
Flagged for the wave-1 gate.
**Decision 2: naming conventions enforced by tests only, not at
registration.** Convention 9 (lowercase kebab/snake, one name per
template kind) governs alkgen's own assets; validating names at
registration would bake that house style into the consumer-facing
engine API (ADR-001 consumer-blindness, ADR-008 machinery equality:
consumers would get rejections for naming choices the core has no
mandate to police). "One name per kind" is also semantic, not
mechanical. Pinned the weaker structural facts by tests instead
(fixture names follow the convention; reviewed like source).
Flagged for the wave-1 gate.
**Escaper mechanism (ADR-005).** handlebars 6.4 keeps
`register_escape_fn` with `html_escape` / `no_escape` shipped fn
targets. Chosen: the `Html` posture leaves the substrate's default
html escaper untouched (ADR-005 posture 1 says exactly this: the
default "stays on"); the `NoOp` posture registers
`handlebars::no_escape` registry-wide. The `EscapePolicy` enum is the
explicit per-set choice; the escaper is not swappable afterward
through `TemplateSet`. Both postures pinned by golden render tests
(HTML: `{{& }}` escape-out documented alongside escaped interpolation;
NoOp: `&quot;` passes through verbatim, which the HTML policy would
double-escape).
**Other recorded choices.**
- Re-registration overwrites (substrate semantics) and moves the
name's kind in bookkeeping; no new error variant was invented, per
the error model's no-catch-all rule. If the wave-1 gate wants
collision rejection, it is an additive variant plus one check.
- Zero custom helpers registered by default is structural
(`TemplateSet::new` registers nothing beyond substrate built-ins)
and pinned by a test: invoking an unregistered helper is a typed
`Error::Render`, never silent output.
- `pub(crate) render` was deliberately withheld: the render entry is
`w1-render-entry`'s surface; tests here exercise strict mode through
the wrapped substrate via a `#[cfg(test)]` method.
- ADR-008 consumer path exercised by test: caller-owned `String`s
register through the identical methods; no filesystem anywhere.
- Wasm build to `wasm32-unknown-unknown` verified for the new modules
(ADR-003 posture; the wave-2 battery will pin it standing).
## Summary
> Agent fills this on completion.
Wave-1 registry construction landed on the shipped error model:
`EscapePolicy::{Html, NoOp}` (src/escape.rs) and `TemplateSet`
(src/registry.rs), the per-set strict registry with typed fallible
registration of templates, partials, and the per-set helper seam.
Strict mode is forced and unparameterizable; the escaper is an
explicit construction-time choice pinned per posture; malformed text
is `Error::Registration`, never a panic; caller-supplied text
registers through the identical machinery (ADR-008).
Both flagged decisions recorded in Notes: (1) render-name rule via
set-level bookkeeping (`TemplateSet::is_template`), with the
behavioral rejection landing in `w1-render-entry`; (2) naming
conventions enforced by tests only. Verification: 15 tests pass,
clippy `-D warnings` clean, `cargo fmt --check` clean, wasm
`wasm32-unknown-unknown` release build clean.