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#[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#[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 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 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 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}