diff --git a/tasks/implementation/w2-ssr-proof.md b/tasks/implementation/w2-ssr-proof.md index 7788da9..6274c34 100644 --- a/tasks/implementation/w2-ssr-proof.md +++ b/tasks/implementation/w2-ssr-proof.md @@ -1,7 +1,7 @@ --- id: w2-ssr-proof name: "Wave 2: the ssr-html proof (posture B, consumer-shaped fixture through the public API)" -status: pending +status: completed depends_on: [] scope: narrow risk: low @@ -58,21 +58,21 @@ core invisibly (ADR-001 falsifiability discipline). ## Acceptance Criteria -- [ ] the full fixture set registers from outside the crate through +- [x] the full fixture set registers from outside the crate through the public API only (caller-supplied text; ADR-008 equality exercised at posture-B scale) -- [ ] a page template composes fragment partials; at least one +- [x] a page template composes fragment partials; at least one fragment is a registered template rendered directly by name -- [ ] HTML-escape goldens pin: interpolated hostile data escaped, a +- [x] HTML-escape goldens pin: interpolated hostile data escaped, a deliberate raw-output slot verbatim -- [ ] a strict-mode render failure maps toward the error-page shape +- [x] a strict-mode render failure maps toward the error-page shape through the typed error, panic-free, using only public API data -- [ ] repeated renders produce byte-identical output -- [ ] posture-B hygiene rows confirmed: no banner, no filesystem -- [ ] `cargo test`, `cargo clippy --all-targets -- -D warnings`, +- [x] repeated renders produce byte-identical output +- [x] posture-B hygiene rows confirmed: no banner, no filesystem +- [x] `cargo test`, `cargo clippy --all-targets -- -D warnings`, `cargo fmt --check` pass -- [ ] no core changes required; any gap found is booked for the gate +- [x] no core changes required; any gap found is booked for the gate in Notes per the stop-and-book rule ## References @@ -91,8 +91,59 @@ core invisibly (ADR-001 falsifiability discipline). ## Notes -> To be filled by implementation agent +> Filled by implementation (2026-11-06). The shipped core served the +> full set with **no core changes required**; the stop-and-book rule +> found nothing to book. Details: + +- ADR-008 machinery equality exercised in three caller-supplied text + shapes at once: `include_str!` fixture files (page, header, nav, + footer, settings_page), a runtime-built `String` (the card fragment + template, assembled from `String::push_str` parts), and an inline + literal (the error page). All register through the identical + `register_template` / `register_partial` machinery; no second-class + path exists in the API to even attempt. +- Composition and the render-name rule coexist cleanly: `card` + registers as its own template, is included in the page via + `{{> card }}` inside `{{#each}}` (posture B: fragments a consumer + serves directly are render targets), and renders directly by name; + the header partial's name is rejected by `render` as typed + `Error::Render`. +- Error-page mapping: a strict-mode failure on `settings_page` + (missing `account.email`) returns `Error::Render`; the proof maps it + to a consumer-built fallback page from `err.to_string()` and + `err.source()` (the substrate detail, reachable via + `std::error::Error::source`). Substrate detail observed: template + name, line/column, and the missing path all survive into the mapped + page, entity-escaped. Panic-free by construction. +- Determinism pin: repeated renders byte-identical, byte-identical + across registry rebuilds and fresh contexts, and the write sink + (`render_to_write`) agrees byte-for-byte with the string sink + (ADR-003; the substrate alkforge caching leans on). +- Hygiene rows: the pinned page output is unadorned served HTML + (starts at the doctype, no DO-NOT-EDIT banner, no "Auto-generated" + marker); registration is string-only by construction, no filesystem + path exists. No emit-layer hygiene beyond what `render` returns. +- Template whitespace note (observation, not a gap): the substrate + consumes the newline around lines whose only content is a block + expression (`{{#each cards}}{{> card}}{{/each}}`) while keeping + standalone partial lines' newlines. The goldens pin this behavior, + so it is now part of the pinned surface; consumers relying on exact + byte layouts have the golden as precedent. +- Goldens follow convention 7 and are exact; the handlebars + html-escape posture (including `=` as `=` and `'` as `'` + inside interpolated attribute values) is exercised and pinned. ## Summary -> To be filled on completion +Implemented `tests/ssr_proof.rs` plus fixtures under +`tests/fixtures/ssr_proof/` (page, header, nav, footer, settings_page +templates) following the wave-1 per-binary fixture convention. Four +tests cover: the full consumer fixture set rendered as an exact +golden with escape and raw-slot rows (page), the card fragment +registered as a template and rendered directly, the strict-failure to +error-page mapping built from the typed error's public data, and +determinism across repeats, rebuilds, contexts, and both sinks. All +registration flows through the public API from the test binary; +no core change was needed (stop-and-book: nothing to book). +Verification: `cargo test` (31 passed), `cargo clippy --all-targets +-- -D warnings`, and `cargo fmt --check` all pass. diff --git a/tests/fixtures/ssr_proof/footer.hbs b/tests/fixtures/ssr_proof/footer.hbs new file mode 100644 index 0000000..e872cdf --- /dev/null +++ b/tests/fixtures/ssr_proof/footer.hbs @@ -0,0 +1 @@ + diff --git a/tests/fixtures/ssr_proof/header.hbs b/tests/fixtures/ssr_proof/header.hbs new file mode 100644 index 0000000..02637d7 --- /dev/null +++ b/tests/fixtures/ssr_proof/header.hbs @@ -0,0 +1 @@ +

