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