- code-shaped fixture set: writer + validator template kinds over one composed partial, no-op escaper, strict mode, all public API - ADR-004 mappings in builder Rust (kind->call names, struct name, string-kind flag, source-quoted literals); templates stay dumb - document order (not sort/hash/reverse) drives rendered order; reordered arrays render in the array's new order - determinism: byte-identical across repeated renders, registry rebuilds, differing registration orders, fresh contexts, and both sinks (render vs render_to_write) - banner stays an emit-layer obligation; no banner assertions in the proof (gap recorded in task Notes, honestly) Verification: cargo test (38 passed), cargo clippy --all-targets -- -D warnings, cargo fmt --check, taskgraph validate
423 lines
15 KiB
Rust
423 lines
15 KiB
Rust
//! The wave-2 code-shape proof (posture-A/C shape): a codec/JIT-shaped
|
|
//! fixture set rendered entirely through the public API and pinned by
|
|
//! goldens. Where the wave-1 code double proved the machinery minimally,
|
|
//! this proof grows the set at fuller scale: document-ordered arrays
|
|
//! over the `preserve_order` load-bearing behavior, no-op escaping over
|
|
//! source-escaped literals, and the determinism invariant the posture-A
|
|
//! regeneration story rests on: byte-identical output across repeated
|
|
//! renders, registry rebuilds, registration orders, and both sinks
|
|
//! (ADR-003). On shipped code, this is the row generated-artifact
|
|
//! hygiene is built on (emit.md posture A), minus the DO-NOT-EDIT
|
|
//! banner, which is an emit-layer obligation with no engine-owned set
|
|
//! in v0.1.0 to exercise it (recorded in the task Notes; the tests here
|
|
//! assert nothing banner-related).
|
|
|
|
use alkgen::{EscapePolicy, TemplateSet};
|
|
use serde::Serialize;
|
|
|
|
mod fixtures {
|
|
pub const SCHEMA: &str = include_str!("fixtures/code_proof/schema.json");
|
|
pub const WRITER: &str = include_str!("fixtures/code_proof/writer.hbs");
|
|
pub const VALIDATOR: &str = include_str!("fixtures/code_proof/validator.hbs");
|
|
pub const FIELD_WRITE_LINE: &str = include_str!("fixtures/code_proof/field_write_line.hbs");
|
|
}
|
|
|
|
/// The registration-order variants the determinism proof rebuilds
|
|
/// over: same assets, same final registry contents, changed
|
|
/// registration sequence.
|
|
#[derive(Clone, Copy)]
|
|
enum Asset {
|
|
Writer,
|
|
Validator,
|
|
FieldWriteLine,
|
|
}
|
|
|
|
const ORDER_A: [Asset; 3] = [Asset::Writer, Asset::Validator, Asset::FieldWriteLine];
|
|
const ORDER_B: [Asset; 3] = [Asset::FieldWriteLine, Asset::Validator, Asset::Writer];
|
|
|
|
#[derive(Serialize)]
|
|
struct PacketContext {
|
|
struct_name: String,
|
|
fields: Vec<CodeField>,
|
|
default_consts: Vec<DefaultConst>,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct CodeField {
|
|
name: String,
|
|
writer_call: String,
|
|
check_call: String,
|
|
is_string: bool,
|
|
max_len_const: String,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct DefaultConst {
|
|
const_name: String,
|
|
literal: String,
|
|
}
|
|
|
|
/// The schema-kind to writer-fn mapping (ADR-004): the template renders
|
|
/// whatever call name the builder decided and never interprets a schema
|
|
/// kind itself.
|
|
fn writer_call(schema_kind: &str) -> String {
|
|
match schema_kind {
|
|
"u32" => "write_u32".to_owned(),
|
|
"u64" => "write_u64".to_owned(),
|
|
"string" => "write_str".to_owned(),
|
|
"bool" => "write_bool".to_owned(),
|
|
other => panic!("fixture schema must only carry known kinds, found: {other}"),
|
|
}
|
|
}
|
|
|
|
/// The schema-kind to check-fn mapping: the validator half of the same
|
|
/// builder kind mapping, again decided in Rust (ADR-004).
|
|
fn check_call(schema_kind: &str) -> String {
|
|
match schema_kind {
|
|
"u32" => "check_u32".to_owned(),
|
|
"u64" => "check_u64".to_owned(),
|
|
"string" => "check_str".to_owned(),
|
|
"bool" => "check_bool".to_owned(),
|
|
other => panic!("fixture schema must only carry known kinds, found: {other}"),
|
|
}
|
|
}
|
|
|
|
/// The Rust-type-name mapping (snake_case document name to PascalCase
|
|
/// type): another ADR-004 mapping living in builder code.
|
|
fn rust_struct_name(doc_name: &str) -> String {
|
|
doc_name
|
|
.split('_')
|
|
.map(|part| {
|
|
let mut chars = part.chars();
|
|
match chars.next() {
|
|
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
|
|
None => String::new(),
|
|
}
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Rust source-level string-literal quoting: the codegen posture
|
|
/// escapes literals at the source (the builder), never by entity
|
|
/// mangling, which is why the code set renders with the no-op escaper
|
|
/// (ADR-005).
|
|
fn rust_string_literal(raw: &str) -> String {
|
|
let mut literal = String::new();
|
|
literal.push('"');
|
|
for c in raw.chars() {
|
|
match c {
|
|
'"' => literal.push_str("\\\""),
|
|
'\\' => literal.push_str("\\\\"),
|
|
_ => literal.push(c),
|
|
}
|
|
}
|
|
literal.push('"');
|
|
literal
|
|
}
|
|
|
|
/// The ADR-004 context builder over the document-shaped fixture: fields
|
|
/// stay in the document's array order (convention 5, the
|
|
/// `preserve_order` load-bearing behavior), kind mappings and flags are
|
|
/// decided here in Rust, string defaults are builder-quoted into consts,
|
|
/// and the templates never see a schema kind, only dumb conditionals
|
|
/// over builder-decided fields.
|
|
fn build_packet_context(doc: &serde_json::Value) -> PacketContext {
|
|
let struct_name = doc
|
|
.get("name")
|
|
.and_then(serde_json::Value::as_str)
|
|
.expect("fixture schema should carry a struct name");
|
|
let fields_doc = doc
|
|
.get("fields")
|
|
.and_then(serde_json::Value::as_array)
|
|
.expect("fixture schema should carry a fields array");
|
|
|
|
let mut fields = Vec::new();
|
|
let mut default_consts = Vec::new();
|
|
for field in fields_doc {
|
|
let name = field
|
|
.get("name")
|
|
.and_then(serde_json::Value::as_str)
|
|
.expect("fixture schema field should carry a name");
|
|
let kind = field
|
|
.get("kind")
|
|
.and_then(serde_json::Value::as_str)
|
|
.expect("fixture schema field should carry a kind");
|
|
fields.push(CodeField {
|
|
name: name.to_owned(),
|
|
writer_call: writer_call(kind),
|
|
check_call: check_call(kind),
|
|
is_string: kind == "string",
|
|
max_len_const: format!("{}_MAX", name.to_uppercase()),
|
|
});
|
|
if let Some(default) = field.get("default").and_then(serde_json::Value::as_str) {
|
|
default_consts.push(DefaultConst {
|
|
const_name: format!("{}_DEFAULT", name.to_uppercase()),
|
|
literal: rust_string_literal(default),
|
|
});
|
|
}
|
|
}
|
|
|
|
PacketContext {
|
|
struct_name: rust_struct_name(struct_name),
|
|
fields,
|
|
default_consts,
|
|
}
|
|
}
|
|
|
|
fn register_asset(set: &mut TemplateSet, asset: Asset) {
|
|
let result = match asset {
|
|
Asset::Writer => set.register_template("writer", fixtures::WRITER),
|
|
Asset::Validator => set.register_template("validator", fixtures::VALIDATOR),
|
|
Asset::FieldWriteLine => {
|
|
set.register_partial("field-write-line", fixtures::FIELD_WRITE_LINE)
|
|
}
|
|
};
|
|
result.expect("fixture asset should be well-formed");
|
|
}
|
|
|
|
/// The code-shaped fixture set: two template kinds (`writer`,
|
|
/// `validator`) over one composed partial, under the no-op escaper,
|
|
/// registered in the caller's chosen order.
|
|
fn code_proof_set(order: &[Asset]) -> TemplateSet {
|
|
let mut set = TemplateSet::new(EscapePolicy::NoOp);
|
|
for &asset in order {
|
|
register_asset(&mut set, asset);
|
|
}
|
|
set
|
|
}
|
|
|
|
fn packet_context() -> PacketContext {
|
|
let doc: serde_json::Value =
|
|
serde_json::from_str(fixtures::SCHEMA).expect("fixture schema should parse as JSON");
|
|
build_packet_context(&doc)
|
|
}
|
|
|
|
/// Document field order (zeta, magic, alpha, length), which differs
|
|
/// from both the sorted order (alpha, length, magic, zeta) and the
|
|
/// reversed order (length, alpha, magic, zeta): the proof's field
|
|
/// names are chosen so document order cannot be explained by sort or
|
|
/// hash order.
|
|
const GOLDEN_WRITER: &str = concat!(
|
|
"pub const ZETA_DEFAULT: &str = \"v<1>&\\\"raw\\\"\";\n",
|
|
"pub const LENGTH_DEFAULT: &str = \"a "b <i>\";\n",
|
|
"impl PacketHeader {\n",
|
|
" pub fn write<W: std::io::Write>(&self, out: &mut W) -> std::io::Result<()> {\n",
|
|
" out.write_str(&self.zeta)?;\n",
|
|
" out.write_u64(&self.magic)?;\n",
|
|
" out.write_bool(&self.alpha)?;\n",
|
|
" out.write_str(&self.length)?;\n",
|
|
" Ok(())\n",
|
|
" }\n",
|
|
"}\n",
|
|
);
|
|
|
|
const GOLDEN_VALIDATOR: &str = concat!(
|
|
"impl PacketHeader {\n",
|
|
" pub fn validate(&self) -> Result<(), &'static str> {\n",
|
|
" check_str_len(&self.zeta, ZETA_MAX)?;\n",
|
|
" check_u64(&self.magic)?;\n",
|
|
" check_bool(&self.alpha)?;\n",
|
|
" check_str_len(&self.length, LENGTH_MAX)?;\n",
|
|
" Ok(())\n",
|
|
" }\n",
|
|
"}\n",
|
|
);
|
|
|
|
#[test]
|
|
fn writer_and_validator_goldens_pin_mappings_literals_and_document_order() {
|
|
let set = code_proof_set(&ORDER_A);
|
|
let context = packet_context();
|
|
|
|
let writer = set
|
|
.render("writer", &context)
|
|
.expect("the writer should render through the public API");
|
|
assert_eq!(writer, GOLDEN_WRITER);
|
|
|
|
let validator = set
|
|
.render("validator", &context)
|
|
.expect("the validator should render through the public API");
|
|
assert_eq!(validator, GOLDEN_VALIDATOR);
|
|
}
|
|
|
|
#[test]
|
|
fn noop_escaping_passes_source_escaped_literals_verbatim() {
|
|
let set = code_proof_set(&ORDER_A);
|
|
let writer = set
|
|
.render("writer", &packet_context())
|
|
.expect("the writer should render");
|
|
|
|
assert!(
|
|
writer.contains(r#""v<1>&\"raw\"""#),
|
|
"the builder-quoted literal must pass through the no-op escaper verbatim"
|
|
);
|
|
assert!(
|
|
writer.contains(r#""a "b <i>""#),
|
|
"a literal already carrying escape-shaped source text must not be re-processed in either direction"
|
|
);
|
|
assert!(
|
|
!writer.contains("<"),
|
|
"the HTML escaper would mangle the literal; the code set must never entity-escape"
|
|
);
|
|
assert!(
|
|
!writer.contains("&"),
|
|
"entity-escaping must be absent from the code posture's output"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn document_order_drives_rendered_order_not_sort_or_hash() {
|
|
let set = code_proof_set(&ORDER_A);
|
|
let context = packet_context();
|
|
|
|
let writer = set
|
|
.render("writer", &context)
|
|
.expect("the writer should render");
|
|
let validator = set
|
|
.render("validator", &context)
|
|
.expect("the validator should render");
|
|
|
|
for rendered in [&writer, &validator] {
|
|
let zeta = rendered.find("self.zeta").expect("zeta line should render");
|
|
let magic = rendered
|
|
.find("self.magic")
|
|
.expect("magic line should render");
|
|
let alpha = rendered
|
|
.find("self.alpha")
|
|
.expect("alpha line should render");
|
|
let length = rendered
|
|
.find("self.length")
|
|
.expect("length line should render");
|
|
|
|
assert!(zeta < magic && magic < alpha && alpha < length);
|
|
}
|
|
|
|
assert!(
|
|
writer.find("self.zeta").expect("zeta") < writer.find("self.alpha").expect("alpha"),
|
|
"zeta before alpha already excludes the sorted order"
|
|
);
|
|
assert!(
|
|
writer.find("self.alpha").expect("alpha") < writer.find("self.length").expect("length"),
|
|
"alpha before length excludes the reversed document order too"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn reordered_document_arrays_render_in_new_array_order() {
|
|
let set = code_proof_set(&ORDER_A);
|
|
let mut doc: serde_json::Value =
|
|
serde_json::from_str(fixtures::SCHEMA).expect("fixture schema should parse as JSON");
|
|
let fields = doc
|
|
.get_mut("fields")
|
|
.and_then(serde_json::Value::as_array_mut)
|
|
.expect("fixture schema should carry a fields array");
|
|
fields.sort_by_key(|field| {
|
|
field
|
|
.get("name")
|
|
.and_then(serde_json::Value::as_str)
|
|
.map(str::to_owned)
|
|
.unwrap_or_default()
|
|
});
|
|
|
|
let writer = set
|
|
.render("writer", &build_packet_context(&doc))
|
|
.expect("the reordered writer should render");
|
|
|
|
let alpha = writer.find("self.alpha").expect("alpha line should render");
|
|
let length = writer
|
|
.find("self.length")
|
|
.expect("length line should render");
|
|
let magic = writer.find("self.magic").expect("magic line should render");
|
|
let zeta = writer.find("self.zeta").expect("zeta line should render");
|
|
|
|
assert!(
|
|
alpha < length && length < magic && magic < zeta,
|
|
"the array's new order drives the render, not any stored canonical order"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn repeated_renders_within_one_registry_are_byte_identical() {
|
|
let set = code_proof_set(&ORDER_A);
|
|
let context = packet_context();
|
|
|
|
let writer_first = set
|
|
.render("writer", &context)
|
|
.expect("the writer should render");
|
|
let validator_first = set
|
|
.render("validator", &context)
|
|
.expect("the validator should render");
|
|
for _ in 0..4 {
|
|
let writer_again = set
|
|
.render("writer", &context)
|
|
.expect("repeated renders must succeed");
|
|
let validator_again = set
|
|
.render("validator", &context)
|
|
.expect("repeated renders must succeed");
|
|
assert_eq!(
|
|
writer_again, writer_first,
|
|
"repeated renders must be byte-identical (ADR-003)"
|
|
);
|
|
assert_eq!(
|
|
validator_again, validator_first,
|
|
"repeated renders must be byte-identical (ADR-003)"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn registry_rebuilds_including_registration_order_are_byte_identical() {
|
|
let context = packet_context();
|
|
|
|
let first = code_proof_set(&ORDER_A)
|
|
.render("writer", &context)
|
|
.expect("the writer should render from the first rebuild");
|
|
let validator_first = code_proof_set(&ORDER_A)
|
|
.render("validator", &context)
|
|
.expect("the validator should render from the first rebuild");
|
|
|
|
for order in [&ORDER_A, &ORDER_B] {
|
|
let writer = code_proof_set(order)
|
|
.render("writer", &context)
|
|
.expect("the writer should render from the rebuilt set");
|
|
let validator = code_proof_set(order)
|
|
.render("validator", &context)
|
|
.expect("the validator should render from the rebuilt set");
|
|
assert_eq!(
|
|
writer, first,
|
|
"independent rebuilds must render byte-identical output (ADR-003, the posture-A regeneration posture)"
|
|
);
|
|
assert_eq!(
|
|
validator, validator_first,
|
|
"independent rebuilds must render byte-identical output (ADR-003, the posture-A regeneration posture)"
|
|
);
|
|
}
|
|
|
|
let writer_from_reordered = code_proof_set(&ORDER_B)
|
|
.render("writer", &packet_context())
|
|
.expect("the writer should render with a fresh context");
|
|
assert_eq!(
|
|
writer_from_reordered, first,
|
|
"fresh context builds do not perturb the output either"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn both_sinks_agree_byte_for_byte_on_the_proof_set() {
|
|
let set = code_proof_set(&ORDER_A);
|
|
let context = packet_context();
|
|
|
|
for name in ["writer", "validator"] {
|
|
let string_sink = set
|
|
.render(name, &context)
|
|
.unwrap_or_else(|_| panic!("{name} should render"));
|
|
let mut byte_sink = Vec::new();
|
|
set.render_to_write(name, &context, &mut byte_sink)
|
|
.unwrap_or_else(|_| panic!("{name} should render to the write sink"));
|
|
assert_eq!(
|
|
&byte_sink[..],
|
|
string_sink.as_bytes(),
|
|
"both sinks must agree byte for byte on {name} (ADR-003)"
|
|
);
|
|
}
|
|
}
|