Skip to main content

presolve_compiler/
effect.rs

1use std::collections::{BTreeMap, BTreeSet};
2
3use crate::{
4    ComponentNode, ComputedPurity, ComputedValue, EffectStatementSyntaxKind, ExecutionBoundary,
5    ExpressionGraph, IrComputedEvaluationPlan, IrReactiveGraph, IrReactiveNode, IrReactiveNodeKind,
6    IrReactiveTransitiveAnalysis, IrUpdateScheduler, SemanticId, SemanticOwner, SemanticTypeModel,
7    SourceProvenance, UnsupportedEffectStatementKind,
8};
9
10/// Compiler-owned execution contract for an effect.
11///
12/// The scheduler will consume this policy in later Phase F slices. It is
13/// declared here so effect timing remains compiler semantics rather than a
14/// runtime callback convention.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum EffectExecutionPolicy {
17    AfterInitialRenderAndCompletedActionBatch,
18}
19
20/// Compiler classification of an effect's language-semantic validity.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum EffectValidation {
23    Unvalidated,
24    Valid,
25    Invalid,
26}
27
28/// Unsupported behavior that makes an effect ineligible for later lowering.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
30pub enum EffectSemanticViolationKind {
31    Async,
32    ReactiveStateMutation,
33    ActionInvocation,
34    EffectInvocation,
35    ComponentMethodInvocation,
36    UnresolvedComponentCall,
37    UnresolvedComponentAssignment,
38    UnknownExternalCapability,
39    CapabilitySignature,
40    CapabilityBoundary,
41    CapabilitySerialization,
42    ValueReturn,
43    UnsupportedStatement,
44}
45
46/// One compiler-owned effect semantic violation with canonical provenance.
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct EffectSemanticViolation {
49    pub kind: EffectSemanticViolationKind,
50    pub statement: Option<SemanticId>,
51    pub provenance: SourceProvenance,
52}
53
54/// Transitive reactive topology for one valid terminal effect.
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct EffectReactiveAnalysis {
57    pub effect: SemanticId,
58    pub dependencies: Vec<SemanticId>,
59    pub dependents: Vec<SemanticId>,
60}
61
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct ActionBatch {
64    pub id: SemanticId,
65    pub authored_action_method: SemanticId,
66    pub ordered_write_records: Vec<SemanticId>,
67    pub written_states: Vec<SemanticId>,
68    pub provenance: SourceProvenance,
69}
70
71#[derive(Debug, Clone, PartialEq, Eq)]
72pub struct ActionBatchEffectTrigger {
73    pub action_batch: SemanticId,
74    pub effects: Vec<SemanticId>,
75    pub matched_states: BTreeMap<SemanticId, Vec<SemanticId>>,
76}
77
78#[derive(Debug, Clone, PartialEq, Eq, Default)]
79pub struct EffectTriggerPlan {
80    pub initial_effects: Vec<SemanticId>,
81    pub action_batches: BTreeMap<SemanticId, ActionBatch>,
82    pub action_batch_triggers: Vec<ActionBatchEffectTrigger>,
83}
84
85/// One filtered reference to an existing E9 computed-update batch.
86///
87/// `source_batch_index` identifies the canonical E9 batch; `computed` retains
88/// only the effect-required members without changing E9's ordering or batch
89/// boundaries.
90#[derive(Debug, Clone, PartialEq, Eq)]
91pub struct EffectComputedPrerequisiteBatch {
92    pub source_batch_index: u32,
93    pub computed: Vec<SemanticId>,
94}
95
96/// The compiler-owned boundary separating initial rendering from effects.
97#[derive(Debug, Clone, Copy, PartialEq, Eq)]
98pub enum EffectRenderBoundary {
99    AfterInitialRender,
100}
101
102/// One scheduler-produced terminal batch of effects.
103#[derive(Debug, Clone, PartialEq, Eq)]
104pub struct EffectExecutionBatch {
105    pub index: u32,
106    pub effects: Vec<SemanticId>,
107}
108
109/// The reason an otherwise eligible effect has no execution batch.
110#[derive(Debug, Clone, Copy, PartialEq, Eq)]
111pub enum UnplannedEffectReason {
112    UnavailableComputedPrerequisite,
113}
114
115/// An F8-eligible effect whose required computed values cannot be executed.
116#[derive(Debug, Clone, PartialEq, Eq)]
117pub struct UnplannedEffect {
118    pub effect: SemanticId,
119    pub reason: UnplannedEffectReason,
120    pub computed_dependencies: Vec<SemanticId>,
121}
122
123/// The initial effect schedule, explicitly separated from rendering.
124#[derive(Debug, Clone, PartialEq, Eq, Default)]
125pub struct InitialEffectExecutionPlan {
126    pub required_computed: Vec<SemanticId>,
127    pub prerequisite_batches: Vec<EffectComputedPrerequisiteBatch>,
128    pub render_boundary: Option<EffectRenderBoundary>,
129    pub effect_batches: Vec<EffectExecutionBatch>,
130    pub unplanned_effects: Vec<UnplannedEffect>,
131}
132
133/// The compiler-owned effect schedule for one completed action batch.
134#[derive(Debug, Clone, PartialEq, Eq)]
135pub struct ActionEffectExecutionPlan {
136    pub action_batch: SemanticId,
137    pub required_computed: Vec<SemanticId>,
138    pub prerequisite_batches: Vec<EffectComputedPrerequisiteBatch>,
139    pub effect_batches: Vec<EffectExecutionBatch>,
140    pub unplanned_effects: Vec<UnplannedEffect>,
141}
142
143/// Immutable F9 scheduling product derived from F7, F8, and E9 products.
144#[derive(Debug, Clone, PartialEq, Eq, Default)]
145pub struct EffectExecutionPlan {
146    pub initial: InitialEffectExecutionPlan,
147    pub actions: Vec<ActionEffectExecutionPlan>,
148}
149
150/// A first-class compiler semantic entity for one `@effect()` method.
151#[derive(Debug, Clone, PartialEq, Eq)]
152pub struct Effect {
153    pub id: SemanticId,
154    pub owner: SemanticOwner,
155    pub method: SemanticId,
156    pub name: String,
157    pub execution_boundary: ExecutionBoundary,
158    pub execution_policy: EffectExecutionPolicy,
159    pub validation: EffectValidation,
160    pub semantic_violations: Vec<EffectSemanticViolation>,
161    pub provenance: SourceProvenance,
162}
163
164/// An ordered, compiler-owned effect body.
165#[derive(Debug, Clone, PartialEq, Eq)]
166pub struct EffectBody {
167    pub effect: SemanticId,
168    pub statements: Vec<SemanticId>,
169    pub provenance: SourceProvenance,
170}
171
172/// One compiler-owned statement belonging to an effect body.
173#[derive(Debug, Clone, PartialEq, Eq)]
174pub struct EffectStatement {
175    pub id: SemanticId,
176    pub owner: SemanticId,
177    pub kind: EffectStatementKind,
178    pub provenance: SourceProvenance,
179}
180
181#[derive(Debug, Clone, PartialEq, Eq)]
182pub enum EffectStatementKind {
183    ExternalMemberAssignment {
184        target: SemanticId,
185        value: SemanticId,
186    },
187    CapabilityCall {
188        callee: SemanticId,
189        arguments: Vec<SemanticId>,
190    },
191    EffectReturn {
192        value: Option<SemanticId>,
193    },
194    Empty,
195    Unsupported(UnsupportedEffectStatementKind),
196}
197
198/// Collect canonical effect entities in stable semantic-ID order.
199///
200/// # Panics
201///
202/// Panics when an effect method has no canonical source provenance.
203#[must_use]
204pub fn collect_effects(
205    components: &[ComponentNode],
206    provenance: &BTreeMap<SemanticId, SourceProvenance>,
207) -> BTreeMap<SemanticId, Effect> {
208    components
209        .iter()
210        .flat_map(|component| {
211            component
212                .methods
213                .iter()
214                .filter(|method| method.is_effect())
215                .map(move |method| {
216                    let id = component.id.effect(&method.name);
217                    let provenance = provenance
218                        .get(&method.id)
219                        .expect("effect methods should have canonical provenance")
220                        .clone();
221                    (
222                        id.clone(),
223                        Effect {
224                            id,
225                            owner: SemanticOwner::entity(component.id.clone()),
226                            method: method.id.clone(),
227                            name: method.name.clone(),
228                            execution_boundary: ExecutionBoundary::Client,
229                            execution_policy:
230                                EffectExecutionPolicy::AfterInitialRenderAndCompletedActionBatch,
231                            validation: EffectValidation::Unvalidated,
232                            semantic_violations: Vec::new(),
233                            provenance,
234                        },
235                    )
236                })
237        })
238        .collect()
239}
240
241/// Classifies effect legality from F4 statement facts without adding diagnostics.
242#[allow(clippy::too_many_lines)]
243#[must_use]
244pub fn validate_effects(
245    components: &[ComponentNode],
246    mut effects: BTreeMap<SemanticId, Effect>,
247    statements: &BTreeMap<SemanticId, EffectStatement>,
248    semantic_types: &SemanticTypeModel,
249) -> BTreeMap<SemanticId, Effect> {
250    for effect in effects.values_mut() {
251        let mut violations = Vec::new();
252        let method = effect
253            .owner
254            .entity_id()
255            .and_then(|component_id| {
256                components
257                    .iter()
258                    .find(|component| component.id == *component_id)
259            })
260            .and_then(|component| {
261                component
262                    .methods
263                    .iter()
264                    .find(|method| method.id == effect.method)
265            });
266        if method.is_some_and(|method| method.is_async) {
267            violations.push(EffectSemanticViolation {
268                kind: EffectSemanticViolationKind::Async,
269                statement: None,
270                provenance: effect.provenance.clone(),
271            });
272        }
273        for statement in statements
274            .values()
275            .filter(|statement| statement.owner == effect.id)
276        {
277            let Some(record) = semantic_types.effect_statements.get(&statement.id) else {
278                continue;
279            };
280            let statement_violation = match record.operation_classification {
281                crate::EffectOperationClassification::ReactiveStateAssignment => {
282                    Some(EffectSemanticViolationKind::ReactiveStateMutation)
283                }
284                crate::EffectOperationClassification::ComponentActionCall => {
285                    Some(EffectSemanticViolationKind::ActionInvocation)
286                }
287                crate::EffectOperationClassification::ComponentEffectCall => {
288                    Some(EffectSemanticViolationKind::EffectInvocation)
289                }
290                crate::EffectOperationClassification::ComponentMethodCall => {
291                    Some(EffectSemanticViolationKind::ComponentMethodInvocation)
292                }
293                crate::EffectOperationClassification::UnresolvedComponentCall => {
294                    Some(EffectSemanticViolationKind::UnresolvedComponentCall)
295                }
296                crate::EffectOperationClassification::UnresolvedComponentAssignment => {
297                    Some(EffectSemanticViolationKind::UnresolvedComponentAssignment)
298                }
299                crate::EffectOperationClassification::UnknownExternalCapability => {
300                    Some(EffectSemanticViolationKind::UnknownExternalCapability)
301                }
302                crate::EffectOperationClassification::ValueReturn => {
303                    Some(EffectSemanticViolationKind::ValueReturn)
304                }
305                crate::EffectOperationClassification::UnsupportedStatement => {
306                    Some(EffectSemanticViolationKind::UnsupportedStatement)
307                }
308                crate::EffectOperationClassification::RecognizedCapability
309                | crate::EffectOperationClassification::BareReturn
310                | crate::EffectOperationClassification::Empty => None,
311            };
312            if let Some(kind) = statement_violation {
313                violations.push(EffectSemanticViolation {
314                    kind,
315                    statement: Some(statement.id.clone()),
316                    provenance: statement.provenance.clone(),
317                });
318            }
319            for (compatibility, kind) in [
320                (
321                    record.signature_compatibility,
322                    EffectSemanticViolationKind::CapabilitySignature,
323                ),
324                (
325                    record.boundary_compatibility,
326                    EffectSemanticViolationKind::CapabilityBoundary,
327                ),
328                (
329                    record.serialization_compatibility,
330                    EffectSemanticViolationKind::CapabilitySerialization,
331                ),
332            ] {
333                if compatibility != crate::EffectCompatibility::Compatible
334                    && record.operation_classification
335                        == crate::EffectOperationClassification::RecognizedCapability
336                {
337                    violations.push(EffectSemanticViolation {
338                        kind,
339                        statement: Some(statement.id.clone()),
340                        provenance: statement.provenance.clone(),
341                    });
342                }
343            }
344        }
345        violations.sort_by(|left, right| {
346            (
347                left.kind,
348                left.provenance.path.as_path(),
349                left.provenance.span.start,
350                left.provenance.span.end,
351            )
352                .cmp(&(
353                    right.kind,
354                    right.provenance.path.as_path(),
355                    right.provenance.span.start,
356                    right.provenance.span.end,
357                ))
358        });
359        violations.dedup_by(|left, right| {
360            left.kind == right.kind
361                && left.statement == right.statement
362                && left.provenance == right.provenance
363        });
364        effect.validation = if violations.is_empty() {
365            EffectValidation::Valid
366        } else {
367            EffectValidation::Invalid
368        };
369        effect.semantic_violations = violations;
370    }
371    effects
372}
373
374/// Projects existing reactive transitive analysis into effect-keyed records.
375#[must_use]
376pub fn analyze_effect_reactivity(
377    components: &[ComponentNode],
378    computed_values: &BTreeMap<SemanticId, crate::ComputedValue>,
379    effects: &BTreeMap<SemanticId, Effect>,
380    transitive: &IrReactiveTransitiveAnalysis,
381) -> BTreeMap<SemanticId, EffectReactiveAnalysis> {
382    let identities = components
383        .iter()
384        .flat_map(|component| component.state_fields.iter().map(|field| field.id.clone()))
385        .chain(computed_values.keys().cloned())
386        .chain(effects.keys().cloned())
387        .map(|id| (id.as_str().to_string(), id))
388        .collect::<BTreeMap<_, _>>();
389    effects
390        .values()
391        .filter(|effect| effect.validation == EffectValidation::Valid)
392        .map(|effect| {
393            let map_ids = |ids: Option<&Vec<String>>| {
394                ids.into_iter()
395                    .flatten()
396                    .filter_map(|id| identities.get(id).cloned())
397                    .collect()
398            };
399            (
400                effect.id.clone(),
401                EffectReactiveAnalysis {
402                    effect: effect.id.clone(),
403                    dependencies: map_ids(transitive.dependencies.get(effect.id.as_str())),
404                    dependents: map_ids(transitive.dependents.get(effect.id.as_str())),
405                },
406            )
407        })
408        .collect()
409}
410
411/// # Panics
412///
413/// Panics when an authored action method has no canonical source provenance.
414#[must_use]
415pub fn derive_effect_trigger_plan(
416    components: &[ComponentNode],
417    effects: &BTreeMap<SemanticId, Effect>,
418    analysis: &BTreeMap<SemanticId, EffectReactiveAnalysis>,
419    provenance: &BTreeMap<SemanticId, SourceProvenance>,
420) -> EffectTriggerPlan {
421    let initial_effects = effects
422        .values()
423        .filter(|effect| effect.validation == EffectValidation::Valid)
424        .map(|effect| effect.id.clone())
425        .collect::<Vec<_>>();
426    let mut action_batches = BTreeMap::new();
427    for component in components {
428        for method in component.methods.iter().filter(|method| method.is_action()) {
429            let writes = component
430                .actions
431                .iter()
432                .filter(|action| action.method == method.name)
433                .collect::<Vec<_>>();
434            let id = component.id.action_batch(&method.name);
435            action_batches.insert(
436                id.clone(),
437                ActionBatch {
438                    id,
439                    authored_action_method: method.id.clone(),
440                    ordered_write_records: writes.iter().map(|action| action.id.clone()).collect(),
441                    written_states: writes
442                        .iter()
443                        .map(|action| component.id.state_field(&action.field))
444                        .collect::<std::collections::BTreeSet<_>>()
445                        .into_iter()
446                        .collect(),
447                    provenance: provenance
448                        .get(&method.id)
449                        .expect("action methods should have canonical provenance")
450                        .clone(),
451                },
452            );
453        }
454    }
455    let action_batch_triggers = action_batches
456        .values()
457        .filter_map(|batch| {
458            let matched_states = initial_effects
459                .iter()
460                .filter_map(|effect| {
461                    let matches = analysis
462                        .get(effect)?
463                        .dependencies
464                        .iter()
465                        .filter(|dependency| batch.written_states.contains(dependency))
466                        .cloned()
467                        .collect::<Vec<_>>();
468                    (!matches.is_empty()).then(|| (effect.clone(), matches))
469                })
470                .collect::<BTreeMap<_, _>>();
471            (!matched_states.is_empty()).then(|| ActionBatchEffectTrigger {
472                action_batch: batch.id.clone(),
473                effects: matched_states.keys().cloned().collect(),
474                matched_states,
475            })
476        })
477        .collect();
478    EffectTriggerPlan {
479        initial_effects,
480        action_batches,
481        action_batch_triggers,
482    }
483}
484
485/// Derive minimal, dependency-complete computed prerequisites and terminal
486/// effect batches from existing F7, F8, and E9 compiler products.
487///
488/// This function never inspects effect or computed source expressions. E9
489/// remains the authority for computed ordering; F9 only filters its batches
490/// and asks the existing Phase D scheduler to form terminal effect batches.
491#[must_use]
492pub fn plan_effect_execution(
493    computed_values: &BTreeMap<SemanticId, ComputedValue>,
494    effects: &BTreeMap<SemanticId, Effect>,
495    effect_analysis: &BTreeMap<SemanticId, EffectReactiveAnalysis>,
496    trigger_plan: &EffectTriggerPlan,
497    reactive_transitive_analysis: &IrReactiveTransitiveAnalysis,
498    computed_evaluation_plan: &IrComputedEvaluationPlan,
499) -> EffectExecutionPlan {
500    let computed_by_text = computed_values
501        .keys()
502        .map(|id| (id.as_str().to_string(), id.clone()))
503        .collect::<BTreeMap<_, _>>();
504    let executable = computed_evaluation_plan
505        .evaluation_order
506        .iter()
507        .filter_map(|id| computed_by_text.get(id))
508        .filter(|id| {
509            computed_values
510                .get(*id)
511                .is_some_and(|computed| computed.purity == ComputedPurity::Pure)
512        })
513        .cloned()
514        .collect::<BTreeSet<_>>();
515    let initial = build_initial_effect_execution_plan(
516        &trigger_plan.initial_effects,
517        effects,
518        effect_analysis,
519        computed_values,
520        &executable,
521        computed_evaluation_plan,
522    );
523    let actions = trigger_plan
524        .action_batch_triggers
525        .iter()
526        .filter_map(|trigger| {
527            let batch = trigger_plan.action_batches.get(&trigger.action_batch)?;
528            let invalidated = batch
529                .written_states
530                .iter()
531                .flat_map(|state| reactive_transitive_analysis.dependents_of(state.as_str()))
532                .filter_map(|id| computed_by_text.get(id).cloned())
533                .collect::<BTreeSet<_>>();
534            Some(build_action_effect_execution_plan(
535                &batch.id,
536                &trigger.effects,
537                effects,
538                effect_analysis,
539                computed_values,
540                &executable,
541                &invalidated,
542                computed_evaluation_plan,
543            ))
544        })
545        .collect();
546
547    EffectExecutionPlan { initial, actions }
548}
549
550fn build_initial_effect_execution_plan(
551    eligible_effects: &[SemanticId],
552    effects: &BTreeMap<SemanticId, Effect>,
553    effect_analysis: &BTreeMap<SemanticId, EffectReactiveAnalysis>,
554    computed_values: &BTreeMap<SemanticId, ComputedValue>,
555    executable: &BTreeSet<SemanticId>,
556    computed_evaluation_plan: &IrComputedEvaluationPlan,
557) -> InitialEffectExecutionPlan {
558    let membership = effect_execution_membership(
559        eligible_effects,
560        effects,
561        effect_analysis,
562        computed_values,
563        executable,
564    );
565    let required_computed = membership
566        .schedulable_effects
567        .iter()
568        .flat_map(|effect| required_computed_for_effect(effect, effect_analysis, computed_values))
569        .collect::<BTreeSet<_>>();
570    InitialEffectExecutionPlan {
571        prerequisite_batches: select_prerequisite_batches(
572            &required_computed,
573            computed_evaluation_plan,
574            computed_values,
575        ),
576        required_computed: required_computed.into_iter().collect(),
577        render_boundary: Some(EffectRenderBoundary::AfterInitialRender),
578        effect_batches: schedule_terminal_effects(&membership.schedulable_effects, effects),
579        unplanned_effects: membership.unplanned_effects,
580    }
581}
582
583#[allow(clippy::too_many_arguments)]
584fn build_action_effect_execution_plan(
585    action_batch: &SemanticId,
586    eligible_effects: &[SemanticId],
587    effects: &BTreeMap<SemanticId, Effect>,
588    effect_analysis: &BTreeMap<SemanticId, EffectReactiveAnalysis>,
589    computed_values: &BTreeMap<SemanticId, ComputedValue>,
590    executable: &BTreeSet<SemanticId>,
591    invalidated: &BTreeSet<SemanticId>,
592    computed_evaluation_plan: &IrComputedEvaluationPlan,
593) -> ActionEffectExecutionPlan {
594    let membership = effect_execution_membership(
595        eligible_effects,
596        effects,
597        effect_analysis,
598        computed_values,
599        executable,
600    );
601    let required_computed = membership
602        .schedulable_effects
603        .iter()
604        .flat_map(|effect| required_computed_for_effect(effect, effect_analysis, computed_values))
605        .filter(|computed| invalidated.contains(computed))
606        .collect::<BTreeSet<_>>();
607    ActionEffectExecutionPlan {
608        action_batch: action_batch.clone(),
609        prerequisite_batches: select_prerequisite_batches(
610            &required_computed,
611            computed_evaluation_plan,
612            computed_values,
613        ),
614        required_computed: required_computed.into_iter().collect(),
615        effect_batches: schedule_terminal_effects(&membership.schedulable_effects, effects),
616        unplanned_effects: membership.unplanned_effects,
617    }
618}
619
620#[derive(Debug, Default)]
621struct EffectExecutionMembership {
622    schedulable_effects: Vec<SemanticId>,
623    unplanned_effects: Vec<UnplannedEffect>,
624}
625
626fn effect_execution_membership(
627    eligible_effects: &[SemanticId],
628    effects: &BTreeMap<SemanticId, Effect>,
629    effect_analysis: &BTreeMap<SemanticId, EffectReactiveAnalysis>,
630    computed_values: &BTreeMap<SemanticId, ComputedValue>,
631    executable: &BTreeSet<SemanticId>,
632) -> EffectExecutionMembership {
633    let mut membership = EffectExecutionMembership::default();
634    for effect in eligible_effects {
635        if !effects.contains_key(effect) {
636            continue;
637        }
638        let unavailable = required_computed_for_effect(effect, effect_analysis, computed_values)
639            .into_iter()
640            .filter(|computed| !executable.contains(computed))
641            .collect::<Vec<_>>();
642        if unavailable.is_empty() {
643            membership.schedulable_effects.push(effect.clone());
644        } else {
645            membership.unplanned_effects.push(UnplannedEffect {
646                effect: effect.clone(),
647                reason: UnplannedEffectReason::UnavailableComputedPrerequisite,
648                computed_dependencies: unavailable,
649            });
650        }
651    }
652    membership.schedulable_effects.sort();
653    membership.schedulable_effects.dedup();
654    membership
655        .unplanned_effects
656        .sort_by(|left, right| left.effect.cmp(&right.effect));
657    membership
658}
659
660fn required_computed_for_effect(
661    effect: &SemanticId,
662    effect_analysis: &BTreeMap<SemanticId, EffectReactiveAnalysis>,
663    computed_values: &BTreeMap<SemanticId, ComputedValue>,
664) -> Vec<SemanticId> {
665    effect_analysis
666        .get(effect)
667        .into_iter()
668        .flat_map(|analysis| &analysis.dependencies)
669        .filter(|dependency| computed_values.contains_key(*dependency))
670        .cloned()
671        .collect::<BTreeSet<_>>()
672        .into_iter()
673        .collect()
674}
675
676fn select_prerequisite_batches(
677    required_computed: &BTreeSet<SemanticId>,
678    computed_evaluation_plan: &IrComputedEvaluationPlan,
679    computed_values: &BTreeMap<SemanticId, ComputedValue>,
680) -> Vec<EffectComputedPrerequisiteBatch> {
681    computed_evaluation_plan
682        .update_batches
683        .iter()
684        .enumerate()
685        .filter_map(|(index, batch)| {
686            let computed = batch
687                .iter()
688                .filter_map(|id| {
689                    computed_values
690                        .keys()
691                        .find(|computed| computed.as_str() == id)
692                })
693                .filter(|computed| required_computed.contains(*computed))
694                .cloned()
695                .collect::<Vec<_>>();
696            (!computed.is_empty()).then_some(EffectComputedPrerequisiteBatch {
697                source_batch_index: u32::try_from(index)
698                    .expect("computed scheduler batch index should fit u32"),
699                computed,
700            })
701        })
702        .collect()
703}
704
705fn schedule_terminal_effects(
706    effects: &[SemanticId],
707    canonical_effects: &BTreeMap<SemanticId, Effect>,
708) -> Vec<EffectExecutionBatch> {
709    let nodes = effects
710        .iter()
711        .filter_map(|effect| {
712            canonical_effects.get(effect).map(|canonical| {
713                (
714                    effect.as_str().to_string(),
715                    IrReactiveNode {
716                        id: effect.as_str().to_string(),
717                        kind: IrReactiveNodeKind::Effect,
718                        provenance: canonical.provenance.clone(),
719                    },
720                )
721            })
722        })
723        .collect();
724    let inspection = IrUpdateScheduler::new(IrReactiveGraph {
725        nodes,
726        edges: Vec::new(),
727    })
728    .inspect();
729    inspection
730        .batches
731        .into_iter()
732        .enumerate()
733        .filter_map(|(index, batch)| {
734            let effects = batch
735                .into_iter()
736                .filter_map(|id| {
737                    canonical_effects
738                        .keys()
739                        .find(|effect| effect.as_str() == id)
740                        .cloned()
741                })
742                .collect::<Vec<_>>();
743            (!effects.is_empty()).then_some(EffectExecutionBatch {
744                index: u32::try_from(index).expect("effect scheduler batch index should fit u32"),
745                effects,
746            })
747        })
748        .collect()
749}
750
751/// Lower all authored effect bodies to ordered statement records.
752#[must_use]
753pub fn lower_effect_bodies(
754    components: &[ComponentNode],
755    effects: &BTreeMap<SemanticId, Effect>,
756    expression_graph: &ExpressionGraph,
757) -> (
758    BTreeMap<SemanticId, EffectBody>,
759    BTreeMap<SemanticId, EffectStatement>,
760) {
761    let mut bodies = BTreeMap::new();
762    let mut statements = BTreeMap::new();
763    for effect in effects.values() {
764        let Some(component_id) = effect.owner.entity_id() else {
765            continue;
766        };
767        let Some(method) = components
768            .iter()
769            .find(|component| component.id == *component_id)
770            .and_then(|component| {
771                component
772                    .methods
773                    .iter()
774                    .find(|method| method.id == effect.method)
775            })
776        else {
777            continue;
778        };
779        let Some(syntax) = &method.effect_body else {
780            continue;
781        };
782        let mut body_statement_ids = Vec::new();
783        for (index, statement) in syntax.statements.iter().enumerate() {
784            let id = effect.id.effect_statement(index);
785            let path = format!("statement:{index}");
786            let expression = |suffix: &str| effect.id.expression(&format!("{path}/{suffix}"));
787            let kind = match &statement.kind {
788                EffectStatementSyntaxKind::StaticMemberAssignment { .. } => {
789                    EffectStatementKind::ExternalMemberAssignment {
790                        target: expression("target"),
791                        value: expression("value"),
792                    }
793                }
794                EffectStatementSyntaxKind::CapabilityCall { arguments, .. } => {
795                    EffectStatementKind::CapabilityCall {
796                        callee: expression("callee"),
797                        arguments: (0..arguments.len())
798                            .map(|argument| expression(&format!("argument:{argument}")))
799                            .collect(),
800                    }
801                }
802                EffectStatementSyntaxKind::EffectReturn { value } => {
803                    EffectStatementKind::EffectReturn {
804                        value: value.as_ref().map(|_| expression("return")),
805                    }
806                }
807                EffectStatementSyntaxKind::Empty => EffectStatementKind::Empty,
808                EffectStatementSyntaxKind::Unsupported(kind) => {
809                    EffectStatementKind::Unsupported(*kind)
810                }
811            };
812            assert_effect_statement_expressions_exist(&kind, expression_graph);
813            body_statement_ids.push(id.clone());
814            statements.insert(
815                id.clone(),
816                EffectStatement {
817                    id,
818                    owner: effect.id.clone(),
819                    kind,
820                    provenance: SourceProvenance::new(&effect.provenance.path, statement.span),
821                },
822            );
823        }
824        bodies.insert(
825            effect.id.clone(),
826            EffectBody {
827                effect: effect.id.clone(),
828                statements: body_statement_ids,
829                provenance: effect.provenance.clone(),
830            },
831        );
832    }
833    (bodies, statements)
834}
835
836fn assert_effect_statement_expressions_exist(kind: &EffectStatementKind, graph: &ExpressionGraph) {
837    let expressions = match kind {
838        EffectStatementKind::ExternalMemberAssignment { target, value } => vec![target, value],
839        EffectStatementKind::CapabilityCall { callee, arguments } => {
840            let mut expressions = vec![callee];
841            expressions.extend(arguments);
842            expressions
843        }
844        EffectStatementKind::EffectReturn { value } => value.iter().collect(),
845        EffectStatementKind::Empty | EffectStatementKind::Unsupported(_) => Vec::new(),
846    };
847    assert!(expressions.iter().all(|id| graph.node(id).is_some()));
848}
849
850#[cfg(test)]
851mod tests {
852    use crate::{
853        build_application_semantic_model, build_component_graph, build_semantic_graph,
854        collect_effects, validate_application_semantic_model, EffectExecutionPolicy,
855        EffectSemanticViolationKind, EffectStatementKind, EffectValidation, ExecutionBoundary,
856        ExpressionNodeKind, SemanticEntity, SemanticEntityKind, SemanticOwner,
857        SemanticReferenceKind, UnsupportedEffectStatementKind,
858    };
859
860    #[test]
861    fn collects_stable_effect_entities_from_decorated_methods() {
862        let parsed = presolve_parser::parse_file(
863            "src/Effects.tsx",
864            r#"
865@component("x-effects")
866class Effects extends Component {
867  @effect()
868  syncTitle() {
869    document.title = "Presolve";
870  }
871}
872"#,
873        );
874        let graph = build_component_graph(&parsed);
875        let component = &graph.components[0];
876        let effect = collect_effects(&graph.components, &graph.provenance)
877            .into_values()
878            .next()
879            .expect("effect entity");
880
881        assert_eq!(effect.id.as_str(), "component:x-effects/effect:syncTitle");
882        assert_eq!(effect.method, component.methods[0].id);
883        assert_eq!(effect.owner, component.methods[0].owner);
884        assert_eq!(effect.execution_boundary, ExecutionBoundary::Client);
885        assert_eq!(
886            effect.execution_policy,
887            EffectExecutionPolicy::AfterInitialRenderAndCompletedActionBatch
888        );
889    }
890
891    #[test]
892    fn assembles_effects_into_canonical_asm_without_reactive_products() {
893        let parsed = presolve_parser::parse_file(
894            "src/Effects.tsx",
895            r#"
896@component("x-effects")
897class Effects extends Component {
898  title = state("Presolve");
899
900  @effect()
901  syncTitle() {
902    document.title = this.title;
903  }
904
905  render() {
906    return <p>{this.title}</p>;
907  }
908}
909"#,
910        );
911        let asm = build_application_semantic_model(&parsed);
912        let component = &asm.components[0];
913        let effect_id = component.id.effect("syncTitle");
914        let effect = asm.effect(&effect_id).expect("effect entity");
915
916        assert_eq!(effect.method, component.id.method("syncTitle"));
917        assert_eq!(effect.owner, SemanticOwner::entity(component.id.clone()));
918        assert_eq!(asm.owner(&effect_id), Some(&effect.owner));
919        assert_eq!(asm.provenance(&effect_id), asm.provenance(&effect.method));
920        assert_eq!(
921            asm.entity(&effect_id).map(SemanticEntity::kind),
922            Some(SemanticEntityKind::Effect)
923        );
924        assert!(asm.references_from(&effect_id).iter().any(|reference| {
925            reference.kind == SemanticReferenceKind::EffectState
926                && reference.target == component.id.state_field("title")
927        }));
928        assert!(asm.semantic_type_of(&effect_id).is_none());
929        assert_eq!(validate_application_semantic_model(&asm), Vec::new());
930        assert!(build_semantic_graph(&asm)
931            .nodes
932            .iter()
933            .any(|node| node.id == effect_id));
934    }
935
936    #[test]
937    fn lowers_ordered_effect_statements_and_expression_operands_without_resolution() {
938        let parsed = presolve_parser::parse_file(
939            "src/Effects.tsx",
940            include_str!("../../../fixtures/0052-effect-body-lowering/input/Effects.tsx"),
941        );
942        let asm = build_application_semantic_model(&parsed);
943        let component = &asm.components[0];
944        let sync = component.id.effect("sync");
945        let body = asm.effect_body(&sync).expect("effect body");
946
947        assert_eq!(body.statements.len(), 3);
948        let assignment = asm
949            .effect_statement(&body.statements[0])
950            .expect("assignment");
951        let call = asm.effect_statement(&body.statements[1]).expect("call");
952        let completion = asm.effect_statement(&body.statements[2]).expect("return");
953        let EffectStatementKind::ExternalMemberAssignment { target, value } = &assignment.kind
954        else {
955            panic!("expected static member assignment");
956        };
957        assert!(matches!(
958            asm.expression(target).map(|node| &node.kind),
959            Some(ExpressionNodeKind::MemberAccess { property, .. }) if property == "title"
960        ));
961        assert!(matches!(
962            asm.expression(value).map(|node| &node.kind),
963            Some(ExpressionNodeKind::ThisMember { name }) if name == "title"
964        ));
965        let EffectStatementKind::CapabilityCall { callee, arguments } = &call.kind else {
966            panic!("expected capability call");
967        };
968        assert_eq!(arguments.len(), 2);
969        assert!(matches!(
970            asm.expression(callee).map(|node| &node.kind),
971            Some(ExpressionNodeKind::MemberAccess { property, .. }) if property == "track"
972        ));
973        assert!(matches!(
974            asm.expression(&arguments[1]).map(|node| &node.kind),
975            Some(ExpressionNodeKind::Arithmetic { .. })
976        ));
977        assert!(matches!(
978            completion.kind,
979            EffectStatementKind::EffectReturn { value: None }
980        ));
981        assert!(asm.references_from(&sync).iter().any(|reference| {
982            reference.kind == SemanticReferenceKind::EffectState
983                && reference.target == component.id.state_field("title")
984        }));
985        assert!(asm.semantic_types.assignments.keys().any(|id| id == target));
986        assert_eq!(
987            asm.semantic_type_of(value),
988            Some(&crate::SemanticType::String)
989        );
990
991        let invalid = asm
992            .effect_body(&component.id.effect("invalid"))
993            .expect("invalid body");
994        assert!(matches!(
995            asm.effect_statement(&invalid.statements[0])
996                .map(|statement| &statement.kind),
997            Some(EffectStatementKind::ExternalMemberAssignment { .. })
998        ));
999        assert!(matches!(
1000            asm.effect_statement(&invalid.statements[1])
1001                .map(|statement| &statement.kind),
1002            Some(EffectStatementKind::CapabilityCall { .. })
1003        ));
1004        assert!(matches!(
1005            asm.effect_statement(&invalid.statements[2])
1006                .map(|statement| &statement.kind),
1007            Some(EffectStatementKind::Unsupported(
1008                UnsupportedEffectStatementKind::CleanupReturnCandidate
1009            ))
1010        ));
1011        assert!(matches!(
1012            asm.effect_statement(&invalid.statements[3])
1013                .map(|statement| &statement.kind),
1014            Some(EffectStatementKind::Unsupported(
1015                UnsupportedEffectStatementKind::LocalDeclaration
1016            ))
1017        ));
1018    }
1019
1020    #[test]
1021    #[allow(clippy::too_many_lines)]
1022    fn types_effect_statements_against_the_versioned_capability_registry() {
1023        let parsed = presolve_parser::parse_file(
1024            "src/Effects.tsx",
1025            r#"
1026@component("x-effects")
1027class Effects extends Component {
1028  title = state("Presolve");
1029  theme = state("dark");
1030  total = state(3);
1031  profile = state({ name: "Ada" });
1032
1033  @action()
1034  increment() { this.total += 1; this.total += 1; }
1035
1036  helper() {}
1037
1038  @effect()
1039  sync() {
1040    document.title = this.title;
1041    console.log("total", this.total);
1042    console.info(this.title);
1043    console.warn(this.title);
1044    console.error(this.title);
1045    localStorage.setItem("theme", this.theme);
1046    localStorage.removeItem("theme");
1047    sessionStorage.setItem("theme", this.theme);
1048    sessionStorage.removeItem("theme");
1049  }
1050
1051  @effect()
1052  facts() {
1053    document.title = this.profile;
1054    analytics.track(this.total);
1055    this.total = 1;
1056    this.increment();
1057    this.sync();
1058    this.helper();
1059    this.unknown();
1060    return;
1061  }
1062
1063  render() { return <p />; }
1064}
1065"#,
1066        );
1067        let asm = build_application_semantic_model(&parsed);
1068        let component = &asm.components[0];
1069        let sync = asm
1070            .effect_body(&component.id.effect("sync"))
1071            .expect("sync body");
1072        let facts = asm
1073            .effect_body(&component.id.effect("facts"))
1074            .expect("facts body");
1075        let record = |statement: &crate::SemanticId| {
1076            asm.effect_statement_type(statement)
1077                .expect("statement type")
1078        };
1079
1080        assert_eq!(crate::EFFECT_CAPABILITY_REGISTRY.version(), 1);
1081        assert_eq!(
1082            record(&sync.statements[0]).capability_operation,
1083            Some(crate::CapabilityOperationId(
1084                "builtin.browser.document.title.assign"
1085            ))
1086        );
1087        for statement in &sync.statements[1..5] {
1088            assert_eq!(
1089                record(statement).operation_classification,
1090                crate::EffectOperationClassification::RecognizedCapability
1091            );
1092            assert_eq!(
1093                record(statement).serialization_compatibility,
1094                crate::EffectCompatibility::Compatible
1095            );
1096        }
1097        for statement in &sync.statements[5..] {
1098            assert_eq!(
1099                record(statement).signature_compatibility,
1100                crate::EffectCompatibility::Compatible
1101            );
1102        }
1103        assert_eq!(
1104            record(&facts.statements[0]).signature_compatibility,
1105            crate::EffectCompatibility::Incompatible
1106        );
1107        assert_eq!(
1108            record(&facts.statements[1]).operation_classification,
1109            crate::EffectOperationClassification::UnknownExternalCapability
1110        );
1111        assert_eq!(
1112            record(&facts.statements[1]).signature_compatibility,
1113            crate::EffectCompatibility::Incompatible
1114        );
1115        assert_eq!(
1116            record(&facts.statements[1]).operand_types,
1117            vec![crate::SemanticType::Number]
1118        );
1119        assert_eq!(
1120            record(&facts.statements[2]).operation_classification,
1121            crate::EffectOperationClassification::ReactiveStateAssignment
1122        );
1123        assert_eq!(
1124            record(&facts.statements[3]).operation_classification,
1125            crate::EffectOperationClassification::ComponentActionCall
1126        );
1127        assert_eq!(
1128            record(&facts.statements[4]).operation_classification,
1129            crate::EffectOperationClassification::ComponentEffectCall
1130        );
1131        assert_eq!(
1132            record(&facts.statements[5]).operation_classification,
1133            crate::EffectOperationClassification::ComponentMethodCall
1134        );
1135        assert_eq!(
1136            record(&facts.statements[6]).operation_classification,
1137            crate::EffectOperationClassification::UnresolvedComponentCall
1138        );
1139        assert_eq!(
1140            record(&facts.statements[7]).operation_classification,
1141            crate::EffectOperationClassification::BareReturn
1142        );
1143        assert_eq!(
1144            asm.effect(&component.id.effect("sync"))
1145                .expect("valid effect")
1146                .validation,
1147            EffectValidation::Valid
1148        );
1149        let violations = &asm
1150            .effect(&component.id.effect("facts"))
1151            .expect("invalid effect")
1152            .semantic_violations;
1153        assert_eq!(
1154            asm.effect(&component.id.effect("facts"))
1155                .expect("invalid effect")
1156                .validation,
1157            EffectValidation::Invalid
1158        );
1159        for kind in [
1160            EffectSemanticViolationKind::CapabilitySignature,
1161            EffectSemanticViolationKind::UnknownExternalCapability,
1162            EffectSemanticViolationKind::ReactiveStateMutation,
1163            EffectSemanticViolationKind::ActionInvocation,
1164            EffectSemanticViolationKind::EffectInvocation,
1165            EffectSemanticViolationKind::ComponentMethodInvocation,
1166            EffectSemanticViolationKind::UnresolvedComponentCall,
1167        ] {
1168            assert!(violations.iter().any(|violation| violation.kind == kind));
1169        }
1170        let sync_id = component.id.effect("sync");
1171        let facts_id = component.id.effect("facts");
1172        assert_eq!(
1173            asm.reactive_graph.nodes[sync_id.as_str()].kind,
1174            crate::IrReactiveNodeKind::Effect
1175        );
1176        assert!(!asm.reactive_graph.nodes.contains_key(facts_id.as_str()));
1177        assert!(asm.reactive_graph.edges.iter().any(|edge| {
1178            edge.source == sync_id.as_str()
1179                && edge.target == component.id.state_field("title").as_str()
1180                && edge.kind == crate::IrReactiveEdgeKind::Reads
1181        }));
1182        assert!(asm.reactive_graph.edges.iter().any(|edge| {
1183            edge.source == component.id.state_field("title").as_str()
1184                && edge.target == sync_id.as_str()
1185                && edge.kind == crate::IrReactiveEdgeKind::Invalidates
1186        }));
1187        let analysis = asm
1188            .effect_reactive_analysis(&sync_id)
1189            .expect("effect reactive analysis");
1190        assert!(analysis
1191            .dependencies
1192            .contains(&component.id.state_field("title")));
1193        assert!(analysis
1194            .dependencies
1195            .contains(&component.id.state_field("total")));
1196        assert!(analysis.dependents.is_empty());
1197        assert!(asm.effect_reactive_analysis(&facts_id).is_none());
1198        let trigger_plan = asm.effect_trigger_plan();
1199        assert!(trigger_plan.initial_effects.contains(&sync_id));
1200        assert!(!trigger_plan.initial_effects.contains(&facts_id));
1201        let batch = trigger_plan
1202            .action_batches
1203            .get(&component.id.action_batch("increment"))
1204            .expect("increment action batch");
1205        assert_eq!(batch.ordered_write_records.len(), 2);
1206        assert_eq!(
1207            batch.written_states,
1208            vec![component.id.state_field("total")]
1209        );
1210        let trigger = trigger_plan
1211            .action_batch_triggers
1212            .iter()
1213            .find(|trigger| trigger.action_batch == batch.id)
1214            .expect("batch trigger");
1215        assert_eq!(trigger.effects, vec![sync_id.clone()]);
1216        assert_eq!(
1217            trigger.matched_states[&sync_id],
1218            vec![component.id.state_field("total")]
1219        );
1220        assert_eq!(validate_application_semantic_model(&asm), Vec::new());
1221    }
1222
1223    #[test]
1224    #[allow(clippy::too_many_lines)]
1225    fn schedules_minimal_effect_prerequisites_from_existing_computed_batches() {
1226        let parsed = presolve_parser::parse_file(
1227            "src/EffectExecutionPlan.tsx",
1228            r#"
1229@component("x-effect-execution-plan")
1230class EffectExecutionPlan extends Component {
1231  price = state(1);
1232  locale = state("en-US");
1233  theme = state("light");
1234
1235  @computed()
1236  get subtotal() { return this.price * 2; }
1237
1238  @computed()
1239  get total() { return this.subtotal + 1; }
1240
1241  @computed()
1242  get currentLocale() { return this.locale; }
1243
1244  @computed()
1245  get unrelated() { return this.theme; }
1246
1247  @action()
1248  increment() { this.price += 1; }
1249
1250  @action()
1251  restyle() { this.theme = "dark"; }
1252
1253  @effect()
1254  report() { console.log(this.total, this.currentLocale); }
1255
1256  @effect()
1257  audit() { console.log(this.price); }
1258
1259  @effect()
1260  bootLog() { console.log("ready"); }
1261
1262  render() { return <p />; }
1263}
1264"#,
1265        );
1266        let asm = build_application_semantic_model(&parsed);
1267        let component = &asm.components[0];
1268        let subtotal = component.id.computed("subtotal");
1269        let total = component.id.computed("total");
1270        let current_locale = component.id.computed("currentLocale");
1271        let unrelated = component.id.computed("unrelated");
1272        let report = component.id.effect("report");
1273        let audit = component.id.effect("audit");
1274        let boot_log = component.id.effect("bootLog");
1275        let execution = asm.effect_execution_plan();
1276
1277        assert_eq!(
1278            execution.initial.required_computed,
1279            vec![current_locale.clone(), subtotal.clone(), total.clone()]
1280        );
1281        assert_eq!(
1282            execution.initial.render_boundary,
1283            Some(crate::EffectRenderBoundary::AfterInitialRender)
1284        );
1285        assert_eq!(
1286            execution
1287                .initial
1288                .prerequisite_batches
1289                .iter()
1290                .map(|batch| batch.computed.clone())
1291                .collect::<Vec<_>>(),
1292            vec![vec![current_locale, subtotal.clone()], vec![total.clone()]]
1293        );
1294        assert!(!execution.initial.required_computed.contains(&unrelated));
1295        assert_eq!(
1296            execution.initial.effect_batches,
1297            vec![crate::EffectExecutionBatch {
1298                index: 0,
1299                effects: vec![audit.clone(), boot_log.clone(), report.clone()],
1300            }]
1301        );
1302        assert!(execution.initial.unplanned_effects.is_empty());
1303
1304        assert_eq!(execution.actions.len(), 1);
1305        let action = &execution.actions[0];
1306        assert_eq!(action.action_batch, component.id.action_batch("increment"));
1307        assert_eq!(action.required_computed, vec![subtotal, total.clone()]);
1308        assert_eq!(
1309            action
1310                .prerequisite_batches
1311                .iter()
1312                .map(|batch| batch.computed.clone())
1313                .collect::<Vec<_>>(),
1314            vec![
1315                vec![component.id.computed("subtotal")],
1316                vec![component.id.computed("total")],
1317            ]
1318        );
1319        assert_eq!(
1320            action.effect_batches,
1321            vec![crate::EffectExecutionBatch {
1322                index: 0,
1323                effects: vec![audit, report.clone()],
1324            }]
1325        );
1326        assert!(action.unplanned_effects.is_empty());
1327
1328        let unavailable_plan = crate::IrComputedEvaluationPlan {
1329            evaluation_order: asm
1330                .computed_evaluation_plan
1331                .evaluation_order
1332                .iter()
1333                .filter(|computed| computed.as_str() != total.as_str())
1334                .cloned()
1335                .collect(),
1336            update_batches: asm
1337                .computed_evaluation_plan
1338                .update_batches
1339                .iter()
1340                .map(|batch| {
1341                    batch
1342                        .iter()
1343                        .filter(|computed| computed.as_str() != total.as_str())
1344                        .cloned()
1345                        .collect()
1346                })
1347                .collect(),
1348            unplanned: vec![total.as_str().to_string()],
1349        };
1350        let unavailable = crate::plan_effect_execution(
1351            &asm.computed_values,
1352            &asm.effects,
1353            &asm.effect_reactive_analysis,
1354            asm.effect_trigger_plan(),
1355            &asm.reactive_transitive_analysis,
1356            &unavailable_plan,
1357        );
1358        assert_eq!(
1359            unavailable.initial.unplanned_effects,
1360            vec![crate::UnplannedEffect {
1361                effect: report,
1362                reason: crate::UnplannedEffectReason::UnavailableComputedPrerequisite,
1363                computed_dependencies: vec![total],
1364            }]
1365        );
1366        assert_eq!(validate_application_semantic_model(&asm), Vec::new());
1367    }
1368
1369    #[test]
1370    fn rejects_async_effects_without_runtime_execution() {
1371        let parsed = presolve_parser::parse_file(
1372            "src/AsyncEffect.tsx",
1373            r#"
1374@component("x-async-effect")
1375class AsyncEffect extends Component {
1376  title = state("Presolve");
1377
1378  @effect()
1379  async syncTitle() {
1380    document.title = this.title;
1381  }
1382
1383  @effect()
1384  invalidValueReturn() {
1385    return this.title;
1386  }
1387
1388  @effect()
1389  invalidCleanupReturn() {
1390    return () => unsubscribe();
1391  }
1392
1393  render() { return <p />; }
1394}
1395"#,
1396        );
1397        let asm = build_application_semantic_model(&parsed);
1398        let effect = asm
1399            .effect(&asm.components[0].id.effect("syncTitle"))
1400            .expect("effect");
1401
1402        assert_eq!(effect.validation, EffectValidation::Invalid);
1403        assert!(effect
1404            .semantic_violations
1405            .iter()
1406            .any(|violation| violation.kind == EffectSemanticViolationKind::Async));
1407        for (name, kind) in [
1408            (
1409                "invalidValueReturn",
1410                EffectSemanticViolationKind::ValueReturn,
1411            ),
1412            (
1413                "invalidCleanupReturn",
1414                EffectSemanticViolationKind::UnsupportedStatement,
1415            ),
1416        ] {
1417            let effect = asm
1418                .effect(&asm.components[0].id.effect(name))
1419                .expect("invalid return effect");
1420            assert_eq!(effect.validation, EffectValidation::Invalid);
1421            assert!(effect
1422                .semantic_violations
1423                .iter()
1424                .any(|violation| violation.kind == kind));
1425        }
1426        let codes = asm
1427            .diagnostics
1428            .iter()
1429            .map(|diagnostic| diagnostic.code.as_str())
1430            .collect::<Vec<_>>();
1431        assert_eq!(codes, vec!["PSC1042", "PSC1046", "PSC1046"]);
1432    }
1433}