1use oxc_ast::ast::{
2 AssignmentOperator, AssignmentTarget, BinaryOperator, BindingIdentifier, BindingPattern,
3 Declaration, ExportDefaultDeclarationKind, Expression, FunctionBody, IdentifierReference,
4 IfStatement, LabeledStatement, LogicalExpression, Program, Statement, SwitchCase,
5 VariableDeclaration,
6};
7use oxc_ast_visit::Visit;
8use oxc_semantic::{Scoping, SymbolId};
9use oxc_span::{GetSpan, Span};
10use serde::Serialize;
11use std::collections::{BTreeMap, BTreeSet};
12
13use engine_input_producers::{
14 StringTypeFactsV2, TypeFactControlFlowBlockV2, TypeFactControlFlowGraphV2,
15};
16use omena_abstract_value::{
17 ClassBoundaryEffectV0, DomClassTokenizationV0, OrderedTokenWordV0,
18 external_string_type_facts_from_abstract_class_value, prefix_suffix_class_value,
19};
20
21#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
22#[serde(rename_all = "camelCase")]
23pub struct SourceControlFlowGraphCaptureV0 {
24 pub schema_version: &'static str,
25 pub product: &'static str,
26 pub binding: SourceFlowBindingRefV0,
27 pub variable_name: String,
28 pub reference_byte_offset: usize,
29 pub snapshot: SourceFlowBlockGraphSnapshotV0,
30}
31
32#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
33#[serde(rename_all = "camelCase")]
34pub struct SourceFlowBindingRefV0 {
35 pub symbol_ordinal: usize,
36 pub name: String,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
40#[serde(rename_all = "camelCase")]
41pub struct SourceFlowBlockGraphSnapshotV0 {
42 pub entry_block_id: String,
43 pub blocks: Vec<SourceFlowBlockSnapshotV0>,
44}
45
46#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
47#[serde(rename_all = "camelCase")]
48pub struct SourceFlowBlockSnapshotV0 {
49 pub id: String,
50 pub kind: &'static str,
51 pub transfer_kind: &'static str,
52 pub successor_block_ids: Vec<String>,
53 #[serde(skip_serializing_if = "Option::is_none")]
54 pub binding: Option<SourceFlowBindingRefV0>,
55 #[serde(skip_serializing_if = "Option::is_none")]
56 pub symbol_ordinal: Option<usize>,
57 #[serde(skip_serializing_if = "Option::is_none")]
58 pub variable_name: Option<String>,
59 #[serde(skip_serializing_if = "Option::is_none")]
60 pub expression_kind: Option<&'static str>,
61 pub boundary_effect: ClassBoundaryEffectV0,
62 #[serde(skip_serializing_if = "Option::is_none")]
63 pub ordered_word: Option<OrderedTokenWordV0>,
64 #[serde(skip_serializing_if = "Option::is_none")]
65 pub facts: Option<StringTypeFactsV2>,
66}
67
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub(crate) struct SourceClassSiteExpressionValueV0 {
70 pub facts: Option<StringTypeFactsV2>,
71 pub boundary_effect: ClassBoundaryEffectV0,
72 pub ordered_word: Option<OrderedTokenWordV0>,
73}
74
75enum SourceFlowNode<'a> {
76 Assignment {
77 binding: Option<SourceFlowBindingRefV0>,
78 expression: Option<&'a Expression<'a>>,
79 },
80 Branch {
81 then_nodes: Vec<SourceFlowNode<'a>>,
82 else_nodes: Vec<SourceFlowNode<'a>>,
83 },
84 Loop {
85 body_nodes: Vec<SourceFlowNode<'a>>,
86 },
87 Break,
88 Terminate,
89}
90
91type SourceFlowBlockMetadataV0 = (
92 Option<SourceFlowBindingRefV0>,
93 Option<&'static str>,
94 Option<StringTypeFactsV2>,
95 ClassBoundaryEffectV0,
96);
97
98pub fn summarize_omena_bridge_source_control_flow_graph_for_source_language(
99 source_path: &str,
100 source: &str,
101 source_language: Option<&str>,
102 variable_name: &str,
103 reference_byte_offset: usize,
104) -> Option<SourceControlFlowGraphCaptureV0> {
105 crate::source_syntax::summarize_source_control_flow_graph_with_semantic(
106 source_path,
107 source,
108 source_language,
109 variable_name,
110 reference_byte_offset,
111 )
112}
113
114pub(crate) fn summarize_source_control_flow_graph_from_program<'a>(
115 program: &'a Program<'a>,
116 scoping: &'a Scoping,
117 variable_name: &str,
118 reference_byte_offset: usize,
119) -> Option<SourceControlFlowGraphCaptureV0> {
120 if variable_name.contains('.') {
121 return None;
122 }
123
124 let reference_binding =
125 reference_binding_for_offset(program, scoping, variable_name, reference_byte_offset)?;
126
127 let container = statement_container_for_reference(&program.body, reference_byte_offset);
128 let nodes = build_flow_nodes(container, scoping, reference_byte_offset);
129 Some(SourceControlFlowGraphCaptureV0 {
130 schema_version: "0",
131 product: "omena-bridge.source-control-flow-graph",
132 variable_name: reference_binding.name.clone(),
133 binding: reference_binding,
134 reference_byte_offset,
135 snapshot: SourceFlowBlockGraphSnapshotBuilder::new(&program.body, scoping)
136 .build(nodes.as_slice()),
137 })
138}
139
140pub(crate) fn summarize_source_class_site_expression_value_from_program<'a>(
141 program: &'a Program<'a>,
142 scoping: &'a Scoping,
143 expression: &'a Expression<'a>,
144) -> SourceClassSiteExpressionValueV0 {
145 let reference_byte_offset = expression.span().start as usize;
146 let container = statement_container_for_reference(&program.body, reference_byte_offset);
147 let nodes = build_flow_nodes(container, scoping, reference_byte_offset);
148 let snapshot =
149 SourceFlowBlockGraphSnapshotBuilder::new(&program.body, scoping).build(nodes.as_slice());
150 let facts = expression_type_facts(expression, &program.body, scoping, &snapshot.blocks);
151 SourceClassSiteExpressionValueV0 {
152 ordered_word: facts.as_ref().and_then(ordered_word_for_type_facts),
153 boundary_effect: class_boundary_effect_for_expression(expression),
154 facts,
155 }
156}
157
158pub(crate) fn summarize_source_class_site_literal_value(
159 value: &str,
160) -> SourceClassSiteExpressionValueV0 {
161 let facts = exact_type_facts(value);
162 SourceClassSiteExpressionValueV0 {
163 ordered_word: ordered_word_for_type_facts(&facts),
164 boundary_effect: ClassBoundaryEffectV0::UnknownBoundary,
165 facts: Some(facts),
166 }
167}
168
169pub fn summarize_omena_bridge_source_type_fact_control_flow_graph_for_source_language(
170 source_path: &str,
171 source: &str,
172 source_language: Option<&str>,
173 variable_name: &str,
174 reference_byte_offset: usize,
175) -> Option<TypeFactControlFlowGraphV2> {
176 summarize_omena_bridge_source_control_flow_graph_for_source_language(
177 source_path,
178 source,
179 source_language,
180 variable_name,
181 reference_byte_offset,
182 )
183 .map(|capture| source_type_fact_control_flow_graph_from_snapshot(&capture.snapshot))
184}
185
186pub fn source_type_fact_control_flow_graph_from_snapshot(
187 snapshot: &SourceFlowBlockGraphSnapshotV0,
188) -> TypeFactControlFlowGraphV2 {
189 TypeFactControlFlowGraphV2 {
190 entry_block_id: snapshot.entry_block_id.clone(),
191 blocks: snapshot
192 .blocks
193 .iter()
194 .map(source_type_fact_control_flow_block_from_snapshot)
195 .collect(),
196 }
197}
198
199fn source_type_fact_control_flow_block_from_snapshot(
200 block: &SourceFlowBlockSnapshotV0,
201) -> TypeFactControlFlowBlockV2 {
202 TypeFactControlFlowBlockV2 {
203 id: block.id.clone(),
204 kind: block.kind.to_string(),
205 transfer_kind: block.transfer_kind.to_string(),
206 successor_block_ids: block.successor_block_ids.clone(),
207 symbol_ordinal: block.symbol_ordinal,
208 variable_name: block.variable_name.clone(),
209 expression_kind: block.expression_kind.map(str::to_string),
210 boundary_effect: boundary_effect_wire_value(block.boundary_effect).to_string(),
211 facts: block.facts.clone(),
212 }
213}
214
215fn statement_container_for_reference<'a>(
216 statements: &'a oxc_allocator::Vec<'a, Statement<'a>>,
217 reference_byte_offset: usize,
218) -> &'a oxc_allocator::Vec<'a, Statement<'a>> {
219 find_function_body_statements_containing_reference(statements, reference_byte_offset)
220 .unwrap_or(statements)
221}
222
223fn find_function_body_statements_containing_reference<'a>(
224 statements: &'a oxc_allocator::Vec<'a, Statement<'a>>,
225 reference_byte_offset: usize,
226) -> Option<&'a oxc_allocator::Vec<'a, Statement<'a>>> {
227 for statement in statements {
228 if let Some(body) = function_body_for_statement(statement)
229 && span_contains(body.span, reference_byte_offset)
230 {
231 return find_function_body_statements_containing_reference(
232 &body.statements,
233 reference_byte_offset,
234 )
235 .or(Some(&body.statements));
236 }
237 }
238 None
239}
240
241fn function_body_for_statement<'a>(statement: &'a Statement<'a>) -> Option<&'a FunctionBody<'a>> {
242 match statement {
243 Statement::FunctionDeclaration(function) => function.body.as_deref(),
244 Statement::ExportNamedDeclaration(export) => {
245 if let Some(Declaration::FunctionDeclaration(function)) = &export.declaration {
246 function.body.as_deref()
247 } else {
248 None
249 }
250 }
251 Statement::ExportDefaultDeclaration(export) => {
252 if let ExportDefaultDeclarationKind::FunctionDeclaration(function) = &export.declaration
253 {
254 function.body.as_deref()
255 } else {
256 None
257 }
258 }
259 _ => None,
260 }
261}
262
263fn build_flow_nodes<'a>(
264 statements: &'a oxc_allocator::Vec<'a, Statement<'a>>,
265 scoping: &Scoping,
266 reference_byte_offset: usize,
267) -> Vec<SourceFlowNode<'a>> {
268 let mut nodes = Vec::new();
269
270 for statement in statements {
271 if span_start(statement.span()) >= reference_byte_offset {
272 break;
273 }
274 if matches!(statement, Statement::FunctionDeclaration(_)) {
275 continue;
276 }
277
278 match statement {
279 Statement::IfStatement(if_statement) => {
280 let reference_location =
281 locate_reference_in_if(if_statement, reference_byte_offset);
282 nodes.push(SourceFlowNode::Branch {
283 then_nodes: build_flow_nodes_for_statement(
284 &if_statement.consequent,
285 scoping,
286 branch_reference_offset(reference_location, "then", reference_byte_offset),
287 ),
288 else_nodes: if_statement
289 .alternate
290 .as_ref()
291 .map(|alternate| {
292 build_flow_nodes_for_statement(
293 alternate,
294 scoping,
295 branch_reference_offset(
296 reference_location,
297 "else",
298 reference_byte_offset,
299 ),
300 )
301 })
302 .unwrap_or_default(),
303 });
304 if reference_location != "after" {
305 break;
306 }
307 }
308 Statement::WhileStatement(while_statement) => {
309 nodes.push(SourceFlowNode::Loop {
310 body_nodes: build_loop_body_nodes(
311 &while_statement.body,
312 scoping,
313 reference_byte_offset,
314 ),
315 });
316 if span_contains(while_statement.body.span(), reference_byte_offset) {
317 break;
318 }
319 }
320 Statement::ForStatement(for_statement) => {
321 nodes.push(SourceFlowNode::Loop {
322 body_nodes: build_loop_body_nodes(
323 &for_statement.body,
324 scoping,
325 reference_byte_offset,
326 ),
327 });
328 if span_contains(for_statement.body.span(), reference_byte_offset) {
329 break;
330 }
331 }
332 Statement::DoWhileStatement(do_statement) => {
333 nodes.push(SourceFlowNode::Loop {
334 body_nodes: build_loop_body_nodes(
335 &do_statement.body,
336 scoping,
337 reference_byte_offset,
338 ),
339 });
340 if span_contains(do_statement.body.span(), reference_byte_offset) {
341 break;
342 }
343 }
344 Statement::LabeledStatement(labeled) => {
345 nodes.extend(build_flow_nodes_for_labeled(
346 labeled,
347 scoping,
348 reference_byte_offset,
349 ));
350 if span_contains(labeled.body.span(), reference_byte_offset) {
351 break;
352 }
353 }
354 _ if span_contains(statement.span(), reference_byte_offset) => break,
355 Statement::BreakStatement(_) => {
356 nodes.push(SourceFlowNode::Break);
357 break;
358 }
359 Statement::ReturnStatement(_) | Statement::ThrowStatement(_) => {
360 nodes.push(SourceFlowNode::Terminate);
361 break;
362 }
363 _ => nodes.extend(assignment_nodes_for_statement(statement, scoping)),
364 }
365 }
366
367 nodes
368}
369
370fn build_flow_nodes_for_statement<'a>(
371 statement: &'a Statement<'a>,
372 scoping: &Scoping,
373 reference_byte_offset: usize,
374) -> Vec<SourceFlowNode<'a>> {
375 match statement {
376 Statement::BlockStatement(block) => {
377 build_flow_nodes(&block.body, scoping, reference_byte_offset)
378 }
379 _ => build_flow_nodes_from_slice(
380 std::slice::from_ref(statement),
381 scoping,
382 reference_byte_offset,
383 ),
384 }
385}
386
387fn build_flow_nodes_from_slice<'a>(
388 statements: &'a [Statement<'a>],
389 scoping: &Scoping,
390 reference_byte_offset: usize,
391) -> Vec<SourceFlowNode<'a>> {
392 let mut nodes = Vec::new();
393 for statement in statements {
394 if span_start(statement.span()) >= reference_byte_offset {
395 break;
396 }
397 if span_contains(statement.span(), reference_byte_offset) {
398 break;
399 }
400 nodes.extend(assignment_nodes_for_statement(statement, scoping));
401 }
402 nodes
403}
404
405fn build_loop_body_nodes<'a>(
406 body: &'a Statement<'a>,
407 scoping: &Scoping,
408 reference_byte_offset: usize,
409) -> Vec<SourceFlowNode<'a>> {
410 if span_contains(body.span(), reference_byte_offset) {
411 build_flow_nodes_for_statement(body, scoping, reference_byte_offset)
412 } else {
413 build_flow_nodes_for_statement(body, scoping, usize::MAX)
414 }
415}
416
417fn build_flow_nodes_for_labeled<'a>(
418 labeled: &'a LabeledStatement<'a>,
419 scoping: &Scoping,
420 reference_byte_offset: usize,
421) -> Vec<SourceFlowNode<'a>> {
422 build_flow_nodes_for_statement(&labeled.body, scoping, reference_byte_offset)
423}
424
425fn locate_reference_in_if(
426 statement: &IfStatement<'_>,
427 reference_byte_offset: usize,
428) -> &'static str {
429 if span_contains(statement.consequent.span(), reference_byte_offset) {
430 return "then";
431 }
432 if statement
433 .alternate
434 .as_ref()
435 .is_some_and(|alternate| span_contains(alternate.span(), reference_byte_offset))
436 {
437 return "else";
438 }
439 "after"
440}
441
442fn branch_reference_offset(
443 reference_location: &'static str,
444 branch: &'static str,
445 reference_byte_offset: usize,
446) -> usize {
447 if reference_location == branch {
448 reference_byte_offset
449 } else {
450 usize::MAX
451 }
452}
453
454fn assignment_nodes_for_statement<'a>(
455 statement: &'a Statement<'a>,
456 scoping: &Scoping,
457) -> Vec<SourceFlowNode<'a>> {
458 match statement {
459 Statement::VariableDeclaration(declaration) => {
460 assignment_nodes_for_variable_declaration(declaration)
461 }
462 Statement::ExpressionStatement(statement) => {
463 if let Expression::AssignmentExpression(assignment) = &statement.expression
464 && assignment.operator == AssignmentOperator::Assign
465 && let AssignmentTarget::AssignmentTargetIdentifier(identifier) = &assignment.left
466 {
467 return vec![SourceFlowNode::Assignment {
468 binding: binding_ref_from_reference(scoping, identifier),
469 expression: Some(&assignment.right),
470 }];
471 }
472 Vec::new()
473 }
474 Statement::BlockStatement(block) => build_flow_nodes(&block.body, scoping, usize::MAX)
475 .into_iter()
476 .filter(|node| matches!(node, SourceFlowNode::Assignment { .. }))
477 .collect(),
478 _ => Vec::new(),
479 }
480}
481
482fn assignment_nodes_for_variable_declaration<'a>(
483 declaration: &'a VariableDeclaration<'a>,
484) -> Vec<SourceFlowNode<'a>> {
485 declaration
486 .declarations
487 .iter()
488 .filter_map(|declarator| {
489 binding_pattern_identifier(&declarator.id).map(|identifier| {
490 SourceFlowNode::Assignment {
491 binding: binding_ref_from_binding_identifier(identifier),
492 expression: declarator.init.as_ref(),
493 }
494 })
495 })
496 .collect()
497}
498
499fn reference_binding_for_offset(
500 program: &Program<'_>,
501 scoping: &Scoping,
502 variable_name: &str,
503 reference_byte_offset: usize,
504) -> Option<SourceFlowBindingRefV0> {
505 let mut visitor = SourceReferenceBindingFinder {
506 scoping,
507 variable_name,
508 reference_byte_offset,
509 binding: None,
510 };
511 visitor.visit_program(program);
512 visitor.binding
513}
514
515struct SourceReferenceBindingFinder<'a> {
516 scoping: &'a Scoping,
517 variable_name: &'a str,
518 reference_byte_offset: usize,
519 binding: Option<SourceFlowBindingRefV0>,
520}
521
522impl<'a, 'ast> Visit<'ast> for SourceReferenceBindingFinder<'a> {
523 fn visit_identifier_reference(&mut self, identifier: &IdentifierReference<'ast>) {
524 if self.binding.is_none()
525 && identifier.name.as_str() == self.variable_name
526 && span_contains(identifier.span, self.reference_byte_offset)
527 {
528 self.binding = binding_ref_from_reference(self.scoping, identifier);
529 }
530 }
531}
532
533fn binding_ref_from_reference(
534 scoping: &Scoping,
535 identifier: &IdentifierReference<'_>,
536) -> Option<SourceFlowBindingRefV0> {
537 identifier
538 .reference_id
539 .get()
540 .and_then(|reference_id| scoping.get_reference(reference_id).symbol_id())
541 .map(|symbol_id| binding_ref_from_symbol(symbol_id, identifier.name.as_str()))
542}
543
544fn binding_ref_from_binding_identifier(
545 identifier: &BindingIdentifier<'_>,
546) -> Option<SourceFlowBindingRefV0> {
547 binding_identifier_symbol_id(identifier)
548 .map(|symbol_id| binding_ref_from_symbol(symbol_id, identifier.name.as_str()))
549}
550
551fn binding_identifier_symbol_id(identifier: &BindingIdentifier<'_>) -> Option<SymbolId> {
552 identifier.symbol_id.get()
553}
554
555fn binding_ref_from_symbol(symbol_id: SymbolId, name: &str) -> SourceFlowBindingRefV0 {
556 SourceFlowBindingRefV0 {
557 symbol_ordinal: symbol_id.index(),
558 name: name.to_string(),
559 }
560}
561
562fn binding_pattern_identifier<'a>(
563 pattern: &'a BindingPattern<'a>,
564) -> Option<&'a BindingIdentifier<'a>> {
565 match pattern {
566 BindingPattern::BindingIdentifier(identifier) => Some(identifier),
567 _ => None,
568 }
569}
570
571struct SourceFlowBlockGraphSnapshotBuilder<'a> {
572 blocks: Vec<SourceFlowBlockSnapshotV0>,
573 counters: BTreeMap<&'static str, usize>,
574 root_statements: &'a oxc_allocator::Vec<'a, Statement<'a>>,
575 scoping: &'a Scoping,
576}
577
578impl<'a> SourceFlowBlockGraphSnapshotBuilder<'a> {
579 fn new(
580 root_statements: &'a oxc_allocator::Vec<'a, Statement<'a>>,
581 scoping: &'a Scoping,
582 ) -> Self {
583 Self {
584 blocks: Vec::new(),
585 counters: BTreeMap::new(),
586 root_statements,
587 scoping,
588 }
589 }
590
591 fn build(mut self, nodes: &[SourceFlowNode<'_>]) -> SourceFlowBlockGraphSnapshotV0 {
592 let entry_block_id = self.add_block("entry", Some("entry"), None, None);
593 let tails = self.append_nodes(nodes, vec![entry_block_id], None);
594 let exit_block_id = self.add_block("exit", Some("exit"), None, None);
595 self.connect(tails.as_slice(), exit_block_id.as_str());
596 SourceFlowBlockGraphSnapshotV0 {
597 entry_block_id: "entry".to_string(),
598 blocks: self.blocks,
599 }
600 }
601
602 fn append_nodes(
603 &mut self,
604 nodes: &[SourceFlowNode<'_>],
605 incoming_block_ids: Vec<String>,
606 break_target_block_id: Option<&str>,
607 ) -> Vec<String> {
608 let mut tails = incoming_block_ids;
609 for node in nodes {
610 if tails.is_empty() {
611 return Vec::new();
612 }
613 tails = self.append_node(node, tails, break_target_block_id);
614 }
615 tails
616 }
617
618 fn append_node(
619 &mut self,
620 node: &SourceFlowNode<'_>,
621 incoming_block_ids: Vec<String>,
622 break_target_block_id: Option<&str>,
623 ) -> Vec<String> {
624 match node {
625 SourceFlowNode::Assignment {
626 binding,
627 expression,
628 } => self.append_assignment(binding.as_ref(), *expression, incoming_block_ids),
629 SourceFlowNode::Branch {
630 then_nodes,
631 else_nodes,
632 } => self.append_branch(
633 then_nodes,
634 else_nodes,
635 incoming_block_ids,
636 break_target_block_id,
637 ),
638 SourceFlowNode::Loop { body_nodes } => self.append_loop(body_nodes, incoming_block_ids),
639 SourceFlowNode::Break => {
640 let break_block_id = self.add_block("break", None, None, None);
641 self.connect(incoming_block_ids.as_slice(), break_block_id.as_str());
642 if let Some(target) = break_target_block_id {
643 self.connect(std::slice::from_ref(&break_block_id), target);
644 }
645 Vec::new()
646 }
647 SourceFlowNode::Terminate => {
648 let terminate_block_id = self.add_block("terminate", None, None, None);
649 self.connect(incoming_block_ids.as_slice(), terminate_block_id.as_str());
650 Vec::new()
651 }
652 }
653 }
654
655 fn append_assignment(
656 &mut self,
657 binding: Option<&SourceFlowBindingRefV0>,
658 expression: Option<&Expression<'_>>,
659 incoming_block_ids: Vec<String>,
660 ) -> Vec<String> {
661 let boundary_effect = expression
662 .map(class_boundary_effect_for_expression)
663 .unwrap_or_default();
664 let transfer_kind = if expression.is_some_and(is_concat_expression) {
665 "concatFacts"
666 } else {
667 "assignFacts"
668 };
669 let facts = expression.and_then(|expression| {
670 expression_type_facts(expression, self.root_statements, self.scoping, &self.blocks)
671 });
672 let assignment_block_id = self.add_block(
673 "assignment",
674 None,
675 Some(transfer_kind),
676 Some((binding.cloned(), None, facts, boundary_effect)),
677 );
678 self.connect(incoming_block_ids.as_slice(), assignment_block_id.as_str());
679
680 if let Some(Expression::LogicalExpression(expression)) = expression {
681 return self.append_short_circuit_expression(expression, vec![assignment_block_id]);
682 }
683
684 vec![assignment_block_id]
685 }
686
687 fn append_short_circuit_expression(
688 &mut self,
689 expression: &LogicalExpression<'_>,
690 incoming_block_ids: Vec<String>,
691 ) -> Vec<String> {
692 let expression_kind = logical_expression_kind(expression);
693 let operand_block_id = self.add_block(
694 "logicalOperand",
695 None,
696 None,
697 Some((
698 None,
699 expression_kind,
700 None,
701 ClassBoundaryEffectV0::UnknownBoundary,
702 )),
703 );
704 let rhs_block_id = self.add_block(
705 "logicalRhs",
706 None,
707 None,
708 Some((
709 None,
710 expression_kind,
711 None,
712 ClassBoundaryEffectV0::UnknownBoundary,
713 )),
714 );
715 let join_block_id = self.add_block(
716 "logicalJoin",
717 None,
718 None,
719 Some((
720 None,
721 expression_kind,
722 None,
723 ClassBoundaryEffectV0::UnknownBoundary,
724 )),
725 );
726 self.connect(incoming_block_ids.as_slice(), operand_block_id.as_str());
727 self.connect(
728 std::slice::from_ref(&operand_block_id),
729 join_block_id.as_str(),
730 );
731 self.connect(
732 std::slice::from_ref(&operand_block_id),
733 rhs_block_id.as_str(),
734 );
735 self.connect(std::slice::from_ref(&rhs_block_id), join_block_id.as_str());
736 vec![join_block_id]
737 }
738
739 fn append_branch(
740 &mut self,
741 then_nodes: &[SourceFlowNode<'_>],
742 else_nodes: &[SourceFlowNode<'_>],
743 incoming_block_ids: Vec<String>,
744 break_target_block_id: Option<&str>,
745 ) -> Vec<String> {
746 let branch_block_id = self.add_block("branch", None, None, None);
747 let join_block_id = self.add_block("join", None, None, None);
748 self.connect(incoming_block_ids.as_slice(), branch_block_id.as_str());
749 let then_tails = self.append_nodes(
750 then_nodes,
751 vec![branch_block_id.clone()],
752 break_target_block_id,
753 );
754 let else_tails = if else_nodes.is_empty() {
755 vec![branch_block_id]
756 } else {
757 self.append_nodes(else_nodes, vec![branch_block_id], break_target_block_id)
758 };
759 self.connect(then_tails.as_slice(), join_block_id.as_str());
760 self.connect(else_tails.as_slice(), join_block_id.as_str());
761 vec![join_block_id]
762 }
763
764 fn append_loop(
765 &mut self,
766 body_nodes: &[SourceFlowNode<'_>],
767 incoming_block_ids: Vec<String>,
768 ) -> Vec<String> {
769 let loop_index = self.next_index("loop");
770 let header_block_id = format!("loop:{loop_index}:header");
771 let body_block_id = format!("loop:{loop_index}:body");
772 let exit_block_id = format!("loop:{loop_index}:exit");
773 self.add_block("loopHeader", Some(header_block_id.as_str()), None, None);
774 self.add_block("loopBody", Some(body_block_id.as_str()), None, None);
775 self.add_block("loopExit", Some(exit_block_id.as_str()), None, None);
776 self.connect(incoming_block_ids.as_slice(), header_block_id.as_str());
777 self.connect(
778 std::slice::from_ref(&header_block_id),
779 body_block_id.as_str(),
780 );
781 self.connect(
782 std::slice::from_ref(&header_block_id),
783 exit_block_id.as_str(),
784 );
785 let body_tails = self.append_nodes(
786 body_nodes,
787 vec![body_block_id],
788 Some(exit_block_id.as_str()),
789 );
790 self.connect(body_tails.as_slice(), header_block_id.as_str());
791 vec![exit_block_id]
792 }
793
794 fn add_block(
795 &mut self,
796 kind: &'static str,
797 explicit_id: Option<&str>,
798 transfer_kind: Option<&'static str>,
799 metadata: Option<SourceFlowBlockMetadataV0>,
800 ) -> String {
801 let id = explicit_id
802 .map(str::to_string)
803 .unwrap_or_else(|| format!("{kind}:{}", self.next_index(kind)));
804 let (binding, expression_kind, facts, boundary_effect) = metadata.unwrap_or_default();
805 let ordered_word = facts.as_ref().and_then(ordered_word_for_type_facts);
806 self.blocks.push(SourceFlowBlockSnapshotV0 {
807 id: id.clone(),
808 kind,
809 transfer_kind: transfer_kind.unwrap_or_else(|| transfer_kind_for_block_kind(kind)),
810 successor_block_ids: Vec::new(),
811 symbol_ordinal: binding.as_ref().map(|binding| binding.symbol_ordinal),
812 variable_name: binding.as_ref().map(|binding| binding.name.clone()),
813 binding,
814 expression_kind,
815 boundary_effect,
816 ordered_word,
817 facts,
818 });
819 id
820 }
821
822 fn connect(&mut self, from_block_ids: &[String], to_block_id: &str) {
823 for from_block_id in from_block_ids {
824 if let Some(block) = self
825 .blocks
826 .iter_mut()
827 .find(|candidate| candidate.id == *from_block_id)
828 && !block
829 .successor_block_ids
830 .iter()
831 .any(|candidate| candidate == to_block_id)
832 {
833 block.successor_block_ids.push(to_block_id.to_string());
834 }
835 }
836 }
837
838 fn next_index(&mut self, kind: &'static str) -> usize {
839 let next = self.counters.get(kind).copied().unwrap_or_default();
840 self.counters.insert(kind, next + 1);
841 next
842 }
843}
844
845fn is_concat_expression(expression: &Expression<'_>) -> bool {
846 match transparent_expression(expression) {
847 Expression::BinaryExpression(expression) => expression.operator == BinaryOperator::Addition,
848 Expression::TemplateLiteral(template) => !template.expressions.is_empty(),
849 Expression::CallExpression(call) => {
850 class_boundary_effect_for_call(call) != ClassBoundaryEffectV0::UnknownBoundary
851 }
852 _ => false,
853 }
854}
855
856fn class_boundary_effect_for_expression(expression: &Expression<'_>) -> ClassBoundaryEffectV0 {
857 match transparent_expression(expression) {
858 Expression::BinaryExpression(expression)
859 if expression.operator == BinaryOperator::Addition =>
860 {
861 boundary_effect_for_binary_concat(&expression.left, &expression.right)
862 }
863 Expression::TemplateLiteral(template) if !template.expressions.is_empty() => {
864 let mut saw_literal_delimiter = false;
865 for (index, interpolation) in template.expressions.iter().enumerate() {
866 let left = template
867 .quasis
868 .get(index)
869 .and_then(|quasi| quasi.value.cooked.as_ref())
870 .map(|value| value.as_str())
871 .unwrap_or_default();
872 let right = template
873 .quasis
874 .get(index + 1)
875 .and_then(|quasi| quasi.value.cooked.as_ref())
876 .map(|value| value.as_str())
877 .unwrap_or_default();
878 if left
879 .chars()
880 .next_back()
881 .is_some_and(omena_abstract_value::is_dom_class_ascii_whitespace_v0)
882 || right
883 .chars()
884 .next()
885 .is_some_and(omena_abstract_value::is_dom_class_ascii_whitespace_v0)
886 {
887 return ClassBoundaryEffectV0::ConcatAtTokenBoundary;
888 }
889 saw_literal_delimiter |= !left.is_empty() || !right.is_empty();
890 if class_boundary_effect_for_expression(interpolation)
891 == ClassBoundaryEffectV0::ConcatAtTokenBoundary
892 {
893 return ClassBoundaryEffectV0::ConcatAtTokenBoundary;
894 }
895 }
896 if saw_literal_delimiter {
897 ClassBoundaryEffectV0::ConcatInsideToken
898 } else {
899 ClassBoundaryEffectV0::UnknownBoundary
900 }
901 }
902 Expression::CallExpression(call) => class_boundary_effect_for_call(call),
903 _ => ClassBoundaryEffectV0::UnknownBoundary,
904 }
905}
906
907fn boundary_effect_for_binary_concat(
908 left: &Expression<'_>,
909 right: &Expression<'_>,
910) -> ClassBoundaryEffectV0 {
911 let left_edge = expression_literal_edge(left, LiteralEdgeSide::Trailing);
912 let right_edge = expression_literal_edge(right, LiteralEdgeSide::Leading);
913 if matches!(left_edge, LiteralEdgeV0::Whitespace)
914 || matches!(right_edge, LiteralEdgeV0::Whitespace)
915 {
916 ClassBoundaryEffectV0::ConcatAtTokenBoundary
917 } else if matches!(left_edge, LiteralEdgeV0::NonWhitespace)
918 || matches!(right_edge, LiteralEdgeV0::NonWhitespace)
919 {
920 ClassBoundaryEffectV0::ConcatInsideToken
921 } else {
922 ClassBoundaryEffectV0::UnknownBoundary
923 }
924}
925
926fn class_boundary_effect_for_call(
927 call: &oxc_ast::ast::CallExpression<'_>,
928) -> ClassBoundaryEffectV0 {
929 if matches!(
930 transparent_expression(&call.callee),
931 Expression::Identifier(identifier)
932 if matches!(identifier.name.as_str(), "clsx" | "classnames" | "classNames")
933 ) {
934 return if call.arguments.len() >= 2 {
935 ClassBoundaryEffectV0::ConcatAtTokenBoundary
936 } else {
937 ClassBoundaryEffectV0::UnknownBoundary
938 };
939 }
940 let Expression::StaticMemberExpression(member) = transparent_expression(&call.callee) else {
941 return ClassBoundaryEffectV0::UnknownBoundary;
942 };
943 if member.property.name.as_str() != "join" {
944 return ClassBoundaryEffectV0::UnknownBoundary;
945 }
946 let Some(separator) = call
947 .arguments
948 .first()
949 .and_then(argument_expression)
950 .and_then(static_string_value)
951 else {
952 return ClassBoundaryEffectV0::UnknownBoundary;
953 };
954 if separator
955 .chars()
956 .any(omena_abstract_value::is_dom_class_ascii_whitespace_v0)
957 {
958 ClassBoundaryEffectV0::ConcatAtTokenBoundary
959 } else {
960 ClassBoundaryEffectV0::ConcatInsideToken
961 }
962}
963
964#[derive(Clone, Copy)]
965enum LiteralEdgeSide {
966 Leading,
967 Trailing,
968}
969
970#[derive(Clone, Copy)]
971enum LiteralEdgeV0 {
972 Empty,
973 Whitespace,
974 NonWhitespace,
975 Unknown,
976}
977
978fn expression_literal_edge(expression: &Expression<'_>, side: LiteralEdgeSide) -> LiteralEdgeV0 {
979 match transparent_expression(expression) {
980 Expression::StringLiteral(literal) => literal_edge(literal.value.as_str(), side),
981 Expression::TemplateLiteral(template) => {
982 let value = match side {
983 LiteralEdgeSide::Leading => template.quasis.first(),
984 LiteralEdgeSide::Trailing => template.quasis.last(),
985 }
986 .and_then(|quasi| quasi.value.cooked.as_ref())
987 .map(|value| value.as_str())
988 .unwrap_or_default();
989 literal_edge(value, side)
990 }
991 Expression::BinaryExpression(binary) if binary.operator == BinaryOperator::Addition => {
992 let primary = match side {
993 LiteralEdgeSide::Leading => &binary.left,
994 LiteralEdgeSide::Trailing => &binary.right,
995 };
996 let fallback = match side {
997 LiteralEdgeSide::Leading => &binary.right,
998 LiteralEdgeSide::Trailing => &binary.left,
999 };
1000 match expression_literal_edge(primary, side) {
1001 LiteralEdgeV0::Empty => expression_literal_edge(fallback, side),
1002 edge => edge,
1003 }
1004 }
1005 _ => LiteralEdgeV0::Unknown,
1006 }
1007}
1008
1009fn literal_edge(value: &str, side: LiteralEdgeSide) -> LiteralEdgeV0 {
1010 let character = match side {
1011 LiteralEdgeSide::Leading => value.chars().next(),
1012 LiteralEdgeSide::Trailing => value.chars().next_back(),
1013 };
1014 match character {
1015 None => LiteralEdgeV0::Empty,
1016 Some(character) if omena_abstract_value::is_dom_class_ascii_whitespace_v0(character) => {
1017 LiteralEdgeV0::Whitespace
1018 }
1019 Some(_) => LiteralEdgeV0::NonWhitespace,
1020 }
1021}
1022
1023fn transparent_expression<'a>(expression: &'a Expression<'a>) -> &'a Expression<'a> {
1024 match expression {
1025 Expression::ParenthesizedExpression(expression) => {
1026 transparent_expression(&expression.expression)
1027 }
1028 Expression::TSAsExpression(expression) => transparent_expression(&expression.expression),
1029 Expression::TSSatisfiesExpression(expression) => {
1030 transparent_expression(&expression.expression)
1031 }
1032 Expression::TSTypeAssertion(expression) => transparent_expression(&expression.expression),
1033 Expression::TSNonNullExpression(expression) => {
1034 transparent_expression(&expression.expression)
1035 }
1036 Expression::TSInstantiationExpression(expression) => {
1037 transparent_expression(&expression.expression)
1038 }
1039 _ => expression,
1040 }
1041}
1042
1043fn static_string_value<'a>(expression: &'a Expression<'a>) -> Option<&'a str> {
1044 match transparent_expression(expression) {
1045 Expression::StringLiteral(literal) => Some(literal.value.as_str()),
1046 Expression::TemplateLiteral(template) if template.expressions.is_empty() => template
1047 .quasis
1048 .first()
1049 .and_then(|quasi| quasi.value.cooked.as_ref())
1050 .map(|value| value.as_str()),
1051 _ => None,
1052 }
1053}
1054
1055fn argument_expression<'a>(argument: &'a oxc_ast::ast::Argument<'a>) -> Option<&'a Expression<'a>> {
1056 match argument {
1057 oxc_ast::ast::Argument::SpreadElement(spread) => Some(&spread.argument),
1058 _ => argument.as_expression(),
1059 }
1060}
1061
1062fn ordered_word_for_type_facts(facts: &StringTypeFactsV2) -> Option<OrderedTokenWordV0> {
1063 let mut values = facts.values.as_ref()?.iter();
1064 let first = values.next()?;
1065 let DomClassTokenizationV0::Known { word, .. } =
1066 omena_abstract_value::tokenize_dom_class_attribute_v0(Some(first))
1067 else {
1068 return None;
1069 };
1070 values
1071 .all(|value| {
1072 matches!(
1073 omena_abstract_value::tokenize_dom_class_attribute_v0(Some(value)),
1074 DomClassTokenizationV0::Known { word: candidate, .. } if candidate == word
1075 )
1076 })
1077 .then_some(word)
1078}
1079
1080fn boundary_effect_wire_value(effect: ClassBoundaryEffectV0) -> &'static str {
1081 match effect {
1082 ClassBoundaryEffectV0::ConcatInsideToken => "concatInsideToken",
1083 ClassBoundaryEffectV0::ConcatAtTokenBoundary => "concatAtTokenBoundary",
1084 ClassBoundaryEffectV0::UnknownBoundary => "unknownBoundary",
1085 }
1086}
1087
1088fn logical_expression_kind(expression: &LogicalExpression<'_>) -> Option<&'static str> {
1089 if expression.operator.is_and() {
1090 Some("logicalAnd")
1091 } else if expression.operator.is_or() {
1092 Some("logicalOr")
1093 } else if expression.operator.is_coalesce() {
1094 Some("nullishCoalesce")
1095 } else {
1096 None
1097 }
1098}
1099
1100struct ExpressionFactContext<'ast, 'context> {
1101 root_statements: &'context oxc_allocator::Vec<'ast, Statement<'ast>>,
1102 scoping: &'context Scoping,
1103 binding_facts: &'context BTreeMap<usize, Vec<StringTypeFactsV2>>,
1104}
1105
1106fn expression_type_facts<'ast>(
1107 expression: &Expression<'ast>,
1108 root_statements: &oxc_allocator::Vec<'ast, Statement<'ast>>,
1109 scoping: &Scoping,
1110 prior_blocks: &[SourceFlowBlockSnapshotV0],
1111) -> Option<StringTypeFactsV2> {
1112 let mut binding_facts = BTreeMap::<usize, Vec<StringTypeFactsV2>>::new();
1113 for block in prior_blocks {
1114 if let (Some(symbol_ordinal), Some(facts)) = (block.symbol_ordinal, block.facts.clone()) {
1115 binding_facts.entry(symbol_ordinal).or_default().push(facts);
1116 }
1117 }
1118 let context = ExpressionFactContext {
1119 root_statements,
1120 scoping,
1121 binding_facts: &binding_facts,
1122 };
1123 expression_type_facts_inner(
1124 expression,
1125 &context,
1126 &mut BTreeSet::new(),
1127 &mut BTreeSet::new(),
1128 )
1129}
1130
1131fn expression_type_facts_inner(
1132 expression: &Expression<'_>,
1133 context: &ExpressionFactContext<'_, '_>,
1134 seen_functions: &mut BTreeSet<String>,
1135 seen_bindings: &mut BTreeSet<usize>,
1136) -> Option<StringTypeFactsV2> {
1137 match expression {
1138 Expression::StringLiteral(literal) => Some(exact_type_facts(literal.value.as_str())),
1139 Expression::Identifier(identifier) => {
1140 let symbol_ordinal =
1141 binding_ref_from_reference(context.scoping, identifier)?.symbol_ordinal;
1142 if !seen_bindings.insert(symbol_ordinal) {
1143 return None;
1144 }
1145 let facts = merge_type_facts(
1146 context
1147 .binding_facts
1148 .get(&symbol_ordinal)?
1149 .iter()
1150 .cloned()
1151 .map(Some),
1152 );
1153 seen_bindings.remove(&symbol_ordinal);
1154 facts
1155 }
1156 Expression::TemplateLiteral(template) => {
1157 let first = template.quasis.first()?.value.cooked.as_ref()?.as_str();
1158 let mut facts = Some(exact_type_facts(first));
1159 for (index, expression) in template.expressions.iter().enumerate() {
1160 facts = concatenate_type_facts(
1161 facts,
1162 expression_type_facts_inner(expression, context, seen_functions, seen_bindings),
1163 );
1164 let suffix = template
1165 .quasis
1166 .get(index + 1)?
1167 .value
1168 .cooked
1169 .as_ref()?
1170 .as_str();
1171 facts = concatenate_type_facts(facts, Some(exact_type_facts(suffix)));
1172 }
1173 facts
1174 }
1175 Expression::ParenthesizedExpression(expression) => expression_type_facts_inner(
1176 &expression.expression,
1177 context,
1178 seen_functions,
1179 seen_bindings,
1180 ),
1181 Expression::TSAsExpression(expression) => expression_type_facts_inner(
1182 &expression.expression,
1183 context,
1184 seen_functions,
1185 seen_bindings,
1186 ),
1187 Expression::TSSatisfiesExpression(expression) => expression_type_facts_inner(
1188 &expression.expression,
1189 context,
1190 seen_functions,
1191 seen_bindings,
1192 ),
1193 Expression::TSTypeAssertion(expression) => expression_type_facts_inner(
1194 &expression.expression,
1195 context,
1196 seen_functions,
1197 seen_bindings,
1198 ),
1199 Expression::TSNonNullExpression(expression) => expression_type_facts_inner(
1200 &expression.expression,
1201 context,
1202 seen_functions,
1203 seen_bindings,
1204 ),
1205 Expression::TSInstantiationExpression(expression) => expression_type_facts_inner(
1206 &expression.expression,
1207 context,
1208 seen_functions,
1209 seen_bindings,
1210 ),
1211 Expression::ConditionalExpression(expression) => merge_type_facts([
1212 expression_type_facts_inner(
1213 &expression.consequent,
1214 context,
1215 seen_functions,
1216 seen_bindings,
1217 ),
1218 expression_type_facts_inner(
1219 &expression.alternate,
1220 context,
1221 seen_functions,
1222 seen_bindings,
1223 ),
1224 ]),
1225 Expression::LogicalExpression(expression) => {
1226 if expression.operator.is_and() {
1227 return merge_type_facts([
1228 Some(exact_type_facts("")),
1229 expression_type_facts_inner(
1230 &expression.right,
1231 context,
1232 seen_functions,
1233 seen_bindings,
1234 ),
1235 ]);
1236 }
1237 merge_type_facts([
1238 expression_type_facts_inner(
1239 &expression.left,
1240 context,
1241 seen_functions,
1242 seen_bindings,
1243 ),
1244 expression_type_facts_inner(
1245 &expression.right,
1246 context,
1247 seen_functions,
1248 seen_bindings,
1249 ),
1250 ])
1251 }
1252 Expression::BinaryExpression(expression)
1253 if expression.operator == BinaryOperator::Addition =>
1254 {
1255 concatenate_type_facts(
1256 expression_type_facts_inner(
1257 &expression.left,
1258 context,
1259 seen_functions,
1260 seen_bindings,
1261 ),
1262 expression_type_facts_inner(
1263 &expression.right,
1264 context,
1265 seen_functions,
1266 seen_bindings,
1267 ),
1268 )
1269 }
1270 Expression::CallExpression(call) => {
1271 if let Some(facts) =
1272 class_boundary_call_type_facts(call, context, seen_functions, seen_bindings)
1273 {
1274 return Some(facts);
1275 }
1276 let Expression::Identifier(callee) = &call.callee else {
1277 return None;
1278 };
1279 function_return_type_facts(callee.name.as_str(), context, seen_functions, seen_bindings)
1280 }
1281 _ => None,
1282 }
1283}
1284
1285fn class_boundary_call_type_facts(
1286 call: &oxc_ast::ast::CallExpression<'_>,
1287 context: &ExpressionFactContext<'_, '_>,
1288 seen_functions: &mut BTreeSet<String>,
1289 seen_bindings: &mut BTreeSet<usize>,
1290) -> Option<StringTypeFactsV2> {
1291 if matches!(
1292 transparent_expression(&call.callee),
1293 Expression::Identifier(identifier)
1294 if matches!(identifier.name.as_str(), "clsx" | "classnames" | "classNames")
1295 ) {
1296 let facts = call
1297 .arguments
1298 .iter()
1299 .map(|argument| {
1300 argument_expression(argument).and_then(|expression| {
1301 class_list_argument_type_facts(
1302 expression,
1303 context,
1304 seen_functions,
1305 seen_bindings,
1306 )
1307 })
1308 })
1309 .collect::<Option<Vec<_>>>()?;
1310 return concatenate_type_fact_sequence(facts, " ");
1311 }
1312
1313 let Expression::StaticMemberExpression(member) = transparent_expression(&call.callee) else {
1314 return None;
1315 };
1316 if member.property.name.as_str() != "join" {
1317 return None;
1318 }
1319 let Expression::ArrayExpression(array) = transparent_expression(&member.object) else {
1320 return None;
1321 };
1322 let separator = call
1323 .arguments
1324 .first()
1325 .and_then(argument_expression)
1326 .and_then(static_string_value)
1327 .unwrap_or(",");
1328 let facts = array
1329 .elements
1330 .iter()
1331 .map(|element| {
1332 let expression = match element {
1333 oxc_ast::ast::ArrayExpressionElement::SpreadElement(spread) => &spread.argument,
1334 oxc_ast::ast::ArrayExpressionElement::Elision(_) => return None,
1335 _ => element.as_expression()?,
1336 };
1337 expression_type_facts_inner(expression, context, seen_functions, seen_bindings)
1338 })
1339 .collect::<Option<Vec<_>>>()?;
1340 concatenate_type_fact_sequence(facts, separator)
1341}
1342
1343fn class_list_argument_type_facts(
1344 expression: &Expression<'_>,
1345 context: &ExpressionFactContext<'_, '_>,
1346 seen_functions: &mut BTreeSet<String>,
1347 seen_bindings: &mut BTreeSet<usize>,
1348) -> Option<StringTypeFactsV2> {
1349 match transparent_expression(expression) {
1350 Expression::BooleanLiteral(_) | Expression::NullLiteral(_) => Some(exact_type_facts("")),
1351 Expression::Identifier(identifier) if identifier.name.as_str() == "undefined" => {
1352 Some(exact_type_facts(""))
1353 }
1354 Expression::LogicalExpression(expression) if expression.operator.is_and() => {
1355 merge_type_facts([
1356 Some(exact_type_facts("")),
1357 class_list_argument_type_facts(
1358 &expression.right,
1359 context,
1360 seen_functions,
1361 seen_bindings,
1362 ),
1363 ])
1364 }
1365 Expression::ConditionalExpression(expression) => merge_type_facts([
1366 class_list_argument_type_facts(
1367 &expression.consequent,
1368 context,
1369 seen_functions,
1370 seen_bindings,
1371 ),
1372 class_list_argument_type_facts(
1373 &expression.alternate,
1374 context,
1375 seen_functions,
1376 seen_bindings,
1377 ),
1378 ]),
1379 expression => {
1380 expression_type_facts_inner(expression, context, seen_functions, seen_bindings)
1381 }
1382 }
1383}
1384
1385fn concatenate_type_fact_sequence(
1386 facts: Vec<StringTypeFactsV2>,
1387 separator: &str,
1388) -> Option<StringTypeFactsV2> {
1389 let mut facts = facts.into_iter();
1390 let mut combined = Some(facts.next()?);
1391 for fact in facts {
1392 combined = concatenate_type_facts(combined, Some(exact_type_facts(separator)));
1393 combined = concatenate_type_facts(combined, Some(fact));
1394 }
1395 combined
1396}
1397
1398fn function_return_type_facts(
1399 function_name: &str,
1400 context: &ExpressionFactContext<'_, '_>,
1401 seen_functions: &mut BTreeSet<String>,
1402 seen_bindings: &mut BTreeSet<usize>,
1403) -> Option<StringTypeFactsV2> {
1404 if !seen_functions.insert(function_name.to_string()) {
1405 return None;
1406 }
1407 let body = context
1408 .root_statements
1409 .iter()
1410 .find_map(|statement| function_body_for_named_statement(statement, function_name))?;
1411 let facts = merge_type_facts(
1412 body.statements
1413 .iter()
1414 .flat_map(|statement| {
1415 return_type_facts_for_statement(statement, context, seen_functions, seen_bindings)
1416 })
1417 .map(Some),
1418 );
1419 seen_functions.remove(function_name);
1420 facts
1421}
1422
1423fn function_body_for_named_statement<'a>(
1424 statement: &'a Statement<'a>,
1425 function_name: &str,
1426) -> Option<&'a FunctionBody<'a>> {
1427 match statement {
1428 Statement::FunctionDeclaration(function)
1429 if function
1430 .id
1431 .as_ref()
1432 .is_some_and(|id| id.name.as_str() == function_name) =>
1433 {
1434 function.body.as_deref()
1435 }
1436 Statement::ExportNamedDeclaration(export) => {
1437 if let Some(Declaration::FunctionDeclaration(function)) = &export.declaration
1438 && function
1439 .id
1440 .as_ref()
1441 .is_some_and(|id| id.name.as_str() == function_name)
1442 {
1443 return function.body.as_deref();
1444 }
1445 None
1446 }
1447 _ => None,
1448 }
1449}
1450
1451fn return_type_facts_for_statement(
1452 statement: &Statement<'_>,
1453 context: &ExpressionFactContext<'_, '_>,
1454 seen_functions: &mut BTreeSet<String>,
1455 seen_bindings: &mut BTreeSet<usize>,
1456) -> Vec<StringTypeFactsV2> {
1457 match statement {
1458 Statement::ReturnStatement(statement) => statement
1459 .argument
1460 .as_ref()
1461 .and_then(|expression| {
1462 expression_type_facts_inner(expression, context, seen_functions, seen_bindings)
1463 })
1464 .into_iter()
1465 .collect(),
1466 Statement::BlockStatement(block) => block
1467 .body
1468 .iter()
1469 .flat_map(|statement| {
1470 return_type_facts_for_statement(statement, context, seen_functions, seen_bindings)
1471 })
1472 .collect(),
1473 Statement::IfStatement(statement) => {
1474 let mut facts = return_type_facts_for_statement(
1475 &statement.consequent,
1476 context,
1477 seen_functions,
1478 seen_bindings,
1479 );
1480 if let Some(alternate) = &statement.alternate {
1481 facts.extend(return_type_facts_for_statement(
1482 alternate,
1483 context,
1484 seen_functions,
1485 seen_bindings,
1486 ));
1487 }
1488 facts
1489 }
1490 Statement::SwitchStatement(statement) => statement
1491 .cases
1492 .iter()
1493 .flat_map(|case| {
1494 return_type_facts_for_switch_case(case, context, seen_functions, seen_bindings)
1495 })
1496 .collect(),
1497 _ => Vec::new(),
1498 }
1499}
1500
1501fn return_type_facts_for_switch_case(
1502 case: &SwitchCase<'_>,
1503 context: &ExpressionFactContext<'_, '_>,
1504 seen_functions: &mut BTreeSet<String>,
1505 seen_bindings: &mut BTreeSet<usize>,
1506) -> Vec<StringTypeFactsV2> {
1507 case.consequent
1508 .iter()
1509 .flat_map(|statement| {
1510 return_type_facts_for_statement(statement, context, seen_functions, seen_bindings)
1511 })
1512 .collect()
1513}
1514
1515fn merge_type_facts(
1516 facts: impl IntoIterator<Item = Option<StringTypeFactsV2>>,
1517) -> Option<StringTypeFactsV2> {
1518 let mut values = BTreeSet::new();
1519 for fact in facts {
1520 let fact = fact?;
1521 for value in finite_values_for_type_facts(&fact)? {
1522 values.insert(value);
1523 }
1524 }
1525 finite_type_facts(values)
1526}
1527
1528fn concatenate_type_facts(
1529 left: Option<StringTypeFactsV2>,
1530 right: Option<StringTypeFactsV2>,
1531) -> Option<StringTypeFactsV2> {
1532 if left.as_ref().and_then(single_finite_value).as_deref() == Some("") {
1533 return right;
1534 }
1535 if right.as_ref().and_then(single_finite_value).as_deref() == Some("") {
1536 return left;
1537 }
1538 match (left, right) {
1539 (Some(left), Some(right)) => {
1540 if let (Some(left_values), Some(right_values)) = (
1541 finite_values_for_type_facts(&left),
1542 finite_values_for_type_facts(&right),
1543 ) {
1544 return finite_type_facts(left_values.iter().flat_map(|left| {
1545 right_values
1546 .iter()
1547 .map(move |right| format!("{left}{right}"))
1548 }));
1549 }
1550 if left.constraint_kind.as_deref() == Some("prefix")
1551 && let Some(suffix) = single_finite_value(&right)
1552 {
1553 return Some(prefix_suffix_type_facts(
1554 left.prefix.as_deref().unwrap_or_default(),
1555 suffix.as_str(),
1556 ));
1557 }
1558 if right.constraint_kind.as_deref() == Some("suffix")
1559 && let Some(prefix) = single_finite_value(&left)
1560 {
1561 return Some(prefix_suffix_type_facts(
1562 prefix.as_str(),
1563 right.suffix.as_deref().unwrap_or_default(),
1564 ));
1565 }
1566 None
1567 }
1568 (Some(left), None) => finite_values_for_type_facts(&left)
1569 .and_then(|values| longest_common_prefix(values.as_slice()))
1570 .map(|prefix| {
1571 constrained_type_facts("prefix", Some(prefix), None, "concatUnknownRight")
1572 }),
1573 (None, Some(right)) => finite_values_for_type_facts(&right)
1574 .and_then(|values| longest_common_suffix(values.as_slice()))
1575 .map(|suffix| {
1576 constrained_type_facts("suffix", None, Some(suffix), "concatUnknownLeft")
1577 }),
1578 (None, None) => None,
1579 }
1580}
1581
1582fn exact_type_facts(value: &str) -> StringTypeFactsV2 {
1583 let mut facts = empty_type_facts("exact");
1584 facts.values = Some(vec![value.to_string()]);
1585 facts
1586}
1587
1588fn finite_type_facts(values: impl IntoIterator<Item = String>) -> Option<StringTypeFactsV2> {
1589 let values = values.into_iter().collect::<BTreeSet<_>>();
1590 if values.is_empty() {
1591 return None;
1592 }
1593 if values.len() == 1 {
1594 return values.iter().next().map(|value| exact_type_facts(value));
1595 }
1596 let mut facts = empty_type_facts("finiteSet");
1597 facts.values = Some(values.into_iter().collect());
1598 Some(facts)
1599}
1600
1601fn prefix_suffix_type_facts(prefix: &str, suffix: &str) -> StringTypeFactsV2 {
1602 let mut facts = constrained_type_facts(
1603 "prefixSuffix",
1604 Some(prefix.to_string()),
1605 Some(suffix.to_string()),
1606 "concatKnownEdges",
1607 );
1608 let value = prefix_suffix_class_value(
1609 prefix,
1610 suffix,
1611 Some(prefix.len().saturating_add(suffix.len())),
1612 None,
1613 );
1614 facts.min_len = external_string_type_facts_from_abstract_class_value(&value).min_len;
1615 facts
1616}
1617
1618fn constrained_type_facts(
1619 constraint_kind: &str,
1620 prefix: Option<String>,
1621 suffix: Option<String>,
1622 provenance: &str,
1623) -> StringTypeFactsV2 {
1624 let mut facts = empty_type_facts("constrained");
1625 facts.constraint_kind = Some(constraint_kind.to_string());
1626 facts.prefix = prefix;
1627 facts.suffix = suffix;
1628 facts.provenance = Some(provenance.to_string());
1629 facts
1630}
1631
1632fn empty_type_facts(kind: &str) -> StringTypeFactsV2 {
1633 StringTypeFactsV2 {
1634 kind: kind.to_string(),
1635 values: None,
1636 constraint_kind: None,
1637 prefix: None,
1638 suffix: None,
1639 min_len: None,
1640 max_len: None,
1641 char_must: None,
1642 char_may: None,
1643 may_include_other_chars: None,
1644 provenance: None,
1645 }
1646}
1647
1648fn finite_values_for_type_facts(facts: &StringTypeFactsV2) -> Option<Vec<String>> {
1649 match facts.kind.as_str() {
1650 "exact" | "finiteSet" => facts.values.clone(),
1651 _ => None,
1652 }
1653}
1654
1655fn single_finite_value(facts: &StringTypeFactsV2) -> Option<String> {
1656 let values = finite_values_for_type_facts(facts)?;
1657 (values.len() == 1).then(|| values[0].clone())
1658}
1659
1660fn longest_common_prefix(values: &[String]) -> Option<String> {
1661 let first = values.first()?;
1662 let mut prefix = first.clone();
1663 for value in values.iter().skip(1) {
1664 while !value.starts_with(prefix.as_str()) {
1665 prefix.pop()?;
1666 }
1667 }
1668 (!prefix.is_empty()).then_some(prefix)
1669}
1670
1671fn longest_common_suffix(values: &[String]) -> Option<String> {
1672 let first = values.first()?;
1673 let mut suffix = first.clone();
1674 for value in values.iter().skip(1) {
1675 while !value.ends_with(suffix.as_str()) {
1676 let mut chars = suffix.chars();
1677 chars.next()?;
1678 suffix = chars.collect();
1679 }
1680 }
1681 (!suffix.is_empty()).then_some(suffix)
1682}
1683
1684fn transfer_kind_for_block_kind(kind: &str) -> &'static str {
1685 match kind {
1686 "entry" => "entry",
1687 "assignment" => "assignFacts",
1688 "branch" | "logicalOperand" => "branch",
1689 "join" | "logicalJoin" => "join",
1690 "loopHeader" | "loopBody" | "loopExit" => "loop",
1691 "break" => "break",
1692 "terminate" => "terminate",
1693 "logicalRhs" => "assignFacts",
1694 "exit" => "exit",
1695 _ => "exit",
1696 }
1697}
1698
1699fn span_contains(span: Span, byte_offset: usize) -> bool {
1700 span_start(span) <= byte_offset && byte_offset < span_end(span)
1701}
1702
1703fn span_start(span: Span) -> usize {
1704 span.start as usize
1705}
1706
1707fn span_end(span: Span) -> usize {
1708 span.end as usize
1709}
1710
1711#[cfg(test)]
1712mod tests {
1713 use super::{
1714 ClassBoundaryEffectV0, boundary_effect_wire_value, prefix_suffix_type_facts,
1715 source_type_fact_control_flow_graph_from_snapshot,
1716 summarize_omena_bridge_source_control_flow_graph_for_source_language,
1717 };
1718
1719 #[test]
1720 fn captures_branchy_css_module_source_cfg_shape() -> Result<(), String> {
1721 let source = [
1722 "export function Card({ enabled }: { enabled: boolean }) {",
1723 " let size = \"card\";",
1724 " if (enabled) {",
1725 " size = \"card--active\";",
1726 " }",
1727 " return <div className={size} />;",
1728 "}",
1729 "",
1730 ]
1731 .join("\n");
1732 let Some(reference) = source.rfind("size") else {
1733 return Err("fixture contains size reference".to_string());
1734 };
1735 let Some(graph) = summarize_omena_bridge_source_control_flow_graph_for_source_language(
1736 "/fake/ws/src/Card.tsx",
1737 source.as_str(),
1738 Some("typescriptreact"),
1739 "size",
1740 reference,
1741 ) else {
1742 return Err("fixture should produce CFG".to_string());
1743 };
1744
1745 assert_eq!(graph.product, "omena-bridge.source-control-flow-graph");
1746 assert_eq!(graph.snapshot.entry_block_id, "entry");
1747 assert_eq!(
1748 graph
1749 .snapshot
1750 .blocks
1751 .iter()
1752 .map(|block| block.kind)
1753 .collect::<Vec<_>>(),
1754 vec![
1755 "entry",
1756 "assignment",
1757 "branch",
1758 "join",
1759 "assignment",
1760 "exit"
1761 ]
1762 );
1763 assert!(
1764 graph
1765 .snapshot
1766 .blocks
1767 .iter()
1768 .any(|block| block.variable_name.as_deref() == Some("size"))
1769 );
1770 let symbol_ordinals = graph
1771 .snapshot
1772 .blocks
1773 .iter()
1774 .filter(|block| block.variable_name.as_deref() == Some("size"))
1775 .map(|block| block.symbol_ordinal)
1776 .collect::<Vec<_>>();
1777 assert!(symbol_ordinals.iter().all(Option::is_some));
1778 assert!(symbol_ordinals.contains(&Some(graph.binding.symbol_ordinal)));
1779 let type_fact_graph = source_type_fact_control_flow_graph_from_snapshot(&graph.snapshot);
1780 assert_eq!(
1781 type_fact_graph.entry_block_id,
1782 graph.snapshot.entry_block_id
1783 );
1784 assert_eq!(
1785 type_fact_graph
1786 .blocks
1787 .iter()
1788 .map(|block| block.kind.as_str())
1789 .collect::<Vec<_>>(),
1790 graph
1791 .snapshot
1792 .blocks
1793 .iter()
1794 .map(|block| block.kind)
1795 .collect::<Vec<_>>()
1796 );
1797 assert!(type_fact_graph.blocks.iter().any(|block| {
1798 block.symbol_ordinal == Some(graph.binding.symbol_ordinal)
1799 && block.variable_name.as_deref() == Some("size")
1800 }));
1801 Ok(())
1802 }
1803
1804 #[test]
1805 fn captures_assignment_value_facts_for_concatenated_source_cfg() -> Result<(), String> {
1806 let source = [
1807 "export function Card(variant: string) {",
1808 " const size = \"btn-\" + variant + \"-chip\";",
1809 " return cx(size);",
1810 "}",
1811 "",
1812 ]
1813 .join("\n");
1814 let Some(reference) = source.rfind("size") else {
1815 return Err("fixture contains size reference".to_string());
1816 };
1817 let Some(graph) = summarize_omena_bridge_source_control_flow_graph_for_source_language(
1818 "/fake/ws/src/Card.tsx",
1819 source.as_str(),
1820 Some("typescriptreact"),
1821 "size",
1822 reference,
1823 ) else {
1824 return Err("fixture should produce CFG".to_string());
1825 };
1826
1827 let Some(block) = graph
1828 .snapshot
1829 .blocks
1830 .iter()
1831 .find(|block| block.variable_name.as_deref() == Some("size"))
1832 else {
1833 return Err("size assignment block should be present".to_string());
1834 };
1835 let Some(facts) = &block.facts else {
1836 return Err("size assignment should carry value facts".to_string());
1837 };
1838 assert_eq!(facts.kind, "constrained");
1839 assert_eq!(facts.constraint_kind.as_deref(), Some("prefixSuffix"));
1840 assert_eq!(facts.prefix.as_deref(), Some("btn-"));
1841 assert_eq!(facts.suffix.as_deref(), Some("-chip"));
1842 assert_eq!(facts.min_len, Some(9));
1843
1844 let type_fact_graph = source_type_fact_control_flow_graph_from_snapshot(&graph.snapshot);
1845 assert!(type_fact_graph.blocks.iter().any(|block| {
1846 block.variable_name.as_deref() == Some("size")
1847 && block
1848 .facts
1849 .as_ref()
1850 .is_some_and(|facts| facts.constraint_kind.as_deref() == Some("prefixSuffix"))
1851 }));
1852 Ok(())
1853 }
1854
1855 #[test]
1856 fn delimiter_content_drives_boundary_effects_and_ordered_words() -> Result<(), String> {
1857 let source = [
1858 "export function Card(flag: boolean) {",
1859 " const token = flag ? \"large\" : \"small\";",
1860 " const binary = \"btn-\" + token;",
1861 " const template = `btn-${token}`;",
1862 " const explicitBoundary = \"btn-\" + \" \" + token;",
1863 " const joinedInside = [\"a\", \"b\"].join(\"\");",
1864 " const joinedBoundary = [\"a\", \"b\"].join(\" \" );",
1865 " const listed = clsx(\"a\", \"b\");",
1866 " const guarded = clsx({ active: flag });",
1867 " const opaque = left() + right();",
1868 " return opaque;",
1869 "}",
1870 "",
1871 ]
1872 .join("\n");
1873 let reference = source
1874 .rfind("opaque")
1875 .ok_or_else(|| "fixture contains opaque reference".to_string())?;
1876 let graph = summarize_omena_bridge_source_control_flow_graph_for_source_language(
1877 "/fake/ws/src/Card.tsx",
1878 source.as_str(),
1879 Some("typescriptreact"),
1880 "opaque",
1881 reference,
1882 )
1883 .ok_or_else(|| "fixture should produce CFG".to_string())?;
1884 let block = |name: &str| {
1885 graph
1886 .snapshot
1887 .blocks
1888 .iter()
1889 .find(|block| block.variable_name.as_deref() == Some(name))
1890 .ok_or_else(|| format!("missing {name} assignment"))
1891 };
1892
1893 let binary = block("binary")?;
1894 let template = block("template")?;
1895 assert_eq!(
1896 binary.boundary_effect,
1897 ClassBoundaryEffectV0::ConcatInsideToken
1898 );
1899 assert_eq!(template.boundary_effect, binary.boundary_effect);
1900 assert_eq!(template.facts, binary.facts);
1901 assert_eq!(
1902 binary.facts.as_ref().and_then(|facts| facts.values.clone()),
1903 Some(vec!["btn-large".to_string(), "btn-small".to_string()])
1904 );
1905
1906 assert_eq!(
1907 block("explicitBoundary")?.boundary_effect,
1908 ClassBoundaryEffectV0::ConcatAtTokenBoundary
1909 );
1910 assert_eq!(
1911 block("joinedInside")?.boundary_effect,
1912 ClassBoundaryEffectV0::ConcatInsideToken
1913 );
1914 assert_eq!(
1915 block("joinedBoundary")?.boundary_effect,
1916 ClassBoundaryEffectV0::ConcatAtTokenBoundary
1917 );
1918 assert_eq!(
1919 block("listed")?.boundary_effect,
1920 ClassBoundaryEffectV0::ConcatAtTokenBoundary
1921 );
1922 assert_eq!(
1923 block("listed")?.ordered_word.as_ref().map(|word| word
1924 .tokens()
1925 .iter()
1926 .map(|token| token.as_str())
1927 .collect::<Vec<_>>()),
1928 Some(vec!["a", "b"])
1929 );
1930 assert_eq!(
1931 block("guarded")?.boundary_effect,
1932 ClassBoundaryEffectV0::UnknownBoundary
1933 );
1934 assert!(block("guarded")?.ordered_word.is_none());
1935 assert_eq!(
1936 block("opaque")?.boundary_effect,
1937 ClassBoundaryEffectV0::UnknownBoundary
1938 );
1939 assert!(block("opaque")?.ordered_word.is_none());
1940 Ok(())
1941 }
1942
1943 #[test]
1944 fn class_boundary_transfer_table_names_each_delimiter_authority()
1945 -> Result<(), Box<dyn std::error::Error>> {
1946 let rows: serde_json::Value =
1947 serde_json::from_str(include_str!("../data/class-boundary-transfer-v0.json"))?;
1948 let rows = rows
1949 .as_array()
1950 .ok_or_else(|| std::io::Error::other("boundary transfer table must be an array"))?;
1951 assert_eq!(rows.len(), 6);
1952 assert!(rows.iter().all(|row| row["delimiterFact"].is_string()));
1953 assert_eq!(rows[0]["construct"], "binaryAddition");
1954 assert_eq!(rows[1]["construct"], "templateInterpolation");
1955 assert_eq!(rows[2]["construct"], "clsxOrClassnamesArguments");
1956 assert_eq!(rows[3]["construct"], "arrayJoin");
1957 assert_eq!(rows[4]["construct"], "objectMapEntry");
1958 assert_eq!(rows[5]["construct"], "opaqueOperands");
1959
1960 let mut source = vec![
1961 "declare function clsx(...values: unknown[]): string;".to_string(),
1962 "declare function left(): string;".to_string(),
1963 "declare function right(): string;".to_string(),
1964 "export function Probe(flag: boolean) {".to_string(),
1965 ];
1966 for (index, row) in rows.iter().enumerate() {
1967 let expression = row["probeExpression"]
1968 .as_str()
1969 .ok_or_else(|| std::io::Error::other("transfer row needs probeExpression"))?;
1970 source.push(format!(" const probe_{index} = {expression};"));
1971 if let (Some(control), Some(_)) = (
1972 row["controlExpression"].as_str(),
1973 row["controlBoundaryEffect"].as_str(),
1974 ) {
1975 source.push(format!(" const control_{index} = {control};"));
1976 }
1977 }
1978 source.push(" return probe_5;".to_string());
1979 source.push("}".to_string());
1980 let source = source.join("\n");
1981 let reference = source
1982 .rfind("probe_5")
1983 .ok_or_else(|| std::io::Error::other("transfer probe reference"))?;
1984 let graph = summarize_omena_bridge_source_control_flow_graph_for_source_language(
1985 "/fake/ws/src/BoundaryProbe.ts",
1986 source.as_str(),
1987 Some("typescript"),
1988 "probe_5",
1989 reference,
1990 )
1991 .ok_or_else(|| std::io::Error::other("transfer probes should produce CFG"))?;
1992 for (index, row) in rows.iter().enumerate() {
1993 let expected = row["expectedBoundaryEffect"]
1994 .as_str()
1995 .ok_or_else(|| std::io::Error::other("transfer row needs expected effect"))?;
1996 let probe = graph
1997 .snapshot
1998 .blocks
1999 .iter()
2000 .find(|block| block.variable_name.as_deref() == Some(&format!("probe_{index}")))
2001 .ok_or_else(|| std::io::Error::other("missing transfer probe block"))?;
2002 assert_eq!(boundary_effect_wire_value(probe.boundary_effect), expected);
2003 if let Some(expected_control) = row["controlBoundaryEffect"].as_str() {
2004 let control = graph
2005 .snapshot
2006 .blocks
2007 .iter()
2008 .find(|block| {
2009 block.variable_name.as_deref() == Some(&format!("control_{index}"))
2010 })
2011 .ok_or_else(|| std::io::Error::other("missing transfer control block"))?;
2012 assert_eq!(
2013 boundary_effect_wire_value(control.boundary_effect),
2014 expected_control
2015 );
2016 }
2017 }
2018 Ok(())
2019 }
2020
2021 #[test]
2022 fn source_cfg_prefix_suffix_fact_preserves_explicit_concat_utf16_length() {
2023 let facts = prefix_suffix_type_facts("카드-", "-활성");
2024
2025 assert_eq!(facts.min_len, Some(6));
2026 assert_eq!(facts.prefix.as_deref(), Some("카드-"));
2027 assert_eq!(facts.suffix.as_deref(), Some("-활성"));
2028 }
2029
2030 #[test]
2031 fn captures_same_file_helper_return_facts_for_source_cfg() -> Result<(), String> {
2032 let source = [
2033 "type Status = \"idle\" | \"busy\" | \"error\";",
2034 "function resolveStatusClass(status: Status): string {",
2035 " switch (status) {",
2036 " case \"idle\": return \"state-idle\";",
2037 " case \"busy\": return \"state-busy\";",
2038 " case \"error\": return \"state-error\";",
2039 " default: return \"state-idle\";",
2040 " }",
2041 "}",
2042 "export function Card(status: Status) {",
2043 " const size = resolveStatusClass(status);",
2044 " return cx(size);",
2045 "}",
2046 "",
2047 ]
2048 .join("\n");
2049 let Some(reference) = source.rfind("size") else {
2050 return Err("fixture contains size reference".to_string());
2051 };
2052 let Some(graph) = summarize_omena_bridge_source_control_flow_graph_for_source_language(
2053 "/fake/ws/src/Card.tsx",
2054 source.as_str(),
2055 Some("typescriptreact"),
2056 "size",
2057 reference,
2058 ) else {
2059 return Err("fixture should produce CFG".to_string());
2060 };
2061
2062 let values = graph
2063 .snapshot
2064 .blocks
2065 .iter()
2066 .find(|block| block.variable_name.as_deref() == Some("size"))
2067 .and_then(|block| block.facts.as_ref())
2068 .and_then(|facts| facts.values.clone())
2069 .unwrap_or_default();
2070 assert_eq!(values, vec!["state-busy", "state-error", "state-idle"]);
2071 Ok(())
2072 }
2073
2074 #[test]
2075 fn source_cfg_serializes_symbol_ordinals_without_raw_symbol_ids() -> Result<(), String> {
2076 let source = [
2077 "export function Card() {",
2078 " const size = \"card\";",
2079 " return cx(size);",
2080 "}",
2081 "",
2082 ]
2083 .join("\n");
2084 let Some(reference) = source.rfind("size") else {
2085 return Err("fixture contains size reference".to_string());
2086 };
2087 let Some(graph) = summarize_omena_bridge_source_control_flow_graph_for_source_language(
2088 "/fake/ws/src/Card.tsx",
2089 source.as_str(),
2090 Some("typescriptreact"),
2091 "size",
2092 reference,
2093 ) else {
2094 return Err("fixture should produce CFG".to_string());
2095 };
2096
2097 let value = serde_json::to_value(&graph).map_err(|error| error.to_string())?;
2098 assert!(value.pointer("/binding/symbolOrdinal").is_some());
2099 assert!(value.pointer("/snapshot/blocks/1/symbolOrdinal").is_some());
2100 let serialized = serde_json::to_string(&value).map_err(|error| error.to_string())?;
2101 assert!(!serialized.contains("SymbolId"));
2102 Ok(())
2103 }
2104}