diff --git a/node-graph/node-macro/src/codegen/classify.rs b/node-graph/node-macro/src/codegen/classify.rs index bd09a009ec..835ebc06e9 100644 --- a/node-graph/node-macro/src/codegen/classify.rs +++ b/node-graph/node-macro/src/codegen/classify.rs @@ -312,26 +312,35 @@ pub(crate) fn unbounded_generic(parsed: &ParsedNodeFn, ty: &Type) -> Option Option { + record_shape_checked(parsed).ok().flatten() +} + +/// [`record_shape`] with its refusals named. `Ok(None)` is "not a record node", +/// which the other classes still answer for; `Err` is a node that declares +/// attribute io the record lowering cannot serve, and only a diagnostic ends it. +pub(crate) fn record_shape_checked(parsed: &ParsedNodeFn) -> Result, &'static str> { let source = is_async_source(parsed); let value = match kernel_kind(&parsed.output_type) { KernelKind::Plain => parsed.output_type.clone(), KernelKind::Interrupt(inner) => inner, _ if source => slot_value_type(&parsed.output_type), - _ => return None, + _ => return Ok(None), }; let writes = record_writes(&value); let has_reads = parsed.fields.iter().any(|field| !field.attribute_reads.is_empty() && matches!(field.ty, ParsedFieldType::Regular(_))); if !has_reads && writes.is_none() { - return None; + return Ok(None); } // An async source's slot stores the kernel's plain tuple; the per-eval lift // writes it through the claim, and the reads have no input to bind against. if source && (has_reads || writes.is_none()) { - return None; + return Err("an async source writes its tuple through the claim, so it takes writes and no reads"); } - let carrier_field = parsed.fields.first()?; + let Some(carrier_field) = parsed.fields.first() else { + return Err("attribute io needs a primary input as the first parameter after the context"); + }; if carrier_field.is_data_field { - return None; + return Err("a `#[data]` field is not a record carrier"); } // A first-field lazy carrier: the kernel evaluates the derived content // itself and returns its opaque row token beside the write set. @@ -343,30 +352,32 @@ pub(crate) fn record_shape(parsed: &ParsedNodeFn) -> Option { ParsedFieldType::Regular(_) => false, }; if parsed.fields.iter().skip(lazy_carrier as usize).any(unsupported_lazy_secondary) { - return None; + return Err("a lazy secondary consumes a plain element, so it takes no served, ranked, or reading input"); } let reads_well_placed = parsed.fields.iter().enumerate().all(|(index, field)| { field.attribute_reads.is_empty() || (lazy_carrier && index == 0) || (!field.is_data_field && matches!(&field.ty, ParsedFieldType::Regular(RegularParsedField { lend: None, .. }))) }); if !reads_well_placed { - return None; + return Err("an attribute read needs an owned regular input, not a `#[data]` field or a `&T`"); } if lazy_carrier { let ParsedFieldType::Node(NodeParsedField { output_type, .. }) = &carrier_field.ty else { unreachable!("guarded by the lazy_carrier match"); }; - let token = unbounded_generic(parsed, output_type)?; + let Some(token) = unbounded_generic(parsed, output_type) else { + return Err("a lazy carrier passes an unbounded generic row token through"); + }; let element = match writes { Some(RecordWrites { element, .. }) => element, None => value, }; if !matches!(bare_ident(&element), Some(ident) if ident == &token) { - return None; + return Err("a lazy carrier returns the row token it received, so the element is that generic"); } - return Some(RecordShape { carrier: RecordCarrier::LazyToken }); + return Ok(Some(RecordShape { carrier: RecordCarrier::LazyToken })); } let ParsedFieldType::Regular(RegularParsedField { ty, lend: None, implementations, .. }) = &carrier_field.ty else { - return None; + return Err("a record carrier is an owned value input, not a `&T` or a lazy one"); }; // A gathered subject is never read as an element, so its generic stays open. let gathers = crate::codegen::ir::gathers_lane(parsed); @@ -378,7 +389,7 @@ pub(crate) fn record_shape(parsed: &ParsedNodeFn) -> Option { Some(token) => Some(token), None => { if !gathers && !carrier_rows && contains_open_generic(parsed, ty) { - return None; + return Err("a reading carrier's element is monomorphic; spell it concretely or give it an `#[implementations]` list"); } None } @@ -398,29 +409,29 @@ pub(crate) fn record_shape(parsed: &ParsedNodeFn) -> Option { // fresh one instead of passing the token through. Some(_) if opaque_reading_carrier(parsed).is_some() => { if contains_open_generic(parsed, &element) { - return None; + return Err("an opaque reading carrier writes a concrete element, since its own is never read"); } } Some(token) => { if !matches!(bare_ident(&element), Some(ident) if ident == token) { - return None; + return Err("a passthrough generic element returns unchanged in the first tuple position"); } } None => { if !gathers && !carrier_rows && contains_open_generic(parsed, &element) { - return None; + return Err("a written element is monomorphic; spell it concretely or give the carrier an `#[implementations]` list"); } } } if matches!(carrier, RecordCarrier::None) && !removes.is_empty() { - return None; + return Err("a node with no carrier has no columns to remove"); } // The byte-carried token never becomes a value, so it cannot cross a // future boundary. if source && matches!(carrier, RecordCarrier::Token) { - return None; + return Err("a byte-carried token never becomes a value, so it cannot cross a future boundary"); } - Some(RecordShape { carrier }) + Ok(Some(RecordShape { carrier })) } pub(crate) fn is_poll_kernel(output: &Type) -> bool { diff --git a/node-graph/node-macro/src/validation.rs b/node-graph/node-macro/src/validation.rs index de872ec69e..618f4797cb 100644 --- a/node-graph/node-macro/src/validation.rs +++ b/node-graph/node-macro/src/validation.rs @@ -15,6 +15,7 @@ pub fn validate_node_fn(parsed: &ParsedNodeFn) -> syn::Result<()> { validate_lend_fields, validate_record_io, validate_lazy_reads, + validate_lowering_supported, ]; for validator in validators { @@ -24,6 +25,28 @@ pub fn validate_node_fn(parsed: &ParsedNodeFn) -> syn::Result<()> { Ok(()) } +/// A signature no lowering claims generates no `Node` impl at all, so the node +/// compiles and is simply missing at runtime. Naming the gap is the only thing +/// that ends it, since nothing downstream can tell "declined" from "absent". +fn validate_lowering_supported(parsed: &ParsedNodeFn) { + // A record-io refusal already reported its own reason. + if crate::codegen::classify::record_shape_checked(parsed).is_err() || crate::codegen::classify::analyze(parsed).is_some() { + return; + } + + // An async kernel with lazy inputs is refused by `validate_async_source`, which + // names the spawn boundary rather than the missing lowering. + if parsed.is_async && parsed.fields.iter().any(|field| matches!(field.ty, ParsedFieldType::Node(_))) { + return; + } + + emit_error!( + parsed.fn_name.span(), + "no lowering supports this signature, so the node would generate no `Node` impl"; + help = "a node is one of: attribute io (`Attr<..>` reads or writes), routing (an unbounded generic forwarded whole), a flipped kernel over an owned primary, or one with a ranked `IList` input" + ); +} + fn validate_record_io(parsed: &ParsedNodeFn) { let value = crate::codegen::slot_value_type(&parsed.output_type); if let Type::Tuple(tuple) = &value { @@ -44,6 +67,15 @@ fn validate_record_io(parsed: &ParsedNodeFn) { return; } + // A shape the record lowering refuses generates no node impl at all, so without a + // diagnostic the node compiles and is simply absent from the registry. The lowering + // is the authority on what it can serve, so its refusal is the one error reported: + // the checks below describe a shape it already accepted. + if let Err(reason) = crate::codegen::classify::record_shape_checked(parsed) { + emit_error!(parsed.fn_name.span(), "this node declares attribute io the record lowering cannot serve: {}", reason); + return; + } + let async_source = crate::codegen::classify::is_async_source(parsed); if async_source && has_reads { emit_error!(parsed.fn_name.span(), "attribute reads are not supported on async source kernels, only writes");