Skip to main content

sqry_lang_javascript/relations/
graph_builder.rs

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