{{site_name}}

{{#if tagline}}

{{tagline}}

{{/if}}
diff --git a/tests/fixtures/ssr_proof/nav.hbs b/tests/fixtures/ssr_proof/nav.hbs new file mode 100644 index 0000000..3f0fa2e --- /dev/null +++ b/tests/fixtures/ssr_proof/nav.hbs @@ -0,0 +1 @@ + diff --git a/tests/fixtures/ssr_proof/page.hbs b/tests/fixtures/ssr_proof/page.hbs new file mode 100644 index 0000000..de2bc2f --- /dev/null +++ b/tests/fixtures/ssr_proof/page.hbs @@ -0,0 +1,13 @@ + + +{{> header}} + +{{> nav}} +
+{{#each cards}}{{> card}}{{/each}} +
+{{#if show_banner}}{{/if}} +{{& trusted_embed}} +{{> footer}} + + diff --git a/tests/fixtures/ssr_proof/settings_page.hbs b/tests/fixtures/ssr_proof/settings_page.hbs new file mode 100644 index 0000000..94254ad --- /dev/null +++ b/tests/fixtures/ssr_proof/settings_page.hbs @@ -0,0 +1,8 @@ + + +{{> header}} + +

Account settings

Signed in as {{account.email}}

+{{> footer}} + + diff --git a/tests/ssr_proof.rs b/tests/ssr_proof.rs new file mode 100644 index 0000000..bb4b466 --- /dev/null +++ b/tests/ssr_proof.rs @@ -0,0 +1,308 @@ +//! The wave-2 ssr-html proof (posture B, consumer-shaped): a full +//! fixture set modeled on the alkforge posture, rendered entirely through +//! the public API and pinned by goldens. Where the wave-1 double proved +//! the machinery minimally, this proof walks the ssr-html target's real +//! shape at fuller scale: if the shipped core serves this set without a +//! core change, ADR-001's generality claim holds on shipped code. + +use std::error::Error as StdError; + +use alkgen::{Error, EscapePolicy, TemplateSet}; +use serde::Serialize; +use serde_json::json; + +mod fixtures { + pub const PAGE: &str = include_str!("fixtures/ssr_proof/page.hbs"); + pub const HEADER: &str = include_str!("fixtures/ssr_proof/header.hbs"); + pub const NAV: &str = include_str!("fixtures/ssr_proof/nav.hbs"); + pub const FOOTER: &str = include_str!("fixtures/ssr_proof/footer.hbs"); + pub const SETTINGS_PAGE: &str = include_str!("fixtures/ssr_proof/settings_page.hbs"); +} + +/// The caller-supplied error page the failed render maps toward: built +/// on the consumer side from the typed error's data, never from a core +/// path. +const ERROR_PAGE: &str = "\n\n

Something went wrong

{{message}}

\n\n"; + +#[derive(Serialize)] +struct NavLink { + href: String, + label: String, +} + +#[derive(Serialize)] +struct Card { + kind: String, + title: String, + body: String, +} + +#[derive(Serialize)] +struct PageContext { + site_name: String, + tagline: String, + nav_links: Vec, + cards: Vec, + show_banner: bool, + banner_text: String, + trusted_embed: String, + year: u16, +} + +fn nav_link(href: &str, label: &str) -> NavLink { + NavLink { + href: href.to_owned(), + label: label.to_owned(), + } +} + +fn card(kind: &str, title: &str, body: &str) -> Card { + Card { + kind: kind.to_owned(), + title: title.to_owned(), + body: body.to_owned(), + } +} + +fn context_with(show_banner: bool, banner_text: &str) -> PageContext { + PageContext { + site_name: "Alk Forge".into(), + tagline: "Your repositories".into(), + nav_links: vec![ + nav_link("/", "Home"), + nav_link("/search?q="), + ], + cards: vec![ + card( + "repo", + "Repo \"settings\" & ", + "if (a < b) { alert(\"hi\") }", + ), + card("link", "Plain card", "A plain body."), + ], + show_banner, + banner_text: banner_text.to_owned(), + trusted_embed: r#""#.into(), + year: 2026, + } +} + +/// The card fragment's template text arrives as a consumer-built +/// `String`: the caller owns the text however it wants (inline literal, +/// runtime-built, or fixture file) and registers it through the same +/// API engine-owned sets use (ADR-008 machinery equality). +fn caller_supplied_card_text() -> String { + let mut text = String::from("
"); + text.push_str("

{{title}}

"); + text.push_str("

{{body}}

"); + text.push_str("
"); + text +} + +/// The full consumer-shaped fixture set: pages, fragments, and the +/// error page all register from the test binary through the public API. +/// Partials exist to be composed; fragments a consumer serves directly +/// register as their own templates. +fn consumer_fixture_set() -> TemplateSet { + let mut set = TemplateSet::new(EscapePolicy::Html); + set.register_template("page", fixtures::PAGE) + .expect("fixture page template should be well-formed"); + set.register_template("card", &caller_supplied_card_text()) + .expect("caller-supplied card template should be well-formed"); + set.register_template("settings_page", fixtures::SETTINGS_PAGE) + .expect("fixture settings page template should be well-formed"); + set.register_template("error_page", ERROR_PAGE) + .expect("caller-supplied error page template should be well-formed"); + set.register_partial("header", fixtures::HEADER) + .expect("fixture header partial should be well-formed"); + set.register_partial("nav", fixtures::NAV) + .expect("fixture nav partial should be well-formed"); + set.register_partial("footer", fixtures::FOOTER) + .expect("fixture footer partial should be well-formed"); + set +} + +const GOLDEN_PAGE: &str = concat!( + "\n", + "\n", + "

Alk Forge

Your repositories

\n", + "\n", + "\n", + "
\n", + "

Repo "settings" & <config>

if (a < b) { alert("<b>hi</b>") }

Plain card

A plain body.

\n
\n", + "
<em>scheduled</em> maintenance
\n", + "\n", + "

© 2026 Alk Forge

\n", + "\n", + "\n", +); + +const GOLDEN_CARD: &str = concat!( + "
", + "

Repo "settings" & <config>

", + "

if (a < b) { alert("<b>hi</b>") }

", + "
", +); + +const GOLDEN_ERROR_PAGE: &str = concat!( + "\n", + "\n", + "

Something went wrong

render failed: Error rendering "settings_page" line 5, col 48: Failed to access variable in strict mode Some("account.email")

\n", + "\n", +); + +#[test] +fn page_golden_pins_composition_escaping_and_raw_slot_at_fuller_scale() { + let set = consumer_fixture_set(); + let context = context_with(true, "scheduled maintenance"); + + let rendered = set + .render("page", &context) + .expect("the full consumer fixture set should render through the public API"); + assert_eq!(rendered, GOLDEN_PAGE); + + assert!( + rendered.contains(r#"<script>alert('x')</script>"#), + "hostile interpolated data must be entity-escaped" + ); + assert!( + rendered.contains(r#"

Repo "settings" & <config>

"#), + "hostile card data must be entity-escaped everywhere it interpolates" + ); + assert!( + rendered.contains(r#""#), + "the deliberate raw-output slot ({{& }}) must pass through verbatim" + ); + assert!( + rendered.starts_with(""), + "the output unit is the served page, unadorned" + ); + assert!( + !rendered.contains("DO NOT EDIT"), + "posture-B hygiene: rendered HTML is the product, not a checked-in generated artifact" + ); + assert!( + !rendered.contains("Auto-generated"), + "posture-B hygiene: no emit-layer banner on rendered HTML" + ); +} + +#[test] +fn card_fragment_registers_as_template_and_renders_directly_by_name() { + let set = consumer_fixture_set(); + assert!(set.is_template("card")); + + let rendered = set + .render( + "card", + &card( + "repo", + "Repo \"settings\" & ", + "if (a < b) { alert(\"hi\") }", + ), + ) + .expect("a fragment registered as its own template must render directly by name"); + assert_eq!(rendered, GOLDEN_CARD); + + let err = set + .render("header", &context_with(false, "")) + .expect_err("partial names are never render targets (the render-name rule)"); + assert!(matches!(err, Error::Render(_))); +} + +#[test] +fn strict_failure_maps_to_the_error_page_shape_panic_free() { + let set = consumer_fixture_set(); + let context = json!({ + "site_name": "Alk Forge", + "tagline": "", + "year": 2026 + }); + + let err = set + .render("settings_page", &context) + .expect_err("a strict-mode render failure must return the typed error, never a panic"); + assert!(matches!(err, Error::Render(_))); + + let source = err + .source() + .expect("everything a consumer needs for the mapping is reachable through the public enum"); + assert!( + format!("{source}").contains("account.email"), + "the substrate's missing-variable detail stays reachable consumer-side" + ); + + let error_page = set + .render("error_page", &json!({ "message": err.to_string() })) + .expect("the consumer-built error page must render over the error's data"); + assert_eq!(error_page, GOLDEN_ERROR_PAGE); + assert!( + error_page.contains(""settings_page"") + && error_page.contains(""account.email""), + "the failing template's identity and the missing path surface entity-escaped" + ); + let detail_begin = error_page + .find(r#"

"#) + .map_or(0, |at| at + r#"

"#.len()); + let detail_end = error_page[detail_begin..] + .find("

") + .expect("the detail slot closes"); + let detail = &error_page[detail_begin..detail_begin + detail_end]; + assert!( + detail.contains(""") && !detail.contains('"') && !detail.contains('<'), + "interpolated error detail is entity-escaped; the static markup alone carries quotes" + ); +} + +#[test] +fn repeated_renders_across_rebuilds_and_sinks_are_byte_identical() { + let set_a = consumer_fixture_set(); + let context = context_with(true, "scheduled maintenance"); + + let first = set_a + .render("page", &context) + .expect("the page should render"); + for _ in 0..4 { + let again = set_a + .render("page", &context) + .expect("repeated renders must succeed"); + assert_eq!( + again, first, + "repeated renders must be byte-identical (ADR-003, the caching substrate)" + ); + } + + let rebuilt_context = context_with(true, "scheduled maintenance"); + let rebuilt = consumer_fixture_set() + .render("page", &rebuilt_context) + .expect("a rebuilt set should render"); + assert_eq!( + rebuilt, first, + "same registry contents rebuilt must render byte-identical output" + ); + + let card_again = consumer_fixture_set() + .render( + "card", + &card( + "repo", + "Repo \"settings\" & ", + "if (a < b) { alert(\"hi\") }", + ), + ) + .expect("the fragment should render from the rebuilt set"); + assert_eq!( + card_again, GOLDEN_CARD, + "fragment renders are byte-identical across registry rebuilds" + ); + + let mut sink = Vec::new(); + consumer_fixture_set() + .render_to_write("page", &context, &mut sink) + .expect("the write sink should render the page"); + assert_eq!( + &sink[..], + first.as_bytes(), + "both sinks must agree byte for byte" + ); +}