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::CleanupLifecycleUnavailable
249        | EffectSemanticViolationKind::ReactiveStateMutation
250        | EffectSemanticViolationKind::ActionInvocation
251        | EffectSemanticViolationKind::EffectInvocation
252        | EffectSemanticViolationKind::ComponentMethodInvocation
253        | EffectSemanticViolationKind::ValueReturn => 2,
254        EffectSemanticViolationKind::UnresolvedComponentCall
255        | EffectSemanticViolationKind::UnresolvedComponentAssignment => 3,
256        EffectSemanticViolationKind::UnknownExternalCapability => 4,
257        EffectSemanticViolationKind::CapabilityBoundary => 5,
258        EffectSemanticViolationKind::CapabilitySignature => 6,
259        EffectSemanticViolationKind::CapabilitySerialization => 7,
260    }
261}
262
263fn violation_code(kind: EffectSemanticViolationKind) -> EffectDiagnosticCode {
264    match kind {
265        EffectSemanticViolationKind::UnsupportedStatement => EffectDiagnosticCode::UnsupportedBody,
266        EffectSemanticViolationKind::UnresolvedComponentAssignment => {
267            EffectDiagnosticCode::UnresolvedReference
268        }
269        EffectSemanticViolationKind::ReactiveStateMutation => {
270            EffectDiagnosticCode::ReactiveStateMutation
271        }
272        EffectSemanticViolationKind::ActionInvocation
273        | EffectSemanticViolationKind::EffectInvocation
274        | EffectSemanticViolationKind::ComponentMethodInvocation
275        | EffectSemanticViolationKind::UnresolvedComponentCall => {
276            EffectDiagnosticCode::InvalidComponentInvocation
277        }
278        EffectSemanticViolationKind::Async
279        | EffectSemanticViolationKind::CleanupLifecycleUnavailable
280        | EffectSemanticViolationKind::ValueReturn => {
281            EffectDiagnosticCode::AsyncOrCleanupUnsupported
282        }
283        EffectSemanticViolationKind::UnknownExternalCapability => {
284            EffectDiagnosticCode::UnknownCapability
285        }
286        EffectSemanticViolationKind::CapabilitySignature => {
287            EffectDiagnosticCode::CapabilitySignature
288        }
289        EffectSemanticViolationKind::CapabilityBoundary => EffectDiagnosticCode::CapabilityBoundary,
290        EffectSemanticViolationKind::CapabilitySerialization => {
291            EffectDiagnosticCode::CapabilitySerialization
292        }
293    }
294}
295
296fn violation_message(
297    model: &ApplicationSemanticModel,
298    effect: &Effect,
299    violation: &EffectSemanticViolation,
300    code: EffectDiagnosticCode,
301) -> String {
302    let statement = violation
303        .statement
304        .as_ref()
305        .and_then(|id| model.effect_statements.get(id));
306    match code {
307        EffectDiagnosticCode::ReactiveStateMutation => format!(
308            "Effect `{}` writes reactive state{}. Effects synchronize reactive values with external systems and cannot mutate component state. Move this write into an `@action()` method.",
309            effect.name,
310            reactive_state_name(model, statement).map_or_else(String::new, |name| format!(" `{name}`")),
311        ),
312        EffectDiagnosticCode::InvalidComponentInvocation => format!(
313            "Effect `{}` {}. Effects cannot invoke component actions, effects, or methods.",
314            effect.name,
315            component_invocation_description(model, effect, statement),
316        ),
317        EffectDiagnosticCode::AsyncOrCleanupUnsupported => match violation.kind {
318            EffectSemanticViolationKind::Async => format!(
319                "Effect `{}` is async. Effects are synchronous and do not support async or cleanup semantics.",
320                effect.name
321            ),
322            EffectSemanticViolationKind::CleanupLifecycleUnavailable => format!(
323                "Effect `{}` returns a cleanup callback, but its lifecycle runtime program is unavailable.",
324                effect.name
325            ),
326            _ => format!(
327                "Effect `{}` returns a value. Effects do not support cleanup callbacks or value-return semantics.",
328                effect.name
329            ),
330        },
331        EffectDiagnosticCode::UnknownCapability => format!(
332            "Effect `{}` uses unknown capability `{}`. Effects may call only compiler-recognized capabilities from registry version 1.",
333            effect.name,
334            capability_path(model, statement).unwrap_or_else(|| "<dynamic capability>".to_string()),
335        ),
336        EffectDiagnosticCode::CapabilitySignature => format!(
337            "Effect `{}` uses a capability with an incompatible signature or value type{}.",
338            effect.name,
339            recognized_capability_suffix(model, violation.statement.as_ref()),
340        ),
341        EffectDiagnosticCode::CapabilityBoundary => format!(
342            "Effect `{}` uses a capability incompatible with the client execution boundary{}.",
343            effect.name,
344            recognized_capability_suffix(model, violation.statement.as_ref()),
345        ),
346        EffectDiagnosticCode::CapabilitySerialization => format!(
347            "Effect `{}` passes a value incompatible with the capability serialization policy{}.",
348            effect.name,
349            recognized_capability_suffix(model, violation.statement.as_ref()),
350        ),
351        EffectDiagnosticCode::UnsupportedBody => format!(
352            "Effect `{}` contains an unsupported effect-body statement.",
353            effect.name
354        ),
355        EffectDiagnosticCode::UnresolvedReference => format!(
356            "Effect `{}` assigns an unresolved component reference.",
357            effect.name
358        ),
359        EffectDiagnosticCode::InvalidDeclaration => format!(
360            "Effect `{}` has an invalid declaration.",
361            effect.name
362        ),
363        EffectDiagnosticCode::UnavailableComputedPrerequisite => unreachable!(),
364    }
365}
366
367fn secondary_labels(
368    model: &ApplicationSemanticModel,
369    effect: &Effect,
370    violation: &EffectSemanticViolation,
371) -> Vec<DiagnosticSecondaryLabel> {
372    let Some(statement_id) = violation.statement.as_ref() else {
373        return Vec::new();
374    };
375    let Some(statement) = model.effect_statements.get(statement_id) else {
376        return Vec::new();
377    };
378    let Some(component_id) = effect.owner.entity_id() else {
379        return Vec::new();
380    };
381    let Some(component) = model
382        .components
383        .iter()
384        .find(|component| component.id == *component_id)
385    else {
386        return Vec::new();
387    };
388    match (&violation.kind, &statement.kind) {
389        (
390            EffectSemanticViolationKind::ReactiveStateMutation,
391            EffectStatementKind::ExternalMemberAssignment { target, .. },
392        ) => this_member_name(model, target)
393            .and_then(|name| {
394                component
395                    .state_fields
396                    .iter()
397                    .find(|field| field.name == name)
398                    .and_then(|field| {
399                        model
400                            .provenance(&field.id)
401                            .map(|provenance| DiagnosticSecondaryLabel {
402                                provenance: provenance.clone(),
403                                message: format!("Reactive state `{name}` is declared here."),
404                            })
405                    })
406            })
407            .into_iter()
408            .collect(),
409        (
410            EffectSemanticViolationKind::ActionInvocation
411            | EffectSemanticViolationKind::EffectInvocation
412            | EffectSemanticViolationKind::ComponentMethodInvocation,
413            EffectStatementKind::CapabilityCall { callee, .. },
414        ) => this_member_name(model, callee)
415            .and_then(|name| {
416                component
417                    .methods
418                    .iter()
419                    .find(|method| method.name == name)
420                    .and_then(|method| {
421                        model
422                            .provenance(&method.id)
423                            .map(|provenance| DiagnosticSecondaryLabel {
424                                provenance: provenance.clone(),
425                                message: format!("Component method `{name}` is declared here."),
426                            })
427                    })
428            })
429            .into_iter()
430            .collect(),
431        _ => Vec::new(),
432    }
433}
434
435fn reactive_state_name(
436    model: &ApplicationSemanticModel,
437    statement: Option<&crate::EffectStatement>,
438) -> Option<String> {
439    match statement?.kind {
440        EffectStatementKind::ExternalMemberAssignment { ref target, .. } => {
441            this_member_name(model, target)
442        }
443        _ => None,
444    }
445}
446
447fn component_invocation_description(
448    model: &ApplicationSemanticModel,
449    effect: &Effect,
450    statement: Option<&crate::EffectStatement>,
451) -> String {
452    let name = match statement.map(|statement| &statement.kind) {
453        Some(EffectStatementKind::CapabilityCall { callee, .. }) => this_member_name(model, callee),
454        _ => None,
455    };
456    let category = effect
457        .owner
458        .entity_id()
459        .and_then(|owner| {
460            model
461                .components
462                .iter()
463                .find(|component| component.id == *owner)
464        })
465        .and_then(|component| {
466            name.as_ref()
467                .and_then(|name| component.methods.iter().find(|method| method.name == *name))
468        })
469        .map_or("an unresolved component method", |method| {
470            if method.is_action() {
471                "an `@action()` method"
472            } else if method.is_effect() {
473                "an `@effect()` method"
474            } else {
475                "a component method"
476            }
477        });
478    name.map_or_else(
479        || format!("invokes {category}"),
480        |name| format!("invokes {category} `{name}`"),
481    )
482}
483
484fn recognized_capability_suffix(
485    model: &ApplicationSemanticModel,
486    statement: Option<&SemanticId>,
487) -> String {
488    let operation = statement
489        .and_then(|id| model.semantic_types.effect_statements.get(id))
490        .and_then(|record| record.capability_operation)
491        .and_then(|id| EFFECT_CAPABILITY_REGISTRY.operation(id));
492    operation.map_or_else(String::new, |operation| {
493        format!(" for `{}`", operation.static_path.0)
494    })
495}
496
497fn capability_path(
498    model: &ApplicationSemanticModel,
499    statement: Option<&crate::EffectStatement>,
500) -> Option<String> {
501    match statement?.kind {
502        EffectStatementKind::ExternalMemberAssignment { ref target, .. } => {
503            static_path(model, target)
504        }
505        EffectStatementKind::CapabilityCall { ref callee, .. } => static_path(model, callee),
506        _ => None,
507    }
508}
509
510fn static_path(model: &ApplicationSemanticModel, id: &SemanticId) -> Option<String> {
511    match &model.expression(id)?.kind {
512        ExpressionNodeKind::Identifier(name) => Some(name.clone()),
513        ExpressionNodeKind::ThisMember { name } => Some(format!("this.{name}")),
514        ExpressionNodeKind::MemberAccess {
515            object, property, ..
516        } => Some(format!("{}.{}", static_path(model, object)?, property)),
517        _ => None,
518    }
519}
520
521fn this_member_name(model: &ApplicationSemanticModel, id: &SemanticId) -> Option<String> {
522    match &model.expression(id)?.kind {
523        ExpressionNodeKind::ThisMember { name } => Some(name.clone()),
524        _ => None,
525    }
526}
527
528fn normalized_labels(mut labels: Vec<DiagnosticSecondaryLabel>) -> Vec<DiagnosticSecondaryLabel> {
529    labels.sort_by(|left, right| {
530        (
531            left.provenance.path.as_path(),
532            left.provenance.span.start,
533            left.provenance.span.end,
534            left.message.as_str(),
535        )
536            .cmp(&(
537                right.provenance.path.as_path(),
538                right.provenance.span.start,
539                right.provenance.span.end,
540                right.message.as_str(),
541            ))
542    });
543    labels.dedup();
544    labels
545}
546
547fn diagnostic_order(diagnostic: &ComponentDiagnostic) -> (&str, &std::path::Path, usize, &str) {
548    (
549        diagnostic.effect_id.as_ref().map_or("", EffectId::as_str),
550        diagnostic.provenance.as_ref().map_or_else(
551            || std::path::Path::new(""),
552            |provenance| provenance.path.as_path(),
553        ),
554        diagnostic
555            .provenance
556            .as_ref()
557            .map_or(usize::MAX, |provenance| provenance.span.start),
558        diagnostic.code.as_str(),
559    )
560}
561
562#[cfg(test)]
563mod tests {
564    use crate::{
565        build_application_semantic_model, collect_effect_diagnostics, EffectSemanticViolationKind,
566        UnplannedEffect, UnplannedEffectReason,
567    };
568
569    #[test]
570    fn projects_ordered_effect_validation_facts_with_shared_diagnostic_metadata() {
571        let parsed = presolve_parser::parse_file(
572            "src/EffectDiagnostics.tsx",
573            r#"
574@component("x-effect-diagnostics")
575class EffectDiagnostics extends Component {
576  count = state(1);
577  @action() increment() { this.count += 1; }
578  @effect() invalid() { this.count = 0; this.increment(); analytics.track(this.count); }
579}
580"#,
581        );
582        let model = build_application_semantic_model(&parsed);
583        let diagnostics = collect_effect_diagnostics(&model);
584        let codes = diagnostics
585            .iter()
586            .map(|diagnostic| diagnostic.code.as_str())
587            .collect::<Vec<_>>();
588        assert_eq!(codes, vec!["PSC1044", "PSC1045", "PSC1047"]);
589        assert!(diagnostics.iter().all(|diagnostic| {
590            diagnostic.effect_id.is_some()
591                && diagnostic.statement_id.is_some()
592                && diagnostic.severity == crate::ComponentDiagnosticSeverity::Error
593        }));
594        assert_eq!(diagnostics[0].secondary_labels.len(), 1);
595        assert_eq!(diagnostics[1].secondary_labels.len(), 1);
596    }
597
598    #[test]
599    fn maps_each_existing_f5_violation_category_to_one_stable_public_code() {
600        let cases = [
601            (EffectSemanticViolationKind::UnsupportedStatement, "PSC1042"),
602            (
603                EffectSemanticViolationKind::UnresolvedComponentAssignment,
604                "PSC1043",
605            ),
606            (
607                EffectSemanticViolationKind::ReactiveStateMutation,
608                "PSC1044",
609            ),
610            (EffectSemanticViolationKind::ActionInvocation, "PSC1045"),
611            (EffectSemanticViolationKind::EffectInvocation, "PSC1045"),
612            (
613                EffectSemanticViolationKind::ComponentMethodInvocation,
614                "PSC1045",
615            ),
616            (
617                EffectSemanticViolationKind::UnresolvedComponentCall,
618                "PSC1045",
619            ),
620            (EffectSemanticViolationKind::Async, "PSC1046"),
621            (
622                EffectSemanticViolationKind::CleanupLifecycleUnavailable,
623                "PSC1046",
624            ),
625            (EffectSemanticViolationKind::ValueReturn, "PSC1046"),
626            (
627                EffectSemanticViolationKind::UnknownExternalCapability,
628                "PSC1047",
629            ),
630            (EffectSemanticViolationKind::CapabilitySignature, "PSC1048"),
631            (EffectSemanticViolationKind::CapabilityBoundary, "PSC1049"),
632            (
633                EffectSemanticViolationKind::CapabilitySerialization,
634                "PSC1050",
635            ),
636        ];
637
638        for (kind, code) in cases {
639            assert_eq!(super::violation_code(kind).as_str(), code);
640        }
641    }
642
643    #[test]
644    fn projects_f9_unavailable_prerequisites_once_with_computed_evidence() {
645        let parsed = presolve_parser::parse_file(
646            "src/UnplannedEffect.tsx",
647            r#"
648@component("x-unplanned-effect")
649class UnplannedEffect extends Component {
650  count = state(1);
651  @action() increment() { this.count += 1; }
652  @computed() get total() { return this.count; }
653  @effect() report() { console.log(this.total); }
654  render() { return <p />; }
655}
656"#,
657        );
658        let mut model = build_application_semantic_model(&parsed);
659        let component = &model.components[0];
660        let effect = component.id.effect("report");
661        let computed = component.id.computed("total");
662        model.effect_execution_plan.initial.unplanned_effects = vec![UnplannedEffect {
663            effect: effect.clone(),
664            reason: UnplannedEffectReason::UnavailableComputedPrerequisite,
665            computed_dependencies: vec![computed.clone()],
666        }];
667        // Repeating the same F9 fact in an action plan cannot duplicate the
668        // root scheduling diagnostic.
669        model.effect_execution_plan.actions[0].unplanned_effects = vec![UnplannedEffect {
670            effect: effect.clone(),
671            reason: UnplannedEffectReason::UnavailableComputedPrerequisite,
672            computed_dependencies: vec![computed],
673        }];
674
675        let diagnostics = collect_effect_diagnostics(&model);
676        assert_eq!(diagnostics.len(), 1);
677        let diagnostic = &diagnostics[0];
678        assert_eq!(diagnostic.code, "PSC1051");
679        assert_eq!(
680            diagnostic.effect_id.as_ref().map(crate::EffectId::as_str),
681            Some(effect.as_str())
682        );
683        assert!(diagnostic.statement_id.is_none());
684        assert_eq!(diagnostic.secondary_labels.len(), 1);
685    }
686}