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