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