1use std::collections::BTreeSet;
2
3use crate::component_graph::MethodSemanticRole;
4use crate::{
5 ApplicationSemanticModel, AuthoredDeclarationKind, ComponentDiagnostic,
6 ComponentDiagnosticSeverity, ComponentInvocationResolutionStatus, CompositionCompatibility,
7 DiagnosticSecondaryLabel, InstanceContextResolutionStatus, SemanticId, SlotBindingStatus,
8 SlotContentFragmentViolation, SlotDeclarationViolation, SlotOutletViolation, SourceProvenance,
9};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub struct ComponentDiagnosticContract {
17 pub code: &'static str,
18 pub message: &'static str,
19 pub authority: &'static str,
20 pub primary_role: &'static str,
21 pub identities: &'static str,
22 pub secondary_roles: &'static str,
23 pub deduplication: &'static str,
24 pub suppression: &'static str,
25 pub projection: &'static str,
26}
27
28pub const COMPONENT_DIAGNOSTIC_CONTRACTS: [ComponentDiagnosticContract; 16] = [
29 contract((
30 "PSC1068",
31 "Invalid slot declaration.",
32 "H1 slot declaration candidates",
33 "invalid authored slot declaration",
34 "component_id",
35 "related slot declarations",
36 "candidate identity",
37 "none",
38 "shared diagnostic envelope",
39 )),
40 contract((
41 "PSC1069",
42 "Invalid component invocation.",
43 "H2 invocation resolution",
44 "component invocation",
45 "component_id, invocation_id",
46 "candidate definitions",
47 "invocation_id",
48 "inheritance",
49 "shared diagnostic envelope",
50 )),
51 contract((
52 "PSC1070",
53 "Unresolved component symbol.",
54 "H2 invocation resolution",
55 "component invocation symbol",
56 "component_id, invocation_id",
57 "none",
58 "invocation_id",
59 "inheritance",
60 "shared diagnostic envelope",
61 )),
62 contract((
63 "PSC1071",
64 "Component composition cycle.",
65 "H9 composition analysis",
66 "cycle invocation",
67 "component_id, invocation_id",
68 "cycle edges",
69 "cycle invocation_id",
70 "inheritance and unresolved invocation",
71 "shared diagnostic envelope",
72 )),
73 contract((
74 "PSC1072",
75 "Component inheritance is unsupported.",
76 "H1 normalized component heritage",
77 "component base class",
78 "component_id",
79 "none",
80 "component_id and base",
81 "none",
82 "shared diagnostic envelope",
83 )),
84 contract((
85 "PSC1073",
86 "Inherited semantic declaration is unsupported.",
87 "H1 normalized heritage and semantic declarations",
88 "component base class",
89 "component_id",
90 "inherited semantic declarations",
91 "component_id and inherited declaration",
92 "generic inheritance",
93 "shared diagnostic envelope",
94 )),
95 contract((
96 "PSC1074",
97 "Unknown slot.",
98 "H3 slot fragment/outlet registry",
99 "unknown supplied slot",
100 "component_id, invocation_id",
101 "none",
102 "fragment identity",
103 "invocation",
104 "shared diagnostic envelope",
105 )),
106 contract((
107 "PSC1075",
108 "Duplicate slot content.",
109 "H3 slot fragment registry",
110 "duplicate supplied content",
111 "component_id, invocation_id, slot_id",
112 "duplicate fragments",
113 "fragment identity",
114 "invocation",
115 "shared diagnostic envelope",
116 )),
117 contract((
118 "PSC1076",
119 "Duplicate slot outlet.",
120 "H3 slot outlet registry",
121 "duplicate callee outlet",
122 "component_id, slot_id",
123 "other outlets",
124 "slot_id and outlet provenance",
125 "inheritance",
126 "shared diagnostic envelope",
127 )),
128 contract((
129 "PSC1077",
130 "Missing slot outlet.",
131 "H7 slot binding registry",
132 "instance slot binding",
133 "component_id, invocation_id, component_instance_id, slot_binding_id, slot_id",
134 "slot declaration",
135 "slot_binding_id",
136 "invocation and invalid slot",
137 "shared diagnostic envelope",
138 )),
139 contract((
140 "PSC1078",
141 "Invalid slot content ownership.",
142 "H7 binding and H8 ownership typing",
143 "instance slot binding",
144 "component_id, invocation_id, component_instance_id, slot_binding_id, slot_id",
145 "content and outlet owners",
146 "slot_binding_id",
147 "invocation and invalid slot",
148 "shared diagnostic envelope",
149 )),
150 contract((
151 "PSC1079",
152 "Slot type or boundary incompatibility.",
153 "H8 composition typing",
154 "instance slot binding",
155 "component_id, invocation_id, component_instance_id, slot_binding_id, slot_id",
156 "declared slot type",
157 "slot_binding_id",
158 "invocation and invalid slot",
159 "shared diagnostic envelope",
160 )),
161 contract((
162 "PSC1080",
163 "Component instance planning failure.",
164 "H4 blocked instance plan",
165 "blocked component instance",
166 "component_id, invocation_id, component_instance_id",
167 "parent instance",
168 "component_instance_id",
169 "invocation, cycle, and slot",
170 "shared diagnostic envelope",
171 )),
172 contract((
173 "PSC1081",
174 "Instance-aware Context binding unavailable.",
175 "H6 instance Context and H8 typing",
176 "consumer instance binding",
177 "component_id, component_instance_id, provider_instance_id, consumer_instance_id",
178 "candidate Provider instances",
179 "consumer_instance_id",
180 "component and slot failures",
181 "shared diagnostic envelope",
182 )),
183 contract((
184 "PSC1082",
185 "Structural region cannot be planned.",
186 "H4 blocked structural instance plan",
187 "structural region",
188 "component_id, invocation_id, component_instance_id, structural_region_id",
189 "parent instance",
190 "component_instance_id and structural_region_id",
191 "invocation, cycle, slot, and Context",
192 "shared diagnostic envelope",
193 )),
194 contract((
195 "PSC1083",
196 "Component/slot source cannot be lowered.",
197 "H10 initialization exclusions and H11 lowering",
198 "blocked component or slot source",
199 "component_id, invocation_id, component_instance_id, slot_binding_id",
200 "none",
201 "typed blocked source identity",
202 "all earlier subject failures",
203 "shared diagnostic envelope",
204 )),
205];
206
207const fn contract(
208 details: (
209 &'static str,
210 &'static str,
211 &'static str,
212 &'static str,
213 &'static str,
214 &'static str,
215 &'static str,
216 &'static str,
217 &'static str,
218 ),
219) -> ComponentDiagnosticContract {
220 let (
221 code,
222 message,
223 authority,
224 primary_role,
225 identities,
226 secondary_roles,
227 deduplication,
228 suppression,
229 projection,
230 ) = details;
231 ComponentDiagnosticContract {
232 code,
233 message,
234 authority,
235 primary_role,
236 identities,
237 secondary_roles,
238 deduplication,
239 suppression,
240 projection,
241 }
242}
243
244#[must_use]
247pub fn collect_component_diagnostics(model: &ApplicationSemanticModel) -> Vec<ComponentDiagnostic> {
248 let mut diagnostics = Vec::new();
249 collect_declarations_and_inheritance(model, &mut diagnostics);
250 collect_invocations_and_cycles(model, &mut diagnostics);
251 collect_slot_composition(model, &mut diagnostics);
252 collect_slot_bindings(model, &mut diagnostics);
253 collect_instance_context(model, &mut diagnostics);
254 collect_planning_and_lowering(model, &mut diagnostics);
255 suppress_derivative_cascades(&mut diagnostics);
256 canonicalize(&mut diagnostics);
257 diagnostics
258}
259
260fn collect_declarations_and_inheritance(
261 model: &ApplicationSemanticModel,
262 diagnostics: &mut Vec<ComponentDiagnostic>,
263) {
264 for component in &model.components {
265 for candidate in &component.slot_declaration_candidates {
266 let Some(violation) = candidate.violations.first() else {
267 continue;
268 };
269 let primary = slot_declaration_primary(candidate, violation);
270 let mut diagnostic = diagnostic("PSC1068", primary, Some(component.id.clone()));
271 diagnostic.secondary_labels = component
272 .slot_declaration_candidates
273 .iter()
274 .filter(|other| {
275 other.id != candidate.id && other.field_name == candidate.field_name
276 })
277 .map(|other| label(other.provenance.clone(), "Related Slot declaration."))
278 .collect();
279 diagnostics.push(diagnostic);
280 }
281
282 let Some(heritage) = &component.heritage else {
283 continue;
284 };
285 if heritage.base == "Component" {
286 continue;
287 }
288 let inherited = inherited_semantic_provenances(model, component, &heritage.base);
289 if inherited.is_empty() {
290 diagnostics.push(diagnostic(
291 "PSC1072",
292 heritage.provenance.clone(),
293 Some(component.id.clone()),
294 ));
295 } else {
296 for provenance in inherited {
297 let mut item = diagnostic(
298 "PSC1073",
299 heritage.provenance.clone(),
300 Some(component.id.clone()),
301 );
302 item.secondary_labels
303 .push(label(provenance, "Inherited semantic declaration is here."));
304 diagnostics.push(item);
305 }
306 }
307 }
308}
309
310fn inherited_semantic_provenances(
311 model: &ApplicationSemanticModel,
312 component: &crate::ComponentNode,
313 base: &str,
314) -> Vec<SourceProvenance> {
315 let component_path = model.provenance(&component.id).map(|item| &item.path);
316 let Some(base_component) = model.components.iter().find(|candidate| {
317 candidate.class_name == base
318 && model.provenance(&candidate.id).map(|item| &item.path) == component_path
319 }) else {
320 return Vec::new();
321 };
322 let mut result = Vec::new();
323 result.extend(
324 base_component
325 .state_fields
326 .iter()
327 .filter_map(|item| model.provenance(&item.id))
328 .cloned(),
329 );
330 result.extend(
331 base_component
332 .context_declaration_candidates
333 .iter()
334 .map(|item| item.provenance.clone()),
335 );
336 result.extend(
337 base_component
338 .slot_declaration_candidates
339 .iter()
340 .map(|item| item.provenance.clone()),
341 );
342 result.extend(
343 base_component
344 .methods
345 .iter()
346 .filter(|method| method.semantic_role != MethodSemanticRole::Standard)
347 .filter_map(|method| model.provenance(&method.id))
348 .cloned(),
349 );
350 result.sort_by(provenance_order);
351 result.dedup();
352 result
353}
354
355fn collect_invocations_and_cycles(
356 model: &ApplicationSemanticModel,
357 diagnostics: &mut Vec<ComponentDiagnostic>,
358) {
359 for invocation in model.component_invocations.values() {
360 let code = match invocation.status {
361 ComponentInvocationResolutionStatus::Resolved => continue,
362 ComponentInvocationResolutionStatus::UnresolvedSymbol => "PSC1070",
363 ComponentInvocationResolutionStatus::ResolvedNonComponent
364 | ComponentInvocationResolutionStatus::Ambiguous
365 | ComponentInvocationResolutionStatus::UnsupportedDynamicTarget => "PSC1069",
366 };
367 let mut item = diagnostic(
368 code,
369 invocation.provenance.clone(),
370 Some(invocation.owner_component.clone()),
371 );
372 item.invocation_id = Some(invocation.id.clone());
373 diagnostics.push(item);
374 }
375 for cycle in &model.component_composition.cycles {
376 for invocation_id in &cycle.invocations {
377 let Some(invocation) = model.component_invocations.get(invocation_id) else {
378 continue;
379 };
380 let mut item = diagnostic(
381 "PSC1071",
382 invocation.provenance.clone(),
383 Some(invocation.owner_component.clone()),
384 );
385 item.invocation_id = Some(invocation.id.clone());
386 item.secondary_labels = cycle
387 .invocations
388 .iter()
389 .filter(|other| *other != invocation_id)
390 .filter_map(|other| model.component_invocations.get(other))
391 .map(|other| label(other.provenance.clone(), "Composition cycle edge."))
392 .collect();
393 diagnostics.push(item);
394 }
395 }
396}
397
398fn collect_slot_composition(
399 model: &ApplicationSemanticModel,
400 diagnostics: &mut Vec<ComponentDiagnostic>,
401) {
402 for fragment in model.slot_content_fragments.values() {
403 let code = if fragment
404 .violations
405 .contains(&SlotContentFragmentViolation::DuplicateFragment)
406 {
407 Some("PSC1075")
408 } else if fragment.violations.iter().any(|item| {
409 matches!(
410 item,
411 SlotContentFragmentViolation::MissingSlotDeclaration
412 | SlotContentFragmentViolation::UnsupportedDynamicSlotName
413 | SlotContentFragmentViolation::InvalidNestedWrapper
414 | SlotContentFragmentViolation::InvalidWrapperForm
415 )
416 }) {
417 Some("PSC1074")
418 } else {
419 None
420 };
421 let Some(code) = code else { continue };
422 let mut item = diagnostic(
423 code,
424 fragment.provenance.clone(),
425 Some(fragment.owner_component.clone()),
426 );
427 item.invocation_id = Some(fragment.invocation.clone());
428 item.slot_id.clone_from(&fragment.slot);
429 item.secondary_labels = fragment
430 .secondary_provenances
431 .iter()
432 .cloned()
433 .map(|provenance| label(provenance, "Related Slot content."))
434 .collect();
435 diagnostics.push(item);
436 }
437 let mut emitted_outlet_groups = BTreeSet::new();
438 for outlet in model.slot_outlets.values().filter(|outlet| {
439 outlet
440 .violations
441 .contains(&SlotOutletViolation::DuplicateOutlet)
442 }) {
443 let group = (
444 outlet.owner_component.clone(),
445 outlet.requested_slot_name.clone(),
446 );
447 if !emitted_outlet_groups.insert(group) {
448 continue;
449 }
450 let mut item = diagnostic(
451 "PSC1076",
452 outlet.provenance.clone(),
453 Some(outlet.owner_component.clone()),
454 );
455 item.slot_id.clone_from(&outlet.slot);
456 item.secondary_labels = model
457 .slot_outlets
458 .values()
459 .filter(|other| {
460 other.id != outlet.id
461 && other.owner_component == outlet.owner_component
462 && other.requested_slot_name == outlet.requested_slot_name
463 })
464 .map(|other| label(other.provenance.clone(), "Other Slot outlet."))
465 .collect();
466 diagnostics.push(item);
467 }
468}
469
470fn collect_slot_bindings(
471 model: &ApplicationSemanticModel,
472 diagnostics: &mut Vec<ComponentDiagnostic>,
473) {
474 for binding in model.slot_bindings.bindings.values() {
475 let code = match binding.status {
476 SlotBindingStatus::MissingOutlet => Some("PSC1077"),
477 SlotBindingStatus::InvalidOwnership => Some("PSC1078"),
478 _ => None,
479 };
480 if let Some(code) = code {
481 diagnostics.push(binding_diagnostic(model, binding, code));
482 }
483 if model
484 .composition_types
485 .slot_bindings
486 .get(&binding.id)
487 .is_some_and(|record| {
488 record.type_compatibility == CompositionCompatibility::Incompatible
489 })
490 {
491 diagnostics.push(binding_diagnostic(model, binding, "PSC1079"));
492 }
493 }
494}
495
496fn binding_diagnostic(
497 model: &ApplicationSemanticModel,
498 binding: &crate::SlotBinding,
499 code: &str,
500) -> ComponentDiagnostic {
501 let component = model
502 .component_instance_plan
503 .instances
504 .get(&binding.callee_instance)
505 .map(|item| item.component.clone());
506 let mut item = diagnostic(code, binding.provenance.clone(), component);
507 item.invocation_id = Some(binding.invocation.clone());
508 item.component_instance_id = Some(binding.callee_instance.clone());
509 item.slot_binding_id = Some(binding.id.clone());
510 item.slot_id.clone_from(&binding.slot);
511 if let Some(slot) = binding.slot.as_ref().and_then(|id| model.slot(id)) {
512 item.secondary_labels
513 .push(label(slot.provenance.clone(), "Slot declaration."));
514 }
515 if code == "PSC1078" {
516 if let Some(fragment) = binding
517 .content_fragment
518 .as_ref()
519 .and_then(|id| model.slot_content_fragments.get(id))
520 {
521 item.secondary_labels.push(label(
522 fragment.provenance.clone(),
523 "Caller-owned Slot content.",
524 ));
525 }
526 if let Some(outlet) = binding
527 .outlet
528 .as_ref()
529 .and_then(|id| model.slot_outlets.get(id))
530 {
531 item.secondary_labels.push(label(
532 outlet.provenance.clone(),
533 "Callee-owned Slot outlet.",
534 ));
535 }
536 }
537 item
538}
539
540fn collect_instance_context(
541 model: &ApplicationSemanticModel,
542 diagnostics: &mut Vec<ComponentDiagnostic>,
543) {
544 for resolution in model.instance_context.resolutions.values() {
545 let incompatible = model
546 .composition_types
547 .instance_context_bindings
548 .get(&resolution.consumer_instance)
549 .is_some_and(|record| record.overall != CompositionCompatibility::Compatible);
550 if !incompatible
551 && matches!(
552 resolution.status,
553 InstanceContextResolutionStatus::ProviderSelected
554 | InstanceContextResolutionStatus::ContextDefaultSelected
555 )
556 {
557 continue;
558 }
559 let component = model
560 .instance_context
561 .consumer_instances
562 .get(&resolution.consumer_instance)
563 .map(|item| item.component.clone());
564 let mut item = diagnostic("PSC1081", resolution.provenance.clone(), component);
565 item.context_id.clone_from(&resolution.context);
566 item.provider_id = resolution
567 .provider_instance
568 .as_ref()
569 .map(|id| id.provider.clone());
570 item.consumer_id = Some(resolution.consumer_instance.consumer.clone());
571 item.component_instance_id = Some(resolution.consumer_instance.component_instance.clone());
572 item.provider_instance_id
573 .clone_from(&resolution.provider_instance);
574 item.consumer_instance_id = Some(resolution.consumer_instance.clone());
575 item.secondary_labels = resolution
576 .candidate_provider_instances
577 .iter()
578 .filter_map(|id| model.instance_context.provider_instances.get(id))
579 .map(|record| label(record.provenance.clone(), "Candidate Provider instance."))
580 .collect();
581 diagnostics.push(item);
582 }
583}
584
585fn collect_planning_and_lowering(
586 model: &ApplicationSemanticModel,
587 diagnostics: &mut Vec<ComponentDiagnostic>,
588) {
589 let blocked_ids = model
590 .component_initialization
591 .blocked_instances
592 .iter()
593 .collect::<BTreeSet<_>>();
594 for blocked in model.component_instance_plan.blocked.values() {
595 let code = if blocked.structural_region.is_some() {
596 "PSC1082"
597 } else {
598 "PSC1080"
599 };
600 let component = blocked.target_component.clone().or_else(|| {
601 model
602 .component_invocations
603 .get(&blocked.invocation)
604 .map(|item| item.owner_component.clone())
605 });
606 let mut item = diagnostic(code, blocked.provenance.clone(), component.clone());
607 item.invocation_id = Some(blocked.invocation.clone());
608 item.component_instance_id = Some(blocked.id.clone());
609 item.structural_region_id
610 .clone_from(&blocked.structural_region);
611 if let Some(parent) = model
612 .component_instance_plan
613 .instances
614 .get(&blocked.parent_instance)
615 {
616 item.secondary_labels
617 .push(label(parent.provenance.clone(), "Planned parent instance."));
618 }
619 diagnostics.push(item);
620 if blocked_ids.contains(&blocked.id) {
621 let mut lowering = diagnostic("PSC1083", blocked.provenance.clone(), component);
622 lowering.invocation_id = Some(blocked.invocation.clone());
623 lowering.component_instance_id = Some(blocked.id.clone());
624 lowering
625 .structural_region_id
626 .clone_from(&blocked.structural_region);
627 diagnostics.push(lowering);
628 }
629 }
630 for binding in model.slot_bindings.bindings.values().filter(|binding| {
631 !matches!(
632 binding.status,
633 SlotBindingStatus::Bound | SlotBindingStatus::Empty
634 )
635 }) {
636 let mut item = binding_diagnostic(model, binding, "PSC1083");
637 item.secondary_labels.clear();
638 diagnostics.push(item);
639 }
640}
641
642fn suppress_derivative_cascades(diagnostics: &mut Vec<ComponentDiagnostic>) {
643 let invalid_components = diagnostics
644 .iter()
645 .filter(|item| matches!(item.code.as_str(), "PSC1072" | "PSC1073"))
646 .filter_map(|item| item.component_id.clone())
647 .collect::<BTreeSet<_>>();
648 let unresolved_invocations = diagnostics
649 .iter()
650 .filter(|item| matches!(item.code.as_str(), "PSC1069" | "PSC1070"))
651 .filter_map(|item| item.invocation_id.clone())
652 .collect::<BTreeSet<_>>();
653 let cycle_invocations = diagnostics
654 .iter()
655 .filter(|item| item.code == "PSC1071")
656 .filter_map(|item| item.invocation_id.clone())
657 .collect::<BTreeSet<_>>();
658 let invalid_bindings = diagnostics
659 .iter()
660 .filter(|item| {
661 matches!(
662 item.code.as_str(),
663 "PSC1074" | "PSC1075" | "PSC1076" | "PSC1077" | "PSC1078" | "PSC1079"
664 )
665 })
666 .filter_map(|item| item.slot_binding_id.clone())
667 .collect::<BTreeSet<_>>();
668 let invalid_slot_invocations = diagnostics
669 .iter()
670 .filter(|item| matches!(item.code.as_str(), "PSC1074" | "PSC1075"))
671 .filter_map(|item| item.invocation_id.clone())
672 .collect::<BTreeSet<_>>();
673 let context_instances = diagnostics
674 .iter()
675 .filter(|item| item.code == "PSC1081")
676 .filter_map(|item| item.component_instance_id.clone())
677 .collect::<BTreeSet<_>>();
678 let planning_failures = diagnostics
679 .iter()
680 .filter(|item| matches!(item.code.as_str(), "PSC1080" | "PSC1082"))
681 .filter_map(|item| item.component_instance_id.clone())
682 .collect::<BTreeSet<_>>();
683 diagnostics.retain(|item| {
684 let rank = suppression_rank(&item.code);
685 let invalid_component = rank > 1
686 && item
687 .component_id
688 .as_ref()
689 .is_some_and(|id| invalid_components.contains(id));
690 let unresolved_invocation = rank > 2
691 && item
692 .invocation_id
693 .as_ref()
694 .is_some_and(|id| unresolved_invocations.contains(id));
695 let cyclic_invocation = rank > 3
696 && item
697 .invocation_id
698 .as_ref()
699 .is_some_and(|id| cycle_invocations.contains(id));
700 let invalid_binding = rank > 4
701 && item
702 .slot_binding_id
703 .as_ref()
704 .is_some_and(|id| invalid_bindings.contains(id));
705 let invalid_slot_invocation = rank > 4
706 && item
707 .invocation_id
708 .as_ref()
709 .is_some_and(|id| invalid_slot_invocations.contains(id));
710 let unavailable_context = rank > 5
711 && item
712 .component_instance_id
713 .as_ref()
714 .is_some_and(|id| context_instances.contains(id));
715 let failed_planning = item.code == "PSC1083"
716 && item
717 .component_instance_id
718 .as_ref()
719 .is_some_and(|id| planning_failures.contains(id));
720 !(invalid_component
721 || unresolved_invocation
722 || cyclic_invocation
723 || invalid_binding
724 || invalid_slot_invocation
725 || unavailable_context
726 || failed_planning)
727 });
728}
729
730const fn suppression_rank(code: &str) -> u8 {
731 match code.as_bytes() {
732 b"PSC1068" | b"PSC1072" | b"PSC1073" => 1,
733 b"PSC1069" | b"PSC1070" => 2,
734 b"PSC1071" => 3,
735 b"PSC1074" | b"PSC1075" | b"PSC1076" | b"PSC1077" | b"PSC1078" | b"PSC1079" => 4,
736 b"PSC1081" => 5,
737 _ => 6,
738 }
739}
740
741fn canonicalize(diagnostics: &mut Vec<ComponentDiagnostic>) {
742 for item in diagnostics.iter_mut() {
743 item.secondary_labels.sort_by_key(secondary_order);
744 item.secondary_labels.dedup();
745 if let Some(primary) = &item.provenance {
746 item.secondary_labels
747 .retain(|label| &label.provenance != primary);
748 }
749 }
750 diagnostics.sort_by(diagnostic_order);
751 diagnostics.dedup();
752}
753
754fn diagnostic_order(left: &ComponentDiagnostic, right: &ComponentDiagnostic) -> std::cmp::Ordering {
755 diagnostic_key(left).cmp(&diagnostic_key(right))
756}
757
758fn diagnostic_key(item: &ComponentDiagnostic) -> (String, String, usize, usize, String) {
759 let provenance = item.provenance.as_ref();
760 (
761 item.code.clone(),
762 provenance
763 .map(|item| item.path.display().to_string())
764 .unwrap_or_default(),
765 provenance.map_or(0, |item| item.span.start),
766 provenance.map_or(0, |item| item.span.end),
767 identity_key(item),
768 )
769}
770
771fn identity_key(item: &ComponentDiagnostic) -> String {
772 format!(
773 "{:?}|{:?}|{:?}|{:?}|{:?}|{:?}|{:?}|{:?}",
774 item.component_id,
775 item.slot_id,
776 item.invocation_id,
777 item.component_instance_id,
778 item.slot_binding_id,
779 item.structural_region_id,
780 item.provider_instance_id,
781 item.consumer_instance_id
782 )
783}
784
785fn secondary_order(label: &DiagnosticSecondaryLabel) -> (String, usize, usize, String) {
786 (
787 label.provenance.path.display().to_string(),
788 label.provenance.span.start,
789 label.provenance.span.end,
790 label.message.clone(),
791 )
792}
793
794fn provenance_order(left: &SourceProvenance, right: &SourceProvenance) -> std::cmp::Ordering {
795 (left.path.as_path(), left.span.start, left.span.end).cmp(&(
796 right.path.as_path(),
797 right.span.start,
798 right.span.end,
799 ))
800}
801
802fn slot_declaration_primary(
803 candidate: &crate::AuthoredSlotDeclarationCandidate,
804 violation: &SlotDeclarationViolation,
805) -> SourceProvenance {
806 match violation {
807 SlotDeclarationViolation::StaticDeclarationUnsupported => {
808 candidate.static_modifier_provenance.as_ref()
809 }
810 SlotDeclarationViolation::ForbiddenInitializer => candidate.initializer_provenance.as_ref(),
811 SlotDeclarationViolation::InvalidDeclarationKind {
812 actual:
813 AuthoredDeclarationKind::Method
814 | AuthoredDeclarationKind::Getter
815 | AuthoredDeclarationKind::Parameter,
816 } => Some(&candidate.provenance),
817 _ => Some(&candidate.decorator_provenance),
818 }
819 .unwrap_or(&candidate.provenance)
820 .clone()
821}
822
823fn diagnostic(
824 code: &str,
825 provenance: SourceProvenance,
826 component_id: Option<SemanticId>,
827) -> ComponentDiagnostic {
828 let message = COMPONENT_DIAGNOSTIC_CONTRACTS
829 .iter()
830 .find(|item| item.code == code)
831 .expect("H19 code has a contract")
832 .message;
833 ComponentDiagnostic {
834 code: code.to_string(),
835 severity: ComponentDiagnosticSeverity::Error,
836 message: message.to_string(),
837 provenance: Some(provenance),
838 effect_id: None,
839 statement_id: None,
840 context_declaration_candidate_id: None,
841 context_id: None,
842 provider_id: None,
843 consumer_id: None,
844 slot_id: None,
845 invocation_id: None,
846 component_instance_id: None,
847 slot_binding_id: None,
848 structural_region_id: None,
849 component_id,
850 provider_instance_id: None,
851 consumer_instance_id: None,
852 secondary_labels: Vec::new(),
853 }
854}
855
856fn label(provenance: SourceProvenance, message: &str) -> DiagnosticSecondaryLabel {
857 DiagnosticSecondaryLabel {
858 provenance,
859 message: message.to_string(),
860 }
861}
862
863#[cfg(test)]
864mod tests {
865 use std::collections::BTreeSet;
866
867 use super::{collect_component_diagnostics, COMPONENT_DIAGNOSTIC_CONTRACTS};
868 use crate::{
869 build_application_semantic_model, validate_application_semantic_model,
870 BlockedComponentInstancePlan, BlockedComponentInstanceReason, ComponentInstanceStatus,
871 ComponentInvocationId, ComponentInvocationResolutionStatus, CompositionCompatibility,
872 SemanticId, SlotBindingStatus,
873 };
874
875 #[test]
876 fn contract_table_covers_the_complete_reserved_catalog() {
877 let codes = COMPONENT_DIAGNOSTIC_CONTRACTS
878 .iter()
879 .map(|item| item.code)
880 .collect::<Vec<_>>();
881 assert_eq!(
882 codes,
883 (1068..=1083)
884 .map(|code| format!("PSC{code}"))
885 .collect::<Vec<_>>()
886 );
887 for item in COMPONENT_DIAGNOSTIC_CONTRACTS {
888 assert!(item.message.ends_with('.'));
889 assert!(!item.authority.is_empty());
890 assert!(!item.primary_role.is_empty());
891 assert!(!item.identities.is_empty());
892 assert!(!item.secondary_roles.is_empty());
893 assert!(!item.deduplication.is_empty());
894 assert!(!item.suppression.is_empty());
895 assert_eq!(item.projection, "shared diagnostic envelope");
896 }
897 }
898
899 #[test]
900 fn projects_declaration_invocation_cycle_slot_and_context_diagnostics_deterministically() {
901 let source = r#"
902class Base { @slot() inherited!: SlotContent; render() { return <div />; } }
903@component("x-card") class Card extends Component {
904 @slot("bad") bad!: SlotContent;
905 @slot() children!: SlotContent;
906 render() { return <article><slot /><slot /></article>; }
907}
908@component("x-cycle") class Cycle extends Component { render() { return <Cycle />; } }
909@component("x-page") class Page extends Base {
910 render() { return <main />; }
911}
912@component("x-app") class App extends Component {
913 render() { return <main><Missing /><Card><template slot="unknown"><b /></template></Card></main>; }
914}
915"#;
916 let model =
917 build_application_semantic_model(&presolve_parser::parse_file("src/H19.tsx", source));
918 let first = collect_component_diagnostics(&model);
919 let second = collect_component_diagnostics(&model);
920 assert_eq!(first, second);
921 let codes = first
922 .iter()
923 .map(|item| item.code.as_str())
924 .collect::<BTreeSet<_>>();
925 for code in [
926 "PSC1068", "PSC1070", "PSC1071", "PSC1073", "PSC1074", "PSC1076",
927 ] {
928 assert!(codes.contains(code), "missing {code}: {first:#?}");
929 }
930 assert!(first
931 .iter()
932 .all(|item| item.severity == crate::ComponentDiagnosticSeverity::Error));
933 let validation = validate_application_semantic_model(&model);
934 assert!(validation.is_empty(), "{validation:#?}");
935 }
936
937 #[test]
938 fn nearby_valid_component_composition_emits_no_h19_diagnostic() {
939 let model = build_application_semantic_model(&presolve_parser::parse_file(
940 "src/ValidH19.tsx",
941 r#"@component("x-card") class Card extends Component { @slot() children!: SlotContent; render() { return <article><slot /></article>; } } @component("x-page") class Page extends Component { render() { return <Card><p /></Card>; } }"#,
942 ));
943 assert!(collect_component_diagnostics(&model).is_empty());
944 }
945
946 #[test]
947 #[allow(clippy::too_many_lines)]
948 fn every_code_has_canonical_identity_provenance_label_order_and_deduplication() {
949 for contract in COMPONENT_DIAGNOSTIC_CONTRACTS {
950 let item = example_diagnostic(contract.code);
951 assert_eq!(item.code, contract.code);
952 assert_eq!(item.message, contract.message);
953 assert_eq!(item.severity, crate::ComponentDiagnosticSeverity::Error);
954 assert!(item.provenance.is_some(), "{} primary", contract.code);
955 assert_required_identities(&item, contract.identities);
956 let mut labels = item.secondary_labels.clone();
957 labels.sort_by(|left, right| {
958 super::secondary_order(left).cmp(&super::secondary_order(right))
959 });
960 labels.dedup();
961 assert_eq!(labels, item.secondary_labels, "{} labels", contract.code);
962 assert!(item
963 .secondary_labels
964 .iter()
965 .all(|label| Some(&label.provenance) != item.provenance.as_ref()));
966 }
967 }
968
969 #[test]
970 fn suppression_removes_only_derivative_planning_and_lowering_failures() {
971 let unresolved = model(
972 r#"@component("x-page") class Page extends Component { render() { return <main><Missing /></main>; } }"#,
973 );
974 let diagnostics = collect_component_diagnostics(&unresolved);
975 assert!(diagnostics.iter().any(|item| item.code == "PSC1070"));
976 assert!(!diagnostics
977 .iter()
978 .any(|item| matches!(item.code.as_str(), "PSC1080" | "PSC1082" | "PSC1083")));
979
980 let independent = model(
981 r#"@component("x-a") class A extends Component { @slot("bad") invalid!: SlotContent; render() { return <B />; } } @component("x-b") class B extends Component { render() { return <A />; } }"#,
982 );
983 let codes = collect_component_diagnostics(&independent)
984 .into_iter()
985 .map(|item| item.code)
986 .collect::<BTreeSet<_>>();
987 assert!(codes.contains("PSC1068"));
988 assert!(codes.contains("PSC1071"));
989 }
990
991 #[test]
992 fn validation_rejects_every_noncanonical_component_diagnostic_mutation() {
993 let mut unknown_identity = model(
994 r#"@component("x-page") class Page extends Component { render() { return <Missing />; } }"#,
995 );
996 let index = unknown_identity
997 .diagnostics
998 .iter()
999 .position(|item| item.code == "PSC1070")
1000 .expect("component diagnostic");
1001 unknown_identity.diagnostics[index].component_id =
1002 Some(SemanticId::component(Some("x-fabricated"), "Fabricated"));
1003 unknown_identity.diagnostics[index].invocation_id =
1004 Some(ComponentInvocationId::for_template_entity(
1005 &SemanticId::component(Some("x-fabricated"), "Fabricated"),
1006 "missing",
1007 ));
1008 assert!(validate_application_semantic_model(&unknown_identity)
1009 .iter()
1010 .any(|item| item.code == "PSASM1201"));
1011
1012 let mut cross_owner = model(
1013 r#"@component("x-a") class A extends Component { render() { return <Missing />; } } @component("x-b") class B extends Component { render() { return <main />; } }"#,
1014 );
1015 let other = cross_owner
1016 .components
1017 .iter()
1018 .find(|item| item.element_name.as_deref() == Some("x-b"))
1019 .unwrap()
1020 .id
1021 .clone();
1022 let index = cross_owner
1023 .diagnostics
1024 .iter()
1025 .position(|item| item.code == "PSC1070")
1026 .unwrap();
1027 cross_owner.diagnostics[index].component_id = Some(other);
1028 assert!(validate_application_semantic_model(&cross_owner)
1029 .iter()
1030 .any(|item| item.code == "PSASM1201"));
1031
1032 let mut bad_provenance = model(
1033 r#"@component("x-page") class Page extends Component { render() { return <Missing />; } }"#,
1034 );
1035 let index = bad_provenance
1036 .diagnostics
1037 .iter()
1038 .position(|item| item.code == "PSC1070")
1039 .unwrap();
1040 bad_provenance.diagnostics[index]
1041 .provenance
1042 .as_mut()
1043 .unwrap()
1044 .span
1045 .start += 1;
1046 assert!(validate_application_semantic_model(&bad_provenance)
1047 .iter()
1048 .any(|item| item.code == "PSASM1201"));
1049
1050 let mut bad_labels = model(
1051 r#"@component("x-card") class Card extends Component { @slot() children!: SlotContent; render() { return <main><slot /><slot /></main>; } }"#,
1052 );
1053 let index = bad_labels
1054 .diagnostics
1055 .iter()
1056 .position(|item| item.code == "PSC1076")
1057 .unwrap();
1058 let duplicate = bad_labels.diagnostics[index].secondary_labels[0].clone();
1059 bad_labels.diagnostics[index]
1060 .secondary_labels
1061 .push(duplicate);
1062 assert!(validate_application_semantic_model(&bad_labels)
1063 .iter()
1064 .any(|item| item.code == "PSASM1201"));
1065
1066 let mut primary_repeated = model(
1067 r#"@component("x-card") class Card extends Component { @slot() children!: SlotContent; render() { return <main><slot /><slot /></main>; } }"#,
1068 );
1069 let index = primary_repeated
1070 .diagnostics
1071 .iter()
1072 .position(|item| item.code == "PSC1076")
1073 .unwrap();
1074 primary_repeated.diagnostics[index].secondary_labels[0].provenance = primary_repeated
1075 .diagnostics[index]
1076 .provenance
1077 .clone()
1078 .unwrap();
1079 assert!(validate_application_semantic_model(&primary_repeated)
1080 .iter()
1081 .any(|item| item.code == "PSASM1201"));
1082
1083 let mut unsupported = model(
1084 r#"@component("x-page") class Page extends Component { render() { return <Missing />; } }"#,
1085 );
1086 let mut fabricated = unsupported
1087 .diagnostics
1088 .iter()
1089 .find(|item| item.code == "PSC1070")
1090 .unwrap()
1091 .clone();
1092 fabricated.code = "PSC1083".to_string();
1093 fabricated.message = "Component/slot source cannot be lowered.".to_string();
1094 unsupported.diagnostics.push(fabricated);
1095 assert!(validate_application_semantic_model(&unsupported)
1096 .iter()
1097 .any(|item| item.code == "PSASM1201"));
1098 }
1099
1100 fn example_model(code: &str) -> crate::ApplicationSemanticModel {
1101 match code {
1102 "PSC1068" => model(
1103 r#"@component("x-page") class Page extends Component { @slot("bad") value!: SlotContent; render() { return <main />; } }"#,
1104 ),
1105 "PSC1069" => model(
1106 r#"@component("x-page") class Page extends Component { render() { return <Registry.Card />; } }"#,
1107 ),
1108 "PSC1070" => model(
1109 r#"@component("x-page") class Page extends Component { render() { return <Missing />; } }"#,
1110 ),
1111 "PSC1071" => model(
1112 r#"@component("x-a") class A extends Component { render() { return <B />; } } @component("x-b") class B extends Component { render() { return <A />; } }"#,
1113 ),
1114 "PSC1072" => model(
1115 r#"class Base { render() { return <div />; } } @component("x-page") class Page extends Base { render() { return <main />; } }"#,
1116 ),
1117 "PSC1073" => model(
1118 r#"class Base { value = state(0); render() { return <div />; } } @component("x-page") class Page extends Base { render() { return <main />; } }"#,
1119 ),
1120 "PSC1074" => slot_model(r#"<Card><template slot="missing"><b /></template></Card>"#),
1121 "PSC1075" => slot_model(
1122 r#"<Card><template slot="header"><b /></template><template slot="header"><i /></template></Card>"#,
1123 ),
1124 "PSC1076" => model(
1125 r#"@component("x-card") class Card extends Component { @slot() children!: SlotContent; render() { return <article><slot /><slot /></article>; } }"#,
1126 ),
1127 "PSC1077" => slot_model(r#"<Card><template slot="header"><b /></template></Card>"#),
1128 "PSC1078" | "PSC1079" | "PSC1083" => {
1129 slot_model(r#"<Card><template slot="header"><b /></template></Card>"#)
1130 }
1131 "PSC1080" => model(
1132 r#"@component("x-page") class Page extends Component { render() { return <Missing />; } } @component("x-card") class Card extends Component { render() { return <div />; } }"#,
1133 ),
1134 "PSC1081" => model(
1135 r#"@component("x-theme") class Theme extends Component { @context() color!: string; render() { return <div />; } } @component("x-leaf") class Leaf extends Component { @consume(Theme.color) color!: number; render() { return <span />; } } @component("x-card") class Card extends Component { @provide(Theme.color) color: string = "blue"; render() { return <Leaf />; } } @component("x-page") class Page extends Component { render() { return <Card />; } }"#,
1136 ),
1137 "PSC1082" => model(
1138 r#"@component("x-card") class Card extends Component { render() { return <div />; } } @component("x-page") class Page extends Component { visible = state(true); render() { return <main>{this.visible ? <Card /> : <span />}</main>; } }"#,
1139 ),
1140 _ => unreachable!(),
1141 }
1142 }
1143
1144 fn example_diagnostic(code: &str) -> crate::ComponentDiagnostic {
1145 let mut model = example_model(code);
1146
1147 match code {
1148 "PSC1078" => {
1149 model
1150 .slot_bindings
1151 .bindings
1152 .values_mut()
1153 .next()
1154 .unwrap()
1155 .status = SlotBindingStatus::InvalidOwnership;
1156 }
1157 "PSC1079" => {
1158 let record = model
1159 .composition_types
1160 .slot_bindings
1161 .values_mut()
1162 .next()
1163 .unwrap();
1164 record.type_compatibility = CompositionCompatibility::Incompatible;
1165 record.overall = CompositionCompatibility::Incompatible;
1166 }
1167 "PSC1080" => {
1168 let target = model
1169 .components
1170 .iter()
1171 .find(|item| item.element_name.as_deref() == Some("x-card"))
1172 .unwrap()
1173 .id
1174 .clone();
1175 let invocation_id = model
1176 .component_instance_plan
1177 .blocked
1178 .values()
1179 .next()
1180 .unwrap()
1181 .invocation
1182 .clone();
1183 let invocation = model.component_invocations.get_mut(&invocation_id).unwrap();
1184 invocation.status = ComponentInvocationResolutionStatus::Resolved;
1185 invocation.target_component = Some(target.clone());
1186 model
1187 .component_instance_plan
1188 .blocked
1189 .values_mut()
1190 .next()
1191 .unwrap()
1192 .target_component = Some(target);
1193 }
1194 "PSC1082" => {
1195 let id = model
1196 .component_instance_plan
1197 .instances
1198 .values()
1199 .find(|item| item.status == ComponentInstanceStatus::StructuralTemplate)
1200 .unwrap()
1201 .id
1202 .clone();
1203 let instance = model.component_instance_plan.instances.remove(&id).unwrap();
1204 model.component_instance_plan.blocked.insert(
1205 id.clone(),
1206 BlockedComponentInstancePlan {
1207 id,
1208 invocation: instance.invocation.unwrap(),
1209 parent_instance: instance.parent_instance.unwrap(),
1210 owner_root: instance.owner_root,
1211 target_component: Some(instance.component),
1212 structural_region: instance.structural_region,
1213 depth: instance.depth,
1214 reason: BlockedComponentInstanceReason::InvalidParentPlan,
1215 provenance: instance.provenance,
1216 },
1217 );
1218 }
1219 "PSC1083" => {
1220 model
1221 .slot_bindings
1222 .bindings
1223 .values_mut()
1224 .next()
1225 .unwrap()
1226 .status = SlotBindingStatus::BlockedInvocation;
1227 }
1228 _ => {}
1229 }
1230 collect_component_diagnostics(&model)
1231 .into_iter()
1232 .find(|item| item.code == code)
1233 .unwrap_or_else(|| panic!("missing {code}"))
1234 }
1235
1236 fn model(source: &str) -> crate::ApplicationSemanticModel {
1237 build_application_semantic_model(&presolve_parser::parse_file(
1238 "src/DiagnosticMatrix.tsx",
1239 source,
1240 ))
1241 }
1242
1243 fn slot_model(invocation: &str) -> crate::ApplicationSemanticModel {
1244 model(&format!(
1245 r#"@component("x-card") class Card extends Component {{ @slot() children!: SlotContent; @slot() header!: SlotContent; render() {{ return <article><slot /></article>; }} }} @component("x-page") class Page extends Component {{ render() {{ return <main>{invocation}</main>; }} }}"#
1246 ))
1247 }
1248
1249 fn assert_required_identities(item: &crate::ComponentDiagnostic, identities: &str) {
1250 if identities.contains("component_id") {
1251 assert!(item.component_id.is_some(), "{} component", item.code);
1252 }
1253 if identities.contains("slot_id") {
1254 assert!(item.slot_id.is_some(), "{} slot", item.code);
1255 }
1256 if identities.contains("invocation_id") {
1257 assert!(item.invocation_id.is_some(), "{} invocation", item.code);
1258 }
1259 if identities.contains("component_instance_id") {
1260 assert!(
1261 item.component_instance_id.is_some(),
1262 "{} instance",
1263 item.code
1264 );
1265 }
1266 if identities.contains("slot_binding_id") {
1267 assert!(item.slot_binding_id.is_some(), "{} binding", item.code);
1268 }
1269 if identities.contains("structural_region_id") {
1270 assert!(item.structural_region_id.is_some(), "{} region", item.code);
1271 }
1272 if identities.contains("provider_instance_id") {
1273 assert!(
1274 item.provider_instance_id.is_some(),
1275 "{} provider instance",
1276 item.code
1277 );
1278 }
1279 if identities.contains("consumer_instance_id") {
1280 assert!(
1281 item.consumer_instance_id.is_some(),
1282 "{} consumer instance",
1283 item.code
1284 );
1285 }
1286 }
1287}