Fix H3: field-disc union wire convention — shared-then-variant (review #006)
Decision (recorded as an ADR-011 addendum): the packed-mode wire layout for a field-name-discriminator TUnion is shared-then-variant — the union's declared `fields` (disc + shared fields) first, then the variant's own fields. Reader and materializer already implemented this; LayoutBuilder was corrected from variant-only layout. Enforcement in BastUnion::parse (the choke point every consumer inherits — union roots at BastDoc::new, referenced unions at resolve_ref): - discriminator field must be declared in `fields` - `fields` must not contain duplicate names - variants must not re-declare shared fields (checked inline and through $ref resolution — parse chain now threads the doc root) - the discriminator field must be the FIRST entry in `fields` (the reader reads the disc at the union start; a later position made it dispatch on the wrong bytes — H3 item 2, probe-verified) Schemas relying on the old variant-only builder convention (variants re-declaring shared fields) are rejected with a clean Schema error naming the convention. Breaking for 0.2.0-era re-declaring schemas; announced with 0.3.x. - L5: FieldValue::Union::variant_start doc now states per-kind semantics (byte-disc: union_start + disc.offset + disc.size; field-disc: after the shared walk). - L6: roundtrip test added (poc_roundtrip.rs) — LayoutBuilder write → SequentialReader read → materialize_packed → validate_bytes over a field-disc union with a second shared field and non-redeclaring variant; pins event.type@0/seq@1/handle@5, total 10. - ADR-011: Status-block addendum recording the convention decision, the no-re-declare rule, and the breaking-constraint note. - Review #006 updated: H3/L5/L6 resolution blocks, resolution log, recommended order. Verified: 488 tests green (410+17+34+15+12, 2 pre-existing ignored), clippy -D warnings clean, wasm32 build green, cargo doc zero warnings.
This commit is contained in:
1 parent
2d166f567b
commit
05a2a42983
7 files changed
+561
-44
No files matched your search
+95
-25
@@ -75,7 +75,7 @@ impl BastDoc {
|
||||
/// access time.
|
||||
pub fn new(root: &Value, root_name: &str) -> Result<Self, AlkTypeError> {
|
||||
let raw_root_def = Self::lookup_def_raw(root, root_name)?;
|
||||
let root_def = BastDef::parse(raw_root_def, root_name, "")?;
|
||||
let root_def = BastDef::parse(raw_root_def, root_name, "", root)?;
|
||||
let doc = Self {
|
||||
root: root.clone(),
|
||||
root_name: root_name.to_string(),
|
||||
@@ -111,7 +111,7 @@ impl BastDoc {
|
||||
/// [`BastRef`]s and resolve on demand.
|
||||
pub fn resolve_ref(&self, r: &BastRef) -> Result<BastDef, AlkTypeError> {
|
||||
let raw = self.lookup_def(r.name())?;
|
||||
BastDef::parse(raw, r.name(), "")
|
||||
BastDef::parse(raw, r.name(), "", &self.root)
|
||||
}
|
||||
|
||||
/// Resolve a [`BastType`] that may be a [`Ref`](BastType::Ref) into
|
||||
@@ -213,7 +213,7 @@ impl BastDef {
|
||||
&self.source
|
||||
}
|
||||
|
||||
fn parse(node: &Value, name: &str, path: &str) -> Result<Self, AlkTypeError> {
|
||||
fn parse(node: &Value, name: &str, path: &str, doc_root: &Value) -> Result<Self, AlkTypeError> {
|
||||
let kind_str = node
|
||||
.get("kind")
|
||||
.and_then(Value::as_str)
|
||||
@@ -225,13 +225,13 @@ impl BastDef {
|
||||
let alk_kind = AlkTypeKind::from_bast_str(kind_str)?;
|
||||
let kind = match alk_kind {
|
||||
AlkTypeKind::Struct => {
|
||||
BastDefKind::Struct(BastStruct::parse(node, path)?)
|
||||
BastDefKind::Struct(BastStruct::parse(node, path, doc_root)?)
|
||||
}
|
||||
AlkTypeKind::Union => {
|
||||
BastDefKind::Union(BastUnion::parse(node, path)?)
|
||||
BastDefKind::Union(BastUnion::parse(node, path, doc_root)?)
|
||||
}
|
||||
AlkTypeKind::Enum => {
|
||||
BastDefKind::Enum(BastEnum::parse(node, path)?)
|
||||
BastDefKind::Enum(BastEnum::parse(node, path, doc_root)?)
|
||||
}
|
||||
other => {
|
||||
return Err(AlkTypeError::Schema(format!(
|
||||
@@ -306,7 +306,7 @@ impl BastStruct {
|
||||
&self.source
|
||||
}
|
||||
|
||||
fn parse(node: &Value, path: &str) -> Result<Self, AlkTypeError> {
|
||||
fn parse(node: &Value, path: &str, doc_root: &Value) -> Result<Self, AlkTypeError> {
|
||||
let endian = parse_endian_opt(node).unwrap_or(Endian::Little);
|
||||
let align = parse_align(node);
|
||||
let raw_fields = node
|
||||
@@ -320,7 +320,7 @@ impl BastStruct {
|
||||
let mut fields = Vec::new();
|
||||
for (i, raw_field) in raw_fields.iter().enumerate() {
|
||||
let field_path = format!("{path}.fields[{i}]");
|
||||
fields.push(BastField::parse(raw_field, &field_path)?);
|
||||
fields.push(BastField::parse(raw_field, &field_path, doc_root)?);
|
||||
}
|
||||
Ok(Self {
|
||||
endian,
|
||||
@@ -409,7 +409,7 @@ impl BastField {
|
||||
self.endian.unwrap_or(default)
|
||||
}
|
||||
|
||||
fn parse(node: &Value, path: &str) -> Result<Self, AlkTypeError> {
|
||||
fn parse(node: &Value, path: &str, doc_root: &Value) -> Result<Self, AlkTypeError> {
|
||||
let name = node
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
@@ -425,7 +425,7 @@ impl BastField {
|
||||
"bast: field {name:?} at {path} has no `kind`"
|
||||
))
|
||||
})?;
|
||||
let ty = BastType::parse(raw_kind, path)?;
|
||||
let ty = BastType::parse(raw_kind, path, doc_root)?;
|
||||
let endian = parse_endian_opt(node);
|
||||
let align = parse_align(node);
|
||||
let encoding = parse_encoding(node);
|
||||
@@ -493,7 +493,7 @@ impl BastUnion {
|
||||
&self.source
|
||||
}
|
||||
|
||||
fn parse(node: &Value, path: &str) -> Result<Self, AlkTypeError> {
|
||||
fn parse(node: &Value, path: &str, doc_root: &Value) -> Result<Self, AlkTypeError> {
|
||||
let endian = parse_endian_opt(node).unwrap_or(Endian::Little);
|
||||
let discriminator = BastDiscriminator::parse(node, path)?;
|
||||
let raw_fields = node.get("fields").and_then(Value::as_array);
|
||||
@@ -507,7 +507,7 @@ impl BastUnion {
|
||||
let mut out = Vec::new();
|
||||
for (i, raw_field) in arr.iter().enumerate() {
|
||||
let field_path = format!("{path}.fields[{i}]");
|
||||
out.push(BastField::parse(raw_field, &field_path)?);
|
||||
out.push(BastField::parse(raw_field, &field_path, doc_root)?);
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -531,7 +531,7 @@ impl BastUnion {
|
||||
let mut mapping = Vec::new();
|
||||
for (key, value) in raw_mapping.iter() {
|
||||
let entry_path = format!("{path}.mapping[{key}]");
|
||||
let ty = BastType::parse(value, &entry_path)?;
|
||||
let ty = BastType::parse(value, &entry_path, doc_root)?;
|
||||
mapping.push((key.clone(), ty));
|
||||
}
|
||||
if mapping.is_empty() {
|
||||
@@ -539,6 +539,75 @@ impl BastUnion {
|
||||
"bast: union at {path} has an empty `mapping`"
|
||||
)));
|
||||
}
|
||||
if let BastDiscriminator::Field { name } = &discriminator {
|
||||
if !fields.iter().any(|f| f.name() == name.as_str()) {
|
||||
return Err(AlkTypeError::Schema(format!(
|
||||
"bast: field-name union at {path} has no field {name:?} in `fields` (the discriminator field must be declared)"
|
||||
)));
|
||||
}
|
||||
let mut names: Vec<&str> = fields.iter().map(|f| f.name()).collect();
|
||||
names.sort_unstable();
|
||||
let dup = names.windows(2).find(|pair| pair[0] == pair[1]);
|
||||
if let Some([a, _]) = dup {
|
||||
return Err(AlkTypeError::Schema(format!(
|
||||
"bast: field-name union at {path} declares field {a:?} more than once in \
|
||||
`fields` (the shared-then-variant wire convention lays the shared fields \
|
||||
out once; a variant must not re-declare the discriminator or any shared \
|
||||
field)"
|
||||
)));
|
||||
}
|
||||
if fields[0].name() != name.as_str() {
|
||||
return Err(AlkTypeError::Schema(format!(
|
||||
"bast: field-name union at {path} has the discriminator field {name:?} at \
|
||||
position {}, but it must be the first entry in `fields` (the packed reader \
|
||||
reads the discriminator at the union's start offset; a later position would \
|
||||
make it read the wrong field's bytes — review #006 H3 item 2)",
|
||||
fields
|
||||
.iter()
|
||||
.position(|f| f.name() == name.as_str())
|
||||
.expect("checked above")
|
||||
)));
|
||||
}
|
||||
for (key, variant_ty) in &mapping {
|
||||
let variant_path = format!("{path}.mapping[{key}]");
|
||||
let clash = match variant_ty {
|
||||
BastType::Struct(s) => s
|
||||
.fields()
|
||||
.iter()
|
||||
.find(|vf| fields.iter().any(|sf| sf.name() == vf.name()))
|
||||
.map(|vf| vf.name().to_string()),
|
||||
BastType::Ref(r) => {
|
||||
let variant_node = BastDoc::lookup_def_raw(doc_root, r.name())?;
|
||||
match BastType::parse(variant_node, &variant_path, doc_root)? {
|
||||
BastType::Struct(s) => s
|
||||
.fields()
|
||||
.iter()
|
||||
.find(|vf| fields.iter().any(|sf| sf.name() == vf.name()))
|
||||
.map(|vf| vf.name().to_string()),
|
||||
other => {
|
||||
return Err(AlkTypeError::Schema(format!(
|
||||
"bast: union at {path} variant '{key}' must be a struct, got {}",
|
||||
other.alk_kind()
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
other => {
|
||||
return Err(AlkTypeError::Schema(format!(
|
||||
"bast: union at {path} variant '{key}' must be a struct, got {}",
|
||||
other.alk_kind()
|
||||
)));
|
||||
}
|
||||
};
|
||||
if let Some(field_name) = clash {
|
||||
return Err(AlkTypeError::Schema(format!(
|
||||
"bast: union at {path} variant '{key}' re-declares field {field_name:?} \
|
||||
which is already in the union's `fields` (the shared-then-variant wire \
|
||||
convention forbids variants from re-declaring shared fields)"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Self {
|
||||
endian,
|
||||
discriminator,
|
||||
@@ -562,7 +631,8 @@ pub enum BastDiscriminator {
|
||||
},
|
||||
/// A length-prefixed string field within the union. Mapping keys are
|
||||
/// string values matching the field's value. The `fields` array
|
||||
/// declares the discriminator field alongside any shared fields.
|
||||
/// declares the discriminator field (which must be the first entry)
|
||||
/// alongside any shared fields.
|
||||
Field {
|
||||
/// The field name that holds the discriminator value.
|
||||
name: String,
|
||||
@@ -658,7 +728,7 @@ impl BastEnum {
|
||||
&self.source
|
||||
}
|
||||
|
||||
fn parse(node: &Value, path: &str) -> Result<Self, AlkTypeError> {
|
||||
fn parse(node: &Value, path: &str, _doc_root: &Value) -> Result<Self, AlkTypeError> {
|
||||
let raw_values = node
|
||||
.get("values")
|
||||
.and_then(Value::as_array)
|
||||
@@ -734,7 +804,7 @@ impl BastType {
|
||||
}
|
||||
}
|
||||
|
||||
fn parse(node: &Value, path: &str) -> Result<Self, AlkTypeError> {
|
||||
fn parse(node: &Value, path: &str, doc_root: &Value) -> Result<Self, AlkTypeError> {
|
||||
if let Some(s) = node.as_str() {
|
||||
let k = AlkTypeKind::from_bast_str(s)?;
|
||||
return Ok(BastType::Primitive(k));
|
||||
@@ -758,11 +828,11 @@ impl BastType {
|
||||
})?;
|
||||
let k = AlkTypeKind::from_bast_str(kind_str)?;
|
||||
match k {
|
||||
AlkTypeKind::Array => Ok(BastType::Array(BastArray::parse(node, path)?)),
|
||||
AlkTypeKind::Record => Ok(BastType::Record(BastRecord::parse(node, path)?)),
|
||||
AlkTypeKind::Struct => Ok(BastType::Struct(BastStruct::parse(node, path)?)),
|
||||
AlkTypeKind::Union => Ok(BastType::Union(BastUnion::parse(node, path)?)),
|
||||
AlkTypeKind::Enum => Ok(BastType::Enum(BastEnum::parse(node, path)?)),
|
||||
AlkTypeKind::Array => Ok(BastType::Array(BastArray::parse(node, path, doc_root)?)),
|
||||
AlkTypeKind::Record => Ok(BastType::Record(BastRecord::parse(node, path, doc_root)?)),
|
||||
AlkTypeKind::Struct => Ok(BastType::Struct(BastStruct::parse(node, path, doc_root)?)),
|
||||
AlkTypeKind::Union => Ok(BastType::Union(BastUnion::parse(node, path, doc_root)?)),
|
||||
AlkTypeKind::Enum => Ok(BastType::Enum(BastEnum::parse(node, path, doc_root)?)),
|
||||
other => Err(AlkTypeError::Schema(format!(
|
||||
"bast: type at {path} has inline kind {other}, which is only valid as a primitive string or a $defs entry"
|
||||
))),
|
||||
@@ -841,13 +911,13 @@ impl BastArray {
|
||||
&self.source
|
||||
}
|
||||
|
||||
fn parse(node: &Value, path: &str) -> Result<Self, AlkTypeError> {
|
||||
fn parse(node: &Value, path: &str, doc_root: &Value) -> Result<Self, AlkTypeError> {
|
||||
let element_node = node.get("element").ok_or_else(|| {
|
||||
AlkTypeError::Schema(format!(
|
||||
"bast: array at {path} has no `element`"
|
||||
))
|
||||
})?;
|
||||
let element = BastType::parse(element_node, &format!("{path}.element"))?;
|
||||
let element = BastType::parse(element_node, &format!("{path}.element"), doc_root)?;
|
||||
let count = parse_usize_field(node, "count", path, "array count")?.ok_or_else(|| {
|
||||
AlkTypeError::Schema(format!(
|
||||
"bast: array at {path} has no `count` (variable-length arrays are not supported in v1, D-BAST-004)"
|
||||
@@ -890,13 +960,13 @@ impl BastRecord {
|
||||
&self.source
|
||||
}
|
||||
|
||||
fn parse(node: &Value, path: &str) -> Result<Self, AlkTypeError> {
|
||||
fn parse(node: &Value, path: &str, doc_root: &Value) -> Result<Self, AlkTypeError> {
|
||||
let values_node = node.get("values").ok_or_else(|| {
|
||||
AlkTypeError::Schema(format!(
|
||||
"bast: record at {path} has no `values`"
|
||||
))
|
||||
})?;
|
||||
let values = BastType::parse(values_node, &format!("{path}.values"))?;
|
||||
let values = BastType::parse(values_node, &format!("{path}.values"), doc_root)?;
|
||||
Ok(Self {
|
||||
values: Box::new(values),
|
||||
source: node.clone(),
|
||||
|
||||
Reference in new issue
Block a user