Skip to main content

presolve_compiler/
effect_diagnostics.rs

1use std::collections::{BTreeMap, BTreeSet};
2
3use crate::{
4    ApplicationSemanticModel, ComponentDiagnostic, ComponentDiagnosticSeverity,
5    DiagnosticSecondaryLabel, Effect, EffectId, EffectSemanticViolation,
6    EffectSemanticViolationKind, EffectStatementId, EffectStatementKind, ExpressionNodeKind,
7    SemanticId, EFFECT_CAPABILITY_REGISTRY,
8};
9
10/// Stable public catalog for compiler-projected effect diagnostics.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
12pub enum EffectDiagnosticCode {
13    InvalidDeclaration,
14    UnsupportedBody,
15    UnresolvedReference,
16    ReactiveStateMutation,
17    InvalidComponentInvocation,
18    AsyncOrCleanupUnsupported,
19    UnknownCapability,
20    CapabilitySignature,
21    CapabilityBoundary,
22    CapabilitySerialization,
23    UnavailableComputedPrerequisite,
24}
25
26impl EffectDiagnosticCode {
27    #[must_use]
28    pub const fn as_str(self) -> &'static str {
29        match self {
30            Self::InvalidDeclaration => "PSC1041",
31            Self::UnsupportedBody => "PSC1042",
32            Self::UnresolvedReference => "PSC1043",
33            Self::ReactiveStateMutation => "PSC1044",
34            Self::InvalidComponentInvocation => "PSC1045",
35            Self::AsyncOrCleanupUnsupported => "PSC1046",
36            Self::UnknownCapability => "PSC1047",
37            Self::CapabilitySignature => "PSC1048",
38            Self::CapabilityBoundary => "PSC1049",
39            Self::CapabilitySerialization => "PSC1050",
40            Self::UnavailableComputedPrerequisite => "PSC1051",
41        }
42    }
43}
44
45/// Projects immutable F2-F5 and F9 facts into shared compiler diagnostics.
46///
47/// This deliberately consumes already-classified statements, compatibility
48/// records, validation violations, and scheduler records. It never rewalks
49/// source or attempts to rediscover an effect capability or dependency.
50#[must_use]
51pub fn collect_effect_diagnostics(model: &ApplicationSemanticModel) -> Vec<ComponentDiagnostic> {
52    let mut diagnostics = Vec::new();
53    for effect in model.effects.values() {
54        let mut violations = effect.semantic_violations.iter().collect::<Vec<_>>();
55        violations.sort_by(|left, right| {
56            (
57                statement_position(model, effect, left.statement.as_ref()),
58                cascade_precedence(left.kind),
59                left.provenance.path.as_path(),
60                left.provenance.span.start,
61                violation_code(left.kind),
62            )
63                .cmp(&(
64                    statement_position(model, effect, right.statement.as_ref()),
65                    cascade_precedence(right.kind),
66                    right.provenance.path.as_path(),
67                    right.provenance.span.start,
68                    violation_code(right.kind),
69                ))
70        });
71
72        // One root diagnostic per statement (or declaration). F5 has already
73        // classified every violation; this only applies the documented cascade
74        // precedence to prevent follow-on facts from obscuring the root cause.
75        let mut reported_subjects = BTreeSet::new();
76        for violation in violations {
77            if !reported_subjects.insert(violation.statement.clone()) {
78                continue;
79            }
80            diagnostics.push(effect_violation_diagnostic(model, effect, violation));
81        }
82    }
83
84    // F9 can report the same unplanned effect from its initial plan and more
85    // than one action plan. Coalesce those plan memberships into one effect
86    // diagnostic with deterministic computed evidence.
87    let mut unavailable = BTreeMap::<SemanticId, BTreeSet<SemanticId>>::new();
88    for unplanned in model
89        .effect_execution_plan
90        .initial
91        .unplanned_effects
92        .iter()
93        .chain(
94            model
95                .effect_execution_plan
96                .actions
97                .iter()
98                .flat_map(|plan| &plan.unplanned_effects),
99        )
100    {
101        unavailable
102            .entry(unplanned.effect.clone())
103            .or_default()
104            .extend(unplanned.computed_dependencies.iter().cloned());
105    }
106    for (effect_id, computed) in unavailable {
107        let Some(effect) = model.effects.get(&effect_id) else {
108            continue;
109        };
110        let computed = computed.into_iter().collect::<Vec<_>>();
111        diagnostics.push(unavailable_prerequisite_diagnostic(
112            model, effect, &computed,
113        ));
114    }
115
116    diagnostics.sort_by(|left, right| diagnostic_order(left).cmp(&diagnostic_order(right)));
117    diagnostics.dedup();
118    diagnostics
119}
120
121fn effect_violation_diagnostic(
122    model: &ApplicationSemanticModel,
123    effect: &Effect,
124    violation: &EffectSemanticViolation,
125) -> ComponentDiagnostic {
126    let code = violation_code(violation.kind);
127    let statement_id = canonical_statement_id(model, effect, violation.statement.as_ref());
128    ComponentDiagnostic {
129        code: code.as_str().to_string(),
130        severity: ComponentDiagnosticSeverity::Error,
131        message: violation_message(model, effect, violation, code),
132        provenance: Some(violation.provenance.clone()),
133        effect_id: Some(EffectId::from_semantic(&effect.id)),
134        statement_id,
135        context_declaration_candidate_id: None,
136        context_id: None,
137        provider_id: None,
138        consumer_id: None,
139        slot_id: None,
140        invocation_id: None,
141        component_instance_id: None,
142        slot_binding_id: None,
143        structural_region_id: None,
144        component_id: None,
145        provider_instance_id: None,
146        consumer_instance_id: None,
147        secondary_labels: normalized_labels(secondary_labels(model, effect, violation)),
148    }
149}
150
151fn unavailable_prerequisite_diagnostic(
152    model: &ApplicationSemanticModel,
153    effect: &Effect,
154    computed: &[SemanticId],
155) -> ComponentDiagnostic {
156    let names = computed
157        .iter()
158        .filter_map(|id| {
159            model
160                .computed_values
161                .get(id)
162                .map(|value| value.name.as_str())
163        })
164        .collect::<Vec<_>>();
165    let subject = if names.is_empty() {
166        "a required computed value".to_string()
167    } else {
168        format!(
169            "computed value{} `{}`",
170            if names.len() == 1 { "" } else { "s" },
171            names.join("`, `")
172        )
173    };
174    let labels = computed
175        .iter()
176        .filter_map(|id| {
177            model
178                .provenance(id)
179                .map(|provenance| DiagnosticSecondaryLabel {
180                    provenance: provenance.clone(),
181                    message: "Required computed value is unavailable for effect scheduling."
182                        .to_string(),
183                })
184        })
185        .collect();
186    ComponentDiagnostic {
187        code: EffectDiagnosticCode::UnavailableComputedPrerequisite
188            .as_str()
189            .to_string(),
190        severity: ComponentDiagnosticSeverity::Error,
191        message: format!(
192            "Effect `{}` cannot be scheduled because {subject} has no executable evaluation plan.",
193            effect.name
194        ),
195        provenance: Some(effect.provenance.clone()),
196        effect_id: Some(EffectId::from_semantic(&effect.id)),
197        statement_id: None,
198        context_declaration_candidate_id: None,
199        context_id: None,
200        provider_id: None,
201        consumer_id: None,
202        slot_id: None,
203        invocation_id: None,
204        component_instance_id: None,
205        slot_binding_id: None,
206        structural_region_id: None,
207        component_id: None,
208        provider_instance_id: None,
209        consumer_instance_id: None,
210        secondary_labels: normalized_labels(labels),
211    }
212}
213
214fn canonical_statement_id(
215    model: &ApplicationSemanticModel,
216    effect: &Effect,
217    statement: Option<&SemanticId>,
218) -> Option<EffectStatementId> {
219    statement
220        .filter(|id| {
221            model
222                .effect_statements
223                .get(*id)
224                .is_some_and(|candidate| candidate.owner == effect.id)
225        })
226        .map(EffectStatementId::from_semantic)
227}
228
229fn statement_position(
230    model: &ApplicationSemanticModel,
231    effect: &Effect,
232    statement: Option<&SemanticId>,
233) -> usize {
234    statement
235        .and_then(|statement| {
236            model
237                .effect_bodies
238                .get(&effect.id)
239                .and_then(|body| body.statements.iter().position(|id| id == statement))
240        })
241        .unwrap_or(usize::MAX)
242}
243
244fn cascade_precedence(kind: EffectSemanticViolationKind) -> u8 {
245    match kind {
246        EffectSemanticViolationKind::UnsupportedStatement => 1,
247        EffectSemanticViolationKind::Async
248        | EffectSemanticViolationKind::ReactiveStateMutation
249        | EffectSemanticViolationKind::ActionInvocation
250        | EffectSemanticViolationKind::EffectInvocation
251        | EffectSemanticViolationKind::ComponentMethodInvocation
252        | EffectSemanticViolationKind::ValueReturn => 2,
253        EffectSemanticViolationKind::UnresolvedComponentCall
254        | EffectSemanticViolationKind::UnresolvedComponentAssignment => 3,
255        EffectSemanticViolationKind::UnknownExternalCapability => 4,
256        EffectSemanticViolationKind::CapabilityBoundary => 5,
257        EffectSemanticViolationKind::CapabilitySignature => 6,
258        EffectSemanticViolationKind::CapabilitySerialization => 7,
259    }
260}
261
262fn violation_code(kind: EffectSemanticViolationKind) -> EffectDiagnosticCode {
263    match kind {
264        EffectSemanticViolationKind::UnsupportedStatement => EffectDiagnosticCode::UnsupportedBody,
265        EffectSemanticViolationKind::UnresolvedComponentAssignment => {
266            EffectDiagnosticCode::UnresolvedReference
267        }
268        EffectSemanticViolationKind::ReactiveStateMutation => {
269            EffectDiagnosticCode::ReactiveStateMutation
270        }
271        EffectSemanticViolationKind::ActionInvocation
272        | EffectSemanticViolationKind::EffectInvocation
273        | EffectSemanticViolationKind::ComponentMethodInvocation
274        | EffectSemanticViolationKind::UnresolvedComponentCall => {
275            EffectDiagnosticCode::InvalidComponentInvocation
276        }
277        EffectSemanticViolationKind::Async | EffectSemanticViolationKind::ValueReturn => {
278            EffectDiagnosticCode::AsyncOrCleanupUnsupported
279        }
280        EffectSemanticViolationKind::UnknownExternalCapability => {
281            EffectDiagnosticCode::UnknownCapability
282        }
283        EffectSemanticViolationKind::CapabilitySignature => {
284            EffectDiagnosticCode::CapabilitySignature
285        }
286        EffectSemanticViolationKind::CapabilityBoundary => EffectDiagnosticCode::CapabilityBoundary,
287        EffectSemanticViolationKind::CapabilitySerialization => {
288            EffectDiagnosticCode::CapabilitySerialization
289        }
290    }
291}
292
293fn violation_message(
294    model: &ApplicationSemanticModel,
295    effect: &Effect,
296    violation: &EffectSemanticViolation,
297    code: EffectDiagnosticCode,
298) -> String {
299    let statement = violation
300        .statement
301        .as_ref()
302        .and_then(|id| model.effect_statements.get(id));
303    match code {
304        EffectDiagnosticCode::ReactiveStateMutation => format!(
305            "Effect `{}` writes reactive state{}. Effects synchronize reactive values with external systems and cannot mutate component state. Move this write into an `@action()` method.",
306            effect.name,
307            reactive_state_name(model, statement).map_or_else(String::new, |name| format!(" `{name}`")),
308        ),
309        EffectDiagnosticCode::InvalidComponentInvocation => format!(
310            "Effect `{}` {}. Effects cannot invoke component actions, effects, or methods.",
311            effect.name,
312            component_invocation_description(model, effect, statement),
313        ),
314        EffectDiagnosticCode::AsyncOrCleanupUnsupported => match violation.kind {
315            EffectSemanticViolationKind::Async => format!(
316                "Effect `{}` is async. Effects are synchronous and do not support async or cleanup semantics.",
317                effect.name
318            ),
319            _ => format!(
320                "Effect `{}` returns a value. Effects do not support cleanup callbacks or value-return semantics.",
321                effect.name
322            ),
323        },
324        EffectDiagnosticCode::UnknownCapability => format!(
325            "Effect `{}` uses unknown capability `{}`. Effects may call only compiler-recognized capabilities from registry version 1.",
326            effect.name,
327            capability_path(model, statement).unwrap_or_else(|| "<dynamic capability>".to_string()),
328        ),
329        EffectDiagnosticCode::CapabilitySignature => format!(
330            "Effect `{}` uses a capability with an incompatible signature or value type{}.",
331            effect.name,
332            recognized_capability_suffix(model, violation.statement.as_ref()),
333        ),
334        EffectDiagnosticCode::CapabilityBoundary => format!(
335            "Effect `{}` uses a capability incompatible with the client execution boundary{}.",
336            effect.name,
337            recognized_capability_suffix(model, violation.statement.as_ref()),
338        ),
339        EffectDiagnosticCode::CapabilitySerialization => format!(
340            "Effect `{}` passes a value incompatible with the capability serialization policy{}.",
341            effect.name,
342            recognized_capability_suffix(model, violation.statement.as_ref()),
343        ),
344        EffectDiagnosticCode::UnsupportedBody => format!(
345            "Effect `{}` contains an unsupported effect-body statement.",
346            effect.name
347        ),
348        EffectDiagnosticCode::UnresolvedReference => format!(
349            "Effect `{}` assigns an unresolved component reference.",
350            effect.name
351        ),
352        EffectDiagnosticCode::InvalidDeclaration => format!(
353            "Effect `{}` has an invalid declaration.",
354            effect.name
355        ),
356        EffectDiagnosticCode::UnavailableComputedPrerequisite => unreachable!(),
357    }
358}
359
360fn secondary_labels(
361    model: &ApplicationSemanticModel,
362    effect: &Effect,
363    violation: &EffectSemanticViolation,
364) -> Vec<DiagnosticSecondaryLabel> {
365    let Some(statement_id) = violation.statement.as_ref() else {
366        return Vec::new();
367    };
368    let Some(statement) = model.effect_statements.get(statement_id) else {
369        return Vec::new();
370    };
371    let Some(component_id) = effect.owner.entity_id() else {
372        return Vec::new();
373    };
374    let Some(component) = model
375        .components
376        .iter()
377        .find(|component| component.id == *component_id)
378    else {
379        return Vec::new();
380    };
381    match (&violation.kind, &statement.kind) {
382        (
383            EffectSemanticViolationKind::ReactiveStateMutation,
384            EffectStatementKind::ExternalMemberAssignment { target, .. },
385        ) => this_member_name(model, target)
386            .and_then(|name| {
387                component
388                    .state_fields
389                    .iter()
390                    .find(|field| field.name == name)
391                    .and_then(|field| {
392                        model
393                            .provenance(&field.id)
394                            .map(|provenance| DiagnosticSecondaryLabel {
395                                provenance: provenance.clone(),
396                                message: format!("Reactive state `{name}` is declared here."),
397                            })
398                    })
399            })
400            .into_iter()
401            .collect(),
402        (
403            EffectSemanticViolationKind::ActionInvocation
404            | EffectSemanticViolationKind::EffectInvocation
405            | EffectSemanticViolationKind::ComponentMethodInvocation,
406            EffectStatementKind::CapabilityCall { callee, .. },
407        ) => this_member_name(model, callee)
408            .and_then(|name| {
409                component
410                    .methods
411                    .iter()
412                    .find(|method| method.name == name)
413                    .and_then(|method| {
414                        model
415                            .provenance(&method.id)
416                            .map(|provenance| DiagnosticSecondaryLabel {
417                                provenance: provenance.clone(),
418                                message: format!("Component method `{name}` is declared here."),
419                            })
420                    })
421            })
422            .into_iter()
423            .collect(),
424        _ => Vec::new(),
425    }
426}
427
428fn reactive_state_name(
429    model: &ApplicationSemanticModel,
430    statement: Option<&crate::EffectStatement>,
431) -> Option<String> {
432    match statement?.kind {
433        EffectStatementKind::ExternalMemberAssignment { ref target, .. } => {
434            this_member_name(model, target)
435        }
436        _ => None,
437    }
438}
439
440fn component_invocation_description(
441    model: &ApplicationSemanticModel,
442    effect: &Effect,
443    statement: Option<&crate::EffectStatement>,
444) -> String {
445    let name = match statement.map(|statement| &statement.kind) {
446        Some(EffectStatementKind::CapabilityCall { callee, .. }) => this_member_name(model, callee),
447        _ => None,
448    };
449    let category = effect
450        .owner
451        .entity_id()
452        .and_then(|owner| {
453            model
454                .components
455                .iter()
456                .find(|component| component.id == *owner)
457        })
458        .and_then(|component| {
459            name.as_ref()
460                .and_then(|name| component.methods.iter().find(|method| method.name == *name))
461        })
462        .map_or("an unresolved component method", |method| {
463            if method.is_action() {
464                "an `@action()` method"
465            } else if method.is_effect() {
466                "an `@effect()` method"
467            } else {
468                "a component method"
469            }
470        });
471    name.map_or_else(
472        || format!("invokes {category}"),
473        |name| format!("invokes {category} `{name}`"),
474    )
475}
476
477fn recognized_capability_suffix(
478    model: &ApplicationSemanticModel,
479    statement: Option<&SemanticId>,
480) -> String {
481    let operation = statement
482        .and_then(|id| model.semantic_types.effect_statements.get(id))
483        .and_then(|record| record.capability_operation)
484        .and_then(|id| EFFECT_CAPABILITY_REGISTRY.operation(id));
485    operation.map_or_else(String::new, |operation| {
486        format!(" for `{}`", operation.static_path.0)
487    })
488}
489
490fn capability_path(
491    model: &ApplicationSemanticModel,
492    statement: Option<&crate::EffectStatement>,
493) -> Option<String> {
494    match statement?.kind {
495        EffectStatementKind::ExternalMemberAssignment { ref target, .. } => {
496            static_path(model, target)
497        }
498        EffectStatementKind::CapabilityCall { ref callee, .. } => static_path(model, callee),
499        _ => None,
500    }
501}
502
503fn static_path(model: &ApplicationSemanticModel, id: &SemanticId) -> Option<String> {
504    match &model.expression(id)?.kind {
505        ExpressionNodeKind::Identifier(name) => Some(name.clone()),
506        ExpressionNodeKind::ThisMember { name } => Some(format!("this.{name}")),
507        ExpressionNodeKind::MemberAccess {
508            object, property, ..
509        } => Some(format!("{}.{}", static_path(model, object)?, property)),
510        _ => None,
511    }
512}
513
514fn this_member_name(model: &ApplicationSemanticModel, id: &SemanticId) -> Option<String> {
515    match &model.expression(id)?.kind {
516        ExpressionNodeKind::ThisMember { name } => Some(name.clone()),
517        _ => None,
518    }
519}
520
521fn normalized_labels(mut labels: Vec<DiagnosticSecondaryLabel>) -> Vec<DiagnosticSecondaryLabel> {
522    labels.sort_by(|left, right| {
523        (
524            left.provenance.path.as_path(),
525            left.provenance.span.start,
526            left.provenance.span.end,
527            left.message.as_str(),
528        )
529            .cmp(&(
530                right.provenance.path.as_path(),
531                right.provenance.span.start,
532                right.provenance.span.end,
533                right.message.as_str(),
534            ))
535    });
536    labels.dedup();
537    labels
538}
539
540fn diagnostic_order(diagnostic: &ComponentDiagnostic) -> (&str, &std::path::Path, usize, &str) {
541    (
542        diagnostic.effect_id.as_ref().map_or("", EffectId::as_str),
543        diagnostic.provenance.as_ref().map_or_else(
544            || std::path::Path::new(""),
545            |provenance| provenance.path.as_path(),
546        ),
547        diagnostic
548            .provenance
549            .as_ref()
550            .map_or(usize::MAX, |provenance| provenance.span.start),
551        diagnostic.code.as_str(),
552    )
553}
554
555#[cfg(test)]
556mod tests {
557    use crate::{
558        build_application_semantic_model, collect_effect_diagnostics, EffectSemanticViolationKind,
559        UnplannedEffect, UnplannedEffectReason,
560    };
561
562    #[test]
563    fn projects_ordered_effect_validation_facts_with_shared_diagnostic_metadata() {
564        let parsed = presolve_parser::parse_file(
565            "src/EffectDiagnostics.tsx",
566            r#"
567@component("x-effect-diagnostics")
568class EffectDiagnostics extends Component {
569  count = state(1);
570  @action() increment() { this.count += 1; }
571  @effect() invalid() { this.count = 0; this.increment(); analytics.track(this.count); }
572}
573"#,
574        );
575        let model = build_application_semantic_model(&parsed);
576        let diagnostics = collect_effect_diagnostics(&model);
577        let codes = diagnostics
578            .iter()
579            .map(|diagnostic| diagnostic.code.as_str())
580            .collect::<Vec<_>>();
581        assert_eq!(codes, vec!["PSC1044", "PSC1045", "PSC1047"]);
582        assert!(diagnostics.iter().all(|diagnostic| {
583            diagnostic.effect_id.is_some()
584                && diagnostic.statement_id.is_some()
585                && diagnostic.severity == crate::ComponentDiagnosticSeverity::Error
586        }));
587        assert_eq!(diagnostics[0].secondary_labels.len(), 1);
588        assert_eq!(diagnostics[1].secondary_labels.len(), 1);
589    }
590
591    #[test]
592    fn maps_each_existing_f5_violation_category_to_one_stable_public_code() {
593        let cases = [
594            (EffectSemanticViolationKind::UnsupportedStatement, "PSC1042"),
595            (
596                EffectSemanticViolationKind::UnresolvedComponentAssignment,
597                "PSC1043",
598            ),
599            (
600                EffectSemanticViolationKind::ReactiveStateMutation,
601                "PSC1044",
602            ),
603            (EffectSemanticViolationKind::ActionInvocation, "PSC1045"),
604            (EffectSemanticViolationKind::EffectInvocation, "PSC1045"),
605            (
606                EffectSemanticViolationKind::ComponentMethodInvocation,
607                "PSC1045",
608            ),
609            (
610                EffectSemanticViolationKind::UnresolvedComponentCall,
611                "PSC1045",
612            ),
613            (EffectSemanticViolationKind::Async, "PSC1046"),
614            (EffectSemanticViolationKind::ValueReturn, "PSC1046"),
615            (
616                EffectSemanticViolationKind::UnknownExternalCapability,
617                "PSC1047",
618            ),
619            (EffectSemanticViolationKind::CapabilitySignature, "PSC1048"),
620            (EffectSemanticViolationKind::CapabilityBoundary, "PSC1049"),
621            (
622                EffectSemanticViolationKind::CapabilitySerialization,
623                "PSC1050",
624            ),
625        ];
626
627        for (kind, code) in cases {
628            assert_eq!(super::violation_code(kind).as_str(), code);
629        }
630    }
631
632    #[test]
633    fn projects_f9_unavailable_prerequisites_once_with_computed_evidence() {
634        let parsed = presolve_parser::parse_file(
635            "src/UnplannedEffect.tsx",
636            r#"
637@component("x-unplanned-effect")
638class UnplannedEffect extends Component {
639  count = state(1);
640  @action() increment() { this.count += 1; }
641  @computed() get total() { return this.count; }
642  @effect() report() { console.log(this.total); }
643  render() { return <p />; }
644}
645"#,
646        );
647        let mut model = build_application_semantic_model(&parsed);
648        let component = &model.components[0];
649        let effect = component.id.effect("report");
650        let computed = component.id.computed("total");
651        model.effect_execution_plan.initial.unplanned_effects = vec![UnplannedEffect {
652            effect: effect.clone(),
653            reason: UnplannedEffectReason::UnavailableComputedPrerequisite,
654            computed_dependencies: vec![computed.clone()],
655        }];
656        // Repeating the same F9 fact in an action plan cannot duplicate the
657        // root scheduling diagnostic.
658        model.effect_execution_plan.actions[0].unplanned_effects = vec![UnplannedEffect {
659            effect: effect.clone(),
660            reason: UnplannedEffectReason::UnavailableComputedPrerequisite,
661            computed_dependencies: vec![computed],
662        }];
663
664        let diagnostics = collect_effect_diagnostics(&model);
665        assert_eq!(diagnostics.len(), 1);
666        let diagnostic = &diagnostics[0];
667        assert_eq!(diagnostic.code, "PSC1051");
668        assert_eq!(
669            diagnostic.effect_id.as_ref().map(crate::EffectId::as_str),
670            Some(effect.as_str())
671        );
672        assert!(diagnostic.statement_id.is_none());
673        assert_eq!(diagnostic.secondary_labels.len(), 1);
674    }
675}