Skip to main content

presolve_compiler/
semantic_graph.rs

1use serde::Serialize;
2
3use crate::{
4    ApplicationSemanticModel, ConsumerEntity, ContextEntity, SemanticEntity, SemanticId,
5    SemanticOwner, SemanticReferenceKind, SourceProvenance, TemplateSemanticKind,
6};
7
8pub const SEMANTIC_GRAPH_SCHEMA_VERSION: u32 = 6;
9
10/// A stable, backend-independent graph projection of the canonical ASM.
11#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
12pub struct SemanticGraph {
13    pub schema_version: u32,
14    pub roots: Vec<SemanticId>,
15    pub nodes: Vec<SemanticGraphNode>,
16    pub edges: Vec<SemanticGraphEdge>,
17}
18
19#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
20pub struct SemanticGraphNode {
21    pub id: SemanticId,
22    pub kind: SemanticGraphNodeKind,
23    pub provenance: SemanticGraphProvenance,
24    #[serde(skip_serializing_if = "Option::is_none")]
25    pub context: Option<SemanticGraphContext>,
26    #[serde(skip_serializing_if = "Option::is_none")]
27    pub provider: Option<SemanticGraphProvider>,
28    #[serde(skip_serializing_if = "Option::is_none")]
29    pub consumer: Option<SemanticGraphConsumer>,
30}
31
32/// Context-specific facts projected from the canonical G1 ASM entity.
33#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
34pub struct SemanticGraphContext {
35    pub owner: SemanticId,
36    pub authored_field: SemanticId,
37    pub declared_type_id: String,
38    pub execution_boundary: &'static str,
39    pub has_default_expression: bool,
40}
41
42/// Provider-specific facts projected from the canonical G2 ASM entity.
43#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
44pub struct SemanticGraphProvider {
45    pub owner: SemanticId,
46    pub authored_field: SemanticId,
47    pub context: SemanticId,
48    pub declared_type_id: String,
49    pub value_expression: SemanticId,
50    pub execution_boundary: &'static str,
51}
52
53/// Consumer-specific facts projected from the canonical G3 ASM entity.
54#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
55pub struct SemanticGraphConsumer {
56    pub owner: SemanticId,
57    pub authored_field: SemanticId,
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub context: Option<SemanticId>,
60    pub requested_type_id: String,
61    pub execution_boundary: &'static str,
62}
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
65#[serde(rename_all = "kebab-case")]
66pub enum SemanticGraphNodeKind {
67    Component,
68    StateField,
69    Method,
70    Context,
71    Provider,
72    Consumer,
73    Form,
74    FormField,
75    FormFieldBinding,
76    ValidationRule,
77    Computed,
78    Effect,
79    Parameter,
80    LocalVariable,
81    Action,
82    EventHandler,
83    Template,
84    TemplateElement,
85    TemplateFragment,
86    TemplateText,
87    TemplateBinding,
88    TemplateAttribute,
89    TemplateAttributeBinding,
90    TemplateEventAttribute,
91    TemplateConditional,
92    TemplateList,
93}
94
95#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
96pub struct SemanticGraphProvenance {
97    pub path: String,
98    pub start: usize,
99    pub end: usize,
100    pub line: usize,
101    pub column: usize,
102}
103
104#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
105pub struct SemanticGraphEdge {
106    pub kind: SemanticGraphEdgeKind,
107    pub source: SemanticId,
108    pub target: SemanticId,
109}
110
111#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
112#[serde(rename_all = "kebab-case")]
113pub enum SemanticGraphEdgeKind {
114    Ownership,
115    ActionState,
116    ComputedState,
117    ComputedComputed,
118    ComputedResource,
119    EffectState,
120    EffectComputed,
121    ProvidesContext,
122    ConsumesContext,
123    ResolvesToProvider,
124    EventMethod,
125    TemplateState,
126    TemplateComputed,
127    TemplateLocal,
128    ComponentOwnsForm,
129    FormOwnsField,
130    FieldOwnsValidationRule,
131    ControlOwnsFieldBinding,
132    FieldBindingBindsField,
133    FieldBindingBelongsToForm,
134    ValidationRuleDependsOnField,
135}
136
137/// Build a deterministic graph export from canonical ASM semantics only.
138///
139/// # Panics
140///
141/// Panics if the ASM ownership map references a missing semantic entity or one
142/// without source provenance, which violates the canonical ASM invariant.
143#[must_use]
144#[allow(clippy::too_many_lines)]
145pub fn build_semantic_graph(asm: &ApplicationSemanticModel) -> SemanticGraph {
146    let nodes = asm
147        .ownership
148        .keys()
149        .filter(|id| {
150            !matches!(
151                asm.entity(id),
152                Some(
153                    SemanticEntity::Slot(_)
154                        | SemanticEntity::ComponentInvocation(_)
155                        | SemanticEntity::ComponentInstance(_)
156                        | SemanticEntity::BlockedComponentInstance(_)
157                        | SemanticEntity::SlotContentFragment(_)
158                        | SemanticEntity::SlotOutlet(_)
159                )
160            )
161        })
162        .map(|id| {
163            let entity = asm
164                .entity(id)
165                .expect("ASM ownership should only contain semantic entities");
166            let provenance = asm
167                .provenance(id)
168                .expect("ASM ownership should have source provenance");
169
170            SemanticGraphNode {
171                id: id.clone(),
172                kind: semantic_graph_node_kind(entity),
173                provenance: provenance.into(),
174                context: semantic_graph_context(entity),
175                provider: semantic_graph_provider(entity),
176                consumer: semantic_graph_consumer(entity),
177            }
178        })
179        .collect();
180    let roots = asm
181        .application_roots()
182        .into_iter()
183        .filter(|id| {
184            !matches!(
185                asm.entity(id),
186                Some(
187                    SemanticEntity::Slot(_)
188                        | SemanticEntity::ComponentInvocation(_)
189                        | SemanticEntity::ComponentInstance(_)
190                        | SemanticEntity::BlockedComponentInstance(_)
191                        | SemanticEntity::SlotContentFragment(_)
192                        | SemanticEntity::SlotOutlet(_)
193                )
194            )
195        })
196        .cloned()
197        .collect();
198    let mut edges = asm
199        .ownership
200        .iter()
201        .filter_map(|(target, owner)| {
202            if matches!(
203                asm.entity(target),
204                Some(
205                    SemanticEntity::Slot(_)
206                        | SemanticEntity::ComponentInvocation(_)
207                        | SemanticEntity::ComponentInstance(_)
208                        | SemanticEntity::BlockedComponentInstance(_)
209                        | SemanticEntity::SlotContentFragment(_)
210                        | SemanticEntity::SlotOutlet(_)
211                )
212            ) {
213                return None;
214            }
215            match owner {
216                SemanticOwner::Application => None,
217                SemanticOwner::Entity(source) => Some(SemanticGraphEdge {
218                    kind: form_ownership_edge_kind(asm, source, target),
219                    source: source.clone(),
220                    target: target.clone(),
221                }),
222            }
223        })
224        .chain(asm.references.iter().map(|reference| SemanticGraphEdge {
225            kind: semantic_graph_edge_kind(reference.kind),
226            source: reference.source.clone(),
227            target: reference.target.clone(),
228        }))
229        .collect::<Vec<_>>();
230    edges.sort_by(|left, right| {
231        (
232            left.kind.as_str(),
233            left.source.as_str(),
234            left.target.as_str(),
235        )
236            .cmp(&(
237                right.kind.as_str(),
238                right.source.as_str(),
239                right.target.as_str(),
240            ))
241    });
242
243    SemanticGraph {
244        schema_version: SEMANTIC_GRAPH_SCHEMA_VERSION,
245        roots,
246        nodes,
247        edges,
248    }
249}
250
251/// Serialize a semantic graph as deterministic, pretty JSON.
252///
253/// # Panics
254///
255/// Panics if the compiler-owned graph cannot serialize to JSON.
256#[must_use]
257pub fn semantic_graph_json(graph: &SemanticGraph) -> String {
258    serde_json::to_string_pretty(graph).expect("semantic graph should serialize") + "\n"
259}
260
261impl From<&SourceProvenance> for SemanticGraphProvenance {
262    fn from(provenance: &SourceProvenance) -> Self {
263        Self {
264            path: provenance.path.display().to_string(),
265            start: provenance.span.start,
266            end: provenance.span.end,
267            line: provenance.span.line,
268            column: provenance.span.column,
269        }
270    }
271}
272
273impl SemanticGraphEdgeKind {
274    const fn as_str(self) -> &'static str {
275        match self {
276            Self::Ownership => "ownership",
277            Self::ActionState => "action-state",
278            Self::ComputedState => "computed-state",
279            Self::ComputedComputed => "computed-computed",
280            Self::ComputedResource => "computed-resource",
281            Self::EffectState => "effect-state",
282            Self::EffectComputed => "effect-computed",
283            Self::ProvidesContext => "provides-context",
284            Self::ConsumesContext => "consumes-context",
285            Self::ResolvesToProvider => "resolves-to-provider",
286            Self::EventMethod => "event-method",
287            Self::TemplateState => "template-state",
288            Self::TemplateComputed => "template-computed",
289            Self::TemplateLocal => "template-local",
290            Self::ComponentOwnsForm => "component-owns-form",
291            Self::FormOwnsField => "form-owns-field",
292            Self::FieldOwnsValidationRule => "field-owns-validation-rule",
293            Self::ControlOwnsFieldBinding => "control-owns-field-binding",
294            Self::FieldBindingBindsField => "field-binding-binds-field",
295            Self::FieldBindingBelongsToForm => "field-binding-belongs-to-form",
296            Self::ValidationRuleDependsOnField => "validation-rule-depends-on-field",
297        }
298    }
299}
300
301fn semantic_graph_node_kind(entity: SemanticEntity<'_>) -> SemanticGraphNodeKind {
302    match entity {
303        SemanticEntity::Component(_) => SemanticGraphNodeKind::Component,
304        SemanticEntity::StateField(_) => SemanticGraphNodeKind::StateField,
305        SemanticEntity::Method(_) => SemanticGraphNodeKind::Method,
306        SemanticEntity::Context(_) => SemanticGraphNodeKind::Context,
307        SemanticEntity::Provider(_) => SemanticGraphNodeKind::Provider,
308        SemanticEntity::Consumer(_) => SemanticGraphNodeKind::Consumer,
309        SemanticEntity::Form(_) => SemanticGraphNodeKind::Form,
310        SemanticEntity::FormField(_) => SemanticGraphNodeKind::FormField,
311        SemanticEntity::FormFieldBinding(_) => SemanticGraphNodeKind::FormFieldBinding,
312        SemanticEntity::ValidationRule(_) => SemanticGraphNodeKind::ValidationRule,
313        SemanticEntity::Slot(_) => {
314            unreachable!("Slots are not projected into frozen semantic graph schema v5")
315        }
316        SemanticEntity::ComponentInvocation(_) => {
317            unreachable!("Component invocations are not projected into semantic graph schema v5")
318        }
319        SemanticEntity::ComponentInstance(_) | SemanticEntity::BlockedComponentInstance(_) => {
320            unreachable!("Component instances are not projected into semantic graph schema v5")
321        }
322        SemanticEntity::SlotContentFragment(_) => {
323            unreachable!("Slot fragments are not projected into semantic graph schema v5")
324        }
325        SemanticEntity::SlotOutlet(_) => {
326            unreachable!("Slot outlets are not projected into semantic graph schema v5")
327        }
328        SemanticEntity::Computed(_) => SemanticGraphNodeKind::Computed,
329        SemanticEntity::Effect(_) => SemanticGraphNodeKind::Effect,
330        SemanticEntity::Parameter(_) => SemanticGraphNodeKind::Parameter,
331        SemanticEntity::LocalVariable(_) => SemanticGraphNodeKind::LocalVariable,
332        SemanticEntity::Action(_) => SemanticGraphNodeKind::Action,
333        SemanticEntity::EventHandler(_) => SemanticGraphNodeKind::EventHandler,
334        SemanticEntity::Template(_) => SemanticGraphNodeKind::Template,
335        SemanticEntity::TemplateEntity(entity) => match entity.kind {
336            TemplateSemanticKind::Element => SemanticGraphNodeKind::TemplateElement,
337            TemplateSemanticKind::Fragment => SemanticGraphNodeKind::TemplateFragment,
338            TemplateSemanticKind::Text => SemanticGraphNodeKind::TemplateText,
339            TemplateSemanticKind::Binding => SemanticGraphNodeKind::TemplateBinding,
340            TemplateSemanticKind::Attribute => SemanticGraphNodeKind::TemplateAttribute,
341            TemplateSemanticKind::AttributeBinding => {
342                SemanticGraphNodeKind::TemplateAttributeBinding
343            }
344            TemplateSemanticKind::EventAttribute => SemanticGraphNodeKind::TemplateEventAttribute,
345            TemplateSemanticKind::Conditional => SemanticGraphNodeKind::TemplateConditional,
346            TemplateSemanticKind::List => SemanticGraphNodeKind::TemplateList,
347        },
348    }
349}
350
351fn semantic_graph_context(entity: SemanticEntity<'_>) -> Option<SemanticGraphContext> {
352    let SemanticEntity::Context(context) = entity else {
353        return None;
354    };
355    Some(context_metadata(context))
356}
357
358fn semantic_graph_provider(entity: SemanticEntity<'_>) -> Option<SemanticGraphProvider> {
359    let SemanticEntity::Provider(provider) = entity else {
360        return None;
361    };
362    Some(SemanticGraphProvider {
363        owner: provider
364            .owner
365            .entity_id()
366            .expect("provider entities should be component-owned")
367            .clone(),
368        authored_field: provider.authored_field.clone(),
369        context: provider.context.as_semantic_id().clone(),
370        declared_type_id: provider.declared_type_id.to_string(),
371        value_expression: provider.value_expression.clone(),
372        execution_boundary: match provider.execution_boundary {
373            crate::ExecutionBoundary::Client => "client",
374            crate::ExecutionBoundary::Server => "server",
375        },
376    })
377}
378
379fn semantic_graph_consumer(entity: SemanticEntity<'_>) -> Option<SemanticGraphConsumer> {
380    let SemanticEntity::Consumer(consumer) = entity else {
381        return None;
382    };
383    Some(consumer_metadata(consumer))
384}
385
386fn consumer_metadata(consumer: &ConsumerEntity) -> SemanticGraphConsumer {
387    SemanticGraphConsumer {
388        owner: consumer
389            .owner
390            .entity_id()
391            .expect("consumer entities should be component-owned")
392            .clone(),
393        authored_field: consumer.authored_field.clone(),
394        context: consumer
395            .context()
396            .map(|context| context.as_semantic_id().clone()),
397        requested_type_id: consumer.requested_type_id.to_string(),
398        execution_boundary: match consumer.execution_boundary {
399            crate::ExecutionBoundary::Client => "client",
400            crate::ExecutionBoundary::Server => "server",
401        },
402    }
403}
404
405fn context_metadata(context: &ContextEntity) -> SemanticGraphContext {
406    SemanticGraphContext {
407        owner: context
408            .owner
409            .entity_id()
410            .expect("context entities should be component-owned")
411            .clone(),
412        authored_field: context.authored_field.clone(),
413        declared_type_id: context.declared_type_id.to_string(),
414        execution_boundary: match context.execution_boundary {
415            crate::ExecutionBoundary::Client => "client",
416            crate::ExecutionBoundary::Server => "server",
417        },
418        has_default_expression: context.default_expression.is_some(),
419    }
420}
421
422fn semantic_graph_edge_kind(kind: SemanticReferenceKind) -> SemanticGraphEdgeKind {
423    match kind {
424        SemanticReferenceKind::ActionState => SemanticGraphEdgeKind::ActionState,
425        SemanticReferenceKind::ComputedState => SemanticGraphEdgeKind::ComputedState,
426        SemanticReferenceKind::ComputedComputed => SemanticGraphEdgeKind::ComputedComputed,
427        SemanticReferenceKind::ComputedResource => SemanticGraphEdgeKind::ComputedResource,
428        SemanticReferenceKind::EffectState => SemanticGraphEdgeKind::EffectState,
429        SemanticReferenceKind::EffectComputed => SemanticGraphEdgeKind::EffectComputed,
430        SemanticReferenceKind::ProvidesContext => SemanticGraphEdgeKind::ProvidesContext,
431        SemanticReferenceKind::ConsumesContext => SemanticGraphEdgeKind::ConsumesContext,
432        SemanticReferenceKind::ResolvesToProvider => SemanticGraphEdgeKind::ResolvesToProvider,
433        SemanticReferenceKind::EventMethod => SemanticGraphEdgeKind::EventMethod,
434        SemanticReferenceKind::TemplateState => SemanticGraphEdgeKind::TemplateState,
435        SemanticReferenceKind::TemplateComputed => SemanticGraphEdgeKind::TemplateComputed,
436        SemanticReferenceKind::TemplateLocal => SemanticGraphEdgeKind::TemplateLocal,
437        SemanticReferenceKind::FieldBindingField => SemanticGraphEdgeKind::FieldBindingBindsField,
438        SemanticReferenceKind::FieldBindingForm => SemanticGraphEdgeKind::FieldBindingBelongsToForm,
439        SemanticReferenceKind::ValidationRuleField => {
440            SemanticGraphEdgeKind::ValidationRuleDependsOnField
441        }
442    }
443}
444
445fn form_ownership_edge_kind(
446    asm: &ApplicationSemanticModel,
447    source: &SemanticId,
448    target: &SemanticId,
449) -> SemanticGraphEdgeKind {
450    match (asm.entity(source), asm.entity(target)) {
451        (Some(SemanticEntity::Component(_)), Some(SemanticEntity::Form(_))) => {
452            SemanticGraphEdgeKind::ComponentOwnsForm
453        }
454        (Some(SemanticEntity::Form(_)), Some(SemanticEntity::FormField(_))) => {
455            SemanticGraphEdgeKind::FormOwnsField
456        }
457        (Some(SemanticEntity::FormField(_)), Some(SemanticEntity::ValidationRule(_))) => {
458            SemanticGraphEdgeKind::FieldOwnsValidationRule
459        }
460        (Some(SemanticEntity::TemplateEntity(_)), Some(SemanticEntity::FormFieldBinding(_))) => {
461            SemanticGraphEdgeKind::ControlOwnsFieldBinding
462        }
463        _ => SemanticGraphEdgeKind::Ownership,
464    }
465}
466
467#[cfg(test)]
468mod tests {
469    use super::{build_semantic_graph, semantic_graph_json, SemanticGraphEdgeKind};
470    use crate::build_application_semantic_model;
471
472    #[test]
473    fn exports_context_nodes_with_compiler_owned_metadata() {
474        let parsed = presolve_parser::parse_file(
475            "src/AppShell.tsx",
476            r#"
477@component("x-app-shell")
478class AppShell extends Component {
479  @context()
480  locale: string = "en";
481  render() { return <main />; }
482}
483"#,
484        );
485        let asm = build_application_semantic_model(&parsed);
486        let graph = build_semantic_graph(&asm);
487        let context_id = asm.contexts()[0].id.as_semantic_id();
488        let node = graph
489            .nodes
490            .iter()
491            .find(|node| node.id == *context_id)
492            .unwrap();
493
494        assert_eq!(node.kind, super::SemanticGraphNodeKind::Context);
495        assert_eq!(node.context.as_ref().unwrap().owner, asm.components[0].id);
496        assert!(node.context.as_ref().unwrap().has_default_expression);
497        assert!(graph.edges.iter().any(|edge| {
498            edge.kind == SemanticGraphEdgeKind::Ownership
499                && edge.source == asm.components[0].id
500                && edge.target == *context_id
501        }));
502    }
503
504    #[test]
505    fn exports_provider_nodes_and_compiler_resolved_context_edges() {
506        let parsed = presolve_parser::parse_file(
507            "src/AppShell.tsx",
508            r#"
509@component("x-app-shell")
510class AppShell extends Component {
511  @context()
512  theme: string;
513  @provide(AppShell.theme)
514  providedTheme: string = this.theme;
515  render() { return <main />; }
516}
517"#,
518        );
519        let asm = build_application_semantic_model(&parsed);
520        let graph = build_semantic_graph(&asm);
521        let provider = asm.providers()[0];
522        let node = graph
523            .nodes
524            .iter()
525            .find(|node| node.id == *provider.id.as_semantic_id())
526            .unwrap();
527
528        assert_eq!(node.kind, super::SemanticGraphNodeKind::Provider);
529        assert_eq!(
530            node.provider.as_ref().unwrap().context,
531            *provider.context.as_semantic_id()
532        );
533        assert!(graph.edges.iter().any(|edge| {
534            edge.kind == SemanticGraphEdgeKind::ProvidesContext
535                && edge.source == *provider.id.as_semantic_id()
536                && edge.target == *provider.context.as_semantic_id()
537        }));
538    }
539
540    #[test]
541    fn exports_consumer_nodes_and_compiler_resolved_context_edges() {
542        let parsed = presolve_parser::parse_file(
543            "src/toolbar.tsx",
544            r#"
545@component("x-app-shell")
546class AppShell extends Component {
547  @context()
548  theme!: Theme;
549  render() { return <main />; }
550}
551@component("x-toolbar")
552class Toolbar extends Component {
553  @consume(AppShell.theme)
554  theme!: Theme;
555  render() { return <main />; }
556}
557"#,
558        );
559        let asm = build_application_semantic_model(&parsed);
560        let graph = build_semantic_graph(&asm);
561        let consumer = asm.consumers()[0];
562        let node = graph
563            .nodes
564            .iter()
565            .find(|node| node.id == *consumer.id.as_semantic_id())
566            .unwrap();
567
568        assert_eq!(node.kind, super::SemanticGraphNodeKind::Consumer);
569        assert_eq!(
570            node.consumer.as_ref().unwrap().context,
571            consumer
572                .context()
573                .map(|context| context.as_semantic_id().clone())
574        );
575        assert!(graph.edges.iter().any(|edge| {
576            edge.kind == SemanticGraphEdgeKind::ConsumesContext
577                && edge.source == *consumer.id.as_semantic_id()
578                && edge.target == *consumer.context().unwrap().as_semantic_id()
579        }));
580    }
581
582    #[test]
583    fn exports_compiler_resolved_consumer_provider_edges() {
584        let parsed = presolve_parser::parse_file(
585            "src/toolbar.tsx",
586            r#"
587@component("x-app-shell")
588class AppShell extends Component {
589  @context()
590  theme!: Theme;
591  render() { return <main />; }
592}
593@component("x-toolbar")
594class Toolbar extends Component {
595  @provide(AppShell.theme)
596  providedTheme: Theme = this.localTheme;
597  @consume(AppShell.theme)
598  theme!: Theme;
599  render() { return <main />; }
600}
601"#,
602        );
603        let asm = build_application_semantic_model(&parsed);
604        let graph = build_semantic_graph(&asm);
605        let consumer = asm.consumers()[0];
606        let provider = asm.resolved_provider(&consumer.id).unwrap();
607
608        assert!(graph.edges.iter().any(|edge| {
609            edge.kind == SemanticGraphEdgeKind::ResolvesToProvider
610                && edge.source == *consumer.id.as_semantic_id()
611                && edge.target == *provider.as_semantic_id()
612        }));
613    }
614
615    #[test]
616    fn exports_a_deterministic_canonical_semantic_graph() {
617        let parsed = presolve_parser::parse_file(
618            "src/Counter.tsx",
619            r#"
620@component("x-counter")
621class Counter extends Component {
622  count = state(0);
623
624  increment() {
625    this.count++;
626  }
627
628  render() {
629    return <button onClick={this.increment}>{this.count}</button>;
630  }
631}
632"#,
633        );
634        let asm = build_application_semantic_model(&parsed);
635        let graph = build_semantic_graph(&asm);
636        let component = &asm.components[0];
637
638        assert_eq!(graph.schema_version, 6);
639        assert_eq!(graph.roots, vec![component.id.clone()]);
640        assert_eq!(
641            graph.nodes.len(),
642            asm.ownership.len() - asm.component_instance_plan.instances.len()
643        );
644        assert!(graph
645            .nodes
646            .windows(2)
647            .all(|nodes| nodes[0].id <= nodes[1].id));
648        assert!(graph.edges.windows(2).all(|edges| {
649            let left = &edges[0];
650            let right = &edges[1];
651            (
652                left.kind.as_str(),
653                left.source.as_str(),
654                left.target.as_str(),
655            ) <= (
656                right.kind.as_str(),
657                right.source.as_str(),
658                right.target.as_str(),
659            )
660        }));
661        assert!(graph.edges.iter().any(|edge| {
662            edge.kind == SemanticGraphEdgeKind::Ownership
663                && edge.source == component.id
664                && edge.target == component.methods[0].id
665        }));
666        assert!(graph.edges.iter().any(|edge| {
667            edge.kind == SemanticGraphEdgeKind::ActionState
668                && edge.source == component.actions[0].id
669                && edge.target == component.state_fields[0].id
670        }));
671
672        let first = semantic_graph_json(&graph);
673        let second = semantic_graph_json(&build_semantic_graph(&asm));
674        assert_eq!(first, second);
675        let document: serde_json::Value =
676            serde_json::from_str(&first).expect("semantic graph JSON should parse");
677        assert_eq!(document["schema_version"], 6);
678        assert_eq!(document["nodes"][0]["kind"], "component");
679    }
680
681    #[test]
682    fn exports_first_class_computed_nodes() {
683        let parsed = presolve_parser::parse_file(
684            "src/Computed.tsx",
685            r#"
686@component("x-computed")
687class Computed extends Component {
688  count = state(1);
689
690  @computed()
691  get remainingCount(): number { return this.count; }
692
693  render() { return <p />; }
694}
695"#,
696        );
697        let asm = build_application_semantic_model(&parsed);
698        let computed_id = asm.components[0].id.computed("remainingCount");
699        let graph = build_semantic_graph(&asm);
700
701        assert!(graph.nodes.iter().any(|node| {
702            node.id == computed_id && node.kind == super::SemanticGraphNodeKind::Computed
703        }));
704        assert!(graph.edges.iter().any(|edge| {
705            edge.kind == SemanticGraphEdgeKind::Ownership
706                && edge.source == asm.components[0].id
707                && edge.target == computed_id
708        }));
709        assert!(graph.edges.iter().any(|edge| {
710            edge.kind == SemanticGraphEdgeKind::ComputedState
711                && edge.source == computed_id
712                && edge.target == asm.components[0].id.state_field("count")
713        }));
714    }
715}