Skip to main content

presolve_compiler/
effect_inspection.rs

1use std::collections::{BTreeMap, BTreeSet};
2
3use serde::Serialize;
4
5use crate::{
6    build_effect_resume_plan, build_runtime_effect_registry, lower_components_to_ir,
7    optimize_effect_ir, ApplicationSemanticModel, CapabilityOperationKind, Effect,
8    EffectActivationStatus, EffectExecutionPolicy, EffectRenderBoundary,
9    EffectSemanticViolationKind, EffectValidation, ExecutionBoundary, IrReactiveEdgeKind,
10    RuntimeEffectRecord, SemanticId, SourceProvenance, EFFECT_CAPABILITY_REGISTRY,
11};
12
13/// Immutable F17 projection of existing effect compiler products for inspection.
14#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize)]
15pub struct EffectInspectionRegistry {
16    pub records: BTreeMap<SemanticId, EffectInspection>,
17}
18
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct EffectInspectionValidationDiagnostic {
21    pub code: String,
22    pub message: String,
23}
24
25#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
26pub struct EffectInspection {
27    pub validation: EffectInspectionValidation,
28    pub direct_dependencies: EffectInspectionDependencies,
29    pub transitive_dependencies: EffectInspectionDependencies,
30    pub dependents: Vec<String>,
31    pub initial_trigger: Option<EffectInspectionInitialTrigger>,
32    pub action_triggers: Vec<EffectInspectionActionTrigger>,
33    pub schedule: EffectInspectionSchedule,
34    pub capabilities: Vec<EffectInspectionCapability>,
35    pub ir: Option<EffectInspectionIr>,
36    pub runtime: EffectInspectionRuntime,
37    pub resumability: Option<EffectInspectionResumability>,
38}
39
40#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
41pub struct EffectInspectionValidation {
42    pub status: &'static str,
43    pub violations: Vec<EffectInspectionViolation>,
44}
45
46#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
47pub struct EffectInspectionViolation {
48    pub category: &'static str,
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub statement_id: Option<String>,
51    pub provenance: EffectInspectionProvenance,
52}
53
54#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
55pub struct EffectInspectionProvenance {
56    pub path: String,
57    pub line: usize,
58    pub column: usize,
59    pub start: usize,
60    pub end: usize,
61}
62
63#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize)]
64pub struct EffectInspectionDependencies {
65    pub state: Vec<String>,
66    pub computed: Vec<String>,
67}
68
69#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
70pub struct EffectInspectionInitialTrigger {
71    pub policy: &'static str,
72    pub batch_index: u32,
73    pub render_boundary: &'static str,
74}
75
76#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
77pub struct EffectInspectionActionTrigger {
78    pub action_batch_id: String,
79    pub matched_states: Vec<String>,
80    pub required_computed: Vec<String>,
81    pub prerequisite_batches: Vec<EffectInspectionPrerequisiteBatch>,
82    pub effect_batch_index: u32,
83}
84
85#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
86pub struct EffectInspectionPrerequisiteBatch {
87    pub source_batch_index: u32,
88    pub computed: Vec<String>,
89}
90
91#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
92pub struct EffectInspectionSchedule {
93    #[serde(skip_serializing_if = "Option::is_none")]
94    pub initial_effect_batch_index: Option<u32>,
95    pub action_batches: Vec<EffectInspectionScheduledAction>,
96    #[serde(skip_serializing_if = "Option::is_none")]
97    pub unplanned: Option<EffectInspectionUnplanned>,
98}
99
100#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
101pub struct EffectInspectionScheduledAction {
102    pub action_batch_id: String,
103    pub effect_batch_index: u32,
104    pub prerequisite_computed_batch_refs: Vec<u32>,
105}
106
107#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
108pub struct EffectInspectionUnplanned {
109    pub reason: &'static str,
110    pub unavailable_computed_dependencies: Vec<String>,
111}
112
113#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
114pub struct EffectInspectionCapability {
115    pub operation_id: String,
116    pub runtime_lowering_id: String,
117    pub kind: &'static str,
118    pub boundary: &'static str,
119}
120
121#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
122pub struct EffectInspectionIr {
123    pub function_id: String,
124    pub instruction_count: usize,
125    pub capability_operation_count: usize,
126}
127
128#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
129pub struct EffectInspectionRuntime {
130    pub registered: bool,
131    #[serde(skip_serializing_if = "Option::is_none")]
132    pub function_id: Option<String>,
133    #[serde(skip_serializing_if = "Option::is_none")]
134    pub execution_policy: Option<&'static str>,
135    #[serde(skip_serializing_if = "Option::is_none")]
136    pub boundary: Option<&'static str>,
137    #[serde(skip_serializing_if = "Option::is_none")]
138    pub initial_membership: Option<EffectInspectionInitialTrigger>,
139    pub action_batch_ids: Vec<String>,
140}
141
142#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
143pub struct EffectInspectionResumability {
144    pub activation_slot_id: Option<String>,
145    pub initial_status: Option<EffectActivationStatus>,
146    pub render_boundary: Option<&'static str>,
147    pub initial_batch_index: Option<u32>,
148    pub action_batch_ids: Vec<String>,
149    pub manifest_schema_version: u32,
150}
151
152/// Build F17 inspection records solely by projecting canonical F5--F16 products.
153#[must_use]
154pub fn build_effect_inspection_registry(
155    model: &ApplicationSemanticModel,
156) -> EffectInspectionRegistry {
157    let ir = lower_components_to_ir(model);
158    let optimized_ir = optimize_effect_ir(&ir).output;
159    let runtime = build_runtime_effect_registry(model, &optimized_ir);
160    let resumability = build_effect_resume_plan(model, &runtime);
161    let executions = optimized_ir
162        .modules
163        .iter()
164        .flat_map(|module| &module.effect_executions)
165        .map(|execution| (execution.effect.clone(), execution))
166        .collect::<BTreeMap<_, _>>();
167    let functions = optimized_ir
168        .modules
169        .iter()
170        .flat_map(|module| &module.functions)
171        .map(|function| (function.id.clone(), function))
172        .collect::<BTreeMap<_, _>>();
173
174    EffectInspectionRegistry {
175        records: model
176            .effects
177            .iter()
178            .map(|(id, effect)| {
179                (
180                    id.clone(),
181                    inspect_effect(
182                        model,
183                        effect,
184                        runtime.record(id),
185                        resumability.record(id),
186                        executions.get(id),
187                        &functions,
188                    ),
189                )
190            })
191            .collect(),
192    }
193}
194
195/// Verify that an inspection registry remains an exact projection of the
196/// canonical F5--F16 products. It does not inspect source or runtime state.
197#[must_use]
198pub fn validate_effect_inspection_registry(
199    model: &ApplicationSemanticModel,
200    registry: &EffectInspectionRegistry,
201) -> Vec<EffectInspectionValidationDiagnostic> {
202    let expected = build_effect_inspection_registry(model);
203    if expected == *registry {
204        return Vec::new();
205    }
206
207    let mut diagnostics = Vec::new();
208    for effect in model.effects.keys() {
209        match (registry.records.get(effect), expected.records.get(effect)) {
210            (None, Some(_)) => diagnostics.push(validation_diagnostic(
211                "PSINS1701",
212                "canonical effect is missing an inspection record",
213            )),
214            (Some(actual), Some(expected)) if actual != expected => {
215                diagnostics.push(validation_diagnostic(
216                    "PSINS1702",
217                    "effect inspection record diverges from canonical compiler products",
218                ));
219            }
220            _ => {}
221        }
222    }
223    if registry
224        .records
225        .keys()
226        .any(|effect| !model.effects.contains_key(effect))
227    {
228        diagnostics.push(validation_diagnostic(
229            "PSINS1703",
230            "effect inspection record does not resolve to a canonical effect",
231        ));
232    }
233    diagnostics
234}
235
236#[allow(clippy::too_many_arguments)]
237fn inspect_effect(
238    model: &ApplicationSemanticModel,
239    effect: &Effect,
240    runtime: Option<&RuntimeEffectRecord>,
241    resume: Option<&crate::EffectResumeRecord>,
242    execution: Option<&&crate::IrEffectExecution>,
243    functions: &BTreeMap<SemanticId, &crate::IrFunction>,
244) -> EffectInspection {
245    let initial_trigger = runtime
246        .and_then(|record| record.initial_trigger.as_ref())
247        .map(initial_trigger);
248    let action_triggers = runtime.map_or_else(Vec::new, |record| {
249        record
250            .action_batch_triggers
251            .iter()
252            .map(action_trigger)
253            .collect()
254    });
255    EffectInspection {
256        validation: validation(effect),
257        direct_dependencies: direct_dependencies(model, &effect.id),
258        transitive_dependencies: transitive_dependencies(model, &effect.id),
259        dependents: model
260            .effect_reactive_analysis(&effect.id)
261            .map_or_else(Vec::new, |analysis| semantic_ids(&analysis.dependents)),
262        initial_trigger: initial_trigger.clone(),
263        action_triggers: action_triggers.clone(),
264        schedule: schedule(
265            model,
266            &effect.id,
267            initial_trigger.as_ref(),
268            &action_triggers,
269        ),
270        capabilities: execution.map_or_else(Vec::new, |execution| capabilities(execution)),
271        ir: execution.and_then(|execution| ir_inspection(execution, functions)),
272        runtime: runtime_inspection(runtime, initial_trigger, &action_triggers),
273        resumability: resumability_inspection(resume),
274    }
275}
276
277fn validation(effect: &Effect) -> EffectInspectionValidation {
278    EffectInspectionValidation {
279        status: match effect.validation {
280            EffectValidation::Valid => "valid",
281            EffectValidation::Invalid => "invalid",
282            EffectValidation::Unvalidated => "unvalidated",
283        },
284        violations: effect
285            .semantic_violations
286            .iter()
287            .map(|violation| EffectInspectionViolation {
288                category: violation_category(violation.kind),
289                statement_id: violation.statement.as_ref().map(ToString::to_string),
290                provenance: provenance(&violation.provenance),
291            })
292            .collect(),
293    }
294}
295
296fn direct_dependencies(
297    model: &ApplicationSemanticModel,
298    effect: &SemanticId,
299) -> EffectInspectionDependencies {
300    let dependencies = model
301        .reactive_graph
302        .edges
303        .iter()
304        .filter(|edge| edge.source == effect.as_str() && edge.kind == IrReactiveEdgeKind::Reads)
305        .map(|edge| edge.target.clone())
306        .collect::<BTreeSet<_>>();
307    split_dependencies(model, &dependencies)
308}
309
310fn transitive_dependencies(
311    model: &ApplicationSemanticModel,
312    effect: &SemanticId,
313) -> EffectInspectionDependencies {
314    let dependencies =
315        model
316            .effect_reactive_analysis(effect)
317            .map_or_else(BTreeSet::new, |analysis| {
318                analysis
319                    .dependencies
320                    .iter()
321                    .map(ToString::to_string)
322                    .collect()
323            });
324    split_dependencies(model, &dependencies)
325}
326
327fn split_dependencies(
328    model: &ApplicationSemanticModel,
329    dependencies: &BTreeSet<String>,
330) -> EffectInspectionDependencies {
331    EffectInspectionDependencies {
332        state: dependencies
333            .iter()
334            .filter(|id| {
335                model.components.iter().any(|component| {
336                    component
337                        .state_fields
338                        .iter()
339                        .any(|field| field.id.as_str() == *id)
340                })
341            })
342            .cloned()
343            .collect(),
344        computed: dependencies
345            .iter()
346            .filter(|id| {
347                model
348                    .computed_values
349                    .keys()
350                    .any(|computed| computed.as_str() == *id)
351            })
352            .cloned()
353            .collect(),
354    }
355}
356
357fn initial_trigger(trigger: &crate::RuntimeInitialEffectTrigger) -> EffectInspectionInitialTrigger {
358    EffectInspectionInitialTrigger {
359        policy: "after_initial_render",
360        batch_index: trigger.effect_batch_index,
361        render_boundary: render_boundary(trigger.render_boundary),
362    }
363}
364
365fn action_trigger(
366    trigger: &crate::RuntimeActionBatchEffectTrigger,
367) -> EffectInspectionActionTrigger {
368    EffectInspectionActionTrigger {
369        action_batch_id: trigger.action_batch.to_string(),
370        matched_states: semantic_ids(&trigger.matched_states),
371        required_computed: semantic_ids(&trigger.required_computed),
372        prerequisite_batches: trigger
373            .prerequisite_batches
374            .iter()
375            .map(|batch| EffectInspectionPrerequisiteBatch {
376                source_batch_index: batch.source_batch_index,
377                computed: semantic_ids(&batch.computed),
378            })
379            .collect(),
380        effect_batch_index: trigger.effect_batch_index,
381    }
382}
383
384fn schedule(
385    model: &ApplicationSemanticModel,
386    effect: &SemanticId,
387    initial: Option<&EffectInspectionInitialTrigger>,
388    actions: &[EffectInspectionActionTrigger],
389) -> EffectInspectionSchedule {
390    let unplanned = model
391        .effect_execution_plan
392        .initial
393        .unplanned_effects
394        .iter()
395        .chain(
396            model
397                .effect_execution_plan
398                .actions
399                .iter()
400                .flat_map(|action| &action.unplanned_effects),
401        )
402        .find(|unplanned| unplanned.effect == *effect)
403        .map(|unplanned| EffectInspectionUnplanned {
404            reason: "unavailable_computed_prerequisite",
405            unavailable_computed_dependencies: semantic_ids(&unplanned.computed_dependencies),
406        });
407    EffectInspectionSchedule {
408        initial_effect_batch_index: initial.map(|trigger| trigger.batch_index),
409        action_batches: actions
410            .iter()
411            .map(|trigger| EffectInspectionScheduledAction {
412                action_batch_id: trigger.action_batch_id.clone(),
413                effect_batch_index: trigger.effect_batch_index,
414                prerequisite_computed_batch_refs: trigger
415                    .prerequisite_batches
416                    .iter()
417                    .map(|batch| batch.source_batch_index)
418                    .collect(),
419            })
420            .collect(),
421        unplanned,
422    }
423}
424
425fn capabilities(execution: &crate::IrEffectExecution) -> Vec<EffectInspectionCapability> {
426    execution
427        .capability_operations
428        .iter()
429        .filter_map(|id| EFFECT_CAPABILITY_REGISTRY.operation(*id))
430        .map(|operation| EffectInspectionCapability {
431            operation_id: operation.id.0.to_string(),
432            runtime_lowering_id: operation.runtime_lowering.0.to_string(),
433            kind: match operation.kind {
434                CapabilityOperationKind::MemberAssignment => "member_assignment",
435                CapabilityOperationKind::MethodCall => "method_call",
436            },
437            boundary: execution_boundary(operation.boundary),
438        })
439        .collect()
440}
441
442fn ir_inspection(
443    execution: &crate::IrEffectExecution,
444    functions: &BTreeMap<SemanticId, &crate::IrFunction>,
445) -> Option<EffectInspectionIr> {
446    let function = functions.get(&execution.function)?;
447    Some(EffectInspectionIr {
448        function_id: execution.function.to_string(),
449        instruction_count: function
450            .blocks
451            .iter()
452            .map(|block| block.instructions.len())
453            .sum(),
454        capability_operation_count: execution.capability_operations.len(),
455    })
456}
457
458fn runtime_inspection(
459    runtime: Option<&RuntimeEffectRecord>,
460    initial: Option<EffectInspectionInitialTrigger>,
461    actions: &[EffectInspectionActionTrigger],
462) -> EffectInspectionRuntime {
463    let Some(runtime) = runtime else {
464        return EffectInspectionRuntime {
465            registered: false,
466            function_id: None,
467            execution_policy: None,
468            boundary: None,
469            initial_membership: None,
470            action_batch_ids: Vec::new(),
471        };
472    };
473    EffectInspectionRuntime {
474        registered: true,
475        function_id: Some(runtime.execution_function.to_string()),
476        execution_policy: Some(execution_policy(runtime.initial_trigger_policy)),
477        boundary: Some(execution_boundary(runtime.execution_boundary)),
478        initial_membership: initial,
479        action_batch_ids: actions
480            .iter()
481            .map(|trigger| trigger.action_batch_id.clone())
482            .collect(),
483    }
484}
485
486fn resumability_inspection(
487    resume: Option<&crate::EffectResumeRecord>,
488) -> Option<EffectInspectionResumability> {
489    let record = resume?;
490    Some(EffectInspectionResumability {
491        activation_slot_id: record
492            .activation_slot
493            .as_ref()
494            .map(|slot| slot.as_str().to_string()),
495        initial_status: record.initial_status,
496        render_boundary: record
497            .initial_plan_membership
498            .as_ref()
499            .map(|membership| render_boundary(membership.render_boundary)),
500        initial_batch_index: record
501            .initial_plan_membership
502            .as_ref()
503            .map(|membership| membership.batch_index),
504        action_batch_ids: semantic_ids(&record.action_batches),
505        manifest_schema_version: crate::RESUME_MANIFEST_SCHEMA_VERSION,
506    })
507}
508
509fn semantic_ids(ids: &[SemanticId]) -> Vec<String> {
510    ids.iter().map(ToString::to_string).collect()
511}
512
513fn provenance(provenance: &SourceProvenance) -> EffectInspectionProvenance {
514    EffectInspectionProvenance {
515        path: provenance.path.display().to_string(),
516        line: provenance.span.line,
517        column: provenance.span.column,
518        start: provenance.span.start,
519        end: provenance.span.end,
520    }
521}
522
523fn violation_category(kind: EffectSemanticViolationKind) -> &'static str {
524    match kind {
525        EffectSemanticViolationKind::Async => "async",
526        EffectSemanticViolationKind::ReactiveStateMutation => "reactive_state_mutation",
527        EffectSemanticViolationKind::ActionInvocation => "action_invocation",
528        EffectSemanticViolationKind::EffectInvocation => "effect_invocation",
529        EffectSemanticViolationKind::ComponentMethodInvocation => "component_method_invocation",
530        EffectSemanticViolationKind::UnresolvedComponentCall => "unresolved_component_call",
531        EffectSemanticViolationKind::UnresolvedComponentAssignment => {
532            "unresolved_component_assignment"
533        }
534        EffectSemanticViolationKind::UnknownExternalCapability => "unknown_external_capability",
535        EffectSemanticViolationKind::CapabilitySignature => "capability_signature",
536        EffectSemanticViolationKind::CapabilityBoundary => "capability_boundary",
537        EffectSemanticViolationKind::CapabilitySerialization => "capability_serialization",
538        EffectSemanticViolationKind::ValueReturn => "value_return",
539        EffectSemanticViolationKind::UnsupportedStatement => "unsupported_statement",
540    }
541}
542
543fn execution_policy(policy: EffectExecutionPolicy) -> &'static str {
544    match policy {
545        EffectExecutionPolicy::AfterInitialRenderAndCompletedActionBatch => {
546            "after_initial_render_and_completed_action_batch"
547        }
548    }
549}
550
551fn render_boundary(boundary: EffectRenderBoundary) -> &'static str {
552    match boundary {
553        EffectRenderBoundary::AfterInitialRender => "after_initial_render",
554    }
555}
556
557fn execution_boundary(boundary: ExecutionBoundary) -> &'static str {
558    match boundary {
559        ExecutionBoundary::Client => "client",
560        ExecutionBoundary::Server => "server",
561    }
562}
563
564fn validation_diagnostic(code: &str, message: &str) -> EffectInspectionValidationDiagnostic {
565    EffectInspectionValidationDiagnostic {
566        code: code.to_string(),
567        message: message.to_string(),
568    }
569}
570
571#[cfg(test)]
572mod tests {
573    use crate::{
574        build_application_semantic_model, build_effect_inspection_registry,
575        validate_effect_inspection_registry,
576    };
577
578    #[test]
579    fn projects_valid_and_invalid_effects_without_creating_runtime_facts() {
580        let parsed = presolve_parser::parse_file(
581            "src/EffectInspection.tsx",
582            r#"
583@component("x-effect-inspection")
584class EffectInspection extends Component {
585  count = state(1);
586
587  @computed()
588  get doubled() { return this.count * 2; }
589
590  @action()
591  increment() { this.count += 1; }
592
593  @effect()
594  report() { console.log(this.doubled); console.info(this.count); }
595
596  @effect()
597  invalid() { this.count = 0; }
598}
599"#,
600        );
601        let model = build_application_semantic_model(&parsed);
602        let registry = build_effect_inspection_registry(&model);
603        let component = &model.components[0].id;
604        let report = registry
605            .records
606            .get(&component.effect("report"))
607            .expect("valid effect inspection");
608        let invalid = registry
609            .records
610            .get(&component.effect("invalid"))
611            .expect("invalid effect inspection");
612
613        assert_eq!(report.validation.status, "valid");
614        assert_eq!(report.direct_dependencies.computed.len(), 1);
615        assert_eq!(report.direct_dependencies.state.len(), 1);
616        assert_eq!(report.dependents, Vec::<String>::new());
617        assert_eq!(report.action_triggers.len(), 1);
618        assert_eq!(report.capabilities.len(), 2);
619        assert!(report.ir.is_some());
620        assert!(report.runtime.registered);
621        assert_eq!(
622            report
623                .resumability
624                .as_ref()
625                .and_then(|record| record.initial_status),
626            Some(crate::EffectActivationStatus::Pending)
627        );
628
629        assert_eq!(invalid.validation.status, "invalid");
630        assert!(!invalid.validation.violations.is_empty());
631        assert!(invalid.ir.is_none());
632        assert!(!invalid.runtime.registered);
633        assert!(invalid.resumability.is_none());
634        assert!(validate_effect_inspection_registry(&model, &registry).is_empty());
635    }
636}