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        // V2 action endpoints are an internal owner layer for ordinary action
307        // writes. The frozen graph schema exposes them through its existing
308        // Action node vocabulary rather than adding a second public node kind.
309        SemanticEntity::ActionEndpoint(_) => SemanticGraphNodeKind::Action,
310        SemanticEntity::Context(_) => SemanticGraphNodeKind::Context,
311        SemanticEntity::Provider(_) => SemanticGraphNodeKind::Provider,
312        SemanticEntity::Consumer(_) => SemanticGraphNodeKind::Consumer,
313        SemanticEntity::Form(_) => SemanticGraphNodeKind::Form,
314        SemanticEntity::FormField(_) => SemanticGraphNodeKind::FormField,
315        SemanticEntity::FormFieldBinding(_) => SemanticGraphNodeKind::FormFieldBinding,
316        SemanticEntity::ValidationRule(_) => SemanticGraphNodeKind::ValidationRule,
317        SemanticEntity::Slot(_) => {
318            unreachable!("Slots are not projected into frozen semantic graph schema v5")
319        }
320        SemanticEntity::ComponentInvocation(_) => {
321            unreachable!("Component invocations are not projected into semantic graph schema v5")
322        }
323        SemanticEntity::ComponentInstance(_) | SemanticEntity::BlockedComponentInstance(_) => {
324            unreachable!("Component instances are not projected into semantic graph schema v5")
325        }
326        SemanticEntity::SlotContentFragment(_) => {
327            unreachable!("Slot fragments are not projected into semantic graph schema v5")
328        }
329        SemanticEntity::SlotOutlet(_) => {
330            unreachable!("Slot outlets are not projected into semantic graph schema v5")
331        }
332        SemanticEntity::Computed(_) => SemanticGraphNodeKind::Computed,
333        SemanticEntity::Effect(_) => SemanticGraphNodeKind::Effect,
334        SemanticEntity::Parameter(_) => SemanticGraphNodeKind::Parameter,
335        SemanticEntity::LocalVariable(_) => SemanticGraphNodeKind::LocalVariable,
336        SemanticEntity::Action(_) => SemanticGraphNodeKind::Action,
337        SemanticEntity::EventHandler(_) => SemanticGraphNodeKind::EventHandler,
338        SemanticEntity::Template(_) => SemanticGraphNodeKind::Template,
339        SemanticEntity::TemplateEntity(entity) => match entity.kind {
340            TemplateSemanticKind::Element => SemanticGraphNodeKind::TemplateElement,
341            TemplateSemanticKind::Fragment => SemanticGraphNodeKind::TemplateFragment,
342            TemplateSemanticKind::Text => SemanticGraphNodeKind::TemplateText,
343            TemplateSemanticKind::Binding => SemanticGraphNodeKind::TemplateBinding,
344            TemplateSemanticKind::Attribute => SemanticGraphNodeKind::TemplateAttribute,
345            TemplateSemanticKind::AttributeBinding => {
346                SemanticGraphNodeKind::TemplateAttributeBinding
347            }
348            TemplateSemanticKind::EventAttribute => SemanticGraphNodeKind::TemplateEventAttribute,
349            TemplateSemanticKind::Conditional => SemanticGraphNodeKind::TemplateConditional,
350            TemplateSemanticKind::List => SemanticGraphNodeKind::TemplateList,
351        },
352    }
353}
354
355fn semantic_graph_context(entity: SemanticEntity<'_>) -> Option<SemanticGraphContext> {
356    let SemanticEntity::Context(context) = entity else {
357        return None;
358    };
359    Some(context_metadata(context))
360}
361
362fn semantic_graph_provider(entity: SemanticEntity<'_>) -> Option<SemanticGraphProvider> {
363    let SemanticEntity::Provider(provider) = entity else {
364        return None;
365    };
366    Some(SemanticGraphProvider {
367        owner: provider
368            .owner
369            .entity_id()
370            .expect("provider entities should be component-owned")
371            .clone(),
372        authored_field: provider.authored_field.clone(),
373        context: provider.context.as_semantic_id().clone(),
374        declared_type_id: provider.declared_type_id.to_string(),
375        value_expression: provider.value_expression.clone(),
376        execution_boundary: match provider.execution_boundary {
377            crate::ExecutionBoundary::Client => "client",
378            crate::ExecutionBoundary::Server => "server",
379        },
380    })
381}
382
383fn semantic_graph_consumer(entity: SemanticEntity<'_>) -> Option<SemanticGraphConsumer> {
384    let SemanticEntity::Consumer(consumer) = entity else {
385        return None;
386    };
387    Some(consumer_metadata(consumer))
388}
389
390fn consumer_metadata(consumer: &ConsumerEntity) -> SemanticGraphConsumer {
391    SemanticGraphConsumer {
392        owner: consumer
393            .owner
394            .entity_id()
395            .expect("consumer entities should be component-owned")
396            .clone(),
397        authored_field: consumer.authored_field.clone(),
398        context: consumer
399            .context()
400            .map(|context| context.as_semantic_id().clone()),
401        requested_type_id: consumer.requested_type_id.to_string(),
402        execution_boundary: match consumer.execution_boundary {
403            crate::ExecutionBoundary::Client => "client",
404            crate::ExecutionBoundary::Server => "server",
405        },
406    }
407}
408
409fn context_metadata(context: &ContextEntity) -> SemanticGraphContext {
410    SemanticGraphContext {
411        owner: context
412            .owner
413            .entity_id()
414            .expect("context entities should be component-owned")
415            .clone(),
416        authored_field: context.authored_field.clone(),
417        declared_type_id: context.declared_type_id.to_string(),
418        execution_boundary: match context.execution_boundary {
419            crate::ExecutionBoundary::Client => "client",
420            crate::ExecutionBoundary::Server => "server",
421        },
422        has_default_expression: context.default_expression.is_some(),
423    }
424}
425
426fn semantic_graph_edge_kind(kind: SemanticReferenceKind) -> SemanticGraphEdgeKind {
427    match kind {
428        SemanticReferenceKind::ActionState => SemanticGraphEdgeKind::ActionState,
429        SemanticReferenceKind::ComputedState => SemanticGraphEdgeKind::ComputedState,
430        SemanticReferenceKind::ComputedComputed => SemanticGraphEdgeKind::ComputedComputed,
431        SemanticReferenceKind::ComputedResource => SemanticGraphEdgeKind::ComputedResource,
432        SemanticReferenceKind::EffectState => SemanticGraphEdgeKind::EffectState,
433        SemanticReferenceKind::EffectComputed => SemanticGraphEdgeKind::EffectComputed,
434        SemanticReferenceKind::ProvidesContext => SemanticGraphEdgeKind::ProvidesContext,
435        SemanticReferenceKind::ConsumesContext => SemanticGraphEdgeKind::ConsumesContext,
436        SemanticReferenceKind::ResolvesToProvider => SemanticGraphEdgeKind::ResolvesToProvider,
437        SemanticReferenceKind::EventMethod => SemanticGraphEdgeKind::EventMethod,
438        SemanticReferenceKind::TemplateState => SemanticGraphEdgeKind::TemplateState,
439        SemanticReferenceKind::TemplateComputed => SemanticGraphEdgeKind::TemplateComputed,
440        SemanticReferenceKind::TemplateLocal => SemanticGraphEdgeKind::TemplateLocal,
441        SemanticReferenceKind::FieldBindingField => SemanticGraphEdgeKind::FieldBindingBindsField,
442        SemanticReferenceKind::FieldBindingForm => SemanticGraphEdgeKind::FieldBindingBelongsToForm,
443        SemanticReferenceKind::ValidationRuleField => {
444            SemanticGraphEdgeKind::ValidationRuleDependsOnField
445        }
446    }
447}
448
449fn form_ownership_edge_kind(
450    asm: &ApplicationSemanticModel,
451    source: &SemanticId,
452    target: &SemanticId,
453) -> SemanticGraphEdgeKind {
454    match (asm.entity(source), asm.entity(target)) {
455        (Some(SemanticEntity::Component(_)), Some(SemanticEntity::Form(_))) => {
456            SemanticGraphEdgeKind::ComponentOwnsForm
457        }
458        (Some(SemanticEntity::Form(_)), Some(SemanticEntity::FormField(_))) => {
459            SemanticGraphEdgeKind::FormOwnsField
460        }
461        (Some(SemanticEntity::FormField(_)), Some(SemanticEntity::ValidationRule(_))) => {
462            SemanticGraphEdgeKind::FieldOwnsValidationRule
463        }
464        (Some(SemanticEntity::TemplateEntity(_)), Some(SemanticEntity::FormFieldBinding(_))) => {
465            SemanticGraphEdgeKind::ControlOwnsFieldBinding
466        }
467        _ => SemanticGraphEdgeKind::Ownership,
468    }
469}
470
471#[cfg(test)]
472mod tests {
473    use super::{build_semantic_graph, semantic_graph_json, SemanticGraphEdgeKind};
474    use crate::build_application_semantic_model;
475
476    #[test]
477    fn exports_context_nodes_with_compiler_owned_metadata() {
478        let parsed = presolve_parser::parse_file(
479            "src/AppShell.tsx",
480            r#"
481@component("x-app-shell")
482class AppShell extends Component {
483  @context()
484  locale: string = "en";
485  render() { return <main />; }
486}
487"#,
488        );
489        let asm = build_application_semantic_model(&parsed);
490        let graph = build_semantic_graph(&asm);
491        let context_id = asm.contexts()[0].id.as_semantic_id();
492        let node = graph
493            .nodes
494            .iter()
495            .find(|node| node.id == *context_id)
496            .unwrap();
497
498        assert_eq!(node.kind, super::SemanticGraphNodeKind::Context);
499        assert_eq!(node.context.as_ref().unwrap().owner, asm.components[0].id);
500        assert!(node.context.as_ref().unwrap().has_default_expression);
501        assert!(graph.edges.iter().any(|edge| {
502            edge.kind == SemanticGraphEdgeKind::Ownership
503                && edge.source == asm.components[0].id
504                && edge.target == *context_id
505        }));
506    }
507
508    #[test]
509    fn exports_provider_nodes_and_compiler_resolved_context_edges() {
510        let parsed = presolve_parser::parse_file(
511            "src/AppShell.tsx",
512            r#"
513@component("x-app-shell")
514class AppShell extends Component {
515  @context()
516  theme: string;
517  @provide(AppShell.theme)
518  providedTheme: string = this.theme;
519  render() { return <main />; }
520}
521"#,
522        );
523        let asm = build_application_semantic_model(&parsed);
524        let graph = build_semantic_graph(&asm);
525        let provider = asm.providers()[0];
526        let node = graph
527            .nodes
528            .iter()
529            .find(|node| node.id == *provider.id.as_semantic_id())
530            .unwrap();
531
532        assert_eq!(node.kind, super::SemanticGraphNodeKind::Provider);
533        assert_eq!(
534            node.provider.as_ref().unwrap().context,
535            *provider.context.as_semantic_id()
536        );
537        assert!(graph.edges.iter().any(|edge| {
538            edge.kind == SemanticGraphEdgeKind::ProvidesContext
539                && edge.source == *provider.id.as_semantic_id()
540                && edge.target == *provider.context.as_semantic_id()
541        }));
542    }
543
544    #[test]
545    fn exports_consumer_nodes_and_compiler_resolved_context_edges() {
546        let parsed = presolve_parser::parse_file(
547            "src/toolbar.tsx",
548            r#"
549@component("x-app-shell")
550class AppShell extends Component {
551  @context()
552  theme!: Theme;
553  render() { return <main />; }
554}
555@component("x-toolbar")
556class Toolbar extends Component {
557  @consume(AppShell.theme)
558  theme!: Theme;
559  render() { return <main />; }
560}
561"#,
562        );
563        let asm = build_application_semantic_model(&parsed);
564        let graph = build_semantic_graph(&asm);
565        let consumer = asm.consumers()[0];
566        let node = graph
567            .nodes
568            .iter()
569            .find(|node| node.id == *consumer.id.as_semantic_id())
570            .unwrap();
571
572        assert_eq!(node.kind, super::SemanticGraphNodeKind::Consumer);
573        assert_eq!(
574            node.consumer.as_ref().unwrap().context,
575            consumer
576                .context()
577                .map(|context| context.as_semantic_id().clone())
578        );
579        assert!(graph.edges.iter().any(|edge| {
580            edge.kind == SemanticGraphEdgeKind::ConsumesContext
581                && edge.source == *consumer.id.as_semantic_id()
582                && edge.target == *consumer.context().unwrap().as_semantic_id()
583        }));
584    }
585
586    #[test]
587    fn exports_compiler_resolved_consumer_provider_edges() {
588        let parsed = presolve_parser::parse_file(
589            "src/toolbar.tsx",
590            r#"
591@component("x-app-shell")
592class AppShell extends Component {
593  @context()
594  theme!: Theme;
595  render() { return <main />; }
596}
597@component("x-toolbar")
598class Toolbar extends Component {
599  @provide(AppShell.theme)
600  providedTheme: Theme = this.localTheme;
601  @consume(AppShell.theme)
602  theme!: Theme;
603  render() { return <main />; }
604}
605"#,
606        );
607        let asm = build_application_semantic_model(&parsed);
608        let graph = build_semantic_graph(&asm);
609        let consumer = asm.consumers()[0];
610        let provider = asm.resolved_provider(&consumer.id).unwrap();
611
612        assert!(graph.edges.iter().any(|edge| {
613            edge.kind == SemanticGraphEdgeKind::ResolvesToProvider
614                && edge.source == *consumer.id.as_semantic_id()
615                && edge.target == *provider.as_semantic_id()
616        }));
617    }
618
619    #[test]
620    fn exports_a_deterministic_canonical_semantic_graph() {
621        let parsed = presolve_parser::parse_file(
622            "src/Counter.tsx",
623            r#"
624@component("x-counter")
625class Counter extends Component {
626  count = state(0);
627
628  increment() {
629    this.count++;
630  }
631
632  render() {
633    return <button onClick={this.increment}>{this.count}</button>;
634  }
635}
636"#,
637        );
638        let asm = build_application_semantic_model(&parsed);
639        let graph = build_semantic_graph(&asm);
640        let component = &asm.components[0];
641
642        assert_eq!(graph.schema_version, 6);
643        assert_eq!(graph.roots, vec![component.id.clone()]);
644        assert_eq!(
645            graph.nodes.len(),
646            asm.ownership.len() - asm.component_instance_plan.instances.len()
647        );
648        assert!(graph
649            .nodes
650            .windows(2)
651            .all(|nodes| nodes[0].id <= nodes[1].id));
652        assert!(graph.edges.windows(2).all(|edges| {
653            let left = &edges[0];
654            let right = &edges[1];
655            (
656                left.kind.as_str(),
657                left.source.as_str(),
658                left.target.as_str(),
659            ) <= (
660                right.kind.as_str(),
661                right.source.as_str(),
662                right.target.as_str(),
663            )
664        }));
665        assert!(graph.edges.iter().any(|edge| {
666            edge.kind == SemanticGraphEdgeKind::Ownership
667                && edge.source == component.id
668                && edge.target == component.methods[0].id
669        }));
670        assert!(graph.edges.iter().any(|edge| {
671            edge.kind == SemanticGraphEdgeKind::ActionState
672                && edge.source == component.actions[0].id
673                && edge.target == component.state_fields[0].id
674        }));
675
676        let first = semantic_graph_json(&graph);
677        let second = semantic_graph_json(&build_semantic_graph(&asm));
678        assert_eq!(first, second);
679        let document: serde_json::Value =
680            serde_json::from_str(&first).expect("semantic graph JSON should parse");
681        assert_eq!(document["schema_version"], 6);
682        assert_eq!(document["nodes"][0]["kind"], "component");
683    }
684
685    #[test]
686    fn exports_first_class_computed_nodes() {
687        let parsed = presolve_parser::parse_file(
688            "src/Computed.tsx",
689            r#"
690@component("x-computed")
691class Computed extends Component {
692  count = state(1);
693
694  @computed()
695  get remainingCount(): number { return this.count; }
696
697  render() { return <p />; }
698}
699"#,
700        );
701        let asm = build_application_semantic_model(&parsed);
702        let computed_id = asm.components[0].id.computed("remainingCount");
703        let graph = build_semantic_graph(&asm);
704
705        assert!(graph.nodes.iter().any(|node| {
706            node.id == computed_id && node.kind == super::SemanticGraphNodeKind::Computed
707        }));
708        assert!(graph.edges.iter().any(|edge| {
709            edge.kind == SemanticGraphEdgeKind::Ownership
710                && edge.source == asm.components[0].id
711                && edge.target == computed_id
712        }));
713        assert!(graph.edges.iter().any(|edge| {
714            edge.kind == SemanticGraphEdgeKind::ComputedState
715                && edge.source == computed_id
716                && edge.target == asm.components[0].id.state_field("count")
717        }));
718    }
719}