Skip to main content

codesynapse_core/
js_import_resolution.rs

1use crate::extract::make_id;
2use crate::import_extension::resolve_js_module_path;
3use crate::types::{Edge, Node};
4use std::collections::{HashMap, HashSet};
5use std::path::{Path, PathBuf};
6
7// ---------------------------------------------------------------------------
8// Fact structs
9// ---------------------------------------------------------------------------
10
11struct SymbolDeclarationFact {
12    file_path: PathBuf,
13    name: String,
14    line: usize,
15}
16
17struct SymbolImportFact {
18    file_path: PathBuf,
19    local_name: String,
20    target_path: PathBuf,
21    imported_name: String,
22    #[allow(dead_code)]
23    line: usize,
24}
25
26struct SymbolAliasFact {
27    file_path: PathBuf,
28    alias: String,
29    target_name: String,
30    #[allow(dead_code)]
31    line: usize,
32}
33
34struct SymbolExportFact {
35    file_path: PathBuf,
36    exported_name: String,
37    #[allow(dead_code)]
38    line: usize,
39    local_name: Option<String>,
40    target_path: Option<PathBuf>,
41    target_name: Option<String>,
42}
43
44struct StarExportFact {
45    file_path: PathBuf,
46    target_path: PathBuf,
47    #[allow(dead_code)]
48    line: usize,
49}
50
51struct SymbolUseFact {
52    file_path: PathBuf,
53    source_id: String,
54    local_name: String,
55    relation: String,
56    #[allow(dead_code)]
57    context: String,
58    #[allow(dead_code)]
59    line: usize,
60}
61
62#[derive(Default)]
63struct SymbolResolutionFacts {
64    declarations: Vec<SymbolDeclarationFact>,
65    imports: Vec<SymbolImportFact>,
66    aliases: Vec<SymbolAliasFact>,
67    exports: Vec<SymbolExportFact>,
68    star_exports: Vec<StarExportFact>,
69    uses: Vec<SymbolUseFact>,
70}
71
72// ---------------------------------------------------------------------------
73// Helper: file stem (mirrors Python _file_stem)
74// ---------------------------------------------------------------------------
75
76pub fn js_file_stem(rel_path: &str) -> String {
77    let path = Path::new(rel_path);
78    let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
79    let parent = path
80        .parent()
81        .and_then(|p| p.file_name())
82        .and_then(|n| n.to_str())
83        .unwrap_or("");
84    if !parent.is_empty() && parent != "." {
85        format!("{}.{}", parent, stem)
86    } else {
87        stem.to_string()
88    }
89}
90
91// ---------------------------------------------------------------------------
92// AST helpers
93// ---------------------------------------------------------------------------
94
95fn walk_nodes(node: tree_sitter::Node) -> Vec<tree_sitter::Node> {
96    let mut stack = vec![node];
97    let mut result = Vec::new();
98    while let Some(n) = stack.pop() {
99        result.push(n);
100        for i in (0..n.child_count()).rev() {
101            if let Some(child) = n.child(i) {
102                stack.push(child);
103            }
104        }
105    }
106    result
107}
108
109fn node_text<'a>(node: tree_sitter::Node<'_>, source: &'a [u8]) -> &'a str {
110    std::str::from_utf8(&source[node.start_byte()..node.end_byte()]).unwrap_or("")
111}
112
113fn string_content(node: tree_sitter::Node<'_>, source: &[u8]) -> Option<String> {
114    let text = node_text(node, source);
115    Some(
116        text.trim_matches(|c| c == '\'' || c == '"' || c == '`')
117            .to_string(),
118    )
119}
120
121fn js_module_specifier(node: tree_sitter::Node<'_>, source: &[u8]) -> Option<String> {
122    if let Some(src) = node.child_by_field_name("source") {
123        return string_content(src, source);
124    }
125    for i in 0..node.child_count() {
126        if let Some(child) = node.child(i) {
127            if child.kind() == "string" {
128                return string_content(child, source);
129            }
130        }
131    }
132    None
133}
134
135fn js_named_specifiers(
136    node: tree_sitter::Node<'_>,
137    source: &[u8],
138    specifier_type: &str,
139) -> Vec<(String, String)> {
140    let mut pairs = Vec::new();
141    for n in walk_nodes(node) {
142        if n.kind() != specifier_type {
143            continue;
144        }
145        let name_node = match n.child_by_field_name("name") {
146            Some(nn) => nn,
147            None => continue,
148        };
149        let name = node_text(name_node, source).to_string();
150        let alias_node = n.child_by_field_name("alias");
151        let alias = if let Some(a) = alias_node {
152            node_text(a, source).to_string()
153        } else {
154            name.clone()
155        };
156        if !name.is_empty() && !alias.is_empty() {
157            pairs.push((name, alias));
158        }
159    }
160    pairs
161}
162
163fn js_lexical_aliases(node: tree_sitter::Node<'_>, source: &[u8]) -> Vec<(String, String)> {
164    if node.kind() != "lexical_declaration" {
165        return vec![];
166    }
167    let mut aliases = Vec::new();
168    for i in 0..node.child_count() {
169        if let Some(child) = node.child(i) {
170            if child.kind() != "variable_declarator" {
171                continue;
172            }
173            let name_node = child.child_by_field_name("name");
174            let value_node = child.child_by_field_name("value");
175            if let (Some(name_n), Some(val_n)) = (name_node, value_node) {
176                if val_n.kind() == "identifier" || val_n.kind() == "type_identifier" {
177                    aliases.push((
178                        node_text(name_n, source).to_string(),
179                        node_text(val_n, source).to_string(),
180                    ));
181                }
182            }
183        }
184    }
185    aliases
186}
187
188fn js_export_is_star(node: tree_sitter::Node<'_>) -> bool {
189    for i in 0..node.child_count() {
190        if let Some(child) = node.child(i) {
191            if child.kind() == "*" {
192                return true;
193            }
194        }
195    }
196    false
197}
198
199fn js_exported_declaration_names(node: tree_sitter::Node<'_>, source: &[u8]) -> Vec<String> {
200    let decl = match node.child_by_field_name("declaration") {
201        Some(d) => d,
202        None => return vec![],
203    };
204    let mut names = Vec::new();
205    match decl.kind() {
206        "lexical_declaration" => {
207            for (alias, _) in js_lexical_aliases(decl, source) {
208                names.push(alias);
209            }
210        }
211        "class_declaration"
212        | "abstract_class_declaration"
213        | "interface_declaration"
214        | "type_alias_declaration"
215        | "function_declaration" => {
216            if let Some(name_node) = decl.child_by_field_name("name") {
217                let name = node_text(name_node, source).to_string();
218                if !name.is_empty() {
219                    names.push(name);
220                }
221            }
222        }
223        _ => {}
224    }
225    names
226}
227
228fn ts_collect_type_refs(
229    node: tree_sitter::Node<'_>,
230    source: &[u8],
231    generic: bool,
232    out: &mut Vec<(String, String)>,
233) {
234    const PRIMITIVES: &[&str] = &[
235        "string",
236        "number",
237        "boolean",
238        "any",
239        "unknown",
240        "void",
241        "never",
242        "object",
243        "null",
244        "undefined",
245        "bigint",
246        "symbol",
247        "this",
248        "T",
249    ];
250
251    let kind = node.kind();
252    match kind {
253        "type_annotation" => {
254            for i in 0..node.child_count() {
255                if let Some(child) = node.child(i) {
256                    if child.is_named() {
257                        ts_collect_type_refs(child, source, generic, out);
258                    }
259                }
260            }
261        }
262        "type_identifier" | "identifier" => {
263            let name = node_text(node, source);
264            if !name.is_empty() && !PRIMITIVES.contains(&name) {
265                out.push((
266                    name.to_string(),
267                    if generic {
268                        "generic_arg".to_string()
269                    } else {
270                        "type".to_string()
271                    },
272                ));
273            }
274        }
275        "generic_type" => {
276            if let Some(name_node) = node.child_by_field_name("name") {
277                let text = node_text(name_node, source)
278                    .rsplit('.')
279                    .next()
280                    .unwrap_or("")
281                    .to_string();
282                if !text.is_empty() && !PRIMITIVES.contains(&text.as_str()) {
283                    out.push((
284                        text,
285                        if generic {
286                            "generic_arg".to_string()
287                        } else {
288                            "type".to_string()
289                        },
290                    ));
291                }
292            }
293            for i in 0..node.child_count() {
294                if let Some(child) = node.child(i) {
295                    if child.kind() == "type_arguments" {
296                        for j in 0..child.child_count() {
297                            if let Some(sub) = child.child(j) {
298                                if sub.is_named() {
299                                    ts_collect_type_refs(sub, source, true, out);
300                                }
301                            }
302                        }
303                    }
304                }
305            }
306        }
307        "nested_type_identifier" => {
308            let text = node_text(node, source)
309                .rsplit('.')
310                .next()
311                .unwrap_or("")
312                .to_string();
313            if !text.is_empty() && !PRIMITIVES.contains(&text.as_str()) {
314                out.push((
315                    text,
316                    if generic {
317                        "generic_arg".to_string()
318                    } else {
319                        "type".to_string()
320                    },
321                ));
322            }
323        }
324        _ => {
325            if node.is_named() {
326                for i in 0..node.child_count() {
327                    if let Some(child) = node.child(i) {
328                        if child.is_named() {
329                            ts_collect_type_refs(child, source, generic, out);
330                        }
331                    }
332                }
333            }
334        }
335    }
336}
337
338fn ts_heritage_clause_entries(clause_node: tree_sitter::Node<'_>, source: &[u8]) -> Vec<String> {
339    let mut out = Vec::new();
340    for i in 0..clause_node.child_count() {
341        let child = match clause_node.child(i) {
342            Some(c) => c,
343            None => continue,
344        };
345        if !child.is_named() {
346            continue;
347        }
348        match child.kind() {
349            "identifier" | "type_identifier" => {
350                let name = node_text(child, source).to_string();
351                if !name.is_empty() {
352                    out.push(name);
353                }
354            }
355            "generic_type" => {
356                if let Some(name_node) = child.child_by_field_name("name") {
357                    let text = node_text(name_node, source)
358                        .rsplit('.')
359                        .next()
360                        .unwrap_or("")
361                        .to_string();
362                    if !text.is_empty() {
363                        out.push(text);
364                    }
365                }
366            }
367            "nested_type_identifier" => {
368                let text = node_text(child, source)
369                    .rsplit('.')
370                    .next()
371                    .unwrap_or("")
372                    .to_string();
373                if !text.is_empty() {
374                    out.push(text);
375                }
376            }
377            _ => {}
378        }
379    }
380    out
381}
382
383// ---------------------------------------------------------------------------
384// Check if a file is JS/TS
385// ---------------------------------------------------------------------------
386
387fn is_js_ts_file(path: &Path) -> bool {
388    matches!(
389        path.extension().and_then(|e| e.to_str()),
390        Some("ts") | Some("tsx") | Some("js") | Some("jsx") | Some("mjs") | Some("svelte")
391    )
392}
393
394fn parse_js_ts(path: &Path, source: &[u8]) -> Option<tree_sitter::Tree> {
395    let mut parser = tree_sitter::Parser::new();
396    let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
397    let lang: tree_sitter::Language = match ext {
398        "ts" | "tsx" => tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
399        _ => tree_sitter_javascript::LANGUAGE.into(),
400    };
401    parser.set_language(&lang).ok()?;
402    parser.parse(source, None)
403}
404
405// ---------------------------------------------------------------------------
406// make_node helper
407// ---------------------------------------------------------------------------
408
409fn make_node(id: String, label: String, source_file: String, line: Option<usize>) -> Node {
410    let source_location = line.map(|l| format!("{}:{}", source_file, l + 1));
411    Node {
412        id,
413        label,
414        file_type: "code".to_string(),
415        source_file,
416        source_location,
417        community: None,
418        rationale: None,
419        docstring: None,
420        metadata: std::collections::HashMap::new(),
421    }
422}
423
424fn make_edge(
425    source: String,
426    target: String,
427    relation: String,
428    source_file: Option<String>,
429) -> Edge {
430    Edge {
431        source,
432        target,
433        relation,
434        confidence: "EXTRACTED".to_string(),
435        source_file,
436        weight: 1.0,
437        context: None,
438    }
439}
440
441// ---------------------------------------------------------------------------
442// Phase 1: extract basic file/symbol/method nodes + file-level import edges
443// ---------------------------------------------------------------------------
444
445fn extract_js_ts_file_nodes(
446    abs_path: &Path,
447    rel_path: &Path,
448    source: &[u8],
449    nodes: &mut Vec<Node>,
450    edges: &mut Vec<Edge>,
451    edge_set: &mut HashSet<(String, String, String)>,
452) {
453    let rel_str = rel_path.to_string_lossy().to_string();
454    let file_id = make_id(&[&rel_str]);
455    let file_label = abs_path
456        .file_name()
457        .and_then(|n| n.to_str())
458        .unwrap_or(&rel_str)
459        .to_string();
460
461    // File node
462    nodes.push(make_node(
463        file_id.clone(),
464        file_label,
465        rel_str.clone(),
466        None,
467    ));
468
469    let tree = match parse_js_ts(abs_path, source) {
470        Some(t) => t,
471        None => return,
472    };
473    let root = tree.root_node();
474    let file_stem = js_file_stem(&rel_str);
475
476    // Walk top-level children for exports, functions, consts, classes
477    for i in 0..root.child_count() {
478        let child = match root.child(i) {
479            Some(c) => c,
480            None => continue,
481        };
482
483        match child.kind() {
484            "export_statement" => {
485                // export { ... } from or export * from (no declaration node)
486                if child.child_by_field_name("declaration").is_none() {
487                    let source_field = child.child_by_field_name("source");
488                    if source_field.is_some() || js_export_is_star(child) {
489                        // re-export statement handled in facts phase
490                    } else {
491                        // export clause (local re-exports), no declaration
492                    }
493                    continue;
494                }
495                // export function/class/const/type ...
496                let names = js_exported_declaration_names(child, source);
497                let line = child.start_position().row;
498                for name in names {
499                    let sym_id = make_id(&[&file_stem, &name]);
500                    nodes.push(make_node(sym_id.clone(), name, rel_str.clone(), Some(line)));
501                    let ek = (file_id.clone(), sym_id.clone(), "contains".to_string());
502                    if edge_set.insert(ek) {
503                        edges.push(make_edge(
504                            file_id.clone(),
505                            sym_id,
506                            "contains".to_string(),
507                            Some(rel_str.clone()),
508                        ));
509                    }
510                    // Check for methods in class declarations inside export
511                    if let Some(decl) = child.child_by_field_name("declaration") {
512                        let decl_kind = decl.kind();
513                        if decl_kind == "class_declaration"
514                            || decl_kind == "abstract_class_declaration"
515                        {
516                            if let Some(name_node) = decl.child_by_field_name("name") {
517                                let class_name = node_text(name_node, source).to_string();
518                                let class_nid = make_id(&[&file_stem, &class_name]);
519                                extract_class_methods(
520                                    decl, source, &class_nid, &rel_str, nodes, edges, edge_set,
521                                );
522                            }
523                        }
524                    }
525                }
526            }
527            "function_declaration" => {
528                let line = child.start_position().row;
529                if let Some(name_node) = child.child_by_field_name("name") {
530                    let name = node_text(name_node, source).to_string();
531                    if !name.is_empty() {
532                        let sym_id = make_id(&[&file_stem, &name]);
533                        if !nodes.iter().any(|n| n.id == sym_id) {
534                            nodes.push(make_node(
535                                sym_id.clone(),
536                                name,
537                                rel_str.clone(),
538                                Some(line),
539                            ));
540                            let ek = (file_id.clone(), sym_id.clone(), "contains".to_string());
541                            if edge_set.insert(ek) {
542                                edges.push(make_edge(
543                                    file_id.clone(),
544                                    sym_id,
545                                    "contains".to_string(),
546                                    Some(rel_str.clone()),
547                                ));
548                            }
549                        }
550                    }
551                }
552            }
553            "lexical_declaration" => {
554                let line = child.start_position().row;
555                for i2 in 0..child.child_count() {
556                    if let Some(declarator) = child.child(i2) {
557                        if declarator.kind() != "variable_declarator" {
558                            continue;
559                        }
560                        let name_node = declarator.child_by_field_name("name");
561                        let value_node = declarator.child_by_field_name("value");
562                        if let (Some(nn), Some(vn)) = (name_node, value_node) {
563                            if vn.kind() == "arrow_function" || vn.kind() == "function" {
564                                let name = node_text(nn, source).to_string();
565                                if !name.is_empty() {
566                                    let sym_id = make_id(&[&file_stem, &name]);
567                                    if !nodes.iter().any(|n| n.id == sym_id) {
568                                        nodes.push(make_node(
569                                            sym_id.clone(),
570                                            name,
571                                            rel_str.clone(),
572                                            Some(line),
573                                        ));
574                                        let ek = (
575                                            file_id.clone(),
576                                            sym_id.clone(),
577                                            "contains".to_string(),
578                                        );
579                                        if edge_set.insert(ek) {
580                                            edges.push(make_edge(
581                                                file_id.clone(),
582                                                sym_id,
583                                                "contains".to_string(),
584                                                Some(rel_str.clone()),
585                                            ));
586                                        }
587                                    }
588                                }
589                            }
590                        }
591                    }
592                }
593            }
594            "class_declaration" | "abstract_class_declaration" => {
595                let line = child.start_position().row;
596                if let Some(name_node) = child.child_by_field_name("name") {
597                    let name = node_text(name_node, source).to_string();
598                    if !name.is_empty() {
599                        let sym_id = make_id(&[&file_stem, &name]);
600                        if !nodes.iter().any(|n| n.id == sym_id) {
601                            nodes.push(make_node(
602                                sym_id.clone(),
603                                name,
604                                rel_str.clone(),
605                                Some(line),
606                            ));
607                            let ek = (file_id.clone(), sym_id.clone(), "contains".to_string());
608                            if edge_set.insert(ek) {
609                                edges.push(make_edge(
610                                    file_id.clone(),
611                                    sym_id.clone(),
612                                    "contains".to_string(),
613                                    Some(rel_str.clone()),
614                                ));
615                            }
616                        }
617                        extract_class_methods(
618                            child, source, &sym_id, &rel_str, nodes, edges, edge_set,
619                        );
620                    }
621                }
622            }
623            _ => {}
624        }
625    }
626}
627
628fn extract_class_methods(
629    class_node: tree_sitter::Node<'_>,
630    source: &[u8],
631    class_nid: &str,
632    rel_str: &str,
633    nodes: &mut Vec<Node>,
634    edges: &mut Vec<Edge>,
635    edge_set: &mut HashSet<(String, String, String)>,
636) {
637    // Find class body
638    let body = match class_node.child_by_field_name("body") {
639        Some(b) => b,
640        None => return,
641    };
642    for i in 0..body.child_count() {
643        let member = match body.child(i) {
644            Some(m) => m,
645            None => continue,
646        };
647        match member.kind() {
648            "method_definition" | "method_signature" | "abstract_method_signature" => {
649                let line = member.start_position().row;
650                if let Some(name_node) = member.child_by_field_name("name") {
651                    let method_name = node_text(name_node, source).to_string();
652                    if method_name.is_empty() || method_name == "constructor" {
653                        continue;
654                    }
655                    let method_nid = make_id(&[class_nid, &method_name]);
656                    if !nodes.iter().any(|n| n.id == method_nid) {
657                        nodes.push(make_node(
658                            method_nid.clone(),
659                            method_name,
660                            rel_str.to_string(),
661                            Some(line),
662                        ));
663                        let ek = (
664                            class_nid.to_string(),
665                            method_nid.clone(),
666                            "contains".to_string(),
667                        );
668                        if edge_set.insert(ek) {
669                            edges.push(make_edge(
670                                class_nid.to_string(),
671                                method_nid,
672                                "contains".to_string(),
673                                Some(rel_str.to_string()),
674                            ));
675                        }
676                    }
677                }
678            }
679            _ => {}
680        }
681    }
682}
683
684// ---------------------------------------------------------------------------
685// Phase 2: collect symbol resolution facts
686// ---------------------------------------------------------------------------
687
688fn collect_js_symbol_resolution_facts(
689    abs_path: &Path,
690    rel_path: &Path,
691    source: &[u8],
692    facts: &mut SymbolResolutionFacts,
693    canon_to_rel: &HashMap<PathBuf, PathBuf>,
694) {
695    let rel_str = rel_path.to_string_lossy().to_string();
696    let file_stem = js_file_stem(&rel_str);
697    let file_dir = abs_path.parent().unwrap_or(Path::new("."));
698
699    let tree = match parse_js_ts(abs_path, source) {
700        Some(t) => t,
701        None => return,
702    };
703    let root = tree.root_node();
704
705    // resolve a module specifier to a canonical path
706    let resolve_module = |raw: &str| -> Option<PathBuf> {
707        let resolved = resolve_js_module_path(raw, file_dir)?;
708        std::fs::canonicalize(&resolved).ok().or(Some(resolved))
709    };
710
711    // Pass 1: declarations, imports, aliases
712    for node in walk_nodes(root) {
713        match node.kind() {
714            "export_statement"
715                // Only collect declarations here (no source = local export)
716                if node.child_by_field_name("source").is_none() && !js_export_is_star(node) => {
717                    for name in js_exported_declaration_names(node, source) {
718                        facts.declarations.push(SymbolDeclarationFact {
719                            file_path: abs_path.to_path_buf(),
720                            name,
721                            line: node.start_position().row,
722                        });
723                    }
724                }
725            "function_declaration"
726                // top-level function declarations (non-exported)
727                if node.parent().map(|p| p.kind()) == Some("program") => {
728                    if let Some(name_node) = node.child_by_field_name("name") {
729                        let name = node_text(name_node, source).to_string();
730                        if !name.is_empty() {
731                            facts.declarations.push(SymbolDeclarationFact {
732                                file_path: abs_path.to_path_buf(),
733                                name,
734                                line: node.start_position().row,
735                            });
736                        }
737                    }
738                }
739            "lexical_declaration"
740                if node.parent().map(|p| p.kind()) == Some("program") => {
741                    // const X = Y style aliases
742                    for (alias, target) in js_lexical_aliases(node, source) {
743                        facts.aliases.push(SymbolAliasFact {
744                            file_path: abs_path.to_path_buf(),
745                            alias,
746                            target_name: target,
747                            line: node.start_position().row,
748                        });
749                    }
750                }
751            "import_statement" => {
752                let line = node.start_position().row;
753                if let Some(raw) = js_module_specifier(node, source) {
754                    if let Some(target_path) = resolve_module(&raw) {
755                        // named imports
756                        let specifiers = js_named_specifiers(node, source, "import_specifier");
757                        for (imported_name, local_name) in specifiers {
758                            facts.imports.push(SymbolImportFact {
759                                file_path: abs_path.to_path_buf(),
760                                local_name,
761                                target_path: target_path.clone(),
762                                imported_name,
763                                line,
764                            });
765                        }
766                    }
767                }
768            }
769            _ => {}
770        }
771    }
772
773    // Pass 2: exports (re-exports with source or export clause)
774    for node in walk_nodes(root) {
775        if node.kind() != "export_statement" {
776            continue;
777        }
778        let line = node.start_position().row;
779        let source_field = node.child_by_field_name("source");
780
781        if let Some(src_node) = source_field {
782            let raw = match string_content(src_node, source) {
783                Some(s) => s,
784                None => continue,
785            };
786            let target_path = match resolve_module(&raw) {
787                Some(p) => p,
788                None => continue,
789            };
790
791            if js_export_is_star(node) {
792                facts.star_exports.push(StarExportFact {
793                    file_path: abs_path.to_path_buf(),
794                    target_path,
795                    line,
796                });
797            } else {
798                // export { X as Y } from '...'
799                let specifiers = js_named_specifiers(node, source, "export_specifier");
800                for (orig_name, exported_name) in specifiers {
801                    facts.exports.push(SymbolExportFact {
802                        file_path: abs_path.to_path_buf(),
803                        exported_name,
804                        line,
805                        local_name: None,
806                        target_path: Some(target_path.clone()),
807                        target_name: Some(orig_name),
808                    });
809                }
810            }
811        } else if node.child_by_field_name("declaration").is_none() {
812            // export { X } or export { X as Y } (local)
813            let specifiers = js_named_specifiers(node, source, "export_specifier");
814            for (local_name, exported_name) in specifiers {
815                facts.exports.push(SymbolExportFact {
816                    file_path: abs_path.to_path_buf(),
817                    exported_name,
818                    line,
819                    local_name: Some(local_name),
820                    target_path: None,
821                    target_name: None,
822                });
823            }
824        } else {
825            // export function/class/const ... (already handled in pass 1, but
826            // also add an export fact so other files can import from here)
827            for name in js_exported_declaration_names(node, source) {
828                facts.exports.push(SymbolExportFact {
829                    file_path: abs_path.to_path_buf(),
830                    exported_name: name.clone(),
831                    line,
832                    local_name: Some(name),
833                    target_path: None,
834                    target_name: None,
835                });
836            }
837        }
838    }
839
840    // Pass 3: function call uses (top-level function bodies & arrow functions)
841    let mut bodies: Vec<tree_sitter::Node> = Vec::new();
842    for i in 0..root.child_count() {
843        let child = match root.child(i) {
844            Some(c) => c,
845            None => continue,
846        };
847        match child.kind() {
848            "function_declaration" => {
849                if let Some(body) = child.child_by_field_name("body") {
850                    bodies.push(body);
851                }
852            }
853            "lexical_declaration" => {
854                for j in 0..child.child_count() {
855                    if let Some(declarator) = child.child(j) {
856                        if declarator.kind() != "variable_declarator" {
857                            continue;
858                        }
859                        if let Some(value) = declarator.child_by_field_name("value") {
860                            if value.kind() == "arrow_function" {
861                                // source_id is the const name
862                                if let Some(name_n) = declarator.child_by_field_name("name") {
863                                    let const_name = node_text(name_n, source).to_string();
864                                    let source_id = make_id(&[&file_stem, &const_name]);
865                                    // collect calls from the body of this arrow function
866                                    let arrow_body = value.child_by_field_name("body");
867                                    let body_node = arrow_body.unwrap_or(value);
868                                    collect_call_uses(
869                                        body_node, source, &source_id, abs_path, &rel_str, facts,
870                                    );
871                                }
872                                continue;
873                            }
874                            if value.kind() == "function" {
875                                if let Some(name_n) = declarator.child_by_field_name("name") {
876                                    let const_name = node_text(name_n, source).to_string();
877                                    let source_id = make_id(&[&file_stem, &const_name]);
878                                    if let Some(body) = value.child_by_field_name("body") {
879                                        collect_call_uses(
880                                            body, source, &source_id, abs_path, &rel_str, facts,
881                                        );
882                                    }
883                                }
884                            }
885                        }
886                    }
887                }
888            }
889            "export_statement" => {
890                if let Some(decl) = child.child_by_field_name("declaration") {
891                    match decl.kind() {
892                        "function_declaration" => {
893                            if let Some(name_n) = decl.child_by_field_name("name") {
894                                let fn_name = node_text(name_n, source).to_string();
895                                let source_id = make_id(&[&file_stem, &fn_name]);
896                                if let Some(body) = decl.child_by_field_name("body") {
897                                    collect_call_uses(
898                                        body, source, &source_id, abs_path, &rel_str, facts,
899                                    );
900                                }
901                            }
902                        }
903                        "lexical_declaration" => {
904                            for j in 0..decl.child_count() {
905                                if let Some(declarator) = decl.child(j) {
906                                    if declarator.kind() != "variable_declarator" {
907                                        continue;
908                                    }
909                                    if let Some(value) = declarator.child_by_field_name("value") {
910                                        if value.kind() == "arrow_function"
911                                            || value.kind() == "function"
912                                        {
913                                            if let Some(name_n) =
914                                                declarator.child_by_field_name("name")
915                                            {
916                                                let const_name =
917                                                    node_text(name_n, source).to_string();
918                                                let source_id = make_id(&[&file_stem, &const_name]);
919                                                let body_node = value
920                                                    .child_by_field_name("body")
921                                                    .unwrap_or(value);
922                                                collect_call_uses(
923                                                    body_node, source, &source_id, abs_path,
924                                                    &rel_str, facts,
925                                                );
926                                            }
927                                        }
928                                    }
929                                }
930                            }
931                        }
932                        _ => {}
933                    }
934                }
935            }
936            _ => {}
937        }
938    }
939
940    // Pass 4: class member type uses
941    for i in 0..root.child_count() {
942        let child = match root.child(i) {
943            Some(c) => c,
944            None => continue,
945        };
946        let class_decl = match child.kind() {
947            "class_declaration" | "abstract_class_declaration" | "interface_declaration" => {
948                Some(child)
949            }
950            "export_statement" => {
951                let decl = child.child_by_field_name("declaration");
952                decl.filter(|d| {
953                    matches!(
954                        d.kind(),
955                        "class_declaration"
956                            | "abstract_class_declaration"
957                            | "interface_declaration"
958                    )
959                })
960            }
961            _ => None,
962        };
963        if let Some(class_node) = class_decl {
964            let class_name = class_node
965                .child_by_field_name("name")
966                .map(|n| node_text(n, source).to_string())
967                .unwrap_or_default();
968            if class_name.is_empty() {
969                continue;
970            }
971            let class_nid = make_id(&[&file_stem, &class_name]);
972
973            // Heritage: extends / implements
974            for j in 0..class_node.child_count() {
975                if let Some(heritage_node) = class_node.child(j) {
976                    match heritage_node.kind() {
977                        "class_heritage" => {
978                            // Look for extends_clause and implements_clause
979                            for k in 0..heritage_node.child_count() {
980                                if let Some(clause) = heritage_node.child(k) {
981                                    match clause.kind() {
982                                        "extends_clause" => {
983                                            for name in ts_heritage_clause_entries(clause, source) {
984                                                facts.uses.push(SymbolUseFact {
985                                                    file_path: abs_path.to_path_buf(),
986                                                    source_id: class_nid.clone(),
987                                                    local_name: name,
988                                                    relation: "inherits".to_string(),
989                                                    context: "extends".to_string(),
990                                                    line: clause.start_position().row,
991                                                });
992                                            }
993                                        }
994                                        "implements_clause" => {
995                                            for name in ts_heritage_clause_entries(clause, source) {
996                                                facts.uses.push(SymbolUseFact {
997                                                    file_path: abs_path.to_path_buf(),
998                                                    source_id: class_nid.clone(),
999                                                    local_name: name,
1000                                                    relation: "implements".to_string(),
1001                                                    context: "implements".to_string(),
1002                                                    line: clause.start_position().row,
1003                                                });
1004                                            }
1005                                        }
1006                                        _ => {}
1007                                    }
1008                                }
1009                            }
1010                        }
1011                        "extends_clause" => {
1012                            for name in ts_heritage_clause_entries(heritage_node, source) {
1013                                facts.uses.push(SymbolUseFact {
1014                                    file_path: abs_path.to_path_buf(),
1015                                    source_id: class_nid.clone(),
1016                                    local_name: name,
1017                                    relation: "inherits".to_string(),
1018                                    context: "extends".to_string(),
1019                                    line: heritage_node.start_position().row,
1020                                });
1021                            }
1022                        }
1023                        "implements_clause" => {
1024                            for name in ts_heritage_clause_entries(heritage_node, source) {
1025                                facts.uses.push(SymbolUseFact {
1026                                    file_path: abs_path.to_path_buf(),
1027                                    source_id: class_nid.clone(),
1028                                    local_name: name,
1029                                    relation: "implements".to_string(),
1030                                    context: "implements".to_string(),
1031                                    line: heritage_node.start_position().row,
1032                                });
1033                            }
1034                        }
1035                        _ => {}
1036                    }
1037                }
1038            }
1039
1040            // Methods: type annotations
1041            if let Some(body) = class_node.child_by_field_name("body") {
1042                for k in 0..body.child_count() {
1043                    let member = match body.child(k) {
1044                        Some(m) => m,
1045                        None => continue,
1046                    };
1047                    match member.kind() {
1048                        "method_definition" | "method_signature" | "abstract_method_signature" => {
1049                            let method_name = member
1050                                .child_by_field_name("name")
1051                                .map(|n| node_text(n, source).to_string())
1052                                .unwrap_or_default();
1053                            if method_name.is_empty() || method_name == "constructor" {
1054                                continue;
1055                            }
1056                            let method_nid = make_id(&[&class_nid, &method_name]);
1057
1058                            // Parameters
1059                            if let Some(params) = member.child_by_field_name("parameters") {
1060                                for m in walk_nodes(params) {
1061                                    match m.kind() {
1062                                        "required_parameter" | "optional_parameter" => {
1063                                            if let Some(type_ann) = m.child_by_field_name("type") {
1064                                                let mut refs = Vec::new();
1065                                                ts_collect_type_refs(
1066                                                    type_ann, source, false, &mut refs,
1067                                                );
1068                                                for (type_name, context) in refs {
1069                                                    facts.uses.push(SymbolUseFact {
1070                                                        file_path: abs_path.to_path_buf(),
1071                                                        source_id: method_nid.clone(),
1072                                                        local_name: type_name,
1073                                                        relation: "references".to_string(),
1074                                                        context: if context == "generic_arg" {
1075                                                            "generic_arg".to_string()
1076                                                        } else {
1077                                                            "parameter_type".to_string()
1078                                                        },
1079                                                        line: m.start_position().row,
1080                                                    });
1081                                                }
1082                                            }
1083                                        }
1084                                        _ => {}
1085                                    }
1086                                }
1087                            }
1088
1089                            // Return type
1090                            if let Some(ret_type) = member.child_by_field_name("return_type") {
1091                                let mut refs = Vec::new();
1092                                ts_collect_type_refs(ret_type, source, false, &mut refs);
1093                                for (type_name, context) in refs {
1094                                    facts.uses.push(SymbolUseFact {
1095                                        file_path: abs_path.to_path_buf(),
1096                                        source_id: method_nid.clone(),
1097                                        local_name: type_name,
1098                                        relation: "references".to_string(),
1099                                        context: if context == "generic_arg" {
1100                                            "generic_arg".to_string()
1101                                        } else {
1102                                            "return_type".to_string()
1103                                        },
1104                                        line: member.start_position().row,
1105                                    });
1106                                }
1107                            }
1108                        }
1109                        _ => {}
1110                    }
1111                }
1112            }
1113        }
1114    }
1115
1116    let _ = canon_to_rel; // suppress warning
1117}
1118
1119fn collect_call_uses(
1120    body: tree_sitter::Node<'_>,
1121    source: &[u8],
1122    source_id: &str,
1123    file_path: &Path,
1124    _rel_str: &str,
1125    facts: &mut SymbolResolutionFacts,
1126) {
1127    for node in walk_nodes(body) {
1128        if node.kind() != "call_expression" {
1129            continue;
1130        }
1131        if let Some(callee) = node.child_by_field_name("function") {
1132            if callee.kind() == "identifier" {
1133                let name = node_text(callee, source).to_string();
1134                if !name.is_empty() {
1135                    facts.uses.push(SymbolUseFact {
1136                        file_path: file_path.to_path_buf(),
1137                        source_id: source_id.to_string(),
1138                        local_name: name,
1139                        relation: "calls".to_string(),
1140                        context: "call".to_string(),
1141                        line: node.start_position().row,
1142                    });
1143                }
1144            }
1145        }
1146    }
1147}
1148
1149// ---------------------------------------------------------------------------
1150// resolve_exported_origin
1151// ---------------------------------------------------------------------------
1152
1153fn resolve_exported_origin(
1154    target_path: &Path,
1155    imported_name: &str,
1156    named_exports: &HashMap<PathBuf, HashMap<String, (PathBuf, String)>>,
1157    star_exports: &HashMap<PathBuf, Vec<PathBuf>>,
1158    symbol_nodes: &HashMap<(PathBuf, String), String>,
1159    seen: &mut HashSet<(PathBuf, String)>,
1160) -> (PathBuf, String) {
1161    let key = (target_path.to_path_buf(), imported_name.to_string());
1162    if seen.contains(&key) {
1163        return key;
1164    }
1165    seen.insert(key.clone());
1166
1167    if let Some(exports) = named_exports.get(target_path) {
1168        if let Some((origin_path, origin_name)) = exports.get(imported_name) {
1169            return resolve_exported_origin(
1170                origin_path,
1171                origin_name,
1172                named_exports,
1173                star_exports,
1174                symbol_nodes,
1175                seen,
1176            );
1177        }
1178    }
1179
1180    let empty_vec: Vec<PathBuf> = Vec::new();
1181    for star_target in star_exports.get(target_path).unwrap_or(&empty_vec) {
1182        let star_key = (star_target.clone(), imported_name.to_string());
1183        if symbol_nodes.contains_key(&star_key) {
1184            return star_key;
1185        }
1186        let resolved = resolve_exported_origin(
1187            star_target,
1188            imported_name,
1189            named_exports,
1190            star_exports,
1191            symbol_nodes,
1192            seen,
1193        );
1194        if symbol_nodes.contains_key(&resolved) {
1195            return resolved;
1196        }
1197    }
1198
1199    key
1200}
1201
1202// ---------------------------------------------------------------------------
1203// Phase 3: apply facts → emit cross-file edges
1204// ---------------------------------------------------------------------------
1205
1206fn apply_js_symbol_resolution_facts(
1207    facts: &SymbolResolutionFacts,
1208    nodes: &mut Vec<Node>,
1209    edges: &mut Vec<Edge>,
1210    edge_set: &mut HashSet<(String, String, String)>,
1211    canon_to_rel: &HashMap<PathBuf, PathBuf>,
1212) {
1213    // Build symbol_nodes: (canonical_path, name) → node_id
1214    let mut symbol_nodes: HashMap<(PathBuf, String), String> = HashMap::new();
1215    for node in nodes.iter() {
1216        if node.file_type != "code" {
1217            continue;
1218        }
1219        // The source_file of a symbol node is the rel_path string
1220        // We need to find the canonical path for that rel_path
1221        // Look for canonical path by matching rel_path string in canon_to_rel values
1222        let source_file_path = PathBuf::from(&node.source_file);
1223        // try to find canonical path that maps to this rel_path
1224        let canon_path = canon_to_rel
1225            .iter()
1226            .find(|(_, rel)| *rel == &source_file_path)
1227            .map(|(canon, _)| canon.clone());
1228        if let Some(canon) = canon_path {
1229            // strip "()" and leading "." from label
1230            let label = node
1231                .label
1232                .trim_start_matches('.')
1233                .trim_end_matches("()")
1234                .to_string();
1235            if !label.is_empty() {
1236                symbol_nodes.insert((canon, label), node.id.clone());
1237            }
1238        }
1239    }
1240
1241    // Build file_id_by_canon: canonical_path → file node id
1242    let mut file_id_by_canon: HashMap<PathBuf, String> = HashMap::new();
1243    for (canon, rel) in canon_to_rel.iter() {
1244        let rel_str = rel.to_string_lossy().to_string();
1245        let file_id = make_id(&[&rel_str]);
1246        file_id_by_canon.insert(canon.clone(), file_id);
1247    }
1248
1249    // ensure_symbol_node: create a symbol node if not present
1250    // returns the node id
1251    let ensure_symbol_node = |canon_path: &PathBuf,
1252                              name: &str,
1253                              line: usize,
1254                              symbol_nodes: &mut HashMap<(PathBuf, String), String>,
1255                              nodes: &mut Vec<Node>| {
1256        let key = (canon_path.clone(), name.to_string());
1257        if let Some(id) = symbol_nodes.get(&key) {
1258            return id.clone();
1259        }
1260        let rel_path = match canon_to_rel.get(canon_path) {
1261            Some(r) => r,
1262            None => return String::new(),
1263        };
1264        let rel_str = rel_path.to_string_lossy().to_string();
1265        let stem = js_file_stem(&rel_str);
1266        let nid = make_id(&[&stem, name]);
1267        if !nodes.iter().any(|n| n.id == nid) {
1268            nodes.push(make_node(
1269                nid.clone(),
1270                name.to_string(),
1271                rel_str,
1272                Some(line),
1273            ));
1274        }
1275        symbol_nodes.insert(key, nid.clone());
1276        nid
1277    };
1278
1279    // Ensure all declarations
1280    for decl in &facts.declarations {
1281        ensure_symbol_node(
1282            &decl.file_path,
1283            &decl.name,
1284            decl.line,
1285            &mut symbol_nodes,
1286            nodes,
1287        );
1288    }
1289
1290    // Build local_aliases_by_file: canon_path → { local_name → (target_canon_path, imported_name) }
1291    let mut local_aliases_by_file: HashMap<PathBuf, HashMap<String, (PathBuf, String)>> =
1292        HashMap::new();
1293    for imp in &facts.imports {
1294        local_aliases_by_file
1295            .entry(imp.file_path.clone())
1296            .or_default()
1297            .insert(
1298                imp.local_name.clone(),
1299                (imp.target_path.clone(), imp.imported_name.clone()),
1300            );
1301    }
1302
1303    // Resolve const aliases (const X = Y where Y is an imported symbol)
1304    for alias in &facts.aliases {
1305        let local_aliases = match local_aliases_by_file.get(&alias.file_path) {
1306            Some(m) => m,
1307            None => continue,
1308        };
1309        if let Some(origin) = local_aliases.get(&alias.target_name).cloned() {
1310            local_aliases_by_file
1311                .entry(alias.file_path.clone())
1312                .or_default()
1313                .insert(alias.alias.clone(), origin);
1314        }
1315    }
1316
1317    // Build named_exports_by_file: canon_path → { exported_name → (origin_canon_path, origin_name) }
1318    let mut named_exports_by_file: HashMap<PathBuf, HashMap<String, (PathBuf, String)>> =
1319        HashMap::new();
1320    for exp in &facts.exports {
1321        let origin =
1322            if let (Some(target_path), Some(target_name)) = (&exp.target_path, &exp.target_name) {
1323                // Re-export from another file
1324                (target_path.clone(), target_name.clone())
1325            } else if let Some(local_name) = &exp.local_name {
1326                // Local export
1327                // Check if local_name is actually an import (from local_aliases)
1328                let local_aliases = local_aliases_by_file.get(&exp.file_path);
1329                if let Some(aliases) = local_aliases {
1330                    if let Some((origin_path, origin_name)) = aliases.get(local_name) {
1331                        (origin_path.clone(), origin_name.clone())
1332                    } else {
1333                        // It's a locally defined symbol
1334                        (exp.file_path.clone(), local_name.clone())
1335                    }
1336                } else {
1337                    (exp.file_path.clone(), local_name.clone())
1338                }
1339            } else {
1340                continue;
1341            };
1342
1343        named_exports_by_file
1344            .entry(exp.file_path.clone())
1345            .or_default()
1346            .insert(exp.exported_name.clone(), origin);
1347    }
1348
1349    // Build star_exports_by_file
1350    let mut star_exports_by_file: HashMap<PathBuf, Vec<PathBuf>> = HashMap::new();
1351    for se in &facts.star_exports {
1352        star_exports_by_file
1353            .entry(se.file_path.clone())
1354            .or_default()
1355            .push(se.target_path.clone());
1356    }
1357
1358    // Emit re_exports edges
1359    // star exports: file_id → target_file_id
1360    for se in &facts.star_exports {
1361        if let (Some(src_id), Some(tgt_id)) = (
1362            file_id_by_canon.get(&se.file_path),
1363            file_id_by_canon.get(&se.target_path),
1364        ) {
1365            let ek = (src_id.clone(), tgt_id.clone(), "re_exports".to_string());
1366            if edge_set.insert(ek) {
1367                edges.push(make_edge(
1368                    src_id.clone(),
1369                    tgt_id.clone(),
1370                    "re_exports".to_string(),
1371                    None,
1372                ));
1373            }
1374        }
1375    }
1376
1377    // named exports from a different file → re_exports edge
1378    for (file_path, exports) in &named_exports_by_file {
1379        let src_id = match file_id_by_canon.get(file_path) {
1380            Some(id) => id.clone(),
1381            None => continue,
1382        };
1383        for (origin_path, _) in exports.values() {
1384            if origin_path != file_path {
1385                if let Some(tgt_id) = file_id_by_canon.get(origin_path) {
1386                    let ek = (src_id.clone(), tgt_id.clone(), "re_exports".to_string());
1387                    if edge_set.insert(ek) {
1388                        edges.push(make_edge(
1389                            src_id.clone(),
1390                            tgt_id.clone(),
1391                            "re_exports".to_string(),
1392                            None,
1393                        ));
1394                    }
1395                }
1396            }
1397        }
1398    }
1399
1400    // Emit imports edges by resolving each import fact
1401    for imp in &facts.imports {
1402        let src_file_id = match file_id_by_canon.get(&imp.file_path) {
1403            Some(id) => id.clone(),
1404            None => continue,
1405        };
1406        let mut seen = HashSet::new();
1407        let (origin_path, origin_name) = resolve_exported_origin(
1408            &imp.target_path,
1409            &imp.imported_name,
1410            &named_exports_by_file,
1411            &star_exports_by_file,
1412            &symbol_nodes,
1413            &mut seen,
1414        );
1415
1416        // ensure the origin symbol node exists
1417        ensure_symbol_node(
1418            &origin_path,
1419            &origin_name,
1420            imp.line,
1421            &mut symbol_nodes,
1422            nodes,
1423        );
1424
1425        if let Some(tgt_sym_id) = symbol_nodes.get(&(origin_path.clone(), origin_name.clone())) {
1426            let tgt_id = tgt_sym_id.clone();
1427            let ek = (src_file_id.clone(), tgt_id.clone(), "imports".to_string());
1428            if edge_set.insert(ek) {
1429                edges.push(make_edge(
1430                    src_file_id.clone(),
1431                    tgt_id,
1432                    "imports".to_string(),
1433                    None,
1434                ));
1435            }
1436        }
1437    }
1438
1439    // Emit uses edges
1440    for use_fact in &facts.uses {
1441        let src_id = use_fact.source_id.clone();
1442        let local_name = &use_fact.local_name;
1443
1444        // try to resolve local_name through local_aliases
1445        let (origin_path, origin_name) =
1446            if let Some(aliases) = local_aliases_by_file.get(&use_fact.file_path) {
1447                if let Some((target_path, imported_name)) = aliases.get(local_name) {
1448                    let mut seen = HashSet::new();
1449                    resolve_exported_origin(
1450                        target_path,
1451                        imported_name,
1452                        &named_exports_by_file,
1453                        &star_exports_by_file,
1454                        &symbol_nodes,
1455                        &mut seen,
1456                    )
1457                } else {
1458                    // Local symbol
1459                    (use_fact.file_path.clone(), local_name.clone())
1460                }
1461            } else {
1462                (use_fact.file_path.clone(), local_name.clone())
1463            };
1464
1465        // Ensure the origin symbol node exists
1466        ensure_symbol_node(
1467            &origin_path,
1468            &origin_name,
1469            use_fact.line,
1470            &mut symbol_nodes,
1471            nodes,
1472        );
1473
1474        if let Some(tgt_id) = symbol_nodes.get(&(origin_path, origin_name)) {
1475            let tgt = tgt_id.clone();
1476            // Don't add self-loops
1477            if src_id == tgt {
1478                continue;
1479            }
1480            let ek = (src_id.clone(), tgt.clone(), use_fact.relation.clone());
1481            if edge_set.insert(ek) {
1482                edges.push(make_edge(src_id, tgt, use_fact.relation.clone(), None));
1483            }
1484        }
1485    }
1486}
1487
1488// ---------------------------------------------------------------------------
1489// Public entry point
1490// ---------------------------------------------------------------------------
1491
1492pub fn extract_js_files(paths: &[PathBuf], root: &Path) -> (Vec<Node>, Vec<Edge>) {
1493    // Build canon_to_rel map
1494    let mut canon_to_rel: HashMap<PathBuf, PathBuf> = HashMap::new();
1495    for path in paths {
1496        if !is_js_ts_file(path) {
1497            continue;
1498        }
1499        let canon = std::fs::canonicalize(path)
1500            .ok()
1501            .unwrap_or_else(|| path.clone());
1502        let rel = path
1503            .strip_prefix(root)
1504            .map(|r| r.to_path_buf())
1505            .unwrap_or_else(|_| path.clone());
1506        canon_to_rel.insert(canon, rel);
1507    }
1508
1509    let mut nodes: Vec<Node> = Vec::new();
1510    let mut edges: Vec<Edge> = Vec::new();
1511    let mut edge_set: HashSet<(String, String, String)> = HashSet::new();
1512
1513    // Phase 1: basic nodes per file
1514    for (canon, rel) in &canon_to_rel {
1515        let source = match std::fs::read(canon) {
1516            Ok(s) => s,
1517            Err(_) => continue,
1518        };
1519        extract_js_ts_file_nodes(canon, rel, &source, &mut nodes, &mut edges, &mut edge_set);
1520    }
1521
1522    // Phase 2: collect facts
1523    let mut facts = SymbolResolutionFacts::default();
1524    for (canon, rel) in &canon_to_rel {
1525        let source = match std::fs::read(canon) {
1526            Ok(s) => s,
1527            Err(_) => continue,
1528        };
1529        collect_js_symbol_resolution_facts(canon, rel, &source, &mut facts, &canon_to_rel);
1530    }
1531
1532    // Phase 3: apply facts
1533    apply_js_symbol_resolution_facts(&facts, &mut nodes, &mut edges, &mut edge_set, &canon_to_rel);
1534
1535    (nodes, edges)
1536}
1537
1538// ---------------------------------------------------------------------------
1539// Tests
1540// ---------------------------------------------------------------------------
1541
1542#[cfg(test)]
1543mod tests {
1544    use super::*;
1545    use std::fs;
1546    use tempfile::TempDir;
1547
1548    fn tmp() -> TempDir {
1549        tempfile::TempDir::new().unwrap()
1550    }
1551
1552    fn write(dir: &Path, name: &str, content: &str) -> PathBuf {
1553        let path = dir.join(name);
1554        if let Some(parent) = path.parent() {
1555            fs::create_dir_all(parent).unwrap();
1556        }
1557        fs::write(&path, content).unwrap();
1558        path
1559    }
1560
1561    fn run(dir: &Path, paths: &[PathBuf]) -> (Vec<Node>, Vec<Edge>) {
1562        extract_js_files(paths, dir)
1563    }
1564
1565    fn make_test_id(s: &str) -> String {
1566        s.chars()
1567            .map(|c| {
1568                if c.is_alphanumeric() || c == '_' {
1569                    c
1570                } else {
1571                    '_'
1572                }
1573            })
1574            .collect::<String>()
1575            .to_lowercase()
1576    }
1577
1578    fn test_file_stem(rel_path: &str) -> String {
1579        js_file_stem(rel_path)
1580    }
1581
1582    fn has_node(nodes: &[Node], id: &str) -> bool {
1583        let nid = make_test_id(id);
1584        nodes.iter().any(|n| n.id == nid)
1585    }
1586
1587    fn has_edge(edges: &[Edge], source: &str, target: &str, relation: &str) -> bool {
1588        let src = make_test_id(source);
1589        let tgt = make_test_id(target);
1590        edges
1591            .iter()
1592            .any(|e| e.source == src && e.target == tgt && e.relation == relation)
1593    }
1594
1595    // has_symbol_edge: source is a file (path string → file node id), target is a symbol node
1596    fn has_symbol_edge(
1597        edges: &[Edge],
1598        source_file: &str,
1599        target_file: &str,
1600        symbol: &str,
1601        relation: &str,
1602    ) -> bool {
1603        let src = make_test_id(source_file);
1604        let stem = test_file_stem(target_file);
1605        let tgt = make_test_id(&format!("{}_{}", stem, symbol));
1606        edges
1607            .iter()
1608            .any(|e| e.source == src && e.target == tgt && e.relation == relation)
1609    }
1610
1611    fn has_symbol_to_symbol_edge(
1612        edges: &[Edge],
1613        src_file: &str,
1614        src_sym: &str,
1615        tgt_file: &str,
1616        tgt_sym: &str,
1617        relation: &str,
1618    ) -> bool {
1619        let src_stem = test_file_stem(src_file);
1620        let tgt_stem = test_file_stem(tgt_file);
1621        let src = make_test_id(&format!("{}_{}", src_stem, src_sym));
1622        let tgt = make_test_id(&format!("{}_{}", tgt_stem, tgt_sym));
1623        edges
1624            .iter()
1625            .any(|e| e.source == src && e.target == tgt && e.relation == relation)
1626    }
1627
1628    // Test 1: simple export function creates file node + symbol node
1629    #[test]
1630    fn test_ts_exported_function_node() {
1631        let dir = tmp();
1632        write(
1633            dir.path(),
1634            "src/lib/foo.ts",
1635            "export function Foo() { return 42; }",
1636        );
1637        let paths = vec![dir.path().join("src/lib/foo.ts")];
1638        let (nodes, _edges) = run(dir.path(), &paths);
1639        // file node id = make_id("src/lib/foo.ts")
1640        assert!(
1641            has_node(&nodes, "src/lib/foo.ts"),
1642            "file node missing; nodes: {:?}",
1643            nodes.iter().map(|n| &n.id).collect::<Vec<_>>()
1644        );
1645        // symbol node id = make_id("lib.foo_Foo")
1646        let sym_id = make_test_id(&format!("{}_Foo", test_file_stem("src/lib/foo.ts")));
1647        assert!(
1648            nodes.iter().any(|n| n.id == sym_id),
1649            "symbol node missing: {}; nodes: {:?}",
1650            sym_id,
1651            nodes.iter().map(|n| &n.id).collect::<Vec<_>>()
1652        );
1653    }
1654
1655    // Test 2: file_stem helper
1656    #[test]
1657    fn test_js_file_stem_with_parent() {
1658        assert_eq!(test_file_stem("src/lib/foo.ts"), "lib.foo");
1659        assert_eq!(test_file_stem("src/routes/page.ts"), "routes.page");
1660        assert_eq!(test_file_stem("foo.ts"), "foo");
1661    }
1662
1663    // Test 3: named import creates imports edge pointing to origin symbol
1664    #[test]
1665    fn test_ts_named_import_to_origin() {
1666        let dir = tmp();
1667        write(dir.path(), "src/lib/foo.ts", "export function Foo() {}");
1668        write(
1669            dir.path(),
1670            "src/routes/page.ts",
1671            "import { Foo } from '../lib/foo';\nexport function handler() { return Foo(); }",
1672        );
1673        let paths = vec![
1674            dir.path().join("src/lib/foo.ts"),
1675            dir.path().join("src/routes/page.ts"),
1676        ];
1677        let (_, edges) = run(dir.path(), &paths);
1678        assert!(
1679            has_symbol_edge(
1680                &edges,
1681                "src/routes/page.ts",
1682                "src/lib/foo.ts",
1683                "Foo",
1684                "imports"
1685            ),
1686            "imports edge missing; edges: {:?}",
1687            edges
1688                .iter()
1689                .map(|e| (&e.source, &e.target, &e.relation))
1690                .collect::<Vec<_>>()
1691        );
1692    }
1693
1694    // Test 4: barrel re-export (named) - import through barrel resolves to origin
1695    #[test]
1696    fn test_ts_named_import_through_barrel() {
1697        let dir = tmp();
1698        write(dir.path(), "src/lib/foo.ts", "export function Foo() {}");
1699        write(
1700            dir.path(),
1701            "src/lib/index.ts",
1702            "export { Foo } from './foo';",
1703        );
1704        write(
1705            dir.path(),
1706            "src/routes/page.ts",
1707            "import { Foo } from '../lib/index';",
1708        );
1709        let paths = vec![
1710            dir.path().join("src/lib/foo.ts"),
1711            dir.path().join("src/lib/index.ts"),
1712            dir.path().join("src/routes/page.ts"),
1713        ];
1714        let (_, edges) = run(dir.path(), &paths);
1715        assert!(
1716            has_symbol_edge(
1717                &edges,
1718                "src/routes/page.ts",
1719                "src/lib/foo.ts",
1720                "Foo",
1721                "imports"
1722            ),
1723            "barrel import edge missing; edges: {:?}",
1724            edges
1725                .iter()
1726                .map(|e| (&e.source, &e.target, &e.relation))
1727                .collect::<Vec<_>>()
1728        );
1729    }
1730
1731    // Test 5: star re-export barrel
1732    #[test]
1733    fn test_ts_star_export_barrel() {
1734        let dir = tmp();
1735        write(dir.path(), "src/lib/foo.ts", "export function Foo() {}");
1736        write(dir.path(), "src/lib/index.ts", "export * from './foo';");
1737        write(
1738            dir.path(),
1739            "src/routes/page.ts",
1740            "import { Foo } from '../lib/index';",
1741        );
1742        let paths = vec![
1743            dir.path().join("src/lib/foo.ts"),
1744            dir.path().join("src/lib/index.ts"),
1745            dir.path().join("src/routes/page.ts"),
1746        ];
1747        let (_, edges) = run(dir.path(), &paths);
1748        assert!(
1749            has_symbol_edge(
1750                &edges,
1751                "src/routes/page.ts",
1752                "src/lib/foo.ts",
1753                "Foo",
1754                "imports"
1755            ),
1756            "star barrel import edge missing; edges: {:?}",
1757            edges
1758                .iter()
1759                .map(|e| (&e.source, &e.target, &e.relation))
1760                .collect::<Vec<_>>()
1761        );
1762    }
1763
1764    // Test 6: re_exports edge emitted for barrel
1765    #[test]
1766    fn test_ts_barrel_re_exports_edge() {
1767        let dir = tmp();
1768        write(dir.path(), "src/lib/foo.ts", "export function Foo() {}");
1769        write(
1770            dir.path(),
1771            "src/lib/index.ts",
1772            "export { Foo } from './foo';",
1773        );
1774        let paths = vec![
1775            dir.path().join("src/lib/foo.ts"),
1776            dir.path().join("src/lib/index.ts"),
1777        ];
1778        let (_, edges) = run(dir.path(), &paths);
1779        assert!(
1780            has_edge(&edges, "src/lib/index.ts", "src/lib/foo.ts", "re_exports"),
1781            "re_exports edge missing; edges: {:?}",
1782            edges
1783                .iter()
1784                .map(|e| (&e.source, &e.target, &e.relation))
1785                .collect::<Vec<_>>()
1786        );
1787    }
1788
1789    // Test 7: star re_exports edge
1790    #[test]
1791    fn test_ts_star_re_exports_edge() {
1792        let dir = tmp();
1793        write(dir.path(), "src/lib/foo.ts", "export function Foo() {}");
1794        write(dir.path(), "src/lib/index.ts", "export * from './foo';");
1795        let paths = vec![
1796            dir.path().join("src/lib/foo.ts"),
1797            dir.path().join("src/lib/index.ts"),
1798        ];
1799        let (_, edges) = run(dir.path(), &paths);
1800        assert!(
1801            has_edge(&edges, "src/lib/index.ts", "src/lib/foo.ts", "re_exports"),
1802            "star re_exports edge missing; edges: {:?}",
1803            edges
1804                .iter()
1805                .map(|e| (&e.source, &e.target, &e.relation))
1806                .collect::<Vec<_>>()
1807        );
1808    }
1809
1810    // Test 8: TypeScript class with inherits creates symbol + edge
1811    #[test]
1812    fn test_ts_class_inherits() {
1813        let dir = tmp();
1814        write(dir.path(), "src/lib/base.ts", "export class Base {}");
1815        write(
1816            dir.path(),
1817            "src/lib/impl.ts",
1818            "import { Base } from './base';\nexport class DataProcessor extends Base {}",
1819        );
1820        let paths = vec![
1821            dir.path().join("src/lib/base.ts"),
1822            dir.path().join("src/lib/impl.ts"),
1823        ];
1824        let (_, edges) = run(dir.path(), &paths);
1825        assert!(
1826            has_symbol_to_symbol_edge(
1827                &edges,
1828                "src/lib/impl.ts",
1829                "DataProcessor",
1830                "src/lib/base.ts",
1831                "Base",
1832                "inherits"
1833            ),
1834            "inherits edge missing; edges: {:?}",
1835            edges
1836                .iter()
1837                .map(|e| (&e.source, &e.target, &e.relation))
1838                .collect::<Vec<_>>()
1839        );
1840    }
1841
1842    // Test 9: TypeScript class with implements creates edge
1843    #[test]
1844    fn test_ts_class_implements() {
1845        let dir = tmp();
1846        write(
1847            dir.path(),
1848            "src/lib/iface.ts",
1849            "export interface IProcessor {}",
1850        );
1851        write(
1852            dir.path(),
1853            "src/lib/impl.ts",
1854            "import { IProcessor } from './iface';\nexport class DataProcessor implements IProcessor {}",
1855        );
1856        let paths = vec![
1857            dir.path().join("src/lib/iface.ts"),
1858            dir.path().join("src/lib/impl.ts"),
1859        ];
1860        let (_, edges) = run(dir.path(), &paths);
1861        assert!(
1862            has_symbol_to_symbol_edge(
1863                &edges,
1864                "src/lib/impl.ts",
1865                "DataProcessor",
1866                "src/lib/iface.ts",
1867                "IProcessor",
1868                "implements"
1869            ),
1870            "implements edge missing; edges: {:?}",
1871            edges
1872                .iter()
1873                .map(|e| (&e.source, &e.target, &e.relation))
1874                .collect::<Vec<_>>()
1875        );
1876    }
1877
1878    // Test 10: method parameter type annotation creates references edge
1879    #[test]
1880    fn test_ts_method_parameter_type_ref() {
1881        let dir = tmp();
1882        write(dir.path(), "src/lib/types.ts", "export interface Config {}");
1883        write(
1884            dir.path(),
1885            "src/lib/impl.ts",
1886            "import { Config } from './types';\nexport class DataProcessor {\n  run(cfg: Config): void {}\n}",
1887        );
1888        let paths = vec![
1889            dir.path().join("src/lib/types.ts"),
1890            dir.path().join("src/lib/impl.ts"),
1891        ];
1892        let (_, edges) = run(dir.path(), &paths);
1893        // method node id = make_id("lib.impl_DataProcessor_run")
1894        let method_id = make_test_id("lib_impl_DataProcessor_run");
1895        let cfg_id = make_test_id(&format!("{}_Config", test_file_stem("src/lib/types.ts")));
1896        assert!(
1897            edges
1898                .iter()
1899                .any(|e| e.source == method_id && e.target == cfg_id && e.relation == "references"),
1900            "references edge missing; edges: {:?}",
1901            edges
1902                .iter()
1903                .map(|e| (&e.source, &e.target, &e.relation))
1904                .collect::<Vec<_>>()
1905        );
1906    }
1907
1908    // Test 11: method return type creates references edge
1909    #[test]
1910    fn test_ts_method_return_type_ref() {
1911        let dir = tmp();
1912        write(dir.path(), "src/lib/types.ts", "export interface Result {}");
1913        write(
1914            dir.path(),
1915            "src/lib/impl.ts",
1916            "import { Result } from './types';\nexport class DataProcessor {\n  run(): Result {}\n}",
1917        );
1918        let paths = vec![
1919            dir.path().join("src/lib/types.ts"),
1920            dir.path().join("src/lib/impl.ts"),
1921        ];
1922        let (_, edges) = run(dir.path(), &paths);
1923        let method_id = make_test_id("lib_impl_DataProcessor_run");
1924        let result_id = make_test_id(&format!("{}_Result", test_file_stem("src/lib/types.ts")));
1925        assert!(
1926            edges.iter().any(|e| e.source == method_id
1927                && e.target == result_id
1928                && e.relation == "references"),
1929            "return type references edge missing; edges: {:?}",
1930            edges
1931                .iter()
1932                .map(|e| (&e.source, &e.target, &e.relation))
1933                .collect::<Vec<_>>()
1934        );
1935    }
1936
1937    // Test 12: arrow function call through barrel resolves to origin symbol
1938    #[test]
1939    fn test_ts_arrow_function_call_through_barrel_targets_origin_symbol() {
1940        let dir = tmp();
1941        write(dir.path(), "src/lib/foo.ts", "export function Foo() {}");
1942        write(
1943            dir.path(),
1944            "src/lib/index.ts",
1945            "export { Foo } from './foo';",
1946        );
1947        write(
1948            dir.path(),
1949            "src/routes/page.ts",
1950            "import { Foo } from '../lib/index';\nconst X = () => Foo();",
1951        );
1952        let paths = vec![
1953            dir.path().join("src/lib/foo.ts"),
1954            dir.path().join("src/lib/index.ts"),
1955            dir.path().join("src/routes/page.ts"),
1956        ];
1957        let (_, edges) = run(dir.path(), &paths);
1958        assert!(
1959            has_symbol_to_symbol_edge(
1960                &edges,
1961                "src/routes/page.ts",
1962                "X",
1963                "src/lib/foo.ts",
1964                "Foo",
1965                "calls"
1966            ),
1967            "calls edge missing; edges: {:?}",
1968            edges
1969                .iter()
1970                .map(|e| (&e.source, &e.target, &e.relation))
1971                .collect::<Vec<_>>()
1972        );
1973    }
1974
1975    // Test 13: regular function call through direct import
1976    #[test]
1977    fn test_ts_function_call_direct_import() {
1978        let dir = tmp();
1979        write(dir.path(), "src/lib/foo.ts", "export function Foo() {}");
1980        write(
1981            dir.path(),
1982            "src/routes/page.ts",
1983            "import { Foo } from '../lib/foo';\nexport function handler() { return Foo(); }",
1984        );
1985        let paths = vec![
1986            dir.path().join("src/lib/foo.ts"),
1987            dir.path().join("src/routes/page.ts"),
1988        ];
1989        let (_, edges) = run(dir.path(), &paths);
1990        assert!(
1991            has_symbol_to_symbol_edge(
1992                &edges,
1993                "src/routes/page.ts",
1994                "handler",
1995                "src/lib/foo.ts",
1996                "Foo",
1997                "calls"
1998            ),
1999            "calls edge missing; edges: {:?}",
2000            edges
2001                .iter()
2002                .map(|e| (&e.source, &e.target, &e.relation))
2003                .collect::<Vec<_>>()
2004        );
2005    }
2006
2007    // Test 14: star barrel call resolves to origin
2008    #[test]
2009    fn test_ts_function_call_through_star_barrel() {
2010        let dir = tmp();
2011        write(dir.path(), "src/lib/foo.ts", "export function Foo() {}");
2012        write(dir.path(), "src/lib/index.ts", "export * from './foo';");
2013        write(
2014            dir.path(),
2015            "src/routes/page.ts",
2016            "import { Foo } from '../lib/index';\nexport function handler() { return Foo(); }",
2017        );
2018        let paths = vec![
2019            dir.path().join("src/lib/foo.ts"),
2020            dir.path().join("src/lib/index.ts"),
2021            dir.path().join("src/routes/page.ts"),
2022        ];
2023        let (_, edges) = run(dir.path(), &paths);
2024        assert!(
2025            has_symbol_to_symbol_edge(
2026                &edges,
2027                "src/routes/page.ts",
2028                "handler",
2029                "src/lib/foo.ts",
2030                "Foo",
2031                "calls"
2032            ),
2033            "calls edge missing; edges: {:?}",
2034            edges
2035                .iter()
2036                .map(|e| (&e.source, &e.target, &e.relation))
2037                .collect::<Vec<_>>()
2038        );
2039    }
2040
2041    // Test 15: svelte.ts file import (tests path resolution with .svelte.ts extension)
2042    #[test]
2043    fn test_ts_svelte_ts_import() {
2044        let dir = tmp();
2045        write(
2046            dir.path(),
2047            "src/lib/hooks/is-mobile.svelte.ts",
2048            "export function isMobile() { return false; }",
2049        );
2050        write(
2051            dir.path(),
2052            "src/routes/page.ts",
2053            "import { isMobile } from '../lib/hooks/is-mobile.svelte';",
2054        );
2055        let paths = vec![
2056            dir.path().join("src/lib/hooks/is-mobile.svelte.ts"),
2057            dir.path().join("src/routes/page.ts"),
2058        ];
2059        let (_, edges) = run(dir.path(), &paths);
2060        // The import edge: src/routes/page.ts → isMobile in src/lib/hooks/is-mobile.svelte.ts
2061        // target file stem: "hooks.is-mobile.svelte" (parent=hooks, stem=is-mobile.svelte)
2062        // Actually file_stem("src/lib/hooks/is-mobile.svelte.ts"):
2063        //   stem = "is-mobile.svelte" (file_stem strips last extension only)
2064        //   parent.name = "hooks"
2065        //   result = "hooks.is-mobile.svelte"
2066        // symbol id = make_id("hooks.is-mobile.svelte_isMobile") = "hooks_is_mobile_svelte_ismobile"
2067        let stem = test_file_stem("src/lib/hooks/is-mobile.svelte.ts");
2068        let sym_id = make_test_id(&format!("{}_isMobile", stem));
2069        let src_id = make_test_id("src/routes/page.ts");
2070        assert!(
2071            edges
2072                .iter()
2073                .any(|e| e.source == src_id && e.target == sym_id && e.relation == "imports"),
2074            "svelte.ts import edge missing; stem={}, sym_id={}, edges: {:?}",
2075            stem,
2076            sym_id,
2077            edges
2078                .iter()
2079                .map(|e| (&e.source, &e.target, &e.relation))
2080                .collect::<Vec<_>>()
2081        );
2082    }
2083
2084    // Test 16: multiple exports from same file
2085    #[test]
2086    fn test_ts_multiple_exports() {
2087        let dir = tmp();
2088        write(
2089            dir.path(),
2090            "src/lib/foo.ts",
2091            "export function Foo() {}\nexport function Bar() {}",
2092        );
2093        let paths = vec![dir.path().join("src/lib/foo.ts")];
2094        let (nodes, _) = run(dir.path(), &paths);
2095        let foo_id = make_test_id(&format!("{}_Foo", test_file_stem("src/lib/foo.ts")));
2096        let bar_id = make_test_id(&format!("{}_Bar", test_file_stem("src/lib/foo.ts")));
2097        assert!(nodes.iter().any(|n| n.id == foo_id), "Foo node missing");
2098        assert!(nodes.iter().any(|n| n.id == bar_id), "Bar node missing");
2099    }
2100
2101    // Test 17: deep chain barrel (A → B → C)
2102    #[test]
2103    fn test_ts_deep_chain_barrel() {
2104        let dir = tmp();
2105        write(dir.path(), "src/lib/foo.ts", "export function Foo() {}");
2106        write(
2107            dir.path(),
2108            "src/lib/index.ts",
2109            "export { Foo } from './foo';",
2110        );
2111        write(
2112            dir.path(),
2113            "src/routes/index.ts",
2114            "export { Foo } from '../lib/index';",
2115        );
2116        write(
2117            dir.path(),
2118            "src/app/main.ts",
2119            "import { Foo } from '../routes/index';",
2120        );
2121        let paths = vec![
2122            dir.path().join("src/lib/foo.ts"),
2123            dir.path().join("src/lib/index.ts"),
2124            dir.path().join("src/routes/index.ts"),
2125            dir.path().join("src/app/main.ts"),
2126        ];
2127        let (_, edges) = run(dir.path(), &paths);
2128        assert!(
2129            has_symbol_edge(
2130                &edges,
2131                "src/app/main.ts",
2132                "src/lib/foo.ts",
2133                "Foo",
2134                "imports"
2135            ),
2136            "deep chain barrel imports edge missing; edges: {:?}",
2137            edges
2138                .iter()
2139                .map(|e| (&e.source, &e.target, &e.relation))
2140                .collect::<Vec<_>>()
2141        );
2142    }
2143
2144    // Test 18: interface declaration creates symbol node
2145    #[test]
2146    fn test_ts_interface_declaration_node() {
2147        let dir = tmp();
2148        write(
2149            dir.path(),
2150            "src/lib/types.ts",
2151            "export interface Config { key: string; }",
2152        );
2153        let paths = vec![dir.path().join("src/lib/types.ts")];
2154        let (nodes, _) = run(dir.path(), &paths);
2155        let sym_id = make_test_id(&format!("{}_Config", test_file_stem("src/lib/types.ts")));
2156        assert!(
2157            nodes.iter().any(|n| n.id == sym_id),
2158            "Config interface node missing; nodes: {:?}",
2159            nodes.iter().map(|n| &n.id).collect::<Vec<_>>()
2160        );
2161    }
2162
2163    // Test 19: type alias creates symbol node
2164    #[test]
2165    fn test_ts_type_alias_node() {
2166        let dir = tmp();
2167        write(
2168            dir.path(),
2169            "src/lib/types.ts",
2170            "export type MyType = string | number;",
2171        );
2172        let paths = vec![dir.path().join("src/lib/types.ts")];
2173        let (nodes, _) = run(dir.path(), &paths);
2174        let sym_id = make_test_id(&format!("{}_MyType", test_file_stem("src/lib/types.ts")));
2175        assert!(
2176            nodes.iter().any(|n| n.id == sym_id),
2177            "MyType alias node missing; nodes: {:?}",
2178            nodes.iter().map(|n| &n.id).collect::<Vec<_>>()
2179        );
2180    }
2181
2182    // Test 20: JS file (not TS) import resolution
2183    #[test]
2184    fn test_js_import_resolution() {
2185        let dir = tmp();
2186        write(dir.path(), "src/lib/foo.js", "export function Foo() {}");
2187        write(
2188            dir.path(),
2189            "src/routes/page.js",
2190            "import { Foo } from '../lib/foo.js';\nexport function handler() { return Foo(); }",
2191        );
2192        let paths = vec![
2193            dir.path().join("src/lib/foo.js"),
2194            dir.path().join("src/routes/page.js"),
2195        ];
2196        let (_, edges) = run(dir.path(), &paths);
2197        assert!(
2198            has_symbol_edge(
2199                &edges,
2200                "src/routes/page.js",
2201                "src/lib/foo.js",
2202                "Foo",
2203                "imports"
2204            ),
2205            "JS imports edge missing; edges: {:?}",
2206            edges
2207                .iter()
2208                .map(|e| (&e.source, &e.target, &e.relation))
2209                .collect::<Vec<_>>()
2210        );
2211    }
2212
2213    // Test 21: method nodes are created for class methods
2214    #[test]
2215    fn test_ts_method_nodes_created() {
2216        let dir = tmp();
2217        write(
2218            dir.path(),
2219            "src/lib/impl.ts",
2220            "export class DataProcessor {\n  run(): void {}\n  stop(): void {}\n}",
2221        );
2222        let paths = vec![dir.path().join("src/lib/impl.ts")];
2223        let (nodes, _) = run(dir.path(), &paths);
2224        let class_nid = make_test_id(&format!(
2225            "{}_DataProcessor",
2226            test_file_stem("src/lib/impl.ts")
2227        ));
2228        let run_id = make_test_id(&format!("{}_run", class_nid));
2229        let stop_id = make_test_id(&format!("{}_stop", class_nid));
2230        assert!(
2231            nodes.iter().any(|n| n.id == run_id),
2232            "run method node missing; nodes: {:?}",
2233            nodes.iter().map(|n| &n.id).collect::<Vec<_>>()
2234        );
2235        assert!(
2236            nodes.iter().any(|n| n.id == stop_id),
2237            "stop method node missing; nodes: {:?}",
2238            nodes.iter().map(|n| &n.id).collect::<Vec<_>>()
2239        );
2240    }
2241}