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