3131//! `stream<u8>` and a `stream<s32>` between the same two components are
3232//! two different streams. See ADR-3.
3333
34- use crate :: parser:: { CanonicalEntry , ComponentTypeKind , ComponentValType , ParsedComponent } ;
34+ use crate :: parser:: {
35+ CanonicalEntry , ComponentFuncDef , ComponentTypeKind , ComponentValType , ParsedComponent ,
36+ } ;
3537use std:: collections:: HashMap ;
38+ use wasmparser:: ComponentExternalKind ;
3639
3740/// The element type carried by a `stream<T>`, parsed from the
3841/// component-type descriptor the parser records.
@@ -491,24 +494,152 @@ pub fn pair_has_stream_typed_import(
491494 false
492495}
493496
497+ /// Resolve a component-level `Func` export by name to the stream
498+ /// element types its signature carries.
499+ ///
500+ /// Walks `comp.component_func_defs[export.index]` to find the source
501+ /// of the exported function:
502+ ///
503+ /// - [`ComponentFuncDef::Import`] → look up `comp.imports[idx].ty`
504+ /// and reuse [`stream_elements_in_typeref`].
505+ /// - [`ComponentFuncDef::Lift`] → look up the `CanonicalEntry::Lift`
506+ /// entry, extract its `type_index`, and walk it as
507+ /// `ComponentTypeRef::Func(type_index)`.
508+ /// - [`ComponentFuncDef::InstanceExportAlias`] → returns empty for
509+ /// now; alias chains require chasing through nested instance
510+ /// types and are deferred. The common-case stream-bearing exports
511+ /// are Lift entries (component-defined functions) or Import
512+ /// re-exports (forwarding pattern), both covered above.
513+ ///
514+ /// Returns an empty Vec for non-Func exports, unresolved indices,
515+ /// or alias-export chains — same "this edge carries no streams"
516+ /// signal as the importer-side walker.
517+ pub fn export_stream_elements ( comp : & ParsedComponent , export_name : & str ) -> Vec < StreamElement > {
518+ let Some ( export) = comp. exports . iter ( ) . find ( |e| e. name == export_name) else {
519+ return Vec :: new ( ) ;
520+ } ;
521+ if !matches ! ( export. kind, ComponentExternalKind :: Func ) {
522+ return Vec :: new ( ) ;
523+ }
524+ let Some ( def) = comp. component_func_defs . get ( export. index as usize ) else {
525+ return Vec :: new ( ) ;
526+ } ;
527+ match def {
528+ ComponentFuncDef :: Import ( import_idx) => comp
529+ . imports
530+ . get ( * import_idx)
531+ . map ( |imp| stream_elements_in_typeref ( comp, & imp. ty ) )
532+ . unwrap_or_default ( ) ,
533+ ComponentFuncDef :: Lift ( canon_idx) => {
534+ let Some ( entry) = comp. canonical_functions . get ( * canon_idx) else {
535+ return Vec :: new ( ) ;
536+ } ;
537+ let CanonicalEntry :: Lift { type_index, .. } = entry else {
538+ return Vec :: new ( ) ;
539+ } ;
540+ stream_elements_in_typeref ( comp, & wasmparser:: ComponentTypeRef :: Func ( * type_index) )
541+ }
542+ ComponentFuncDef :: InstanceExportAlias ( _) => {
543+ // Deferred — would require following the alias chain
544+ // through `comp.component_aliases[idx]` and then
545+ // resolving the alias's target instance type. Tracked
546+ // in LS-R-11's "limits" block.
547+ Vec :: new ( )
548+ }
549+ }
550+ }
551+
494552/// Run #142's static validation passes over a built [`StreamPairGraph`].
495553///
496554/// Returns an empty vec when no issues were found. Caller decides
497555/// whether to surface as warnings or hard errors (the resolver hoists
498556/// each issue into [`crate::error::Error::StreamValidation`]).
557+ ///
558+ /// **Check (i) precision**: per-edge type comparison. For each
559+ /// resolved import where both sides carry stream<T> references,
560+ /// the importer-side and exporter-side element types are compared
561+ /// directly. Falls back to the role-list heuristic only when the
562+ /// exporter side is unresolvable (alias-chain exports, which
563+ /// haven't been threaded through `component_func_defs` walking
564+ /// yet). The previous v0.13.0 release shipped only the heuristic;
565+ /// v0.15.0 promotes most fusion connections to the precise check.
499566pub fn validate_stream_pair_graph (
500567 components : & [ ParsedComponent ] ,
501568 resolved_imports : & HashMap < ( usize , String ) , ( usize , String ) > ,
502569 graph : & StreamPairGraph ,
503570) -> Vec < StreamValidationIssue > {
504571 let roles: Vec < Vec < ( StreamElement , StreamRole ) > > =
505572 components. iter ( ) . map ( component_stream_roles) . collect ( ) ;
506- let connections = fusion_connections ( resolved_imports) ;
507573
508574 let mut issues = Vec :: new ( ) ;
575+ let mut precise_pairs: std:: collections:: HashSet < ( usize , usize ) > =
576+ std:: collections:: HashSet :: new ( ) ;
577+
578+ // Check (i), layer 2 (per-edge precise): walk every resolved
579+ // import. If the importer's import is stream-typed AND the
580+ // exporter's matching export is stream-typed AND the element-
581+ // type multisets differ, emit a TypeMismatch keyed to the
582+ // exact (importer, exporter) component pair. Once a pair has
583+ // been precisely checked (whether or not it raised), suppress
584+ // the layer-1 heuristic for that pair to avoid double-counting.
585+ for ( ( importer, import_name) , ( exporter, export_name) ) in resolved_imports {
586+ if importer == exporter {
587+ continue ;
588+ }
589+ let Some ( importer_comp) = components. get ( * importer) else {
590+ continue ;
591+ } ;
592+ let Some ( import) = importer_comp
593+ . imports
594+ . iter ( )
595+ . find ( |i| & i. name == import_name)
596+ else {
597+ continue ;
598+ } ;
599+ let imp_elems = stream_elements_in_typeref ( importer_comp, & import. ty ) ;
600+ if imp_elems. is_empty ( ) {
601+ continue ;
602+ }
603+ let Some ( exporter_comp) = components. get ( * exporter) else {
604+ continue ;
605+ } ;
606+ let exp_elems = export_stream_elements ( exporter_comp, export_name) ;
607+ if exp_elems. is_empty ( ) {
608+ // Exporter side unresolved (alias chain, missing
609+ // export, etc.). Leave to the layer-1 heuristic.
610+ continue ;
611+ }
612+ precise_pairs. insert ( ( * importer, * exporter) ) ;
613+
614+ // Compare as multisets. Sort + equality is fine for the
615+ // small list sizes typical of stream-bearing function
616+ // signatures (usually 1 stream per signature).
617+ let mut imp_sorted: Vec < _ > = imp_elems. iter ( ) . collect ( ) ;
618+ let mut exp_sorted: Vec < _ > = exp_elems. iter ( ) . collect ( ) ;
619+ imp_sorted. sort_by_key ( |e| format ! ( "{e:?}" ) ) ;
620+ exp_sorted. sort_by_key ( |e| format ! ( "{e:?}" ) ) ;
621+ if imp_sorted != exp_sorted {
622+ let issue = StreamValidationIssue :: TypeMismatch {
623+ producer_component : * exporter,
624+ consumer_component : * importer,
625+ producer_types : exp_elems. clone ( ) ,
626+ consumer_types : imp_elems. clone ( ) ,
627+ } ;
628+ if !issues. contains ( & issue) {
629+ issues. push ( issue) ;
630+ }
631+ }
632+ }
509633
510- // Check (i): type-mismatch, filtered by stream-typed-import presence.
634+ // Check (i), layer 1 (role-list heuristic, filtered): only fire
635+ // on fusion connections that DID NOT get a precise check above.
636+ // The filter still gates by stream-typed-import presence so
637+ // sync-only connections with unrelated streams stay silent.
638+ let connections = fusion_connections ( resolved_imports) ;
511639 for & ( a, b) in & connections {
640+ if precise_pairs. contains ( & ( a, b) ) || precise_pairs. contains ( & ( b, a) ) {
641+ continue ;
642+ }
512643 if !pair_has_stream_typed_import ( components, resolved_imports, a, b) {
513644 continue ;
514645 }
@@ -988,6 +1119,16 @@ mod tests {
9881119 exports : Vec < ComponentExport > ,
9891120 types : Vec < ComponentType > ,
9901121 canonical_functions : Vec < CanonicalEntry > ,
1122+ ) -> ParsedComponent {
1123+ make_component_with_func_defs ( imports, exports, types, canonical_functions, vec ! [ ] )
1124+ }
1125+
1126+ fn make_component_with_func_defs (
1127+ imports : Vec < ComponentImport > ,
1128+ exports : Vec < ComponentExport > ,
1129+ types : Vec < ComponentType > ,
1130+ canonical_functions : Vec < CanonicalEntry > ,
1131+ component_func_defs : Vec < ComponentFuncDef > ,
9911132 ) -> ParsedComponent {
9921133 ParsedComponent {
9931134 name : None ,
@@ -1001,7 +1142,7 @@ mod tests {
10011142 component_aliases : vec ! [ ] ,
10021143 component_instances : vec ! [ ] ,
10031144 core_entity_order : vec ! [ ] ,
1004- component_func_defs : vec ! [ ] ,
1145+ component_func_defs,
10051146 component_instance_defs : vec ! [ ] ,
10061147 component_type_defs : vec ! [ ] ,
10071148 original_size : 0 ,
@@ -1235,4 +1376,172 @@ mod tests {
12351376 "sync function must not surface stream elements; got {elems:?}"
12361377 ) ;
12371378 }
1379+
1380+ // ─── LS-R-11 layer-2 (precise per-edge) tests ─────────────────────
1381+
1382+ /// Direct unit test of [`export_stream_elements`] for the
1383+ /// `ComponentFuncDef::Lift` resolution path. A component that
1384+ /// exports a `canon lift` of a function taking `stream<U8>` must
1385+ /// surface that stream element type via the export-side walker —
1386+ /// this is the path the layer-2 precise check relies on.
1387+ #[ test]
1388+ fn export_stream_elements_walks_lift_function_signature ( ) {
1389+ let comp = make_component_with_func_defs (
1390+ vec ! [ ] ,
1391+ vec ! [ ComponentExport {
1392+ name: "produce" . into( ) ,
1393+ kind: ComponentExternalKind :: Func ,
1394+ index: 0 ,
1395+ } ] ,
1396+ vec ! [
1397+ stream_type( "U8" ) , // types[0]: stream<U8>
1398+ func_type_taking_stream( 0 ) , // types[1]: fn(stream<U8>)
1399+ ] ,
1400+ vec ! [ CanonicalEntry :: Lift {
1401+ core_func_index: 0 ,
1402+ type_index: 1 ,
1403+ options: options( ) ,
1404+ } ] ,
1405+ // component_func_defs[0] = Lift(0) — the exported function
1406+ // came from canonical_functions[0].
1407+ vec ! [ ComponentFuncDef :: Lift ( 0 ) ] ,
1408+ ) ;
1409+ let elems = export_stream_elements ( & comp, "produce" ) ;
1410+ assert_eq ! ( elems, vec![ typed( "U8" ) ] ) ;
1411+ }
1412+
1413+ /// `export_stream_elements` must return empty for an export
1414+ /// resolved through an alias chain (`InstanceExportAlias`) —
1415+ /// that path is deferred per the LS-R-11 limits documentation,
1416+ /// and the precise check falls back to the layer-1 heuristic
1417+ /// for those edges.
1418+ #[ test]
1419+ fn export_stream_elements_returns_empty_for_alias_export ( ) {
1420+ let comp = make_component_with_func_defs (
1421+ vec ! [ ] ,
1422+ vec ! [ ComponentExport {
1423+ name: "aliased" . into( ) ,
1424+ kind: ComponentExternalKind :: Func ,
1425+ index: 0 ,
1426+ } ] ,
1427+ vec ! [ ] ,
1428+ vec ! [ ] ,
1429+ // component_func_defs[0] = InstanceExportAlias(0). The
1430+ // alias index doesn't matter for this test — the walker
1431+ // returns empty without inspecting it.
1432+ vec ! [ ComponentFuncDef :: InstanceExportAlias ( 0 ) ] ,
1433+ ) ;
1434+ let elems = export_stream_elements ( & comp, "aliased" ) ;
1435+ assert ! (
1436+ elems. is_empty( ) ,
1437+ "alias-export path should return empty until alias chains are walked; got {elems:?}"
1438+ ) ;
1439+ }
1440+
1441+ /// LS-R-11 layer-2 regression: when the importer's
1442+ /// `stream<u8>` import is resolved to an exporter's Lift-defined
1443+ /// `stream<s32>` export, the per-edge precise check MUST raise
1444+ /// a TypeMismatch with the actual element types — not just
1445+ /// "no element type pairs".
1446+ #[ test]
1447+ fn ls_r_11_per_edge_lift_export_mismatch_raises ( ) {
1448+ let comp_a = make_component_with_func_defs (
1449+ vec ! [ ComponentImport {
1450+ name: "data" . into( ) ,
1451+ ty: wasmparser:: ComponentTypeRef :: Func ( 1 ) ,
1452+ } ] ,
1453+ vec ! [ ] ,
1454+ vec ! [ stream_type( "U8" ) , func_type_taking_stream( 0 ) ] ,
1455+ vec ! [ ] ,
1456+ vec ! [ ComponentFuncDef :: Import ( 0 ) ] ,
1457+ ) ;
1458+ let comp_b = make_component_with_func_defs (
1459+ vec ! [ ] ,
1460+ vec ! [ ComponentExport {
1461+ name: "data" . into( ) ,
1462+ kind: ComponentExternalKind :: Func ,
1463+ index: 0 ,
1464+ } ] ,
1465+ vec ! [
1466+ stream_type( "S32" ) , // types[0]: stream<S32> ≠ A's stream<U8>
1467+ func_type_taking_stream( 0 ) , // types[1]: fn(stream<S32>)
1468+ ] ,
1469+ vec ! [ CanonicalEntry :: Lift {
1470+ core_func_index: 0 ,
1471+ type_index: 1 ,
1472+ options: options( ) ,
1473+ } ] ,
1474+ vec ! [ ComponentFuncDef :: Lift ( 0 ) ] ,
1475+ ) ;
1476+ let components = vec ! [ comp_a, comp_b] ;
1477+ let mut resolved: HashMap < ( usize , String ) , ( usize , String ) > = HashMap :: new ( ) ;
1478+ resolved. insert ( ( 0 , "data" . into ( ) ) , ( 1 , "data" . into ( ) ) ) ;
1479+ let graph = StreamPairGraph :: default ( ) ;
1480+
1481+ let issues = validate_stream_pair_graph ( & components, & resolved, & graph) ;
1482+ let mismatch = issues. iter ( ) . find_map ( |i| match i {
1483+ StreamValidationIssue :: TypeMismatch {
1484+ producer_component,
1485+ consumer_component,
1486+ producer_types,
1487+ consumer_types,
1488+ } => Some ( (
1489+ * producer_component,
1490+ * consumer_component,
1491+ producer_types. clone ( ) ,
1492+ consumer_types. clone ( ) ,
1493+ ) ) ,
1494+ _ => None ,
1495+ } ) ;
1496+ let mismatch = mismatch. expect ( "expected exactly one TypeMismatch" ) ;
1497+ // Per-edge: producer is the exporter (comp 1), consumer is
1498+ // the importer (comp 0). Types are the precise element types
1499+ // from each side's signature, not role-list multisets.
1500+ assert_eq ! ( mismatch. 0 , 1 , "producer_component should be the exporter" ) ;
1501+ assert_eq ! ( mismatch. 1 , 0 , "consumer_component should be the importer" ) ;
1502+ assert_eq ! ( mismatch. 2 , vec![ typed( "S32" ) ] ) ;
1503+ assert_eq ! ( mismatch. 3 , vec![ typed( "U8" ) ] ) ;
1504+ }
1505+
1506+ /// LS-R-11 layer-2: matching stream types on a resolved edge
1507+ /// must NOT raise (precise check path, not the heuristic). Pins
1508+ /// the no-false-positive property of the precise check.
1509+ #[ test]
1510+ fn per_edge_matching_lift_export_does_not_raise ( ) {
1511+ let comp_a = make_component_with_func_defs (
1512+ vec ! [ ComponentImport {
1513+ name: "data" . into( ) ,
1514+ ty: wasmparser:: ComponentTypeRef :: Func ( 1 ) ,
1515+ } ] ,
1516+ vec ! [ ] ,
1517+ vec ! [ stream_type( "U8" ) , func_type_taking_stream( 0 ) ] ,
1518+ vec ! [ ] ,
1519+ vec ! [ ComponentFuncDef :: Import ( 0 ) ] ,
1520+ ) ;
1521+ let comp_b = make_component_with_func_defs (
1522+ vec ! [ ] ,
1523+ vec ! [ ComponentExport {
1524+ name: "data" . into( ) ,
1525+ kind: ComponentExternalKind :: Func ,
1526+ index: 0 ,
1527+ } ] ,
1528+ vec ! [ stream_type( "U8" ) , func_type_taking_stream( 0 ) ] ,
1529+ vec ! [ CanonicalEntry :: Lift {
1530+ core_func_index: 0 ,
1531+ type_index: 1 ,
1532+ options: options( ) ,
1533+ } ] ,
1534+ vec ! [ ComponentFuncDef :: Lift ( 0 ) ] ,
1535+ ) ;
1536+ let components = vec ! [ comp_a, comp_b] ;
1537+ let mut resolved: HashMap < ( usize , String ) , ( usize , String ) > = HashMap :: new ( ) ;
1538+ resolved. insert ( ( 0 , "data" . into ( ) ) , ( 1 , "data" . into ( ) ) ) ;
1539+ let graph = StreamPairGraph :: default ( ) ;
1540+
1541+ let issues = validate_stream_pair_graph ( & components, & resolved, & graph) ;
1542+ assert ! (
1543+ issues. is_empty( ) ,
1544+ "matching stream types on resolved edge must not raise; got {issues:?}"
1545+ ) ;
1546+ }
12381547}
0 commit comments