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(node, content, helper)?;
1427            process_constructor_this_assignments(node, content, helper)?;
1428        }
1429        _ => {}
1430    }
1431
1432    // Recurse into children
1433    let mut cursor = node.walk();
1434    for child in node.children(&mut cursor) {
1435        process_jsdoc_annotations(child, content, helper)?;
1436    }
1437
1438    Ok(())
1439}
1440
1441/// Process `JSDoc` for function declarations
1442fn process_function_jsdoc(
1443    func_node: Node,
1444    content: &[u8],
1445    helper: &mut GraphBuildHelper,
1446) -> GraphResult<()> {
1447    // Extract JSDoc comment
1448    let Some(jsdoc_text) = extract_jsdoc_comment(func_node, content) else {
1449        return Ok(());
1450    };
1451
1452    // Parse JSDoc tags
1453    let tags = parse_jsdoc_tags(&jsdoc_text);
1454
1455    // Get function name
1456    let Some(name_node) = func_node.child_by_field_name("name") else {
1457        return Ok(());
1458    };
1459
1460    let function_name = name_node
1461        .utf8_text(content)
1462        .map_err(|_| GraphBuilderError::ParseError {
1463            span: span_from_node(func_node),
1464            reason: "failed to read function name".to_string(),
1465        })?
1466        .trim()
1467        .to_string();
1468
1469    if function_name.is_empty() {
1470        return Ok(());
1471    }
1472
1473    // Get or create function node
1474    let func_node_id = helper.ensure_callee(
1475        &function_name,
1476        span_from_node(func_node),
1477        CalleeKindHint::Function,
1478    );
1479
1480    // ISSUE 1 FIX: Extract AST parameter list with indices
1481    // Map JSDoc tags to AST parameters by name, use AST index (not JSDoc order)
1482    let ast_params = extract_ast_parameters(func_node, content);
1483    let ast_param_map: HashMap<&str, usize> = ast_params
1484        .iter()
1485        .map(|(idx, name)| (name.as_str(), *idx))
1486        .collect();
1487
1488    // Process @param tags - map to AST indices by name
1489    for param_tag in &tags.params {
1490        // Find AST index for this JSDoc parameter name
1491        // Handle optional params [name], rest params ...name, dotted names (options.foo)
1492        let mut normalized_name = param_tag
1493            .name
1494            .trim_start_matches("...")
1495            .trim_matches(|c| c == '[' || c == ']');
1496
1497        // Handle dotted parameter names (e.g., "options.name" -> "options")
1498        // For property-path JSDoc tags, use the base parameter name
1499        if let Some(base_name) = normalized_name.split('.').next() {
1500            normalized_name = base_name;
1501        }
1502
1503        let Some(&ast_index) = ast_param_map.get(normalized_name) else {
1504            // JSDoc tag doesn't match any AST parameter - skip it
1505            continue;
1506        };
1507
1508        // Create TypeOf edge: function -> parameter type
1509        let canonical_type = canonical_type_string(&param_tag.type_str);
1510        let type_node_id = helper.add_type(&canonical_type, None);
1511        helper.add_typeof_edge_with_context(
1512            func_node_id,
1513            type_node_id,
1514            Some(TypeOfContext::Parameter),
1515            ast_index.try_into().ok(), // Use AST index, not JSDoc order
1516            Some(&param_tag.name),
1517        );
1518
1519        // Create Reference edges: function -> each referenced type
1520        let type_names = extract_type_names(&param_tag.type_str);
1521        for type_name in type_names {
1522            let ref_type_id = helper.add_type(&type_name, None);
1523            helper.add_reference_edge(func_node_id, ref_type_id);
1524        }
1525    }
1526
1527    // Process @returns tag
1528    if let Some(return_type) = &tags.returns {
1529        let canonical_type = canonical_type_string(return_type);
1530        let type_node_id = helper.add_type(&canonical_type, None);
1531        helper.add_typeof_edge_with_context(
1532            func_node_id,
1533            type_node_id,
1534            Some(TypeOfContext::Return),
1535            Some(0),
1536            None,
1537        );
1538
1539        // Create Reference edges for return type
1540        let type_names = extract_type_names(return_type);
1541        for type_name in type_names {
1542            let ref_type_id = helper.add_type(&type_name, None);
1543            helper.add_reference_edge(func_node_id, ref_type_id);
1544        }
1545    }
1546
1547    Ok(())
1548}
1549
1550/// Process `JSDoc` for method definitions
1551fn process_method_jsdoc(
1552    method_node: Node,
1553    content: &[u8],
1554    helper: &mut GraphBuildHelper,
1555) -> GraphResult<()> {
1556    // Extract JSDoc comment
1557    let Some(jsdoc_text) = extract_jsdoc_comment(method_node, content) else {
1558        return Ok(());
1559    };
1560
1561    // Parse JSDoc tags
1562    let tags = parse_jsdoc_tags(&jsdoc_text);
1563
1564    // Get method name
1565    let Some(name_node) = method_node.child_by_field_name("name") else {
1566        return Ok(());
1567    };
1568
1569    let method_name = name_node
1570        .utf8_text(content)
1571        .map_err(|_| GraphBuilderError::ParseError {
1572            span: span_from_node(method_node),
1573            reason: "failed to read method name".to_string(),
1574        })?
1575        .trim()
1576        .to_string();
1577
1578    if method_name.is_empty() {
1579        return Ok(());
1580    }
1581
1582    // Find the class name by walking up the tree
1583    let class_name = get_enclosing_class_name(method_node, content)?;
1584    let Some(class_name) = class_name else {
1585        return Ok(());
1586    };
1587
1588    // Create qualified method name: ClassName.methodName
1589    let qualified_name = format!("{class_name}.{method_name}");
1590
1591    // Get existing method node (should already exist from main traversal)
1592    // Use ensure_method to handle case where it might not exist yet
1593    let method_node_id = helper.ensure_method(&qualified_name, None, false, false);
1594
1595    // ISSUE 1 FIX: Extract AST parameter list with indices
1596    // Map JSDoc tags to AST parameters by name, use AST index (not JSDoc order)
1597    let ast_params = extract_ast_parameters(method_node, content);
1598    let ast_param_map: HashMap<&str, usize> = ast_params
1599        .iter()
1600        .map(|(idx, name)| (name.as_str(), *idx))
1601        .collect();
1602
1603    // Process @param tags - map to AST indices by name
1604    for param_tag in &tags.params {
1605        // Find AST index for this JSDoc parameter name
1606        // Handle optional params [name], rest params ...name, dotted names (options.foo)
1607        let mut normalized_name = param_tag
1608            .name
1609            .trim_start_matches("...")
1610            .trim_matches(|c| c == '[' || c == ']');
1611
1612        // Handle dotted parameter names (e.g., "options.name" -> "options")
1613        // For property-path JSDoc tags, use the base parameter name
1614        if let Some(base_name) = normalized_name.split('.').next() {
1615            normalized_name = base_name;
1616        }
1617
1618        let Some(&ast_index) = ast_param_map.get(normalized_name) else {
1619            // JSDoc tag doesn't match any AST parameter - skip it
1620            continue;
1621        };
1622
1623        let canonical_type = canonical_type_string(&param_tag.type_str);
1624        let type_node_id = helper.add_type(&canonical_type, None);
1625        helper.add_typeof_edge_with_context(
1626            method_node_id,
1627            type_node_id,
1628            Some(TypeOfContext::Parameter),
1629            ast_index.try_into().ok(), // Use AST index, not JSDoc order
1630            Some(&param_tag.name),
1631        );
1632
1633        // Create Reference edges
1634        let type_names = extract_type_names(&param_tag.type_str);
1635        for type_name in type_names {
1636            let ref_type_id = helper.add_type(&type_name, None);
1637            helper.add_reference_edge(method_node_id, ref_type_id);
1638        }
1639    }
1640
1641    // Process @returns tag
1642    if let Some(return_type) = &tags.returns {
1643        let canonical_type = canonical_type_string(return_type);
1644        let type_node_id = helper.add_type(&canonical_type, None);
1645        helper.add_typeof_edge_with_context(
1646            method_node_id,
1647            type_node_id,
1648            Some(TypeOfContext::Return),
1649            Some(0),
1650            None,
1651        );
1652
1653        // Create Reference edges
1654        let type_names = extract_type_names(return_type);
1655        for type_name in type_names {
1656            let ref_type_id = helper.add_type(&type_name, None);
1657            helper.add_reference_edge(method_node_id, ref_type_id);
1658        }
1659    }
1660
1661    Ok(())
1662}
1663
1664/// Process `JSDoc` @type annotations for variables
1665fn process_variable_jsdoc(
1666    decl_node: Node,
1667    content: &[u8],
1668    helper: &mut GraphBuildHelper,
1669) -> GraphResult<()> {
1670    // Check if this is a top-level variable (not inside a function)
1671    if !is_top_level_variable(decl_node) {
1672        return Ok(());
1673    }
1674
1675    // Extract JSDoc comment
1676    let Some(jsdoc_text) = extract_jsdoc_comment(decl_node, content) else {
1677        return Ok(());
1678    };
1679
1680    // Parse JSDoc tags
1681    let tags = parse_jsdoc_tags(&jsdoc_text);
1682
1683    // Only process if there's a @type annotation
1684    let Some(type_annotation) = &tags.type_annotation else {
1685        return Ok(());
1686    };
1687
1688    // Find all variable declarators in this declaration
1689    let mut cursor = decl_node.walk();
1690    for child in decl_node.children(&mut cursor) {
1691        if child.kind() == "variable_declarator"
1692            && let Some(name_node) = child.child_by_field_name("name")
1693        {
1694            let var_name = name_node
1695                .utf8_text(content)
1696                .map_err(|_| GraphBuilderError::ParseError {
1697                    span: span_from_node(child),
1698                    reason: "failed to read variable name".to_string(),
1699                })?
1700                .trim()
1701                .to_string();
1702
1703            if !var_name.is_empty() {
1704                // Get or create variable node
1705                let var_node_id = helper.add_variable(&var_name, None);
1706
1707                // Create TypeOf edge
1708                let canonical_type = canonical_type_string(type_annotation);
1709                let type_node_id = helper.add_type(&canonical_type, None);
1710                helper.add_typeof_edge_with_context(
1711                    var_node_id,
1712                    type_node_id,
1713                    Some(TypeOfContext::Variable),
1714                    None,
1715                    None,
1716                );
1717
1718                // Create Reference edges
1719                let type_names = extract_type_names(type_annotation);
1720                for type_name in type_names {
1721                    let ref_type_id = helper.add_type(&type_name, None);
1722                    helper.add_reference_edge(var_node_id, ref_type_id);
1723                }
1724            }
1725        }
1726    }
1727
1728    Ok(())
1729}
1730
1731/// Resolve the class name for a `class_declaration` or `class` expression node.
1732///
1733/// For named classes: reads the `name` field child.
1734/// For anonymous class expressions: falls back to the binding identifier when
1735/// the class is assigned to a `variable_declarator` or `assignment_expression`
1736/// (mirrors the historic behaviour of `process_class_fields_jsdoc`).
1737///
1738/// Returns `None` for anonymous classes that are not bound to an identifier
1739/// (e.g. immediately invoked or passed as an argument). Callers must skip
1740/// emission in that case to avoid creating ill-formed `Class.field` names.
1741fn resolve_class_name_for_fields(
1742    class_node: Node<'_>,
1743    content: &[u8],
1744) -> GraphResult<Option<String>> {
1745    if let Some(name_node) = class_node.child_by_field_name("name") {
1746        let name = name_node
1747            .utf8_text(content)
1748            .map_err(|_| GraphBuilderError::ParseError {
1749                span: span_from_node(class_node),
1750                reason: "failed to read class name".to_string(),
1751            })?
1752            .trim()
1753            .to_string();
1754        if name.is_empty() {
1755            return Ok(None);
1756        }
1757        return Ok(Some(name));
1758    }
1759
1760    // Anonymous class expression โ€” try to find the binding identifier.
1761    let Some(parent) = class_node.parent() else {
1762        return Ok(None);
1763    };
1764
1765    match parent.kind() {
1766        "variable_declarator" => {
1767            if let Some(name_node) = parent.child_by_field_name("name")
1768                && let Ok(var_name) = name_node.utf8_text(content)
1769            {
1770                let var_name = var_name.trim().to_string();
1771                if var_name.is_empty() {
1772                    return Ok(None);
1773                }
1774                return Ok(Some(var_name));
1775            }
1776            Ok(None)
1777        }
1778        "assignment_expression" => {
1779            if let Some(left) = parent.child_by_field_name("left")
1780                && let Ok(assign_name) = left.utf8_text(content)
1781            {
1782                let assign_name = assign_name.trim().to_string();
1783                if assign_name.is_empty() {
1784                    return Ok(None);
1785                }
1786                return Ok(Some(assign_name));
1787            }
1788            Ok(None)
1789        }
1790        _ => Ok(None),
1791    }
1792}
1793
1794/// Emit Property nodes for every `field_definition` in a class body, and
1795/// optionally enrich them with `TypeOf{Field}` + `References` edges when a
1796/// `JSDoc` `@type` annotation is present (REQ:R0001..R0006, R0008, R0023).
1797///
1798/// Replaces the historic JSDoc-gated `process_class_fields_jsdoc` function:
1799/// emission is now unconditional. `JSDoc`, when present, is treated as
1800/// enrichment for the type edge rather than a gate.
1801///
1802/// AC mapping:
1803/// - AC-1 unconditional Property emission on every `field_definition`
1804/// - AC-2 span sourced from the field-definition node
1805/// - AC-3 `static` modifier โ†’ `is_static = true`
1806/// - AC-4 `private_property_identifier` (`#name`) โ†’ visibility = "private"
1807/// - AC-5 `TypeOf` edge name = bare field name (not `Class.field`)
1808/// - AC-7 `JSDoc` `@type` is preserved as enrichment, not a gate
1809fn process_class_fields(
1810    class_node: Node<'_>,
1811    content: &[u8],
1812    helper: &mut GraphBuildHelper,
1813) -> GraphResult<()> {
1814    let Some(class_name) = resolve_class_name_for_fields(class_node, content)? else {
1815        return Ok(());
1816    };
1817
1818    let Some(body_node) = class_node.child_by_field_name("body") else {
1819        return Ok(());
1820    };
1821
1822    let mut cursor = body_node.walk();
1823    for child in body_node.children(&mut cursor) {
1824        if child.kind() != "field_definition" {
1825            continue;
1826        }
1827        emit_class_field_node(child, content, helper, &class_name)?;
1828    }
1829
1830    Ok(())
1831}
1832
1833/// Emit a single class field as a Property node and (when `JSDoc` `@type` is
1834/// present) the corresponding `TypeOf{Field}` + Reference edges.
1835fn emit_class_field_node(
1836    field_node: Node<'_>,
1837    content: &[u8],
1838    helper: &mut GraphBuildHelper,
1839    class_name: &str,
1840) -> GraphResult<()> {
1841    // Field name lives under the `property` field for `field_definition` in
1842    // tree-sitter-javascript. The child node is either an identifier or a
1843    // `private_property_identifier` (the `#name` form).
1844    let Some(name_node) = field_node.child_by_field_name("property") else {
1845        return Ok(());
1846    };
1847
1848    let raw_name = name_node
1849        .utf8_text(content)
1850        .map_err(|_| GraphBuilderError::ParseError {
1851            span: span_from_node(field_node),
1852            reason: "failed to read field name".to_string(),
1853        })?
1854        .trim()
1855        .to_string();
1856
1857    if raw_name.is_empty() {
1858        return Ok(());
1859    }
1860
1861    let is_hash_private = name_node.kind() == "private_property_identifier";
1862
1863    // Scan modifier-like direct children. tree-sitter-javascript surfaces
1864    // `static` as an anonymous keyword child of `field_definition`; there is
1865    // no accessibility-modifier surface in the JS grammar (visibility is
1866    // inferred from the `#`-prefix only).
1867    let mut is_static = false;
1868    let mut mod_cursor = field_node.walk();
1869    for modifier in field_node.children(&mut mod_cursor) {
1870        if modifier.kind() == "static" {
1871            is_static = true;
1872        }
1873    }
1874
1875    // Per design ยง3.3 + AC-4: JS field visibility is syntactic.
1876    // `#`-prefix โ†’ "private"; otherwise โ†’ "public". Underscore-prefix
1877    // naming heuristics (e.g. `_foo`) are deliberately NOT applied at the
1878    // field call site โ€” the field contract is grammar-level, not
1879    // naming-convention-based.
1880    let visibility: Option<&str> = if is_hash_private {
1881        Some("private")
1882    } else {
1883        Some("public")
1884    };
1885
1886    let qualified_name = format!("{class_name}.{raw_name}");
1887    let span = Some(span_from_node(field_node));
1888
1889    let field_id = helper.add_property_with_static_and_visibility(
1890        &qualified_name,
1891        span,
1892        is_static,
1893        visibility,
1894    );
1895
1896    // JSDoc `@type` is now enrichment, not a gate. When present, emit the
1897    // `TypeOf{Field}` edge with the BARE field name (AC-5) and add
1898    // Reference edges for every named type appearing in the annotation.
1899    if let Some(jsdoc_text) = extract_jsdoc_comment(field_node, content) {
1900        let tags = parse_jsdoc_tags(&jsdoc_text);
1901        if let Some(type_annotation) = &tags.type_annotation {
1902            let canonical_type = canonical_type_string(type_annotation);
1903            let type_node_id = helper.add_type(&canonical_type, None);
1904            helper.add_typeof_edge_with_context(
1905                field_id,
1906                type_node_id,
1907                Some(TypeOfContext::Field),
1908                None,
1909                Some(&raw_name),
1910            );
1911
1912            let type_names = extract_type_names(type_annotation);
1913            for type_name in type_names {
1914                let ref_type_id = helper.add_type(&type_name, None);
1915                helper.add_reference_edge(field_id, ref_type_id);
1916            }
1917        }
1918    }
1919
1920    Ok(())
1921}
1922
1923/// Walk a class body and, for every constructor body, emit Property nodes
1924/// for each `this.<identifier> = ...` assignment encountered (AC-6).
1925///
1926/// The walker recurses through all assignment expressions in the constructor
1927/// body โ€” including those inside nested arrow functions (which inherit
1928/// `this`). Non-`this` assignments, `this.x.y = ...` deep paths, and
1929/// computed `this[expr] = ...` accesses are skipped.
1930///
1931/// Deduplication with explicit field declarations (FR-13) is handled by the
1932/// helper's `node_cache`: an existing `Property` with the same canonical
1933/// qualified name is returned without creating a duplicate node.
1934fn process_constructor_this_assignments(
1935    class_node: Node<'_>,
1936    content: &[u8],
1937    helper: &mut GraphBuildHelper,
1938) -> GraphResult<()> {
1939    let Some(class_name) = resolve_class_name_for_fields(class_node, content)? else {
1940        return Ok(());
1941    };
1942
1943    let Some(body_node) = class_node.child_by_field_name("body") else {
1944        return Ok(());
1945    };
1946
1947    let mut cursor = body_node.walk();
1948    for child in body_node.children(&mut cursor) {
1949        if child.kind() != "method_definition" {
1950            continue;
1951        }
1952
1953        // Only process the constructor โ€” `this.x = ...` in other methods is
1954        // not necessarily a field declaration site (it may shadow or
1955        // mutate). Constructor-time assignments are the standard
1956        // class-field discovery surface.
1957        let Some(name_node) = child.child_by_field_name("name") else {
1958            continue;
1959        };
1960        let Ok(method_name) = name_node.utf8_text(content) else {
1961            continue;
1962        };
1963        if method_name.trim() != "constructor" {
1964            continue;
1965        }
1966
1967        let Some(method_body) = child.child_by_field_name("body") else {
1968            continue;
1969        };
1970
1971        walk_for_this_assignments(method_body, content, helper, &class_name);
1972    }
1973
1974    Ok(())
1975}
1976
1977/// Recursively scan a subtree for `assignment_expression` nodes whose left
1978/// side is `this.<identifier>` and emit Property nodes for the corresponding
1979/// `Class.<identifier>` qualified names.
1980fn walk_for_this_assignments(
1981    node: Node<'_>,
1982    content: &[u8],
1983    helper: &mut GraphBuildHelper,
1984    class_name: &str,
1985) {
1986    if node.kind() == "assignment_expression"
1987        && let Some(left) = node.child_by_field_name("left")
1988        && left.kind() == "member_expression"
1989        && let Some(object) = left.child_by_field_name("object")
1990        && object.kind() == "this"
1991        && let Some(property) = left.child_by_field_name("property")
1992        && property.kind() == "property_identifier"
1993        && let Ok(field_name) = property.utf8_text(content)
1994    {
1995        let field_name = field_name.trim();
1996        if !field_name.is_empty() {
1997            let qualified_name = format!("{class_name}.{field_name}");
1998            // Span sourced from the `this.<name>` member expression so the
1999            // node carries a useful location even when the explicit-field
2000            // path did not run.
2001            // Per design ยง3.3 + AC-4: JS field visibility is syntactic.
2002            // `this.<name>` discovered fields lack a `#`-prefix surface
2003            // (the property_identifier branch only matches non-private
2004            // identifiers โ€” private-instance access uses
2005            // `private_property_identifier` and is filtered out above),
2006            // so they default to "public".
2007            let _ = helper.add_property_with_static_and_visibility(
2008                &qualified_name,
2009                Some(span_from_node(left)),
2010                false,
2011                Some("public"),
2012            );
2013        }
2014    }
2015
2016    // Recurse into all children. Nested arrow functions are intentionally
2017    // walked because they inherit `this`. Non-arrow nested functions also
2018    // recurse, but `this` inside them is rebound so a `this.x = ...` there
2019    // would be misattributed; this is a known limitation that mirrors the
2020    // best-effort behaviour of class-field discovery elsewhere in the
2021    // ecosystem (the JS grammar offers no static way to distinguish at
2022    // tree-walk time without full scope analysis).
2023    let mut cursor = node.walk();
2024    for child in node.children(&mut cursor) {
2025        walk_for_this_assignments(child, content, helper, class_name);
2026    }
2027}
2028
2029/// Helper: Get enclosing class name for a method
2030/// Supports both named classes and anonymous classes assigned to variables
2031/// ISSUE 3 FIX: Handle anonymous class expressions
2032fn get_enclosing_class_name(method_node: Node, content: &[u8]) -> GraphResult<Option<String>> {
2033    // Walk up the tree to find the class declaration or expression
2034    let mut current = method_node;
2035    while let Some(parent) = current.parent() {
2036        if parent.kind() == "class_declaration" {
2037            // Named class declaration
2038            if let Some(name_node) = parent.child_by_field_name("name") {
2039                let class_name = name_node
2040                    .utf8_text(content)
2041                    .map_err(|_| GraphBuilderError::ParseError {
2042                        span: span_from_node(parent),
2043                        reason: "failed to read class name".to_string(),
2044                    })?
2045                    .trim()
2046                    .to_string();
2047
2048                if !class_name.is_empty() {
2049                    return Ok(Some(class_name));
2050                }
2051            }
2052        } else if parent.kind() == "class" {
2053            // Anonymous class expression - check if assigned to variable
2054            // Example: const MyClass = class { ... }
2055            if let Some(grandparent) = parent.parent() {
2056                if grandparent.kind() == "variable_declarator" {
2057                    // Get variable name
2058                    if let Some(name_node) = grandparent.child_by_field_name("name")
2059                        && let Ok(var_name) = name_node.utf8_text(content)
2060                    {
2061                        let var_name = var_name.trim().to_string();
2062                        if !var_name.is_empty() {
2063                            return Ok(Some(var_name));
2064                        }
2065                    }
2066                } else if grandparent.kind() == "assignment_expression" {
2067                    // Assignment: SomeClass = class { ... }
2068                    if let Some(left) = grandparent.child_by_field_name("left")
2069                        && let Ok(assign_name) = left.utf8_text(content)
2070                    {
2071                        let assign_name = assign_name.trim().to_string();
2072                        if !assign_name.is_empty() {
2073                            return Ok(Some(assign_name));
2074                        }
2075                    }
2076                }
2077            }
2078            // If anonymous and not assigned, return None
2079            // (Methods won't get JSDoc edges, but won't crash)
2080            return Ok(None);
2081        }
2082        current = parent;
2083    }
2084    Ok(None)
2085}
2086
2087/// Extract parameter names and AST indices from function/method parameter list
2088/// Returns Vec<(`ast_index`, `param_name`)> for mapping `JSDoc` tags to AST positions
2089fn extract_ast_parameters(func_node: Node, content: &[u8]) -> Vec<(usize, String)> {
2090    let Some(params_node) = func_node.child_by_field_name("parameters") else {
2091        return Vec::new();
2092    };
2093
2094    let mut cursor = params_node.walk();
2095    params_node
2096        .named_children(&mut cursor)
2097        .enumerate()
2098        .filter_map(|(ast_index, param)| {
2099            // Handle different parameter node types
2100            let param_name = match param.kind() {
2101                "identifier" => param
2102                    .utf8_text(content)
2103                    .ok()
2104                    .map(std::string::ToString::to_string),
2105                "required_parameter" | "optional_parameter" => {
2106                    // Get the pattern node (identifier)
2107                    param
2108                        .child_by_field_name("pattern")
2109                        .and_then(|p| p.utf8_text(content).ok())
2110                        .map(std::string::ToString::to_string)
2111                }
2112                "rest_pattern" => {
2113                    // Rest parameters: ...args
2114                    // Get identifier inside rest pattern
2115                    param
2116                        .named_child(0)
2117                        .and_then(|n| n.utf8_text(content).ok())
2118                        .map(|s| s.trim_start_matches("...").to_string())
2119                }
2120                "assignment_pattern" => {
2121                    // Default parameters: x = 10
2122                    // Get left side identifier
2123                    param
2124                        .child_by_field_name("left")
2125                        .filter(|left| left.kind() == "identifier")
2126                        .and_then(|left| left.utf8_text(content).ok())
2127                        .map(std::string::ToString::to_string)
2128                }
2129                _ => None,
2130            };
2131
2132            param_name.map(|name| (ast_index, name))
2133        })
2134        .collect()
2135}
2136
2137/// Helper: Check if a variable declaration is top-level (module-scope only)
2138/// Excludes variables inside functions, methods, AND block scopes (if, for, while, try, etc.)
2139fn is_top_level_variable(decl_node: Node) -> bool {
2140    let mut current = decl_node;
2141    while let Some(parent) = current.parent() {
2142        match parent.kind() {
2143            // Functions/methods - not top-level
2144            "function_declaration"
2145            | "generator_function_declaration"
2146            | "function_expression"
2147            | "arrow_function"
2148            | "method_definition" => return false,
2149
2150            // Block scopes - not top-level (Issue 2 fix)
2151            "statement_block" | "if_statement" | "for_statement" | "for_in_statement"
2152            | "for_of_statement" | "while_statement" | "do_statement" | "try_statement"
2153            | "catch_clause" | "finally_clause" | "switch_statement" | "switch_case"
2154            | "switch_default" | "class_body" | "class_static_block" | "with_statement" => {
2155                return false;
2156            }
2157
2158            // Program/module root - is top-level
2159            // Export statements are top-level
2160            "program" | "export_statement" => return true,
2161
2162            _ => {}
2163        }
2164        current = parent;
2165    }
2166    true
2167}
2168
2169// ========== FFI Detection ==========
2170
2171/// Build FFI edges for call expressions.
2172///
2173/// Detects:
2174/// - `WebAssembly.instantiate(buffer)` / `WebAssembly.instantiateStreaming(fetch(...))`
2175/// - `WebAssembly.compile(buffer)` / `WebAssembly.compileStreaming(fetch(...))`
2176/// - `require('./native.node')` - Node.js native addons
2177/// - `process.dlopen(module, filename)` - Node.js dynamic loading
2178///
2179/// Returns true if an FFI edge was created, false otherwise.
2180fn build_ffi_call_edge(
2181    ast_graph: &ASTGraph,
2182    call_node: Node<'_>,
2183    content: &[u8],
2184    helper: &mut GraphBuildHelper,
2185) -> GraphResult<bool> {
2186    let Some(callee_expr) = call_node.child_by_field_name("function") else {
2187        return Ok(false);
2188    };
2189
2190    let callee_text = callee_expr
2191        .utf8_text(content)
2192        .map_err(|_| GraphBuilderError::ParseError {
2193            span: span_from_node(call_node),
2194            reason: "failed to read call expression".to_string(),
2195        })?
2196        .trim();
2197
2198    // Check for WebAssembly API calls
2199    if callee_text.starts_with("WebAssembly.") {
2200        return Ok(build_webassembly_call_edge(
2201            ast_graph,
2202            call_node,
2203            content,
2204            callee_text,
2205            helper,
2206        ));
2207    }
2208
2209    // Check for Node.js native addon require
2210    if callee_text == "require" {
2211        return Ok(build_require_ffi_edge(
2212            ast_graph, call_node, content, helper,
2213        ));
2214    }
2215
2216    // Check for process.dlopen
2217    if callee_text == "process.dlopen" {
2218        return Ok(build_dlopen_edge(ast_graph, call_node, content, helper));
2219    }
2220
2221    Ok(false)
2222}
2223
2224/// Build FFI edges for new expressions (constructor calls).
2225///
2226/// Detects:
2227/// - `new WebAssembly.Module(buffer)`
2228/// - `new WebAssembly.Instance(module, imports)`
2229///
2230/// Returns true if an FFI edge was created, false otherwise.
2231fn build_ffi_new_edge(
2232    ast_graph: &ASTGraph,
2233    new_node: Node<'_>,
2234    content: &[u8],
2235    helper: &mut GraphBuildHelper,
2236) -> GraphResult<bool> {
2237    let Some(constructor_expr) = new_node.child_by_field_name("constructor") else {
2238        return Ok(false);
2239    };
2240
2241    let constructor_text = constructor_expr
2242        .utf8_text(content)
2243        .map_err(|_| GraphBuilderError::ParseError {
2244            span: span_from_node(new_node),
2245            reason: "failed to read constructor expression".to_string(),
2246        })?
2247        .trim();
2248
2249    // Check for WebAssembly constructors
2250    if constructor_text == "WebAssembly.Module" || constructor_text == "WebAssembly.Instance" {
2251        return Ok(build_webassembly_constructor_edge(
2252            ast_graph,
2253            new_node,
2254            content,
2255            constructor_text,
2256            helper,
2257        ));
2258    }
2259
2260    Ok(false)
2261}
2262
2263/// Build WebAssembly call edge for API calls like instantiate/compile.
2264fn build_webassembly_call_edge(
2265    ast_graph: &ASTGraph,
2266    call_node: Node<'_>,
2267    content: &[u8],
2268    callee_text: &str,
2269    helper: &mut GraphBuildHelper,
2270) -> bool {
2271    // Extract the method name
2272    let method_name = callee_text
2273        .strip_prefix("WebAssembly.")
2274        .unwrap_or(callee_text);
2275
2276    // Only handle known WebAssembly methods that load/instantiate WASM
2277    let is_wasm_load = matches!(
2278        method_name,
2279        "instantiate" | "instantiateStreaming" | "compile" | "compileStreaming" | "validate"
2280    );
2281
2282    if !is_wasm_load {
2283        return false;
2284    }
2285
2286    // Get caller context
2287    let caller_id = get_caller_node_id(ast_graph, call_node, content, helper);
2288
2289    // Try to extract module path from arguments (if it's a fetch() call or string literal)
2290    let wasm_module_name = extract_wasm_module_name(call_node, content)
2291        .unwrap_or_else(|| format!("wasm::{method_name}"));
2292
2293    // Create WASM module node with qualified name
2294    let wasm_node_id = helper.add_module(&wasm_module_name, Some(span_from_node(call_node)));
2295
2296    // Add WebAssembly edge
2297    helper.add_webassembly_edge(caller_id, wasm_node_id);
2298
2299    true
2300}
2301
2302/// Build WebAssembly edge for constructor calls (new WebAssembly.Module/Instance).
2303fn build_webassembly_constructor_edge(
2304    ast_graph: &ASTGraph,
2305    new_node: Node<'_>,
2306    content: &[u8],
2307    constructor_text: &str,
2308    helper: &mut GraphBuildHelper,
2309) -> bool {
2310    // Get caller context
2311    let caller_id = get_caller_node_id(ast_graph, new_node, content, helper);
2312
2313    // Determine module name
2314    let type_name = constructor_text
2315        .strip_prefix("WebAssembly.")
2316        .unwrap_or(constructor_text);
2317    let wasm_module_name = format!("wasm::{type_name}");
2318
2319    // Create WASM module node
2320    let wasm_node_id = helper.add_module(&wasm_module_name, Some(span_from_node(new_node)));
2321
2322    // Add WebAssembly edge
2323    helper.add_webassembly_edge(caller_id, wasm_node_id);
2324
2325    true
2326}
2327
2328/// Build Import edge for `CommonJS` `require()` calls, plus FFI edge for native addons.
2329///
2330/// Creates an Import edge for all `require()` calls (`CommonJS` module system).
2331/// Additionally creates an FFI edge if the module is a native addon.
2332fn build_require_ffi_edge(
2333    ast_graph: &ASTGraph,
2334    call_node: Node<'_>,
2335    content: &[u8],
2336    helper: &mut GraphBuildHelper,
2337) -> bool {
2338    // Get the first argument (module path)
2339    let Some(args) = call_node.child_by_field_name("arguments") else {
2340        return false;
2341    };
2342
2343    let mut cursor = args.walk();
2344    let first_arg = args
2345        .children(&mut cursor)
2346        .find(|child| !matches!(child.kind(), "(" | ")" | ","));
2347
2348    let Some(arg_node) = first_arg else {
2349        return false;
2350    };
2351
2352    // Extract the module path
2353    let module_path = extract_string_literal(&arg_node, content);
2354    let Some(path) = module_path else {
2355        return false;
2356    };
2357
2358    // Always create an Import edge for CommonJS require() calls
2359    let from_id = helper.add_module("<module>", None);
2360
2361    // Resolve the import path and create import node
2362    let resolved_path = if path.starts_with('.') {
2363        // Relative import - resolve against file path
2364        sqry_core::graph::resolve_import_path(std::path::Path::new(helper.file_path()), &path)
2365            .unwrap_or_else(|_| simple_name(&path).to_string())
2366    } else {
2367        // Package import - use as-is (simple name)
2368        simple_name(&path).to_string()
2369    };
2370
2371    let to_id = helper.add_import(&resolved_path, Some(span_from_node(call_node)));
2372    helper.add_import_edge(from_id, to_id);
2373
2374    // Check if this is a native addon (.node file or known native packages)
2375    let is_native_addon = std::path::Path::new(&path)
2376        .extension()
2377        .is_some_and(|ext| ext.eq_ignore_ascii_case("node"))
2378        || is_known_native_addon(&path);
2379
2380    if is_native_addon {
2381        // Get caller context
2382        let caller_id = get_caller_node_id(ast_graph, call_node, content, helper);
2383
2384        // Create FFI target node
2385        let ffi_name = format!("native::{}", simple_name(&path));
2386        let ffi_node_id = helper.add_module(&ffi_name, Some(span_from_node(call_node)));
2387
2388        // Add FFI edge with C convention (Node.js native addons use N-API/C ABI)
2389        helper.add_ffi_edge(caller_id, ffi_node_id, FfiConvention::C);
2390    }
2391
2392    true
2393}
2394
2395/// Build FFI edge for `process.dlopen()` calls.
2396fn build_dlopen_edge(
2397    ast_graph: &ASTGraph,
2398    call_node: Node<'_>,
2399    content: &[u8],
2400    helper: &mut GraphBuildHelper,
2401) -> bool {
2402    // Get caller context
2403    let caller_id = get_caller_node_id(ast_graph, call_node, content, helper);
2404
2405    // Try to extract filename from second argument
2406    let module_name = call_node
2407        .child_by_field_name("arguments")
2408        .and_then(|args| {
2409            let mut cursor = args.walk();
2410            args.children(&mut cursor)
2411                .filter(|child| !matches!(child.kind(), "(" | ")" | ","))
2412                .nth(1) // Second argument is the filename
2413        })
2414        .and_then(|node| extract_string_literal(&node, content))
2415        .map_or_else(
2416            || "native::dlopen".to_string(),
2417            |path| format!("native::{}", simple_name(&path)),
2418        );
2419
2420    // Create FFI target node
2421    let ffi_node_id = helper.add_module(&module_name, Some(span_from_node(call_node)));
2422
2423    // Add FFI edge
2424    helper.add_ffi_edge(caller_id, ffi_node_id, FfiConvention::C);
2425
2426    true
2427}
2428
2429/// Get the caller node ID from AST context.
2430fn get_caller_node_id(
2431    ast_graph: &ASTGraph,
2432    node: Node<'_>,
2433    content: &[u8],
2434    helper: &mut GraphBuildHelper,
2435) -> sqry_core::graph::unified::NodeId {
2436    let module_context;
2437    let call_context = if let Some(ctx) = ast_graph.get_callable_context(node.id()) {
2438        ctx
2439    } else {
2440        module_context = CallContext {
2441            name: Arc::from("<module>"),
2442            qualified_name: "<module>".to_string(),
2443            span: (0, content.len()),
2444            is_async: false,
2445        };
2446        &module_context
2447    };
2448
2449    ensure_caller_node(helper, call_context)
2450}
2451
2452fn ensure_caller_node(
2453    helper: &mut GraphBuildHelper,
2454    call_context: &CallContext,
2455) -> sqry_core::graph::unified::NodeId {
2456    let caller_span = Some(Span::from_bytes(call_context.span.0, call_context.span.1));
2457    let qualified_name = call_context.qualified_name();
2458    if qualified_name.contains('.') {
2459        helper.ensure_method(qualified_name, caller_span, call_context.is_async, false)
2460    } else {
2461        helper.ensure_function(qualified_name, caller_span, call_context.is_async, false)
2462    }
2463}
2464
2465/// Try to extract WASM module name from call arguments.
2466///
2467/// Handles patterns like:
2468/// - `WebAssembly.instantiate(fetch('./module.wasm'))` -> "./module.wasm"
2469/// - `WebAssembly.instantiate(buffer)` -> None (can't determine statically)
2470fn extract_wasm_module_name(call_node: Node<'_>, content: &[u8]) -> Option<String> {
2471    let args = call_node.child_by_field_name("arguments")?;
2472
2473    let mut cursor = args.walk();
2474    let first_arg = args
2475        .children(&mut cursor)
2476        .find(|child| !matches!(child.kind(), "(" | ")" | ","))?;
2477
2478    // Check if it's a fetch() call
2479    if first_arg.kind() == "call_expression"
2480        && let Some(func) = first_arg.child_by_field_name("function")
2481    {
2482        let func_text = func.utf8_text(content).ok()?.trim();
2483        if func_text == "fetch" {
2484            // Extract URL from fetch argument
2485            if let Some(fetch_args) = first_arg.child_by_field_name("arguments") {
2486                let mut fetch_cursor = fetch_args.walk();
2487                let url_arg = fetch_args
2488                    .children(&mut fetch_cursor)
2489                    .find(|child| !matches!(child.kind(), "(" | ")" | ","))?;
2490
2491                if let Some(url) = extract_string_literal(&url_arg, content) {
2492                    return Some(format!("wasm::{}", simple_name(&url)));
2493                }
2494            }
2495        }
2496    }
2497
2498    // Check if it's a string literal (file path)
2499    if let Some(path) = extract_string_literal(&first_arg, content) {
2500        return Some(format!("wasm::{}", simple_name(&path)));
2501    }
2502
2503    None
2504}
2505
2506/// Check if a package name is a known native addon.
2507fn is_known_native_addon(package_name: &str) -> bool {
2508    // Common native addon packages
2509    const NATIVE_PACKAGES: &[&str] = &[
2510        "better-sqlite3",
2511        "sqlite3",
2512        "bcrypt",
2513        "sharp",
2514        "canvas",
2515        "node-sass",
2516        "leveldown",
2517        "bufferutil",
2518        "utf-8-validate",
2519        "fsevents",
2520        "cpu-features",
2521        "node-gyp",
2522        "node-pre-gyp",
2523        "prebuild",
2524        "nan",
2525        "node-addon-api",
2526        "ref-napi",
2527        "ffi-napi",
2528    ];
2529
2530    NATIVE_PACKAGES
2531        .iter()
2532        .any(|&pkg| package_name.contains(pkg))
2533}