Skip to main content

sqry_lang_javascript/relations/
graph_builder.rs

1use std::{collections::HashMap, path::Path, sync::Arc};
2
3use sqry_core::graph::unified::edge::kind::TypeOfContext;
4use sqry_core::graph::unified::edge::{ExportKind, FfiConvention, HttpMethod};
5use sqry_core::graph::unified::{GraphBuildHelper, NodeId, StagingGraph};
6use sqry_core::graph::{GraphBuilder, GraphBuilderError, GraphResult, Language, Position, Span};
7use sqry_core::relations::SyntheticNameBuilder;
8use tree_sitter::{Node, Tree};
9
10use super::jsdoc_parser::{extract_jsdoc_comment, parse_jsdoc_tags};
11use super::local_scopes;
12use super::type_extractor::{canonical_type_string, extract_type_names};
13
14const DEFAULT_SCOPE_DEPTH: usize = 4;
15type CallEdgeData = (NodeId, NodeId, u8, bool, Option<Span>);
16type ConstructorEdgeData = (NodeId, NodeId, u8, Option<Span>);
17
18/// Graph builder for JavaScript files using unified `CodeGraph` architecture.
19#[derive(Debug, Clone, Copy)]
20pub struct JavaScriptGraphBuilder {
21    max_scope_depth: usize,
22}
23
24impl Default for JavaScriptGraphBuilder {
25    fn default() -> Self {
26        Self {
27            max_scope_depth: DEFAULT_SCOPE_DEPTH,
28        }
29    }
30}
31
32impl JavaScriptGraphBuilder {
33    #[must_use]
34    pub fn new(max_scope_depth: usize) -> Self {
35        Self { max_scope_depth }
36    }
37}
38
39/// Infer visibility from JavaScript naming convention.
40/// Functions/methods starting with underscore are considered private.
41fn infer_visibility(qualified_name: &str) -> &'static str {
42    // For qualified names like "MyClass._privateMethod", check the method name part
43    let name_part = qualified_name.rsplit('.').next().unwrap_or(qualified_name);
44    if name_part.starts_with('_') {
45        "private"
46    } else {
47        "public"
48    }
49}
50
51impl GraphBuilder for JavaScriptGraphBuilder {
52    fn build_graph(
53        &self,
54        tree: &Tree,
55        content: &[u8],
56        file: &Path,
57        staging: &mut StagingGraph,
58    ) -> GraphResult<()> {
59        // Initialize the helper for this file
60        let mut helper = GraphBuildHelper::new(staging, file, Language::JavaScript);
61        let file_arc = Arc::from(file.to_string_lossy().to_string());
62
63        // Build AST graph for context resolution
64        let ast_graph = ASTGraph::from_tree(tree, content, self.max_scope_depth).map_err(|e| {
65            GraphBuilderError::ParseError {
66                span: Span::default(),
67                reason: e,
68            }
69        })?;
70
71        // Create function/method nodes for all callables
72        for context in ast_graph.contexts() {
73            let span = Some(Span::from_bytes(context.span.0, context.span.1));
74            // Infer visibility from naming convention: leading underscore = private
75            let visibility = infer_visibility(&context.qualified_name);
76
77            // Determine if this is a method (contains a dot indicating it's in a class)
78            if context.qualified_name.contains('.') {
79                helper.add_method_with_visibility(
80                    &context.qualified_name,
81                    span,
82                    context.is_async,
83                    false, // is_static - we don't track this in CallContext
84                    Some(visibility),
85                );
86            } else {
87                helper.add_function_with_visibility(
88                    &context.qualified_name,
89                    span,
90                    context.is_async,
91                    false, // is_unsafe - N/A for JavaScript
92                    Some(visibility),
93                );
94            }
95        }
96
97        // Build local scope tree for variable reference resolution
98        let mut scope_tree = local_scopes::build(tree.root_node(), content)?;
99
100        // Walk the AST to find and build edges
101        let mut cursor = tree.root_node().walk();
102        extract_edges_recursive(
103            tree.root_node(),
104            &mut cursor,
105            content,
106            &file_arc,
107            &ast_graph,
108            &mut helper,
109            &mut scope_tree,
110        )?;
111
112        // Second pass: Process JSDoc annotations for TypeOf and Reference edges
113        process_jsdoc_annotations(tree.root_node(), content, &mut helper)?;
114
115        Ok(())
116    }
117
118    fn language(&self) -> Language {
119        Language::JavaScript
120    }
121}
122
123/// Recursively extract edges (calls, constructors, imports) from the AST
124fn extract_edges_recursive<'a>(
125    node: Node<'a>,
126    cursor: &mut tree_sitter::TreeCursor<'a>,
127    content: &[u8],
128    file: &Arc<str>,
129    ast_graph: &ASTGraph,
130    helper: &mut GraphBuildHelper,
131    scope_tree: &mut local_scopes::JavaScriptScopeTree,
132) -> GraphResult<()> {
133    match node.kind() {
134        "call_expression" => {
135            // Add HTTP request edges when applicable (fetch/axios patterns)
136            let _ = build_http_request_edge(ast_graph, node, content, helper);
137            // Detect Express/Koa/Fastify route endpoint registrations
138            let _ = detect_route_endpoint(node, content, helper);
139            // Check for FFI patterns first (WebAssembly, native addons)
140            let is_ffi = build_ffi_call_edge(ast_graph, node, content, helper)?;
141            if !is_ffi {
142                // Not an FFI call - process as regular call
143                if let Some((caller_id, callee_id, argument_count, is_async, span)) =
144                    build_call_edge_with_helper(ast_graph, node, content, helper)?
145                {
146                    helper.add_call_edge_full_with_span(
147                        caller_id,
148                        callee_id,
149                        argument_count,
150                        is_async,
151                        span.into_iter().collect(),
152                    );
153                }
154            }
155        }
156        "new_expression" => {
157            // Check for WebAssembly constructor patterns
158            let is_ffi = build_ffi_new_edge(ast_graph, node, content, helper)?;
159            if !is_ffi {
160                // Not an FFI constructor - process as regular constructor
161                if let Some((caller_id, callee_id, argument_count, span)) =
162                    build_constructor_edge_with_helper(ast_graph, node, content, helper)?
163                {
164                    helper.add_call_edge_full_with_span(
165                        caller_id,
166                        callee_id,
167                        argument_count,
168                        false,
169                        span.into_iter().collect(),
170                    );
171                }
172            }
173        }
174        "import_statement" => {
175            if let Some((from_id, to_id)) =
176                build_import_edge_with_helper(node, content, file, helper)?
177            {
178                helper.add_import_edge(from_id, to_id);
179            }
180        }
181        "export_statement" => {
182            build_export_edges_with_helper(node, content, file, helper);
183        }
184        "expression_statement" => {
185            // Check for CommonJS export patterns
186            build_commonjs_export_edges(node, content, helper);
187        }
188        "class_declaration" | "class" => {
189            build_inherits_edge_with_helper(node, content, helper);
190        }
191        "identifier" => {
192            local_scopes::handle_identifier_for_reference(node, content, scope_tree, helper);
193        }
194        _ => {}
195    }
196
197    // Recursively process children
198    // Collect children into a vec to avoid borrowing issues
199    let children: Vec<_> = node.children(cursor).collect();
200    for child in children {
201        let mut child_cursor = child.walk();
202        extract_edges_recursive(
203            child,
204            &mut child_cursor,
205            content,
206            file,
207            ast_graph,
208            helper,
209            scope_tree,
210        )?;
211    }
212
213    Ok(())
214}
215
216/// Build a call edge using `GraphBuildHelper`
217fn build_call_edge_with_helper(
218    ast_graph: &ASTGraph,
219    call_node: Node<'_>,
220    content: &[u8],
221    helper: &mut GraphBuildHelper,
222) -> GraphResult<Option<CallEdgeData>> {
223    // Get or create module-level context for top-level calls
224    let module_context;
225    let call_context = if let Some(ctx) = ast_graph.get_callable_context(call_node.id()) {
226        ctx
227    } else {
228        // Create synthetic module-level context for top-level calls
229        module_context = CallContext {
230            name: Arc::from("<module>"),
231            qualified_name: "<module>".to_string(),
232            span: (0, content.len()),
233            is_async: false,
234        };
235        &module_context
236    };
237
238    let Some(callee_expr) = call_node.child_by_field_name("function") else {
239        return Ok(None);
240    };
241
242    let raw_callee_text = callee_expr
243        .utf8_text(content)
244        .map_err(|_| GraphBuilderError::ParseError {
245            span: span_from_node(call_node),
246            reason: "failed to read call expression".to_string(),
247        })?
248        .trim()
249        .to_string();
250
251    // Normalize optional chain syntax
252    let callee_text = if raw_callee_text.contains("?.") {
253        normalize_optional_chain(&raw_callee_text)
254    } else {
255        raw_callee_text
256    };
257
258    if callee_text.is_empty() {
259        return Ok(None);
260    }
261
262    let callee_simple = simple_name(&callee_text);
263    if callee_simple.is_empty() {
264        return Ok(None);
265    }
266
267    // Derive qualified callee name with proper this/super resolution
268    let caller_qname = call_context.qualified_name();
269    let target_qname = if let Some(method_name) = callee_text.strip_prefix("this.") {
270        // Resolve this.method() to ClassName.method()
271        if let Some(scope_idx) = caller_qname.rfind('.') {
272            let class_name = &caller_qname[..scope_idx];
273            format!("{}.{}", class_name, simple_name(method_name))
274        } else {
275            callee_text.clone()
276        }
277    } else if callee_text.starts_with("super.") || callee_text.contains('.') {
278        callee_text.clone()
279    } else {
280        callee_simple.to_string()
281    };
282
283    // Ensure nodes exist using helper
284    let source_id = ensure_caller_node(helper, call_context);
285    let target_id = helper.ensure_function(&target_qname, None, false, false);
286
287    let span = Some(span_from_node(call_node));
288    let argument_count = u8::try_from(count_arguments(call_node)).unwrap_or(u8::MAX);
289    let is_async = check_uses_await(call_node);
290
291    Ok(Some((source_id, target_id, argument_count, is_async, span)))
292}
293
294#[derive(Debug, Clone)]
295struct HttpRequestInfo {
296    method: HttpMethod,
297    url: Option<String>,
298}
299
300fn build_http_request_edge(
301    ast_graph: &ASTGraph,
302    call_node: Node<'_>,
303    content: &[u8],
304    helper: &mut GraphBuildHelper,
305) -> bool {
306    let Some(info) = extract_http_request_info(call_node, content) else {
307        return false;
308    };
309
310    let caller_id = get_caller_node_id(ast_graph, call_node, content, helper);
311    let target_name = info.url.as_ref().map_or_else(
312        || format!("http::{}", info.method.as_str()),
313        |url| format!("http::{url}"),
314    );
315    let target_id = helper.add_module(&target_name, Some(span_from_node(call_node)));
316
317    helper.add_http_request_edge(caller_id, target_id, info.method, info.url.as_deref());
318    true
319}
320
321/// Detect Express/Koa/Fastify-style route endpoint registrations.
322///
323/// Matches patterns like:
324/// - `app.get("/api/users", handler)` -> Endpoint node `route::GET::/api/users`
325/// - `router.post("/api/items", handler)` -> Endpoint node `route::POST::/api/items`
326/// - `app.delete("/api/items/:id", handler)` -> Endpoint node `route::DELETE::/api/items/:id`
327/// - `server.all("/health", handler)` -> Endpoint node `route::ALL::/health`
328///
329/// The receiver can be any variable name (app, router, server, etc.).
330/// Creates an Endpoint node with qualified name `route::METHOD::/path` and a
331/// Contains edge from the endpoint to the handler function if identifiable.
332///
333/// Returns `true` if a route endpoint was detected, `false` otherwise.
334fn detect_route_endpoint(
335    call_node: Node<'_>,
336    content: &[u8],
337    helper: &mut GraphBuildHelper,
338) -> bool {
339    // The callee must be a member_expression (e.g., `app.get`)
340    let Some(callee) = call_node.child_by_field_name("function") else {
341        return false;
342    };
343
344    if callee.kind() != "member_expression" {
345        return false;
346    }
347
348    // Extract the property name (the HTTP method)
349    let Some(property) = callee.child_by_field_name("property") else {
350        return false;
351    };
352
353    let Ok(method_name) = property.utf8_text(content) else {
354        return false;
355    };
356    let method_name = method_name.trim();
357
358    // Map the property name to an HTTP method string for the qualified name
359    let method_str = match method_name {
360        "get" => "GET",
361        "post" => "POST",
362        "put" => "PUT",
363        "delete" => "DELETE",
364        "patch" => "PATCH",
365        "all" => "ALL",
366        _ => return false,
367    };
368
369    // Extract the first argument which should be the route path string
370    let Some(args) = call_node.child_by_field_name("arguments") else {
371        return false;
372    };
373
374    let mut cursor = args.walk();
375    let first_arg = args
376        .children(&mut cursor)
377        .find(|child| !matches!(child.kind(), "(" | ")" | ","));
378
379    let Some(first_arg) = first_arg else {
380        return false;
381    };
382
383    // The first argument must be a string literal containing the path
384    let Some(path) = extract_string_literal(&first_arg, content) else {
385        return false;
386    };
387
388    // Build the qualified endpoint name: route::METHOD::/path
389    let qualified_name = format!("route::{method_str}::{path}");
390
391    // Create the Endpoint node
392    let endpoint_id = helper.add_endpoint(&qualified_name, Some(span_from_node(call_node)));
393
394    // Try to find and link the handler function (second argument)
395    // Supports: identifier references, member expressions
396    let mut handler_cursor = args.walk();
397    let handler_arg = args
398        .children(&mut handler_cursor)
399        .filter(|child| !matches!(child.kind(), "(" | ")" | ","))
400        .nth(1);
401
402    if let Some(handler_node) = handler_arg
403        && let Ok(handler_text) = handler_node.utf8_text(content)
404    {
405        let handler_name = handler_text.trim();
406        if !handler_name.is_empty()
407            && matches!(handler_node.kind(), "identifier" | "member_expression")
408        {
409            let handler_id = helper.ensure_function(handler_name, None, false, false);
410            helper.add_contains_edge(endpoint_id, handler_id);
411        }
412    }
413
414    true
415}
416
417fn extract_http_request_info(call_node: Node<'_>, content: &[u8]) -> Option<HttpRequestInfo> {
418    let callee = call_node.child_by_field_name("function")?;
419    let callee_text = callee.utf8_text(content).ok()?.trim().to_string();
420
421    if callee_text == "fetch" {
422        return Some(extract_fetch_http_info(call_node, content));
423    }
424
425    if callee_text == "axios" {
426        return extract_axios_http_info(call_node, content);
427    }
428
429    if let Some(method_name) = callee_text.strip_prefix("axios.") {
430        let method = http_method_from_name(method_name)?;
431        let url = extract_first_arg_url(call_node, content);
432        return Some(HttpRequestInfo { method, url });
433    }
434
435    None
436}
437
438fn extract_fetch_http_info(call_node: Node<'_>, content: &[u8]) -> HttpRequestInfo {
439    let url = extract_first_arg_url(call_node, content);
440    let method = extract_method_from_options(call_node, content).unwrap_or(HttpMethod::Get);
441    HttpRequestInfo { method, url }
442}
443
444fn extract_axios_http_info(call_node: Node<'_>, content: &[u8]) -> Option<HttpRequestInfo> {
445    let args = call_node.child_by_field_name("arguments")?;
446    let mut cursor = args.walk();
447    let mut non_trivia = args
448        .children(&mut cursor)
449        .filter(|child| !matches!(child.kind(), "(" | ")" | ","));
450
451    let first_arg = non_trivia.next()?;
452    let second_arg = non_trivia.next();
453
454    if first_arg.kind() == "object" {
455        let (method, url) = extract_method_and_url_from_object(first_arg, content);
456        return Some(HttpRequestInfo {
457            method: method.unwrap_or(HttpMethod::Get),
458            url,
459        });
460    }
461
462    let url = extract_string_literal(&first_arg, content);
463    let method = if let Some(config) = second_arg {
464        if config.kind() == "object" {
465            extract_method_from_object(config, content)
466        } else {
467            None
468        }
469    } else {
470        None
471    };
472
473    Some(HttpRequestInfo {
474        method: method.unwrap_or(HttpMethod::Get),
475        url,
476    })
477}
478
479fn extract_first_arg_url(call_node: Node<'_>, content: &[u8]) -> Option<String> {
480    let args = call_node.child_by_field_name("arguments")?;
481    let mut cursor = args.walk();
482    let first_arg = args
483        .children(&mut cursor)
484        .find(|child| !matches!(child.kind(), "(" | ")" | ","))?;
485    extract_string_literal(&first_arg, content)
486}
487
488fn extract_method_from_options(call_node: Node<'_>, content: &[u8]) -> Option<HttpMethod> {
489    let args = call_node.child_by_field_name("arguments")?;
490    let mut cursor = args.walk();
491    let mut non_trivia = args
492        .children(&mut cursor)
493        .filter(|child| !matches!(child.kind(), "(" | ")" | ","));
494
495    let _first_arg = non_trivia.next()?;
496    let second_arg = non_trivia.next()?;
497    if second_arg.kind() != "object" {
498        return None;
499    }
500
501    extract_method_from_object(second_arg, content)
502}
503
504fn extract_method_from_object(obj_node: Node<'_>, content: &[u8]) -> Option<HttpMethod> {
505    let (method, _url) = extract_method_and_url_from_object(obj_node, content);
506    method
507}
508
509fn extract_method_and_url_from_object(
510    obj_node: Node<'_>,
511    content: &[u8],
512) -> (Option<HttpMethod>, Option<String>) {
513    let mut method = None;
514    let mut url = None;
515    let mut cursor = obj_node.walk();
516
517    for child in obj_node.children(&mut cursor) {
518        if child.kind() != "pair" {
519            continue;
520        }
521
522        let Some(key_node) = child.child_by_field_name("key") else {
523            continue;
524        };
525        let key_text = extract_object_key_text(&key_node, content);
526
527        let Some(value_node) = child.child_by_field_name("value") else {
528            continue;
529        };
530
531        if key_text.as_deref() == Some("method") {
532            if let Some(value) = extract_string_literal(&value_node, content) {
533                method = http_method_from_name(&value);
534            }
535        } else if key_text.as_deref() == Some("url") {
536            url = extract_string_literal(&value_node, content);
537        }
538    }
539
540    (method, url)
541}
542
543fn extract_object_key_text(node: &Node<'_>, content: &[u8]) -> Option<String> {
544    let raw = node.utf8_text(content).ok()?.trim().to_string();
545    if let Some(value) = extract_string_literal(node, content) {
546        return Some(value);
547    }
548    if raw.is_empty() {
549        return None;
550    }
551    Some(raw)
552}
553
554fn http_method_from_name(name: &str) -> Option<HttpMethod> {
555    match name.trim().to_ascii_lowercase().as_str() {
556        "get" => Some(HttpMethod::Get),
557        "post" => Some(HttpMethod::Post),
558        "put" => Some(HttpMethod::Put),
559        "delete" => Some(HttpMethod::Delete),
560        "patch" => Some(HttpMethod::Patch),
561        "head" => Some(HttpMethod::Head),
562        "options" => Some(HttpMethod::Options),
563        _ => None,
564    }
565}
566
567/// Build a constructor edge using `GraphBuildHelper`
568fn build_constructor_edge_with_helper(
569    ast_graph: &ASTGraph,
570    new_node: Node<'_>,
571    content: &[u8],
572    helper: &mut GraphBuildHelper,
573) -> GraphResult<Option<ConstructorEdgeData>> {
574    // Get or create module-level context
575    let module_context;
576    let call_context = if let Some(ctx) = ast_graph.get_callable_context(new_node.id()) {
577        ctx
578    } else {
579        module_context = CallContext {
580            name: Arc::from("<module>"),
581            qualified_name: "<module>".to_string(),
582            span: (0, content.len()),
583            is_async: false,
584        };
585        &module_context
586    };
587
588    let Some(constructor_expr) = new_node.child_by_field_name("constructor") else {
589        return Ok(None);
590    };
591
592    let constructor_text = constructor_expr
593        .utf8_text(content)
594        .map_err(|_| GraphBuilderError::ParseError {
595            span: span_from_node(new_node),
596            reason: "failed to read constructor expression".to_string(),
597        })?
598        .trim()
599        .to_string();
600
601    if constructor_text.is_empty() {
602        return Ok(None);
603    }
604
605    let constructor_simple = simple_name(&constructor_text);
606    let source_id = ensure_caller_node(helper, call_context);
607    let target_id = helper.ensure_function(constructor_simple, None, false, false);
608
609    let span = Some(span_from_node(new_node));
610    let argument_count = u8::try_from(count_arguments(new_node)).unwrap_or(u8::MAX);
611
612    Ok(Some((source_id, target_id, argument_count, span)))
613}
614
615/// Build an import edge using `GraphBuildHelper`
616fn build_import_edge_with_helper(
617    import_node: Node<'_>,
618    content: &[u8],
619    file: &Arc<str>,
620    helper: &mut GraphBuildHelper,
621) -> GraphResult<
622    Option<(
623        sqry_core::graph::unified::NodeId,
624        sqry_core::graph::unified::NodeId,
625    )>,
626> {
627    let Some(source_node) = import_node.child_by_field_name("source") else {
628        return Ok(None);
629    };
630
631    let source_text = source_node
632        .utf8_text(content)
633        .map_err(|_| GraphBuilderError::ParseError {
634            span: span_from_node(import_node),
635            reason: "failed to read import source".to_string(),
636        })?
637        .trim()
638        .trim_matches(|c| c == '"' || c == '\'')
639        .to_string();
640
641    if source_text.is_empty() {
642        return Ok(None);
643    }
644
645    // Resolve the import path
646    let resolved_path =
647        sqry_core::graph::resolve_import_path(std::path::Path::new(file.as_ref()), &source_text)?;
648
649    // Create module nodes
650    let from_id = helper.add_module("<module>", None);
651    let to_id = helper.add_import(&resolved_path, Some(span_from_node(import_node)));
652
653    Ok(Some((from_id, to_id)))
654}
655
656/// Build export edges from an `export_statement` node.
657///
658/// Handles all JavaScript/ESM export forms:
659/// - `export default foo` -> Default export
660/// - `export { name }` -> Named export (Direct)
661/// - `export { name as alias }` -> Named export with alias
662/// - `export * from 'module'` -> Wildcard re-export
663/// - `export { name } from 'module'` -> Named re-export
664/// - `export * as ns from 'module'` -> Namespace re-export
665/// - `export function/class/const` -> Declaration exports (Direct)
666#[allow(clippy::too_many_lines)]
667fn build_export_edges_with_helper(
668    export_node: Node<'_>,
669    content: &[u8],
670    file: &Arc<str>,
671    helper: &mut GraphBuildHelper,
672) {
673    // Get the module node (exporter)
674    let module_id = helper.add_module("<module>", None);
675
676    // Check for re-export: has a "source" (from clause)
677    let source_node = export_node.child_by_field_name("source");
678    let is_reexport = source_node.is_some();
679
680    // Check for default export
681    let has_default = export_node
682        .children(&mut export_node.walk())
683        .any(|child| child.kind() == "default");
684
685    // Check for namespace export: `export * as ns from 'module'`
686    let namespace_export = export_node
687        .children(&mut export_node.walk())
688        .find(|child| child.kind() == "namespace_export");
689
690    // Check for wildcard: `export * from 'module'`
691    let has_wildcard = export_node
692        .children(&mut export_node.walk())
693        .any(|child| child.kind() == "*");
694
695    // Check for export clause: `export { foo, bar }`
696    let export_clause = export_node
697        .children(&mut export_node.walk())
698        .find(|child| child.kind() == "export_clause");
699
700    // Check for declaration export: `export function/class/const/let/var`
701    let declaration = export_node.children(&mut export_node.walk()).find(|child| {
702        matches!(
703            child.kind(),
704            "function_declaration"
705                | "class_declaration"
706                | "lexical_declaration"
707                | "variable_declaration"
708                | "generator_function_declaration"
709        )
710    });
711
712    if has_default {
713        // Default export: `export default foo` or `export default function foo() {}`
714        // Find the exported item (identifier, function, class, etc.)
715        let exported_name = if let Some(ref decl) = declaration {
716            // export default function foo() {} or export default class Bar {}
717            decl.child_by_field_name("name")
718                .and_then(|n| n.utf8_text(content).ok())
719                .map_or_else(|| "default".to_string(), |s| s.trim().to_string())
720        } else {
721            // export default identifier
722            export_node
723                .children(&mut export_node.walk())
724                .find(|child| child.kind() == "identifier")
725                .and_then(|n| n.utf8_text(content).ok())
726                .map_or_else(|| "default".to_string(), |s| s.trim().to_string())
727        };
728
729        let exported_id = helper.add_function(&exported_name, None, false, false);
730        helper.add_export_edge_full(module_id, exported_id, ExportKind::Default, None);
731    } else if let Some(ns_export) = namespace_export {
732        // Namespace re-export: `export * as ns from 'module'`
733        // Get the namespace alias from the namespace_export node
734        let alias = ns_export
735            .children(&mut ns_export.walk())
736            .find(|child| child.kind() == "identifier")
737            .and_then(|n| n.utf8_text(content).ok())
738            .map(|s| s.trim().to_string());
739
740        // Create a node representing the source module
741        let source_path = source_node
742            .and_then(|s| s.utf8_text(content).ok())
743            .map_or_else(
744                || "<unknown>".to_string(),
745                |s| s.trim().trim_matches(|c| c == '"' || c == '\'').to_string(),
746            );
747
748        let resolved_path = sqry_core::graph::resolve_import_path(
749            std::path::Path::new(file.as_ref()),
750            &source_path,
751        )
752        .unwrap_or(source_path);
753
754        let source_module_id = helper.add_module(&resolved_path, None);
755        helper.add_export_edge_full(
756            module_id,
757            source_module_id,
758            ExportKind::Namespace,
759            alias.as_deref(),
760        );
761    } else if has_wildcard && is_reexport {
762        // Wildcard re-export: `export * from 'module'`
763        let source_path = source_node
764            .and_then(|s| s.utf8_text(content).ok())
765            .map_or_else(
766                || "<unknown>".to_string(),
767                |s| s.trim().trim_matches(|c| c == '"' || c == '\'').to_string(),
768            );
769
770        let resolved_path = sqry_core::graph::resolve_import_path(
771            std::path::Path::new(file.as_ref()),
772            &source_path,
773        )
774        .unwrap_or(source_path);
775
776        let source_module_id = helper.add_module(&resolved_path, None);
777        // Wildcard re-export uses Reexport kind with no alias
778        helper.add_export_edge_full(module_id, source_module_id, ExportKind::Reexport, None);
779    } else if let Some(clause) = export_clause {
780        // Named exports: `export { foo, bar }` or `export { foo } from 'module'`
781        let mut cursor = clause.walk();
782        for child in clause.children(&mut cursor) {
783            if child.kind() == "export_specifier" {
784                // Get the identifiers from the export specifier
785                // First identifier is the local name, second (if present) is the alias
786                let identifiers: Vec<_> = child
787                    .children(&mut child.walk())
788                    .filter(|n| n.kind() == "identifier")
789                    .collect();
790
791                if let Some(first_ident) = identifiers.first() {
792                    let local_name = first_ident
793                        .utf8_text(content)
794                        .ok()
795                        .map(|s| s.trim().to_string())
796                        .unwrap_or_default();
797
798                    if local_name.is_empty() {
799                        continue;
800                    }
801
802                    // Check if there's an alias (second identifier)
803                    let alias = identifiers.get(1).and_then(|n| {
804                        n.utf8_text(content)
805                            .ok()
806                            .map(|s| s.trim().to_string())
807                            .filter(|s| !s.is_empty())
808                    });
809
810                    let exported_id = helper.add_function(&local_name, None, false, false);
811
812                    let kind = if is_reexport {
813                        ExportKind::Reexport
814                    } else {
815                        ExportKind::Direct
816                    };
817
818                    helper.add_export_edge_full(module_id, exported_id, kind, alias.as_deref());
819                }
820            }
821        }
822    } else if let Some(decl) = declaration {
823        // Declaration export: `export function foo() {}` or `export const x = 1;`
824        match decl.kind() {
825            "function_declaration" | "generator_function_declaration" => {
826                if let Some(name_node) = decl.child_by_field_name("name")
827                    && let Ok(name) = name_node.utf8_text(content)
828                {
829                    let name = name.trim().to_string();
830                    if !name.is_empty() {
831                        let exported_id = helper.add_function(&name, None, false, false);
832                        helper.add_export_edge_full(
833                            module_id,
834                            exported_id,
835                            ExportKind::Direct,
836                            None,
837                        );
838                    }
839                }
840            }
841            "class_declaration" => {
842                if let Some(name_node) = decl.child_by_field_name("name")
843                    && let Ok(name) = name_node.utf8_text(content)
844                {
845                    let name = name.trim().to_string();
846                    if !name.is_empty() {
847                        let exported_id = helper.add_class(&name, None);
848                        helper.add_export_edge_full(
849                            module_id,
850                            exported_id,
851                            ExportKind::Direct,
852                            None,
853                        );
854                    }
855                }
856            }
857            "lexical_declaration" | "variable_declaration" => {
858                // export const/let/var - can have multiple declarators
859                let mut cursor = decl.walk();
860                for child in decl.children(&mut cursor) {
861                    if child.kind() == "variable_declarator"
862                        && let Some(name_node) = child.child_by_field_name("name")
863                        && let Ok(name) = name_node.utf8_text(content)
864                    {
865                        let name = name.trim().to_string();
866                        if !name.is_empty() {
867                            let exported_id = helper.add_variable(&name, None);
868                            helper.add_export_edge_full(
869                                module_id,
870                                exported_id,
871                                ExportKind::Direct,
872                                None,
873                            );
874                        }
875                    }
876                }
877            }
878            _ => {}
879        }
880    }
881}
882
883/// Build export edges for `CommonJS` patterns.
884///
885/// Handles:
886/// - `module.exports = { foo, bar }` -> Named exports from object literal
887/// - `module.exports = foo` -> Default export
888/// - `exports.foo = bar` -> Named export
889/// - `module.exports.foo = bar` -> Named export
890fn build_commonjs_export_edges(
891    expr_stmt_node: Node<'_>,
892    content: &[u8],
893    helper: &mut GraphBuildHelper,
894) {
895    // Get the assignment expression from the expression statement
896    let Some(assignment) = expr_stmt_node
897        .children(&mut expr_stmt_node.walk())
898        .find(|child| child.kind() == "assignment_expression")
899    else {
900        return;
901    };
902
903    let Some(left) = assignment.child_by_field_name("left") else {
904        return;
905    };
906    let Some(right) = assignment.child_by_field_name("right") else {
907        return;
908    };
909
910    let left_text = left.utf8_text(content).ok().map(|s| s.trim().to_string());
911    let Some(left_text) = left_text else {
912        return;
913    };
914
915    let module_id = helper.add_module("<module>", None);
916
917    // Pattern 1: `module.exports = { foo, bar }` or `module.exports = foo`
918    if left_text == "module.exports" {
919        if right.kind() == "object" {
920            // Object literal: export each property as a named export
921            let mut cursor = right.walk();
922            for child in right.children(&mut cursor) {
923                if child.kind() == "shorthand_property_identifier" {
924                    // `{ foo }` - shorthand, name is both local and exported
925                    if let Ok(name) = child.utf8_text(content) {
926                        let name = name.trim();
927                        if !name.is_empty() {
928                            let exported_id = helper.add_function(name, None, false, false);
929                            helper.add_export_edge_full(
930                                module_id,
931                                exported_id,
932                                ExportKind::Direct,
933                                None,
934                            );
935                        }
936                    }
937                } else if child.kind() == "pair" {
938                    // `{ foo: bar }` - key is export name, value is local
939                    if let Some(key_node) = child.child_by_field_name("key")
940                        && let Ok(export_name) = key_node.utf8_text(content)
941                    {
942                        let export_name = export_name.trim();
943                        if !export_name.is_empty() {
944                            let exported_id = helper.add_function(export_name, None, false, false);
945                            helper.add_export_edge_full(
946                                module_id,
947                                exported_id,
948                                ExportKind::Direct,
949                                None,
950                            );
951                        }
952                    }
953                } else if child.kind() == "spread_element" {
954                    // `{ ...other }` - spread export (complex to resolve statically)
955                }
956            }
957        } else if right.kind() == "identifier" || right.kind() == "member_expression" {
958            // Single value export: `module.exports = foo` -> default export
959            let export_name = right
960                .utf8_text(content)
961                .ok()
962                .map_or_else(|| "default".to_string(), |s| s.trim().to_string());
963
964            if !export_name.is_empty() {
965                let exported_id = helper.add_function(&export_name, None, false, false);
966                helper.add_export_edge_full(module_id, exported_id, ExportKind::Default, None);
967            }
968        } else if matches!(
969            right.kind(),
970            "function_expression"
971                | "arrow_function"
972                | "class"
973                | "call_expression"
974                | "new_expression"
975        ) {
976            // Anonymous/inline export: `module.exports = function() {}` -> default export
977            let exported_id = helper.add_function("default", None, false, false);
978            helper.add_export_edge_full(module_id, exported_id, ExportKind::Default, None);
979        }
980    }
981    // Pattern 2: `exports.foo = bar` or `module.exports.foo = bar`
982    else if left_text.starts_with("exports.") || left_text.starts_with("module.exports.") {
983        // Extract the property name being exported
984        let export_name = if let Some(name) = left_text.strip_prefix("module.exports.") {
985            name
986        } else if let Some(name) = left_text.strip_prefix("exports.") {
987            name
988        } else {
989            return;
990        };
991
992        if !export_name.is_empty() {
993            let exported_id = helper.add_function(export_name, None, false, false);
994            helper.add_export_edge_full(module_id, exported_id, ExportKind::Direct, None);
995        }
996    }
997}
998
999/// Build inherits edge for class declarations with extends clause.
1000///
1001/// Handles:
1002/// - `class Child extends Parent {}` (`class_declaration` with simple identifier)
1003/// - `class Child extends Module.Parent {}` (`class_declaration` with qualified path)
1004/// - `const Foo = class extends Base {}` (class expression)
1005fn build_inherits_edge_with_helper(
1006    class_node: Node<'_>,
1007    content: &[u8],
1008    helper: &mut GraphBuildHelper,
1009) {
1010    // Look for class_heritage child which contains the extends clause
1011    let heritage = class_node
1012        .children(&mut class_node.walk())
1013        .find(|child| child.kind() == "class_heritage");
1014
1015    let Some(heritage_node) = heritage else {
1016        return; // No inheritance
1017    };
1018
1019    // Get the class name
1020    let class_name = if class_node.kind() == "class_declaration" {
1021        class_node
1022            .child_by_field_name("name")
1023            .and_then(|n| n.utf8_text(content).ok())
1024            .map(|s| s.trim().to_string())
1025    } else {
1026        // For class expressions, try to get the name from parent variable_declarator
1027        class_node
1028            .parent()
1029            .filter(|p| p.kind() == "variable_declarator")
1030            .and_then(|p| p.child_by_field_name("name"))
1031            .and_then(|n| n.utf8_text(content).ok())
1032            .map(|s| s.trim().to_string())
1033            .or_else(|| {
1034                // Anonymous class expression - use synthetic name
1035                Some(SyntheticNameBuilder::from_node_with_hash(
1036                    &class_node,
1037                    content,
1038                    "class",
1039                ))
1040            })
1041    };
1042
1043    // Get the parent class name from heritage
1044    // Handles both simple identifiers and qualified paths (member_expression)
1045    let parent_name = extract_parent_class_name(heritage_node, content);
1046
1047    // Only create edge if we have both names
1048    if let (Some(child_name), Some(parent_name)) = (class_name, parent_name)
1049        && !child_name.is_empty()
1050        && !parent_name.is_empty()
1051    {
1052        let child_id = helper.add_class(&child_name, None);
1053        let parent_id = helper.add_class(&parent_name, None);
1054        helper.add_inherits_edge(child_id, parent_id);
1055    }
1056}
1057
1058/// Extract the parent class name from a `class_heritage` node.
1059///
1060/// Handles:
1061/// - Simple identifier: `extends Parent` -> "Parent"
1062/// - Member expression: `extends Module.Parent` -> "Module.Parent"
1063/// - Nested member: `extends a.b.c.Parent` -> "a.b.c.Parent"
1064/// - Call expression: `extends mixin(Base)` -> "mixin(Base)" (full expression for clarity)
1065///
1066/// **Note on mixin patterns**: For call expressions like `extends mixin(Base)` or
1067/// `extends WithLogging(Component)`, we store the full expression text rather than
1068/// just the function name. This provides clearer semantics for consumers:
1069/// - The node name shows the actual mixin composition
1070/// - Graph queries can distinguish `mixin(A)` from `mixin(B)`
1071/// - The pattern remains compatible with standard inheritance queries
1072fn extract_parent_class_name(heritage_node: Node<'_>, content: &[u8]) -> Option<String> {
1073    let mut cursor = heritage_node.walk();
1074    for child in heritage_node.children(&mut cursor) {
1075        match child.kind() {
1076            "identifier" => {
1077                // Simple extends: `extends Parent`
1078                return child.utf8_text(content).ok().map(|s| s.trim().to_string());
1079            }
1080            "member_expression" => {
1081                // Qualified extends: `extends Module.Parent` or `extends a.b.c.Parent`
1082                // Get the full text of the member expression
1083                return child.utf8_text(content).ok().map(|s| s.trim().to_string());
1084            }
1085            "call_expression" => {
1086                // Mixin pattern: `extends mixin(Base)` or `extends WithLogging(Component)`
1087                // Store full call expression for semantic clarity - consumers can see
1088                // the actual composition, not just the mixin factory function name.
1089                // This avoids ambiguity when the same mixin is used with different bases.
1090                return child.utf8_text(content).ok().map(|s| s.trim().to_string());
1091            }
1092            _ => {}
1093        }
1094    }
1095    None
1096}
1097
1098fn simple_name(name: &str) -> &str {
1099    // Split on . and / to get the last segment of a qualified name
1100    // Do NOT split on '?' - it's part of ternary (?:) and nullish coalescing (??) operators
1101    name.rsplit(['.', '/']).next().unwrap_or(name)
1102}
1103
1104/// Normalizes optional chain syntax by removing `?.` operators
1105/// Converts `user?.getName` to `user.getName` for consistent processing
1106/// Preserves standalone `?` characters (from ternary/nullish operators) by only replacing `?.`
1107fn normalize_optional_chain(text: &str) -> String {
1108    text.replace("?.", ".")
1109        .trim()
1110        .trim_end_matches('.')
1111        .to_string()
1112}
1113
1114fn check_uses_await(call_node: Node<'_>) -> bool {
1115    // Check if the call_node's parent is an await_expression
1116    let mut current = call_node;
1117    for _ in 0..2 {
1118        // Check up to 2 levels up
1119        if let Some(parent) = current.parent() {
1120            if parent.kind() == "await_expression" {
1121                return true;
1122            }
1123            current = parent;
1124        } else {
1125            break;
1126        }
1127    }
1128    false
1129}
1130
1131fn count_arguments(node: Node<'_>) -> usize {
1132    node.child_by_field_name("arguments").map_or(0, |args| {
1133        let mut count = 0;
1134        let mut cursor = args.walk();
1135        for child in args.children(&mut cursor) {
1136            if !matches!(child.kind(), "(" | ")" | ",") {
1137                count += 1;
1138            }
1139        }
1140        count
1141    })
1142}
1143
1144fn span_from_node(node: Node<'_>) -> Span {
1145    let start = node.start_position();
1146    let end = node.end_position();
1147    Span::new(
1148        Position::new(start.row, start.column),
1149        Position::new(end.row, end.column),
1150    )
1151}
1152
1153fn extract_string_literal(node: &Node, content: &[u8]) -> Option<String> {
1154    let text = node.utf8_text(content).ok()?;
1155    let trimmed = text.trim();
1156
1157    // Remove quotes
1158    trimmed
1159        .strip_prefix('"')
1160        .and_then(|s| s.strip_suffix('"'))
1161        .or_else(|| {
1162            trimmed
1163                .strip_prefix('\'')
1164                .and_then(|s| s.strip_suffix('\''))
1165        })
1166        .or_else(|| trimmed.strip_prefix('`').and_then(|s| s.strip_suffix('`')))
1167        .map(std::string::ToString::to_string)
1168}
1169
1170// ========== ASTGraph: Pre-computed AST metadata ==========
1171
1172#[derive(Debug, Clone)]
1173pub struct CallContext {
1174    #[allow(dead_code)] // Reserved for future context queries
1175    pub name: Arc<str>,
1176    pub qualified_name: String,
1177    pub span: (usize, usize),
1178    pub is_async: bool,
1179}
1180
1181impl CallContext {
1182    pub fn qualified_name(&self) -> &str {
1183        &self.qualified_name
1184    }
1185}
1186
1187pub struct ASTGraph {
1188    /// Maps node ID to its enclosing callable node ID
1189    callable_map: HashMap<usize, usize>,
1190    /// Maps callable node ID to its context (name, scope, etc.)
1191    context_map: HashMap<usize, CallContext>,
1192}
1193
1194impl ASTGraph {
1195    /// Build the graph structure from the AST in a single O(n) pass
1196    pub fn from_tree(tree: &Tree, content: &[u8], max_scope_depth: usize) -> Result<Self, String> {
1197        let mut builder = ASTGraphBuilder::new(content, max_scope_depth);
1198
1199        // Create recursion guard
1200        let recursion_limits = sqry_core::config::RecursionLimits::load_or_default()
1201            .map_err(|e| format!("Failed to load recursion limits: {e}"))?;
1202        let file_ops_depth = recursion_limits
1203            .effective_file_ops_depth()
1204            .map_err(|e| format!("Invalid file_ops_depth configuration: {e}"))?;
1205        let mut guard = sqry_core::query::security::RecursionGuard::new(file_ops_depth)
1206            .map_err(|e| format!("Failed to create recursion guard: {e}"))?;
1207
1208        builder
1209            .visit(tree.root_node(), None, &mut guard)
1210            .map_err(|e| format!("JavaScript AST traversal hit recursion limit: {e}"))?;
1211        Ok(builder.build())
1212    }
1213
1214    /// Get the enclosing callable context for a node (O(1) lookup)
1215    pub fn get_callable_context(&self, node_id: usize) -> Option<&CallContext> {
1216        let callable_id = self.callable_map.get(&node_id)?;
1217        self.context_map.get(callable_id)
1218    }
1219
1220    /// Get all callable contexts
1221    pub fn contexts(&self) -> impl Iterator<Item = &CallContext> {
1222        self.context_map.values()
1223    }
1224}
1225
1226struct ASTGraphBuilder<'a> {
1227    content: &'a [u8],
1228    max_scope_depth: usize,
1229    callable_map: HashMap<usize, usize>,
1230    context_map: HashMap<usize, CallContext>,
1231    #[allow(dead_code)] // Reserved for nested callable tracking
1232    current_callable: Option<usize>,
1233    current_scope: Vec<Arc<str>>,
1234}
1235
1236impl<'a> ASTGraphBuilder<'a> {
1237    fn new(content: &'a [u8], max_scope_depth: usize) -> Self {
1238        Self {
1239            content,
1240            max_scope_depth,
1241            callable_map: HashMap::new(),
1242            context_map: HashMap::new(),
1243            current_callable: None,
1244            current_scope: Vec::new(),
1245        }
1246    }
1247
1248    fn build(self) -> ASTGraph {
1249        ASTGraph {
1250            callable_map: self.callable_map,
1251            context_map: self.context_map,
1252        }
1253    }
1254
1255    /// # Errors
1256    ///
1257    /// Returns [`sqry_core::query::security::RecursionError::DepthLimitExceeded`] if recursion depth exceeds the guard's limit.
1258    fn visit(
1259        &mut self,
1260        node: Node<'_>,
1261        parent_callable: Option<usize>,
1262        guard: &mut sqry_core::query::security::RecursionGuard,
1263    ) -> Result<(), sqry_core::query::security::RecursionError> {
1264        guard.enter()?;
1265
1266        let node_id = node.id();
1267
1268        // Check if this node is a callable (function, method, arrow function)
1269        let callable_name = callable_node_name(node, self.content);
1270
1271        let new_callable = if let Some(name) = callable_name {
1272            // This is a callable - create context
1273            let start = node.start_byte();
1274            let end = node.end_byte();
1275            let is_async = is_async_function(node, self.content);
1276
1277            let qualified_name = if self.current_scope.is_empty() {
1278                name.to_string()
1279            } else if self.current_scope.len() <= self.max_scope_depth {
1280                format!("{}.{}", self.current_scope.join("."), name)
1281            } else {
1282                // Truncate deep scopes
1283                let truncated = &self.current_scope[..self.max_scope_depth];
1284                format!("{}.{}", truncated.join("."), name)
1285            };
1286
1287            let context = CallContext {
1288                name: Arc::from(name),
1289                qualified_name,
1290                span: (start, end),
1291                is_async,
1292            };
1293
1294            self.context_map.insert(node_id, context);
1295            Some(node_id)
1296        } else {
1297            None
1298        };
1299
1300        // Use new callable context if we entered one, otherwise inherit parent's
1301        let effective_callable = new_callable.or(parent_callable);
1302
1303        // Map this node to its enclosing callable
1304        if let Some(callable_id) = effective_callable {
1305            self.callable_map.insert(node_id, callable_id);
1306        }
1307
1308        // Handle scope tracking (classes, objects, etc.)
1309        let scope_name = scope_node_name(node, self.content);
1310        let pushed_scope = if let Some(name) = scope_name {
1311            self.current_scope.push(Arc::from(name));
1312            true
1313        } else {
1314            false
1315        };
1316
1317        // Recursively visit children
1318        let mut cursor = node.walk();
1319        for child in node.children(&mut cursor) {
1320            self.visit(child, effective_callable, guard)?;
1321        }
1322
1323        // Pop scope if we pushed one
1324        if pushed_scope {
1325            self.current_scope.pop();
1326        }
1327
1328        guard.exit();
1329        Ok(())
1330    }
1331}
1332
1333/// Check if a node represents a callable (function, method, arrow function, etc.)
1334fn callable_node_name(node: Node<'_>, content: &[u8]) -> Option<String> {
1335    match node.kind() {
1336        "function_declaration" | "generator_function_declaration" => node
1337            .child_by_field_name("name")
1338            .and_then(|child| child.utf8_text(content).ok().map(|s| s.trim().to_string())),
1339        "function_expression" | "generator_function" => {
1340            // Named function expression
1341            node.child_by_field_name("name")
1342                .and_then(|child| child.utf8_text(content).ok().map(|s| s.trim().to_string()))
1343                .or_else(|| {
1344                    Some(SyntheticNameBuilder::from_node_with_hash(
1345                        &node, content, "function",
1346                    ))
1347                })
1348        }
1349        "arrow_function" => {
1350            // FR-JS-PATCH-1/2 compliance: Differentiate truly anonymous vs variable-assigned
1351            // Variable-assigned: const foo = () => {} → use "foo" (declared name)
1352            // Truly anonymous: [].map(() => {}) → use anon:arrow:<hash> (FR-JS-PATCH-2)
1353            if let Some(parent) = node.parent()
1354                && parent.kind() == "variable_declarator"
1355                && let Some(name_node) = parent.child_by_field_name("name")
1356                && let Ok(name) = name_node.utf8_text(content)
1357            {
1358                let trimmed = name.trim();
1359                if !trimmed.is_empty() {
1360                    return Some(trimmed.to_string());
1361                }
1362            }
1363            // Fallback: truly anonymous arrow functions (callbacks, IIFEs, etc.)
1364            // Use hash-based synthetic naming per FR-JS-PATCH-2
1365            Some(SyntheticNameBuilder::from_node_with_hash(
1366                &node, content, "arrow",
1367            ))
1368        }
1369        "method_definition" => node
1370            .child_by_field_name("name")
1371            .and_then(|child| child.utf8_text(content).ok().map(|s| s.trim().to_string())),
1372        _ => None,
1373    }
1374}
1375
1376fn scope_node_name(node: Node<'_>, content: &[u8]) -> Option<String> {
1377    match node.kind() {
1378        "class_declaration" | "class" => node
1379            .child_by_field_name("name")
1380            .and_then(|child| child.utf8_text(content).ok().map(|s| s.trim().to_string()))
1381            .or_else(|| {
1382                Some(SyntheticNameBuilder::from_node_with_hash(
1383                    &node, content, "class",
1384                ))
1385            }),
1386        _ => None,
1387    }
1388}
1389
1390fn is_async_function(node: Node<'_>, _content: &[u8]) -> bool {
1391    // Check if function has async modifier
1392    let mut cursor = node.walk();
1393    node.children(&mut cursor)
1394        .any(|child| child.kind() == "async")
1395}
1396
1397// ========== JSDoc TypeOf/Reference Processing ==========
1398
1399/// Process `JSDoc` annotations to create `TypeOf` and Reference edges
1400/// This is a post-processing pass that runs after all nodes are created
1401fn process_jsdoc_annotations(
1402    node: Node,
1403    content: &[u8],
1404    helper: &mut GraphBuildHelper,
1405) -> GraphResult<()> {
1406    // Recursively walk the tree looking for nodes with JSDoc
1407    match node.kind() {
1408        "function_declaration" | "generator_function_declaration" => {
1409            process_function_jsdoc(node, content, helper)?;
1410        }
1411        "method_definition" => {
1412            process_method_jsdoc(node, content, helper)?;
1413        }
1414        "lexical_declaration" | "variable_declaration" => {
1415            process_variable_jsdoc(node, content, helper)?;
1416        }
1417        "class_declaration" | "class" => {
1418            process_class_fields_jsdoc(node, content, helper)?;
1419        }
1420        _ => {}
1421    }
1422
1423    // Recurse into children
1424    let mut cursor = node.walk();
1425    for child in node.children(&mut cursor) {
1426        process_jsdoc_annotations(child, content, helper)?;
1427    }
1428
1429    Ok(())
1430}
1431
1432/// Process `JSDoc` for function declarations
1433fn process_function_jsdoc(
1434    func_node: Node,
1435    content: &[u8],
1436    helper: &mut GraphBuildHelper,
1437) -> GraphResult<()> {
1438    // Extract JSDoc comment
1439    let Some(jsdoc_text) = extract_jsdoc_comment(func_node, content) else {
1440        return Ok(());
1441    };
1442
1443    // Parse JSDoc tags
1444    let tags = parse_jsdoc_tags(&jsdoc_text);
1445
1446    // Get function name
1447    let Some(name_node) = func_node.child_by_field_name("name") else {
1448        return Ok(());
1449    };
1450
1451    let function_name = name_node
1452        .utf8_text(content)
1453        .map_err(|_| GraphBuilderError::ParseError {
1454            span: span_from_node(func_node),
1455            reason: "failed to read function name".to_string(),
1456        })?
1457        .trim()
1458        .to_string();
1459
1460    if function_name.is_empty() {
1461        return Ok(());
1462    }
1463
1464    // Get or create function node
1465    let func_node_id = helper.ensure_function(&function_name, None, false, false);
1466
1467    // ISSUE 1 FIX: Extract AST parameter list with indices
1468    // Map JSDoc tags to AST parameters by name, use AST index (not JSDoc order)
1469    let ast_params = extract_ast_parameters(func_node, content);
1470    let ast_param_map: HashMap<&str, usize> = ast_params
1471        .iter()
1472        .map(|(idx, name)| (name.as_str(), *idx))
1473        .collect();
1474
1475    // Process @param tags - map to AST indices by name
1476    for param_tag in &tags.params {
1477        // Find AST index for this JSDoc parameter name
1478        // Handle optional params [name], rest params ...name, dotted names (options.foo)
1479        let mut normalized_name = param_tag
1480            .name
1481            .trim_start_matches("...")
1482            .trim_matches(|c| c == '[' || c == ']');
1483
1484        // Handle dotted parameter names (e.g., "options.name" -> "options")
1485        // For property-path JSDoc tags, use the base parameter name
1486        if let Some(base_name) = normalized_name.split('.').next() {
1487            normalized_name = base_name;
1488        }
1489
1490        let Some(&ast_index) = ast_param_map.get(normalized_name) else {
1491            // JSDoc tag doesn't match any AST parameter - skip it
1492            continue;
1493        };
1494
1495        // Create TypeOf edge: function -> parameter type
1496        let canonical_type = canonical_type_string(&param_tag.type_str);
1497        let type_node_id = helper.add_type(&canonical_type, None);
1498        helper.add_typeof_edge_with_context(
1499            func_node_id,
1500            type_node_id,
1501            Some(TypeOfContext::Parameter),
1502            ast_index.try_into().ok(), // Use AST index, not JSDoc order
1503            Some(&param_tag.name),
1504        );
1505
1506        // Create Reference edges: function -> each referenced type
1507        let type_names = extract_type_names(&param_tag.type_str);
1508        for type_name in type_names {
1509            let ref_type_id = helper.add_type(&type_name, None);
1510            helper.add_reference_edge(func_node_id, ref_type_id);
1511        }
1512    }
1513
1514    // Process @returns tag
1515    if let Some(return_type) = &tags.returns {
1516        let canonical_type = canonical_type_string(return_type);
1517        let type_node_id = helper.add_type(&canonical_type, None);
1518        helper.add_typeof_edge_with_context(
1519            func_node_id,
1520            type_node_id,
1521            Some(TypeOfContext::Return),
1522            Some(0),
1523            None,
1524        );
1525
1526        // Create Reference edges for return type
1527        let type_names = extract_type_names(return_type);
1528        for type_name in type_names {
1529            let ref_type_id = helper.add_type(&type_name, None);
1530            helper.add_reference_edge(func_node_id, ref_type_id);
1531        }
1532    }
1533
1534    Ok(())
1535}
1536
1537/// Process `JSDoc` for method definitions
1538fn process_method_jsdoc(
1539    method_node: Node,
1540    content: &[u8],
1541    helper: &mut GraphBuildHelper,
1542) -> GraphResult<()> {
1543    // Extract JSDoc comment
1544    let Some(jsdoc_text) = extract_jsdoc_comment(method_node, content) else {
1545        return Ok(());
1546    };
1547
1548    // Parse JSDoc tags
1549    let tags = parse_jsdoc_tags(&jsdoc_text);
1550
1551    // Get method name
1552    let Some(name_node) = method_node.child_by_field_name("name") else {
1553        return Ok(());
1554    };
1555
1556    let method_name = name_node
1557        .utf8_text(content)
1558        .map_err(|_| GraphBuilderError::ParseError {
1559            span: span_from_node(method_node),
1560            reason: "failed to read method name".to_string(),
1561        })?
1562        .trim()
1563        .to_string();
1564
1565    if method_name.is_empty() {
1566        return Ok(());
1567    }
1568
1569    // Find the class name by walking up the tree
1570    let class_name = get_enclosing_class_name(method_node, content)?;
1571    let Some(class_name) = class_name else {
1572        return Ok(());
1573    };
1574
1575    // Create qualified method name: ClassName.methodName
1576    let qualified_name = format!("{class_name}.{method_name}");
1577
1578    // Get existing method node (should already exist from main traversal)
1579    // Use ensure_method to handle case where it might not exist yet
1580    let method_node_id = helper.ensure_method(&qualified_name, None, false, false);
1581
1582    // ISSUE 1 FIX: Extract AST parameter list with indices
1583    // Map JSDoc tags to AST parameters by name, use AST index (not JSDoc order)
1584    let ast_params = extract_ast_parameters(method_node, content);
1585    let ast_param_map: HashMap<&str, usize> = ast_params
1586        .iter()
1587        .map(|(idx, name)| (name.as_str(), *idx))
1588        .collect();
1589
1590    // Process @param tags - map to AST indices by name
1591    for param_tag in &tags.params {
1592        // Find AST index for this JSDoc parameter name
1593        // Handle optional params [name], rest params ...name, dotted names (options.foo)
1594        let mut normalized_name = param_tag
1595            .name
1596            .trim_start_matches("...")
1597            .trim_matches(|c| c == '[' || c == ']');
1598
1599        // Handle dotted parameter names (e.g., "options.name" -> "options")
1600        // For property-path JSDoc tags, use the base parameter name
1601        if let Some(base_name) = normalized_name.split('.').next() {
1602            normalized_name = base_name;
1603        }
1604
1605        let Some(&ast_index) = ast_param_map.get(normalized_name) else {
1606            // JSDoc tag doesn't match any AST parameter - skip it
1607            continue;
1608        };
1609
1610        let canonical_type = canonical_type_string(&param_tag.type_str);
1611        let type_node_id = helper.add_type(&canonical_type, None);
1612        helper.add_typeof_edge_with_context(
1613            method_node_id,
1614            type_node_id,
1615            Some(TypeOfContext::Parameter),
1616            ast_index.try_into().ok(), // Use AST index, not JSDoc order
1617            Some(&param_tag.name),
1618        );
1619
1620        // Create Reference edges
1621        let type_names = extract_type_names(&param_tag.type_str);
1622        for type_name in type_names {
1623            let ref_type_id = helper.add_type(&type_name, None);
1624            helper.add_reference_edge(method_node_id, ref_type_id);
1625        }
1626    }
1627
1628    // Process @returns tag
1629    if let Some(return_type) = &tags.returns {
1630        let canonical_type = canonical_type_string(return_type);
1631        let type_node_id = helper.add_type(&canonical_type, None);
1632        helper.add_typeof_edge_with_context(
1633            method_node_id,
1634            type_node_id,
1635            Some(TypeOfContext::Return),
1636            Some(0),
1637            None,
1638        );
1639
1640        // Create Reference edges
1641        let type_names = extract_type_names(return_type);
1642        for type_name in type_names {
1643            let ref_type_id = helper.add_type(&type_name, None);
1644            helper.add_reference_edge(method_node_id, ref_type_id);
1645        }
1646    }
1647
1648    Ok(())
1649}
1650
1651/// Process `JSDoc` @type annotations for variables
1652fn process_variable_jsdoc(
1653    decl_node: Node,
1654    content: &[u8],
1655    helper: &mut GraphBuildHelper,
1656) -> GraphResult<()> {
1657    // Check if this is a top-level variable (not inside a function)
1658    if !is_top_level_variable(decl_node) {
1659        return Ok(());
1660    }
1661
1662    // Extract JSDoc comment
1663    let Some(jsdoc_text) = extract_jsdoc_comment(decl_node, content) else {
1664        return Ok(());
1665    };
1666
1667    // Parse JSDoc tags
1668    let tags = parse_jsdoc_tags(&jsdoc_text);
1669
1670    // Only process if there's a @type annotation
1671    let Some(type_annotation) = &tags.type_annotation else {
1672        return Ok(());
1673    };
1674
1675    // Find all variable declarators in this declaration
1676    let mut cursor = decl_node.walk();
1677    for child in decl_node.children(&mut cursor) {
1678        if child.kind() == "variable_declarator"
1679            && let Some(name_node) = child.child_by_field_name("name")
1680        {
1681            let var_name = name_node
1682                .utf8_text(content)
1683                .map_err(|_| GraphBuilderError::ParseError {
1684                    span: span_from_node(child),
1685                    reason: "failed to read variable name".to_string(),
1686                })?
1687                .trim()
1688                .to_string();
1689
1690            if !var_name.is_empty() {
1691                // Get or create variable node
1692                let var_node_id = helper.add_variable(&var_name, None);
1693
1694                // Create TypeOf edge
1695                let canonical_type = canonical_type_string(type_annotation);
1696                let type_node_id = helper.add_type(&canonical_type, None);
1697                helper.add_typeof_edge_with_context(
1698                    var_node_id,
1699                    type_node_id,
1700                    Some(TypeOfContext::Variable),
1701                    None,
1702                    None,
1703                );
1704
1705                // Create Reference edges
1706                let type_names = extract_type_names(type_annotation);
1707                for type_name in type_names {
1708                    let ref_type_id = helper.add_type(&type_name, None);
1709                    helper.add_reference_edge(var_node_id, ref_type_id);
1710                }
1711            }
1712        }
1713    }
1714
1715    Ok(())
1716}
1717
1718/// Process `JSDoc` @type annotations for class fields
1719fn process_class_fields_jsdoc(
1720    class_node: Node,
1721    content: &[u8],
1722    helper: &mut GraphBuildHelper,
1723) -> GraphResult<()> {
1724    // Get class name - handle both named and anonymous classes
1725    let class_name = if let Some(name_node) = class_node.child_by_field_name("name") {
1726        // Named class
1727        name_node
1728            .utf8_text(content)
1729            .map_err(|_| GraphBuilderError::ParseError {
1730                span: span_from_node(class_node),
1731                reason: "failed to read class name".to_string(),
1732            })?
1733            .trim()
1734            .to_string()
1735    } else {
1736        // Anonymous class expression - try to find variable assignment
1737        // Example: const MyClass = class { ... }
1738        if let Some(parent) = class_node.parent() {
1739            if parent.kind() == "variable_declarator" {
1740                // Get variable name
1741                if let Some(name_node) = parent.child_by_field_name("name")
1742                    && let Ok(var_name) = name_node.utf8_text(content)
1743                {
1744                    let var_name = var_name.trim().to_string();
1745                    if var_name.is_empty() {
1746                        return Ok(());
1747                    }
1748                    var_name
1749                } else {
1750                    return Ok(());
1751                }
1752            } else if parent.kind() == "assignment_expression" {
1753                // Assignment: SomeClass = class { ... }
1754                if let Some(left) = parent.child_by_field_name("left")
1755                    && let Ok(assign_name) = left.utf8_text(content)
1756                {
1757                    let assign_name = assign_name.trim().to_string();
1758                    if assign_name.is_empty() {
1759                        return Ok(());
1760                    }
1761                    assign_name
1762                } else {
1763                    return Ok(());
1764                }
1765            } else {
1766                // Anonymous class not assigned - skip
1767                return Ok(());
1768            }
1769        } else {
1770            return Ok(());
1771        }
1772    };
1773
1774    if class_name.is_empty() {
1775        return Ok(());
1776    }
1777
1778    // Find class body
1779    let Some(body_node) = class_node.child_by_field_name("body") else {
1780        return Ok(());
1781    };
1782
1783    // Iterate through field definitions
1784    let mut cursor = body_node.walk();
1785    for child in body_node.children(&mut cursor) {
1786        if child.kind() == "field_definition" {
1787            // Extract JSDoc for this field
1788            if let Some(jsdoc_text) = extract_jsdoc_comment(child, content) {
1789                let tags = parse_jsdoc_tags(&jsdoc_text);
1790
1791                // Only process if there's a @type annotation
1792                if let Some(type_annotation) = &tags.type_annotation {
1793                    // Get field name
1794                    if let Some(name_node) = child.child_by_field_name("property") {
1795                        let field_name = name_node
1796                            .utf8_text(content)
1797                            .map_err(|_| GraphBuilderError::ParseError {
1798                                span: span_from_node(child),
1799                                reason: "failed to read field name".to_string(),
1800                            })?
1801                            .trim()
1802                            .to_string();
1803
1804                        if !field_name.is_empty() {
1805                            // Create qualified field name: ClassName.fieldName
1806                            let qualified_name = format!("{class_name}.{field_name}");
1807
1808                            // Get or create field node
1809                            let field_node_id = helper.add_variable(&qualified_name, None);
1810
1811                            // Create TypeOf edge
1812                            let canonical_type = canonical_type_string(type_annotation);
1813                            let type_node_id = helper.add_type(&canonical_type, None);
1814                            helper.add_typeof_edge_with_context(
1815                                field_node_id,
1816                                type_node_id,
1817                                Some(TypeOfContext::Field),
1818                                None,
1819                                None,
1820                            );
1821
1822                            // Create Reference edges
1823                            let type_names = extract_type_names(type_annotation);
1824                            for type_name in type_names {
1825                                let ref_type_id = helper.add_type(&type_name, None);
1826                                helper.add_reference_edge(field_node_id, ref_type_id);
1827                            }
1828                        }
1829                    }
1830                }
1831            }
1832        }
1833    }
1834
1835    Ok(())
1836}
1837
1838/// Helper: Get enclosing class name for a method
1839/// Supports both named classes and anonymous classes assigned to variables
1840/// ISSUE 3 FIX: Handle anonymous class expressions
1841fn get_enclosing_class_name(method_node: Node, content: &[u8]) -> GraphResult<Option<String>> {
1842    // Walk up the tree to find the class declaration or expression
1843    let mut current = method_node;
1844    while let Some(parent) = current.parent() {
1845        if parent.kind() == "class_declaration" {
1846            // Named class declaration
1847            if let Some(name_node) = parent.child_by_field_name("name") {
1848                let class_name = name_node
1849                    .utf8_text(content)
1850                    .map_err(|_| GraphBuilderError::ParseError {
1851                        span: span_from_node(parent),
1852                        reason: "failed to read class name".to_string(),
1853                    })?
1854                    .trim()
1855                    .to_string();
1856
1857                if !class_name.is_empty() {
1858                    return Ok(Some(class_name));
1859                }
1860            }
1861        } else if parent.kind() == "class" {
1862            // Anonymous class expression - check if assigned to variable
1863            // Example: const MyClass = class { ... }
1864            if let Some(grandparent) = parent.parent() {
1865                if grandparent.kind() == "variable_declarator" {
1866                    // Get variable name
1867                    if let Some(name_node) = grandparent.child_by_field_name("name")
1868                        && let Ok(var_name) = name_node.utf8_text(content)
1869                    {
1870                        let var_name = var_name.trim().to_string();
1871                        if !var_name.is_empty() {
1872                            return Ok(Some(var_name));
1873                        }
1874                    }
1875                } else if grandparent.kind() == "assignment_expression" {
1876                    // Assignment: SomeClass = class { ... }
1877                    if let Some(left) = grandparent.child_by_field_name("left")
1878                        && let Ok(assign_name) = left.utf8_text(content)
1879                    {
1880                        let assign_name = assign_name.trim().to_string();
1881                        if !assign_name.is_empty() {
1882                            return Ok(Some(assign_name));
1883                        }
1884                    }
1885                }
1886            }
1887            // If anonymous and not assigned, return None
1888            // (Methods won't get JSDoc edges, but won't crash)
1889            return Ok(None);
1890        }
1891        current = parent;
1892    }
1893    Ok(None)
1894}
1895
1896/// Extract parameter names and AST indices from function/method parameter list
1897/// Returns Vec<(`ast_index`, `param_name`)> for mapping `JSDoc` tags to AST positions
1898fn extract_ast_parameters(func_node: Node, content: &[u8]) -> Vec<(usize, String)> {
1899    let Some(params_node) = func_node.child_by_field_name("parameters") else {
1900        return Vec::new();
1901    };
1902
1903    let mut cursor = params_node.walk();
1904    params_node
1905        .named_children(&mut cursor)
1906        .enumerate()
1907        .filter_map(|(ast_index, param)| {
1908            // Handle different parameter node types
1909            let param_name = match param.kind() {
1910                "identifier" => param
1911                    .utf8_text(content)
1912                    .ok()
1913                    .map(std::string::ToString::to_string),
1914                "required_parameter" | "optional_parameter" => {
1915                    // Get the pattern node (identifier)
1916                    param
1917                        .child_by_field_name("pattern")
1918                        .and_then(|p| p.utf8_text(content).ok())
1919                        .map(std::string::ToString::to_string)
1920                }
1921                "rest_pattern" => {
1922                    // Rest parameters: ...args
1923                    // Get identifier inside rest pattern
1924                    param
1925                        .named_child(0)
1926                        .and_then(|n| n.utf8_text(content).ok())
1927                        .map(|s| s.trim_start_matches("...").to_string())
1928                }
1929                "assignment_pattern" => {
1930                    // Default parameters: x = 10
1931                    // Get left side identifier
1932                    param
1933                        .child_by_field_name("left")
1934                        .filter(|left| left.kind() == "identifier")
1935                        .and_then(|left| left.utf8_text(content).ok())
1936                        .map(std::string::ToString::to_string)
1937                }
1938                _ => None,
1939            };
1940
1941            param_name.map(|name| (ast_index, name))
1942        })
1943        .collect()
1944}
1945
1946/// Helper: Check if a variable declaration is top-level (module-scope only)
1947/// Excludes variables inside functions, methods, AND block scopes (if, for, while, try, etc.)
1948fn is_top_level_variable(decl_node: Node) -> bool {
1949    let mut current = decl_node;
1950    while let Some(parent) = current.parent() {
1951        match parent.kind() {
1952            // Functions/methods - not top-level
1953            "function_declaration"
1954            | "generator_function_declaration"
1955            | "function_expression"
1956            | "arrow_function"
1957            | "method_definition" => return false,
1958
1959            // Block scopes - not top-level (Issue 2 fix)
1960            "statement_block" | "if_statement" | "for_statement" | "for_in_statement"
1961            | "for_of_statement" | "while_statement" | "do_statement" | "try_statement"
1962            | "catch_clause" | "finally_clause" | "switch_statement" | "switch_case"
1963            | "switch_default" | "class_body" | "class_static_block" | "with_statement" => {
1964                return false;
1965            }
1966
1967            // Program/module root - is top-level
1968            // Export statements are top-level
1969            "program" | "export_statement" => return true,
1970
1971            _ => {}
1972        }
1973        current = parent;
1974    }
1975    true
1976}
1977
1978// ========== FFI Detection ==========
1979
1980/// Build FFI edges for call expressions.
1981///
1982/// Detects:
1983/// - `WebAssembly.instantiate(buffer)` / `WebAssembly.instantiateStreaming(fetch(...))`
1984/// - `WebAssembly.compile(buffer)` / `WebAssembly.compileStreaming(fetch(...))`
1985/// - `require('./native.node')` - Node.js native addons
1986/// - `process.dlopen(module, filename)` - Node.js dynamic loading
1987///
1988/// Returns true if an FFI edge was created, false otherwise.
1989fn build_ffi_call_edge(
1990    ast_graph: &ASTGraph,
1991    call_node: Node<'_>,
1992    content: &[u8],
1993    helper: &mut GraphBuildHelper,
1994) -> GraphResult<bool> {
1995    let Some(callee_expr) = call_node.child_by_field_name("function") else {
1996        return Ok(false);
1997    };
1998
1999    let callee_text = callee_expr
2000        .utf8_text(content)
2001        .map_err(|_| GraphBuilderError::ParseError {
2002            span: span_from_node(call_node),
2003            reason: "failed to read call expression".to_string(),
2004        })?
2005        .trim();
2006
2007    // Check for WebAssembly API calls
2008    if callee_text.starts_with("WebAssembly.") {
2009        return Ok(build_webassembly_call_edge(
2010            ast_graph,
2011            call_node,
2012            content,
2013            callee_text,
2014            helper,
2015        ));
2016    }
2017
2018    // Check for Node.js native addon require
2019    if callee_text == "require" {
2020        return Ok(build_require_ffi_edge(
2021            ast_graph, call_node, content, helper,
2022        ));
2023    }
2024
2025    // Check for process.dlopen
2026    if callee_text == "process.dlopen" {
2027        return Ok(build_dlopen_edge(ast_graph, call_node, content, helper));
2028    }
2029
2030    Ok(false)
2031}
2032
2033/// Build FFI edges for new expressions (constructor calls).
2034///
2035/// Detects:
2036/// - `new WebAssembly.Module(buffer)`
2037/// - `new WebAssembly.Instance(module, imports)`
2038///
2039/// Returns true if an FFI edge was created, false otherwise.
2040fn build_ffi_new_edge(
2041    ast_graph: &ASTGraph,
2042    new_node: Node<'_>,
2043    content: &[u8],
2044    helper: &mut GraphBuildHelper,
2045) -> GraphResult<bool> {
2046    let Some(constructor_expr) = new_node.child_by_field_name("constructor") else {
2047        return Ok(false);
2048    };
2049
2050    let constructor_text = constructor_expr
2051        .utf8_text(content)
2052        .map_err(|_| GraphBuilderError::ParseError {
2053            span: span_from_node(new_node),
2054            reason: "failed to read constructor expression".to_string(),
2055        })?
2056        .trim();
2057
2058    // Check for WebAssembly constructors
2059    if constructor_text == "WebAssembly.Module" || constructor_text == "WebAssembly.Instance" {
2060        return Ok(build_webassembly_constructor_edge(
2061            ast_graph,
2062            new_node,
2063            content,
2064            constructor_text,
2065            helper,
2066        ));
2067    }
2068
2069    Ok(false)
2070}
2071
2072/// Build WebAssembly call edge for API calls like instantiate/compile.
2073fn build_webassembly_call_edge(
2074    ast_graph: &ASTGraph,
2075    call_node: Node<'_>,
2076    content: &[u8],
2077    callee_text: &str,
2078    helper: &mut GraphBuildHelper,
2079) -> bool {
2080    // Extract the method name
2081    let method_name = callee_text
2082        .strip_prefix("WebAssembly.")
2083        .unwrap_or(callee_text);
2084
2085    // Only handle known WebAssembly methods that load/instantiate WASM
2086    let is_wasm_load = matches!(
2087        method_name,
2088        "instantiate" | "instantiateStreaming" | "compile" | "compileStreaming" | "validate"
2089    );
2090
2091    if !is_wasm_load {
2092        return false;
2093    }
2094
2095    // Get caller context
2096    let caller_id = get_caller_node_id(ast_graph, call_node, content, helper);
2097
2098    // Try to extract module path from arguments (if it's a fetch() call or string literal)
2099    let wasm_module_name = extract_wasm_module_name(call_node, content)
2100        .unwrap_or_else(|| format!("wasm::{method_name}"));
2101
2102    // Create WASM module node with qualified name
2103    let wasm_node_id = helper.add_module(&wasm_module_name, Some(span_from_node(call_node)));
2104
2105    // Add WebAssembly edge
2106    helper.add_webassembly_edge(caller_id, wasm_node_id);
2107
2108    true
2109}
2110
2111/// Build WebAssembly edge for constructor calls (new WebAssembly.Module/Instance).
2112fn build_webassembly_constructor_edge(
2113    ast_graph: &ASTGraph,
2114    new_node: Node<'_>,
2115    content: &[u8],
2116    constructor_text: &str,
2117    helper: &mut GraphBuildHelper,
2118) -> bool {
2119    // Get caller context
2120    let caller_id = get_caller_node_id(ast_graph, new_node, content, helper);
2121
2122    // Determine module name
2123    let type_name = constructor_text
2124        .strip_prefix("WebAssembly.")
2125        .unwrap_or(constructor_text);
2126    let wasm_module_name = format!("wasm::{type_name}");
2127
2128    // Create WASM module node
2129    let wasm_node_id = helper.add_module(&wasm_module_name, Some(span_from_node(new_node)));
2130
2131    // Add WebAssembly edge
2132    helper.add_webassembly_edge(caller_id, wasm_node_id);
2133
2134    true
2135}
2136
2137/// Build Import edge for `CommonJS` `require()` calls, plus FFI edge for native addons.
2138///
2139/// Creates an Import edge for all `require()` calls (`CommonJS` module system).
2140/// Additionally creates an FFI edge if the module is a native addon.
2141fn build_require_ffi_edge(
2142    ast_graph: &ASTGraph,
2143    call_node: Node<'_>,
2144    content: &[u8],
2145    helper: &mut GraphBuildHelper,
2146) -> bool {
2147    // Get the first argument (module path)
2148    let Some(args) = call_node.child_by_field_name("arguments") else {
2149        return false;
2150    };
2151
2152    let mut cursor = args.walk();
2153    let first_arg = args
2154        .children(&mut cursor)
2155        .find(|child| !matches!(child.kind(), "(" | ")" | ","));
2156
2157    let Some(arg_node) = first_arg else {
2158        return false;
2159    };
2160
2161    // Extract the module path
2162    let module_path = extract_string_literal(&arg_node, content);
2163    let Some(path) = module_path else {
2164        return false;
2165    };
2166
2167    // Always create an Import edge for CommonJS require() calls
2168    let from_id = helper.add_module("<module>", None);
2169
2170    // Resolve the import path and create import node
2171    let resolved_path = if path.starts_with('.') {
2172        // Relative import - resolve against file path
2173        sqry_core::graph::resolve_import_path(std::path::Path::new(helper.file_path()), &path)
2174            .unwrap_or_else(|_| simple_name(&path).to_string())
2175    } else {
2176        // Package import - use as-is (simple name)
2177        simple_name(&path).to_string()
2178    };
2179
2180    let to_id = helper.add_import(&resolved_path, Some(span_from_node(call_node)));
2181    helper.add_import_edge(from_id, to_id);
2182
2183    // Check if this is a native addon (.node file or known native packages)
2184    let is_native_addon = std::path::Path::new(&path)
2185        .extension()
2186        .is_some_and(|ext| ext.eq_ignore_ascii_case("node"))
2187        || is_known_native_addon(&path);
2188
2189    if is_native_addon {
2190        // Get caller context
2191        let caller_id = get_caller_node_id(ast_graph, call_node, content, helper);
2192
2193        // Create FFI target node
2194        let ffi_name = format!("native::{}", simple_name(&path));
2195        let ffi_node_id = helper.add_module(&ffi_name, Some(span_from_node(call_node)));
2196
2197        // Add FFI edge with C convention (Node.js native addons use N-API/C ABI)
2198        helper.add_ffi_edge(caller_id, ffi_node_id, FfiConvention::C);
2199    }
2200
2201    true
2202}
2203
2204/// Build FFI edge for `process.dlopen()` calls.
2205fn build_dlopen_edge(
2206    ast_graph: &ASTGraph,
2207    call_node: Node<'_>,
2208    content: &[u8],
2209    helper: &mut GraphBuildHelper,
2210) -> bool {
2211    // Get caller context
2212    let caller_id = get_caller_node_id(ast_graph, call_node, content, helper);
2213
2214    // Try to extract filename from second argument
2215    let module_name = call_node
2216        .child_by_field_name("arguments")
2217        .and_then(|args| {
2218            let mut cursor = args.walk();
2219            args.children(&mut cursor)
2220                .filter(|child| !matches!(child.kind(), "(" | ")" | ","))
2221                .nth(1) // Second argument is the filename
2222        })
2223        .and_then(|node| extract_string_literal(&node, content))
2224        .map_or_else(
2225            || "native::dlopen".to_string(),
2226            |path| format!("native::{}", simple_name(&path)),
2227        );
2228
2229    // Create FFI target node
2230    let ffi_node_id = helper.add_module(&module_name, Some(span_from_node(call_node)));
2231
2232    // Add FFI edge
2233    helper.add_ffi_edge(caller_id, ffi_node_id, FfiConvention::C);
2234
2235    true
2236}
2237
2238/// Get the caller node ID from AST context.
2239fn get_caller_node_id(
2240    ast_graph: &ASTGraph,
2241    node: Node<'_>,
2242    content: &[u8],
2243    helper: &mut GraphBuildHelper,
2244) -> sqry_core::graph::unified::NodeId {
2245    let module_context;
2246    let call_context = if let Some(ctx) = ast_graph.get_callable_context(node.id()) {
2247        ctx
2248    } else {
2249        module_context = CallContext {
2250            name: Arc::from("<module>"),
2251            qualified_name: "<module>".to_string(),
2252            span: (0, content.len()),
2253            is_async: false,
2254        };
2255        &module_context
2256    };
2257
2258    ensure_caller_node(helper, call_context)
2259}
2260
2261fn ensure_caller_node(
2262    helper: &mut GraphBuildHelper,
2263    call_context: &CallContext,
2264) -> sqry_core::graph::unified::NodeId {
2265    let caller_span = Some(Span::from_bytes(call_context.span.0, call_context.span.1));
2266    let qualified_name = call_context.qualified_name();
2267    if qualified_name.contains('.') {
2268        helper.ensure_method(qualified_name, caller_span, call_context.is_async, false)
2269    } else {
2270        helper.ensure_function(qualified_name, caller_span, call_context.is_async, false)
2271    }
2272}
2273
2274/// Try to extract WASM module name from call arguments.
2275///
2276/// Handles patterns like:
2277/// - `WebAssembly.instantiate(fetch('./module.wasm'))` -> "./module.wasm"
2278/// - `WebAssembly.instantiate(buffer)` -> None (can't determine statically)
2279fn extract_wasm_module_name(call_node: Node<'_>, content: &[u8]) -> Option<String> {
2280    let args = call_node.child_by_field_name("arguments")?;
2281
2282    let mut cursor = args.walk();
2283    let first_arg = args
2284        .children(&mut cursor)
2285        .find(|child| !matches!(child.kind(), "(" | ")" | ","))?;
2286
2287    // Check if it's a fetch() call
2288    if first_arg.kind() == "call_expression"
2289        && let Some(func) = first_arg.child_by_field_name("function")
2290    {
2291        let func_text = func.utf8_text(content).ok()?.trim();
2292        if func_text == "fetch" {
2293            // Extract URL from fetch argument
2294            if let Some(fetch_args) = first_arg.child_by_field_name("arguments") {
2295                let mut fetch_cursor = fetch_args.walk();
2296                let url_arg = fetch_args
2297                    .children(&mut fetch_cursor)
2298                    .find(|child| !matches!(child.kind(), "(" | ")" | ","))?;
2299
2300                if let Some(url) = extract_string_literal(&url_arg, content) {
2301                    return Some(format!("wasm::{}", simple_name(&url)));
2302                }
2303            }
2304        }
2305    }
2306
2307    // Check if it's a string literal (file path)
2308    if let Some(path) = extract_string_literal(&first_arg, content) {
2309        return Some(format!("wasm::{}", simple_name(&path)));
2310    }
2311
2312    None
2313}
2314
2315/// Check if a package name is a known native addon.
2316fn is_known_native_addon(package_name: &str) -> bool {
2317    // Common native addon packages
2318    const NATIVE_PACKAGES: &[&str] = &[
2319        "better-sqlite3",
2320        "sqlite3",
2321        "bcrypt",
2322        "sharp",
2323        "canvas",
2324        "node-sass",
2325        "leveldown",
2326        "bufferutil",
2327        "utf-8-validate",
2328        "fsevents",
2329        "cpu-features",
2330        "node-gyp",
2331        "node-pre-gyp",
2332        "prebuild",
2333        "nan",
2334        "node-addon-api",
2335        "ref-napi",
2336        "ffi-napi",
2337    ];
2338
2339    NATIVE_PACKAGES
2340        .iter()
2341        .any(|&pkg| package_name.contains(pkg))
2342}