Skip to main content

agentstatedeveloper_typescript/
lib.rs

1//! TypeScript / JavaScript language adapter for AgentStateDeveloper.
2//!
3//! Implements [`LanguageAdapter`](agentstatedeveloper_core::LanguageAdapter)
4//! on top of `tree-sitter-typescript`. Parses module-level functions,
5//! methods, classes, and arrow-function-bound variable declarations, then
6//! runs a small substring-based effect inference pass and extracts
7//! intra- + cross-module call edges.
8//!
9//! Tree-sitter API: this crate targets tree-sitter-typescript 0.23.x, which
10//! exposes `LANGUAGE_TYPESCRIPT` and `LANGUAGE_TSX` as `LanguageFn`
11//! constants. If you bump to a later 0.23.x and the build breaks, switch
12//! to the `language_typescript()` / `language_tsx()` functions.
13
14use std::collections::{HashMap, HashSet};
15
16use agentstatedeveloper_core::adapter::{
17    CallEdge, LanguageAdapter, ParsedSymbol, WorkspaceSymbols,
18};
19use agentstatedeveloper_core::cross_service::{
20    DetectedEndpoint, Direction, Transport, http_contract,
21};
22use agentstatedeveloper_core::error::{AsdError, Result};
23use agentstatedeveloper_core::schema::{Effect, EffectCategory, SymbolKind};
24use serde_json::json;
25use tree_sitter::{Language, Node, Parser};
26
27/// TypeScript / JavaScript language adapter.
28#[derive(Debug, Default, Clone, Copy)]
29pub struct TypeScriptAdapter;
30
31impl TypeScriptAdapter {
32    pub fn new() -> Self {
33        Self
34    }
35}
36
37/// Pick the right tree-sitter grammar for a file. TSX for `.tsx` / `.jsx`,
38/// plain TypeScript for everything else.
39fn grammar_for(file: &str) -> Language {
40    let lower = file.to_ascii_lowercase();
41    if lower.ends_with(".tsx") || lower.ends_with(".jsx") {
42        tree_sitter_typescript::LANGUAGE_TSX.into()
43    } else {
44        tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into()
45    }
46}
47
48impl LanguageAdapter for TypeScriptAdapter {
49    fn language(&self) -> &str {
50        "typescript"
51    }
52
53    fn file_extensions(&self) -> &'static [&'static str] {
54        &["ts", "tsx", "mts", "cts"]
55    }
56
57    fn parse_symbols(&self, file: &str, source: &str) -> Result<Vec<ParsedSymbol>> {
58        let mut parser = Parser::new();
59        parser
60            .set_language(&grammar_for(file))
61            .map_err(|e| AsdError::Parse(format!("failed to set typescript language: {e}")))?;
62
63        let src_bytes = source.as_bytes();
64        let tree = parser
65            .parse(src_bytes, None)
66            .ok_or_else(|| AsdError::Parse(format!("failed to parse {file}")))?;
67
68        let module_prefix = module_qname_prefix(file);
69        let root = tree.root_node();
70        let mut out = Vec::new();
71        walk(root, src_bytes, &module_prefix, &[], &mut out);
72        Ok(out)
73    }
74
75    fn infer_effects(&self, _source: &str, symbol: &ParsedSymbol) -> Vec<Effect> {
76        infer_effects_from_body(&symbol.body)
77    }
78
79    fn infer_service_endpoints(
80        &self,
81        file: &str,
82        source: &str,
83        symbols: &[ParsedSymbol],
84    ) -> Vec<DetectedEndpoint> {
85        infer_service_endpoints_in_ts(file, source, symbols)
86    }
87
88    fn extract_call_edges(
89        &self,
90        file: &str,
91        source: &str,
92        symbols: &[ParsedSymbol],
93        workspace: &WorkspaceSymbols,
94    ) -> Vec<CallEdge> {
95        extract_call_edges_impl(file, source, symbols, workspace)
96    }
97}
98
99/// Walk path components and return the tail after the first `src` segment.
100/// Falls back to the full path for projects without that convention.
101///
102/// Examples:
103/// - `src/components/Button.tsx` → `components/Button.tsx`
104/// - `packages/ui/src/index.ts`  → `index.ts`
105/// - `lib/util.ts`               → `lib/util.ts`  (no `src` segment, unchanged)
106fn strip_src_prefix(path: &str) -> &str {
107    let mut offset = 0usize;
108    for part in path.split('/') {
109        if part == "src" {
110            let after = offset + part.len() + 1;
111            if after < path.len() {
112                return &path[after..];
113            }
114        }
115        offset += part.len() + 1;
116    }
117    path
118}
119
120/// Derive the dotted module prefix for a file path.
121///
122/// Anchors at the `src/` boundary so the prefix is stable regardless of
123/// which directory `asd index` was invoked from.
124///
125/// `src/components/Button.tsx`      -> `components.Button`
126/// `packages/ui/src/index.ts`       -> `ui.index`   (package-name disambiguates)
127/// `foo/bar.ts`                     -> `foo.bar`    (no src segment, fallback)
128/// `./foo/bar.tsx`                  -> `foo.bar`
129/// `bar.mts`                        -> `bar`
130fn module_qname_prefix(file: &str) -> String {
131    let mut s = file;
132    if let Some(stripped) = s.strip_prefix("./") {
133        s = stripped;
134    }
135    for ext in [".tsx", ".ts", ".mts", ".cts", ".jsx", ".js", ".mjs", ".cjs"] {
136        if let Some(stripped) = s.strip_suffix(ext) {
137            s = stripped;
138            break;
139        }
140    }
141    let after_src = strip_src_prefix(s);
142    // index.ts at the root of a package's src/ dir is not unique across packages.
143    // Prepend the package directory name (the segment before `src/`).
144    if after_src == "index" {
145        if let Some(pkg_name) = package_name_from_path(s) {
146            return format!("{}.index", pkg_name.replace('-', "_"));
147        }
148    }
149    after_src.replace('\\', "/").replace('/', ".")
150}
151
152/// Extract the package name from a path like `packages/my-pkg/src/index.ts`
153/// by finding the segment immediately before a `src` component.
154fn package_name_from_path(path: &str) -> Option<&str> {
155    let parts: Vec<&str> = path.split('/').collect();
156    for (i, &part) in parts.iter().enumerate() {
157        if part == "src" && i > 0 {
158            return Some(parts[i - 1]);
159        }
160    }
161    None
162}
163
164#[derive(Debug, Clone, Copy, PartialEq, Eq)]
165#[allow(dead_code)]
166enum ScopeKind {
167    Class,
168    Function,
169    Namespace,
170}
171
172/// Recursive descent over a TS tree. We enumerate:
173/// - `function_declaration` -> Function
174/// - `method_definition` (inside `class_body`) -> Method
175/// - `class_declaration` -> Class
176/// - `lexical_declaration` / `variable_declaration` with an arrow-function
177///   or function-expression initializer -> Function
178/// - `export_statement` wrappers — unwrapped to the inner declaration
179/// - `internal_module` / `module` (namespace X { ... }) -> walked with a
180///   namespace-prefixed qname; the namespace itself is not emitted.
181///
182/// Nested arrow functions inside function bodies are NOT emitted — they'd
183/// be anonymous and noisy. Interfaces and type aliases are skipped (not
184/// callable).
185fn walk(
186    node: Node<'_>,
187    src: &[u8],
188    module_prefix: &str,
189    scope: &[(String, ScopeKind)],
190    out: &mut Vec<ParsedSymbol>,
191) {
192    let kind = node.kind();
193    match kind {
194        "function_declaration" | "generator_function_declaration" => {
195            let name = node_field_text(node, "name", src).unwrap_or_else(|| "<anon>".to_string());
196            let qname = build_qname(module_prefix, scope, &name);
197            let symbol_kind = if scope.last().map(|s| s.1) == Some(ScopeKind::Class) {
198                SymbolKind::Method
199            } else {
200                SymbolKind::Function
201            };
202            let signature = extract_function_signature(node, src, &name);
203            out.push(make_parsed_symbol(node, src, qname, symbol_kind, signature));
204            // Don't recurse: nested arrow-in-variable and nested fn decls
205            // inside function bodies are intentionally skipped.
206        }
207        "method_definition" => {
208            let name = method_name_text(node, src).unwrap_or_else(|| "<anon>".to_string());
209            let qname = build_qname(module_prefix, scope, &name);
210            let signature = extract_function_signature(node, src, &name);
211            out.push(make_parsed_symbol(
212                node,
213                src,
214                qname,
215                SymbolKind::Method,
216                signature,
217            ));
218            // Don't descend into method bodies for nested arrows.
219        }
220        "class_declaration" | "abstract_class_declaration" => {
221            let name = node_field_text(node, "name", src).unwrap_or_else(|| "<anon>".to_string());
222            let qname = build_qname(module_prefix, scope, &name);
223            let signature = extract_class_signature(node, src, &name);
224            out.push(make_parsed_symbol(
225                node,
226                src,
227                qname,
228                SymbolKind::Class,
229                signature,
230            ));
231
232            let mut new_scope = scope.to_vec();
233            new_scope.push((name, ScopeKind::Class));
234            if let Some(body) = node.child_by_field_name("body") {
235                let mut cursor = body.walk();
236                for child in body.children(&mut cursor) {
237                    walk(child, src, module_prefix, &new_scope, out);
238                }
239            }
240        }
241        "lexical_declaration" | "variable_declaration" => {
242            // `const foo = () => {}`, `let bar = function() {}` — one
243            // declaration may bind multiple variables.
244            let mut cursor = node.walk();
245            for child in node.children(&mut cursor) {
246                if child.kind() != "variable_declarator" {
247                    continue;
248                }
249                process_variable_declarator(child, src, module_prefix, scope, out);
250            }
251        }
252        "export_statement" => {
253            // Unwrap `export function foo()`, `export class C`,
254            // `export const foo = () => {}`, `export default function()`.
255            let mut cursor = node.walk();
256            for child in node.children(&mut cursor) {
257                match child.kind() {
258                    "function_declaration"
259                    | "generator_function_declaration"
260                    | "class_declaration"
261                    | "abstract_class_declaration"
262                    | "lexical_declaration"
263                    | "variable_declaration"
264                    | "internal_module"
265                    | "module" => {
266                        walk(child, src, module_prefix, scope, out);
267                    }
268                    _ => {
269                        // `export default function() { ... }` -> the function
270                        // expression is a direct child. Treat as anonymous
271                        // default export.
272                        if matches!(
273                            child.kind(),
274                            "function_expression" | "arrow_function" | "generator_function"
275                        ) {
276                            let qname = build_qname(module_prefix, scope, "default");
277                            let signature = extract_function_signature(child, src, "default");
278                            out.push(make_parsed_symbol(
279                                child,
280                                src,
281                                qname,
282                                SymbolKind::Function,
283                                signature,
284                            ));
285                        }
286                    }
287                }
288            }
289        }
290        "internal_module" | "module" => {
291            // `namespace Foo { ... }` — don't emit the namespace itself, but
292            // walk its body with the namespace pushed onto the scope.
293            let name = node_field_text(node, "name", src).unwrap_or_default();
294            if !name.is_empty() {
295                let mut new_scope = scope.to_vec();
296                new_scope.push((name, ScopeKind::Namespace));
297                if let Some(body) = node.child_by_field_name("body") {
298                    let mut cursor = body.walk();
299                    for child in body.children(&mut cursor) {
300                        walk(child, src, module_prefix, &new_scope, out);
301                    }
302                }
303            }
304        }
305        _ => {
306            // Descend through program root, statement lists, etc.
307            let mut cursor = node.walk();
308            for child in node.children(&mut cursor) {
309                walk(child, src, module_prefix, scope, out);
310            }
311        }
312    }
313}
314
315/// Inspect a `variable_declarator` node to see if its initializer is a
316/// function/arrow expression. If so, emit a Function symbol.
317fn process_variable_declarator(
318    node: Node<'_>,
319    src: &[u8],
320    module_prefix: &str,
321    scope: &[(String, ScopeKind)],
322    out: &mut Vec<ParsedSymbol>,
323) {
324    let name_node = match node.child_by_field_name("name") {
325        Some(n) => n,
326        None => return,
327    };
328    // Skip destructuring patterns — binding names aren't a single identifier.
329    if name_node.kind() != "identifier" {
330        return;
331    }
332    let value = match node.child_by_field_name("value") {
333        Some(v) => v,
334        None => return,
335    };
336    match value.kind() {
337        "arrow_function" | "function_expression" | "generator_function" => {
338            let name = match node_text(name_node, src) {
339                Some(n) => n,
340                None => return,
341            };
342            let qname = build_qname(module_prefix, scope, &name);
343            let signature = extract_function_signature(value, src, &name);
344            // Use the declarator node's span so callers see the whole
345            // `foo = () => {...}` in the body; that makes effect inference
346            // line up with how Python handles `def foo():`.
347            out.push(make_parsed_symbol(
348                node,
349                src,
350                qname,
351                SymbolKind::Function,
352                signature,
353            ));
354        }
355        _ => {}
356    }
357}
358
359/// Extract the method name from a `method_definition`. The grammar's
360/// `name` field may be an identifier, a property_identifier, or a computed
361/// property — we only handle the plain identifier forms.
362fn method_name_text(node: Node<'_>, src: &[u8]) -> Option<String> {
363    let name = node.child_by_field_name("name")?;
364    match name.kind() {
365        "property_identifier" | "identifier" | "private_property_identifier" => {
366            node_text(name, src)
367        }
368        _ => None,
369    }
370}
371
372fn build_qname(module_prefix: &str, scope: &[(String, ScopeKind)], name: &str) -> String {
373    let mut parts = Vec::with_capacity(scope.len() + 2);
374    if !module_prefix.is_empty() {
375        parts.push(module_prefix.to_string());
376    }
377    for (s, _) in scope {
378        parts.push(s.clone());
379    }
380    parts.push(name.to_string());
381    parts.join(".")
382}
383
384fn node_field_text(node: Node<'_>, field: &str, src: &[u8]) -> Option<String> {
385    node.child_by_field_name(field)
386        .and_then(|n| node_text(n, src))
387}
388
389fn node_text(node: Node<'_>, src: &[u8]) -> Option<String> {
390    src.get(node.byte_range())
391        .map(|b| String::from_utf8_lossy(b).into_owned())
392}
393
394fn make_parsed_symbol(
395    node: Node<'_>,
396    src: &[u8],
397    qname: String,
398    kind: SymbolKind,
399    signature: Option<String>,
400) -> ParsedSymbol {
401    let start = node.start_position();
402    let end = node.end_position();
403    let body = node_text(node, src).unwrap_or_default();
404    let doc = extract_preceding_doc(src, node.start_byte());
405    ParsedSymbol {
406        qname,
407        kind,
408        start_line: (start.row as u32) + 1,
409        start_col: (start.column as u32) + 1,
410        end_line: (end.row as u32) + 1,
411        end_col: (end.column as u32) + 1,
412        body,
413        signature,
414        doc,
415    }
416}
417
418fn extract_preceding_doc(src: &[u8], start_byte: usize) -> Option<String> {
419    let before = std::str::from_utf8(&src[..start_byte]).ok()?;
420    let trimmed_end = before.trim_end();
421    if trimmed_end.ends_with("*/") {
422        if let Some(block_start) = trimmed_end.rfind("/**") {
423            let block = &trimmed_end[block_start + 3..trimmed_end.len() - 2];
424            let cleaned = block
425                .lines()
426                .map(|l| l.trim().trim_start_matches('*').trim())
427                .filter(|l| !l.is_empty())
428                .collect::<Vec<_>>()
429                .join(" ");
430            if !cleaned.is_empty() {
431                return Some(truncate_doc(&cleaned));
432            }
433        }
434    }
435    let lines: Vec<&str> = trimmed_end.lines().collect();
436    let mut comment_lines: Vec<&str> = Vec::new();
437    for line in lines.iter().rev() {
438        let t = line.trim();
439        if t.starts_with("//") {
440            comment_lines.push(t.trim_start_matches("//").trim());
441        } else {
442            break;
443        }
444    }
445    if !comment_lines.is_empty() {
446        comment_lines.reverse();
447        return Some(truncate_doc(&comment_lines.join(" ")));
448    }
449    None
450}
451
452fn truncate_doc(s: &str) -> String {
453    const MAX: usize = 512;
454    if s.len() <= MAX {
455        return s.to_string();
456    }
457    let cut = s[..MAX].rfind(' ').unwrap_or(MAX);
458    s[..cut].to_string()
459}
460
461fn extract_function_signature(node: Node<'_>, src: &[u8], name: &str) -> Option<String> {
462    let params = node.child_by_field_name("parameters")?;
463    let params_text = node_text(params, src)?;
464    let ret = node
465        .child_by_field_name("return_type")
466        .and_then(|n| node_text(n, src))
467        .unwrap_or_default();
468    Some(format!("function {name}{params_text}{ret}"))
469}
470
471fn extract_class_signature(node: Node<'_>, src: &[u8], name: &str) -> Option<String> {
472    let heritage = node
473        .child_by_field_name("heritage")
474        .or_else(|| find_child_by_kind(node, "class_heritage"))
475        .and_then(|n| node_text(n, src))
476        .unwrap_or_default();
477    if heritage.is_empty() {
478        Some(format!("class {name}"))
479    } else {
480        Some(format!("class {name} {}", heritage.trim()))
481    }
482}
483
484fn find_child_by_kind<'a>(node: Node<'a>, kind: &str) -> Option<Node<'a>> {
485    let mut cursor = node.walk();
486    for child in node.children(&mut cursor) {
487        if child.kind() == kind {
488            return Some(child);
489        }
490    }
491    None
492}
493
494// -----------------------------------------------------------------------------
495// Cross-service endpoint detection (t-014)
496//
497// Heuristic, line-based, mirroring the Python detector. Inbound: Express/router
498// route registrations `x.get('/path', …)` where the path is a literal starting
499// with '/'. Outbound: `fetch('/url'[, {method}])` and `axios.<verb>('url')`.
500// Template-literal / variable URLs and Nest decorators (which need the
501// controller-prefix resolved) are skipped — tracked as follow-up.
502// -----------------------------------------------------------------------------
503
504const HTTP_VERBS: &[&str] = &["get", "post", "put", "patch", "delete", "head", "options"];
505
506fn infer_service_endpoints_in_ts(
507    file: &str,
508    source: &str,
509    symbols: &[ParsedSymbol],
510) -> Vec<DetectedEndpoint> {
511    let mut out = Vec::new();
512    for (idx, raw) in source.lines().enumerate() {
513        let line_no = (idx + 1) as u32;
514        let line = raw.trim();
515
516        for (method, path) in ts_routes(line) {
517            if let Some(owner) = owner_for_line(symbols, line_no) {
518                out.push(DetectedEndpoint {
519                    transport: Transport::Http,
520                    direction: Direction::Inbound,
521                    contract: http_contract(&method, &path),
522                    owner_qname: owner.qname.clone(),
523                    file: file.to_string(),
524                    line: line_no,
525                    confidence: 0.9,
526                    note: None,
527                });
528            }
529        }
530        for (method, url) in ts_clients(line) {
531            if let Some(owner) = owner_for_line(symbols, line_no) {
532                out.push(DetectedEndpoint {
533                    transport: Transport::Http,
534                    direction: Direction::Outbound,
535                    contract: http_contract(&method, &url),
536                    owner_qname: owner.qname.clone(),
537                    file: file.to_string(),
538                    line: line_no,
539                    confidence: 0.85,
540                    note: None,
541                });
542            }
543        }
544    }
545    out
546}
547
548/// Innermost symbol whose line range contains `line`.
549fn owner_for_line(symbols: &[ParsedSymbol], line: u32) -> Option<&ParsedSymbol> {
550    symbols
551        .iter()
552        .filter(|s| s.start_line <= line && line <= s.end_line)
553        .max_by_key(|s| s.start_line)
554}
555
556/// Express/router routes: `<obj>.<verb>('/path' …)`. Returns `(METHOD, path)`
557/// only when the first argument is a string literal starting with `/`.
558fn ts_routes(line: &str) -> Vec<(String, String)> {
559    let mut out = Vec::new();
560    for verb in HTTP_VERBS {
561        let needle = format!(".{verb}(");
562        let mut from = 0;
563        while let Some(rel) = line[from..].find(&needle) {
564            let start = from + rel;
565            from = start + needle.len();
566            if let Some(path) = first_ts_string(&line[start + needle.len()..]) {
567                if path.starts_with('/') {
568                    out.push((verb.to_uppercase(), path));
569                }
570            }
571        }
572    }
573    out
574}
575
576/// HTTP client calls: `axios.<verb>('url')` and `fetch('url'[, {method}])`.
577fn ts_clients(line: &str) -> Vec<(String, String)> {
578    let mut out = Vec::new();
579    for verb in HTTP_VERBS {
580        let needle = format!("axios.{verb}(");
581        if let Some(pos) = line.find(&needle) {
582            if call_boundary_ok(line, pos) {
583                if let Some(url) = first_ts_string(&line[pos + needle.len()..]) {
584                    out.push((verb.to_uppercase(), url));
585                }
586            }
587        }
588    }
589    if let Some(pos) = line.find("fetch(") {
590        if call_boundary_ok(line, pos) {
591            let after = &line[pos + "fetch(".len()..];
592            if let Some(url) = first_ts_string(after) {
593                let method = ts_fetch_method(after).unwrap_or_else(|| "GET".to_string());
594                out.push((method, url));
595            }
596        }
597    }
598    out
599}
600
601/// True when the identifier at `pos` is a call boundary, not the tail of a
602/// longer identifier (e.g. `myfetch(` / `xaxios.`).
603fn call_boundary_ok(line: &str, pos: usize) -> bool {
604    pos == 0
605        || !line[..pos]
606            .chars()
607            .next_back()
608            .map(|c| c.is_alphanumeric() || c == '_' || c == '.')
609            .unwrap_or(false)
610}
611
612/// First single/double-quoted string literal. Template literals (backtick) are
613/// rejected — they're usually dynamic, so they can't form a stable contract.
614fn first_ts_string(s: &str) -> Option<String> {
615    let s = s.trim_start();
616    let q = s.as_bytes().first().copied()?;
617    if q != b'\'' && q != b'"' {
618        return None;
619    }
620    let inner = &s[1..];
621    let end = inner.find(q as char)?;
622    Some(inner[..end].to_string())
623}
624
625/// Pull the method out of a `fetch` options object: `method: 'POST'`.
626fn ts_fetch_method(args: &str) -> Option<String> {
627    let pos = args.find("method")?;
628    let rest = args[pos + "method".len()..].trim_start();
629    let rest = rest.strip_prefix(':')?.trim_start();
630    first_ts_string(rest).map(|m| m.to_uppercase())
631}
632
633// -----------------------------------------------------------------------------
634// Effect inference
635// -----------------------------------------------------------------------------
636
637fn infer_effects_from_body(body: &str) -> Vec<Effect> {
638    let mut effects: Vec<Effect> = Vec::new();
639
640    // console.* -> Log
641    let console_needles = [
642        "console.log",
643        "console.info",
644        "console.warn",
645        "console.error",
646        "console.debug",
647    ];
648    if console_needles.iter().any(|n| body.contains(n)) {
649        let note = first_matching_line(body, &console_needles);
650        effects.push(Effect {
651            effect: EffectCategory::Log,
652            qualifiers: serde_json::Value::Null,
653            note,
654            ..Default::default()
655        });
656    }
657
658    // fs read calls -> IoFsRead
659    let fs_read_needles = [
660        "fs.readFile(",
661        "fs.readFileSync(",
662        "fs.promises.readFile(",
663        "fs.createReadStream(",
664        "fs.open(",
665    ];
666    if let Some(note) = first_match_note(body, &fs_read_needles) {
667        effects.push(Effect {
668            effect: EffectCategory::IoFsRead,
669            qualifiers: serde_json::Value::Null,
670            note: Some(note),
671            ..Default::default()
672        });
673    }
674
675    // fs write calls -> IoFsWrite
676    let fs_write_needles = [
677        "fs.writeFile(",
678        "fs.writeFileSync(",
679        "fs.promises.writeFile(",
680        "fs.createWriteStream(",
681        "fs.appendFile(",
682        "fs.appendFileSync(",
683        "fs.unlink(",
684        "fs.unlinkSync(",
685        "fs.rename(",
686        "fs.renameSync(",
687    ];
688    if let Some(note) = first_match_note(body, &fs_write_needles) {
689        effects.push(Effect {
690            effect: EffectCategory::IoFsWrite,
691            qualifiers: serde_json::Value::Null,
692            note: Some(note),
693            ..Default::default()
694        });
695    }
696
697    // Network -> IoNetOut, with URL-host qualifier if we can find one.
698    let net_needles = [
699        "fetch(",
700        "axios.",
701        "http.request(",
702        "https.request(",
703        "http.get(",
704        "https.get(",
705    ];
706    let mut net_hosts: Vec<String> = Vec::new();
707    let mut net_note: Option<String> = None;
708    for needle in net_needles {
709        if needle.ends_with('(') {
710            for call_site in find_calls(body, needle) {
711                let args = &body[call_site.args_start..call_site.args_end];
712                if let Some(host) = extract_url_host(args) {
713                    if !net_hosts.contains(&host) {
714                        net_hosts.push(host);
715                    }
716                }
717                if net_note.is_none() {
718                    net_note = Some(trim_note(
719                        &body[call_site.call_start..call_site.args_end + 1],
720                    ));
721                }
722            }
723        } else if body.contains(needle) && net_note.is_none() {
724            net_note = first_matching_line(body, &[needle]);
725        }
726    }
727    if net_note.is_some() {
728        let qualifiers = if net_hosts.is_empty() {
729            serde_json::Value::Null
730        } else {
731            json!({ "hosts": net_hosts })
732        };
733        effects.push(Effect {
734            effect: EffectCategory::IoNetOut,
735            qualifiers,
736            note: net_note,
737            ..Default::default()
738        });
739    }
740
741    // child_process -> ProcSpawn. Include the bare `spawn(` / `exec(` /
742    // `execFile(` forms too, which show up after destructured imports.
743    let proc_needles = [
744        "child_process.exec",
745        "child_process.spawn",
746        "child_process.execSync",
747        "child_process.execFile",
748        "spawn(",
749        "exec(",
750        "execSync(",
751        "execFile(",
752    ];
753    if let Some(note) = first_match_note(body, &proc_needles) {
754        effects.push(Effect {
755            effect: EffectCategory::ProcSpawn,
756            qualifiers: serde_json::Value::Null,
757            note: Some(note),
758            ..Default::default()
759        });
760    }
761
762    // process.env.* -> EnvRead, with the var name if we can parse it.
763    if body.contains("process.env") {
764        let mut vars: Vec<String> = Vec::new();
765        // `process.env.FOO` and `process.env["FOO"]`
766        for (idx, _) in body.match_indices("process.env.") {
767            let tail = &body[idx + "process.env.".len()..];
768            let end = tail
769                .find(|c: char| !(c.is_ascii_alphanumeric() || c == '_'))
770                .unwrap_or(tail.len());
771            let name = &tail[..end];
772            if !name.is_empty() && !vars.contains(&name.to_string()) {
773                vars.push(name.to_string());
774            }
775        }
776        for (idx, _) in body.match_indices("process.env[") {
777            let tail = &body[idx + "process.env[".len()..];
778            if let Some(v) = extract_first_string_literal(tail) {
779                if !vars.contains(&v) {
780                    vars.push(v);
781                }
782            }
783        }
784        let qualifiers = if vars.is_empty() {
785            serde_json::Value::Null
786        } else {
787            json!({ "vars": vars })
788        };
789        effects.push(Effect {
790            effect: EffectCategory::EnvRead,
791            qualifiers,
792            note: first_matching_line(body, &["process.env"]),
793            ..Default::default()
794        });
795    }
796
797    // setTimeout / setInterval -> TimeSleep
798    let sleep_needles = ["setTimeout(", "setInterval("];
799    if let Some(note) = first_match_note(body, &sleep_needles) {
800        effects.push(Effect {
801            effect: EffectCategory::TimeSleep,
802            qualifiers: serde_json::Value::Null,
803            note: Some(note),
804            ..Default::default()
805        });
806    }
807
808    // Date.now / new Date / performance.now -> TimeRead
809    let time_read_needles = [
810        "Date.now",
811        "new Date(",
812        "performance.now",
813        "Date.parse(",
814        "Temporal.Now",
815    ];
816    if let Some(note) = first_match_note(body, &time_read_needles) {
817        effects.push(Effect {
818            effect: EffectCategory::TimeRead,
819            qualifiers: serde_json::Value::Null,
820            note: Some(note),
821            ..Default::default()
822        });
823    }
824
825    // Math.random / crypto.random* -> Random
826    let random_needles = [
827        "Math.random",
828        "crypto.randomBytes",
829        "crypto.randomUUID",
830        "crypto.getRandomValues",
831        "nanoid(",
832        "uuidv4(",
833        "uuid.v4(",
834    ];
835    if let Some(note) = first_match_note(body, &random_needles) {
836        effects.push(Effect {
837            effect: EffectCategory::Random,
838            qualifiers: serde_json::Value::Null,
839            note: Some(note),
840            ..Default::default()
841        });
842    }
843
844    // DB calls — heuristic: `<receiver>.query(` / `.execute(` where the
845    // receiver matches a db-ish name. We inspect the first arg for a SQL
846    // keyword to classify read vs write.
847    let db_receivers = ["db", "conn", "client", "pool", "knex", "prisma"];
848    let db_methods = ["query", "execute"];
849    let mut seen_db_read = false;
850    let mut seen_db_write = false;
851    for recv in &db_receivers {
852        for method in &db_methods {
853            let needle = format!("{recv}.{method}(");
854            // Also check `this.<recv>.<method>(` form for methods.
855            let this_needle = format!("this.{recv}.{method}(");
856            for variant in &[needle.as_str(), this_needle.as_str()] {
857                for call_site in find_calls(body, variant) {
858                    let args = &body[call_site.args_start..call_site.args_end];
859                    let sql = args.trim_start_matches(|c: char| {
860                        c.is_whitespace() || c == '"' || c == '\'' || c == '`'
861                    });
862                    let upper: String = sql.chars().take(16).collect::<String>().to_uppercase();
863                    let is_write = upper.starts_with("INSERT")
864                        || upper.starts_with("UPDATE")
865                        || upper.starts_with("DELETE")
866                        || upper.starts_with("REPLACE")
867                        || upper.starts_with("CREATE")
868                        || upper.starts_with("DROP")
869                        || upper.starts_with("ALTER")
870                        || upper.starts_with("TRUNCATE");
871                    let is_read = upper.starts_with("SELECT")
872                        || upper.starts_with("WITH")
873                        || upper.starts_with("SHOW");
874                    let note = Some(trim_note(
875                        &body[call_site.call_start..call_site.args_end + 1],
876                    ));
877                    if is_write && !seen_db_write {
878                        effects.push(Effect {
879                            effect: EffectCategory::IoDbWrite,
880                            qualifiers: serde_json::Value::Null,
881                            note,
882                            ..Default::default()
883                        });
884                        seen_db_write = true;
885                    } else if is_read && !seen_db_read {
886                        effects.push(Effect {
887                            effect: EffectCategory::IoDbRead,
888                            qualifiers: serde_json::Value::Null,
889                            note,
890                            ..Default::default()
891                        });
892                        seen_db_read = true;
893                    } else if !is_write && !is_read && !seen_db_read && !seen_db_write {
894                        effects.push(Effect {
895                            effect: EffectCategory::IoDbRead,
896                            qualifiers: serde_json::Value::Null,
897                            note: note.clone(),
898                            ..Default::default()
899                        });
900                        effects.push(Effect {
901                            effect: EffectCategory::IoDbWrite,
902                            qualifiers: serde_json::Value::Null,
903                            note,
904                            ..Default::default()
905                        });
906                        seen_db_read = true;
907                        seen_db_write = true;
908                    }
909                }
910            }
911        }
912    }
913
914    // `throw ` statement -> Throw
915    if has_throw_statement(body) {
916        let note = first_matching_line(body, &["throw "]);
917        effects.push(Effect {
918            effect: EffectCategory::Throw,
919            qualifiers: serde_json::Value::Null,
920            note,
921            ..Default::default()
922        });
923    }
924
925    effects
926}
927
928/// Scan for a literal needle and return the lines we recognize as actual
929/// call sites. We do not attempt to skip string literals — plain substring
930/// is good enough for M6.
931#[derive(Debug, Clone, Copy)]
932struct CallSite {
933    call_start: usize,
934    args_start: usize,
935    args_end: usize,
936}
937
938fn find_calls(body: &str, needle: &str) -> Vec<CallSite> {
939    let mut out = Vec::new();
940    if !needle.ends_with('(') {
941        return out;
942    }
943    let bytes = body.as_bytes();
944    let mut search_from = 0;
945    while let Some(idx) = body[search_from..].find(needle) {
946        let abs = search_from + idx;
947        let args_start = abs + needle.len();
948        if let Some(args_end) = find_matching_paren(bytes, args_start) {
949            out.push(CallSite {
950                call_start: abs,
951                args_start,
952                args_end,
953            });
954            search_from = args_end + 1;
955        } else {
956            search_from = args_start;
957        }
958    }
959    out
960}
961
962/// Given the position just after `(`, find the matching `)`. Handles
963/// nested parens and single/double/backtick quoted strings.
964fn find_matching_paren(bytes: &[u8], start: usize) -> Option<usize> {
965    let mut depth: i32 = 1;
966    let mut i = start;
967    let mut in_string: Option<u8> = None;
968    while i < bytes.len() {
969        let c = bytes[i];
970        if let Some(q) = in_string {
971            if c == b'\\' {
972                i += 2;
973                continue;
974            }
975            if c == q {
976                in_string = None;
977            }
978        } else {
979            match c {
980                b'"' | b'\'' | b'`' => in_string = Some(c),
981                b'(' => depth += 1,
982                b')' => {
983                    depth -= 1;
984                    if depth == 0 {
985                        return Some(i);
986                    }
987                }
988                _ => {}
989            }
990        }
991        i += 1;
992    }
993    None
994}
995
996/// If `args` starts with a quoted string literal, return its contents.
997fn extract_first_string_literal(args: &str) -> Option<String> {
998    let trimmed = args.trim_start();
999    let bytes = trimmed.as_bytes();
1000    if bytes.is_empty() {
1001        return None;
1002    }
1003    let q = bytes[0];
1004    if q != b'"' && q != b'\'' && q != b'`' {
1005        return None;
1006    }
1007    let mut j = 1;
1008    let mut out = String::new();
1009    while j < bytes.len() {
1010        let c = bytes[j];
1011        if c == b'\\' && j + 1 < bytes.len() {
1012            out.push(bytes[j + 1] as char);
1013            j += 2;
1014            continue;
1015        }
1016        if c == q {
1017            return Some(out);
1018        }
1019        out.push(c as char);
1020        j += 1;
1021    }
1022    None
1023}
1024
1025/// Try to extract a host from args containing a URL literal.
1026fn extract_url_host(args: &str) -> Option<String> {
1027    for scheme in ["https://", "http://"] {
1028        if let Some(idx) = args.find(scheme) {
1029            let tail = &args[idx + scheme.len()..];
1030            let end = tail
1031                .find(|c: char| {
1032                    c == '/'
1033                        || c == '"'
1034                        || c == '\''
1035                        || c == '`'
1036                        || c == ')'
1037                        || c == '?'
1038                        || c.is_whitespace()
1039                })
1040                .unwrap_or(tail.len());
1041            let host = &tail[..end];
1042            if !host.is_empty() {
1043                return Some(host.to_string());
1044            }
1045        }
1046    }
1047    None
1048}
1049
1050/// Return a short, single-line note snippet.
1051fn trim_note(s: &str) -> String {
1052    let first_line = s.lines().next().unwrap_or("").trim();
1053    if first_line.len() > 120 {
1054        format!("{}...", &first_line[..120])
1055    } else {
1056        first_line.to_string()
1057    }
1058}
1059
1060/// Return the first line of `body` that contains any of `needles`.
1061fn first_matching_line(body: &str, needles: &[&str]) -> Option<String> {
1062    for line in body.lines() {
1063        for n in needles {
1064            if line.contains(n) {
1065                return Some(line.trim().to_string());
1066            }
1067        }
1068    }
1069    None
1070}
1071
1072/// Return the first matching line if any needle appears in `body`.
1073fn first_match_note(body: &str, needles: &[&str]) -> Option<String> {
1074    for n in needles {
1075        if body.contains(n) {
1076            return first_matching_line(body, needles);
1077        }
1078    }
1079    None
1080}
1081
1082/// Detect a `throw` statement (not the word "throw" mid-comment).
1083fn has_throw_statement(body: &str) -> bool {
1084    for line in body.lines() {
1085        let trimmed = line.trim_start();
1086        if trimmed.starts_with("throw ") || trimmed == "throw" {
1087            return true;
1088        }
1089    }
1090    false
1091}
1092
1093// -----------------------------------------------------------------------------
1094// Call-edge extraction (intra- and cross-module)
1095// -----------------------------------------------------------------------------
1096
1097fn extract_call_edges_impl(
1098    file: &str,
1099    source: &str,
1100    symbols: &[ParsedSymbol],
1101    workspace: &WorkspaceSymbols,
1102) -> Vec<CallEdge> {
1103    let module_prefix = module_qname_prefix(file);
1104
1105    // All known qnames in this file.
1106    let known: HashSet<&str> = symbols.iter().map(|s| s.qname.as_str()).collect();
1107
1108    // simple_name -> qname for single-identifier resolution.
1109    let mut by_simple: HashMap<String, String> = HashMap::new();
1110    for s in symbols {
1111        let simple = s.qname.rsplit('.').next().unwrap_or(&s.qname).to_string();
1112        let module_level = if module_prefix.is_empty() {
1113            s.qname == simple
1114        } else {
1115            s.qname == format!("{}.{}", module_prefix, simple)
1116        };
1117        if module_level {
1118            by_simple.insert(simple, s.qname.clone());
1119        } else {
1120            by_simple.entry(simple).or_insert_with(|| s.qname.clone());
1121        }
1122    }
1123
1124    let mut parser = Parser::new();
1125    if parser.set_language(&grammar_for(file)).is_err() {
1126        return Vec::new();
1127    }
1128
1129    let imports = parse_imports(source, file, &mut parser);
1130
1131    let mut edges: HashSet<CallEdge> = HashSet::new();
1132
1133    for sym in symbols {
1134        if !matches!(sym.kind, SymbolKind::Function | SymbolKind::Method) {
1135            continue;
1136        }
1137
1138        let src_bytes = sym.body.as_bytes();
1139        let tree = match parser.parse(src_bytes, None) {
1140            Some(t) => t,
1141            None => continue,
1142        };
1143
1144        let enclosing_class = enclosing_class_qname(&sym.qname, &module_prefix, &known);
1145        collect_calls(
1146            tree.root_node(),
1147            src_bytes,
1148            sym,
1149            &module_prefix,
1150            &by_simple,
1151            &known,
1152            &imports,
1153            workspace,
1154            enclosing_class.as_deref(),
1155            &mut edges,
1156        );
1157    }
1158
1159    let mut out: Vec<CallEdge> = edges.into_iter().collect();
1160    out.sort_by(|a, b| {
1161        a.caller_qname
1162            .cmp(&b.caller_qname)
1163            .then_with(|| a.callee_qname.cmp(&b.callee_qname))
1164    });
1165    out
1166}
1167
1168/// Imports discovered at module scope, keyed by local binding.
1169#[derive(Debug, Clone)]
1170struct ImportBinding {
1171    /// The qname (or qname prefix) the local name resolves to.
1172    qname_prefix: String,
1173    /// `true` when the binding refers to a module handle (namespace import
1174    /// or default import mapped to module); `false` when it's a single
1175    /// imported symbol.
1176    #[allow(dead_code)]
1177    is_module: bool,
1178}
1179
1180/// Walk top-level statements and collect import bindings.
1181///
1182/// Supports:
1183/// - `import { foo, bar as b } from 'mod'`
1184/// - `import foo from 'mod'` (default — bound as `mod.default`)
1185/// - `import * as ns from 'mod'` — namespace binding pointing at `mod`
1186/// - `import foo, { bar } from 'mod'` — both default and named
1187/// - `import 'mod'` — no bindings
1188///
1189/// Relative specifiers (`./foo`, `../foo`) are resolved against the
1190/// importing file's directory. Bare specifiers (`react`, `lodash`) are
1191/// kept as-is; the workspace lookup will filter them out.
1192///
1193/// Skipped for M6: re-exports (`export { X } from 'mod'`).
1194fn parse_imports(source: &str, file: &str, parser: &mut Parser) -> HashMap<String, ImportBinding> {
1195    let mut out: HashMap<String, ImportBinding> = HashMap::new();
1196    let src_bytes = source.as_bytes();
1197    let tree = match parser.parse(src_bytes, None) {
1198        Some(t) => t,
1199        None => return out,
1200    };
1201    let root = tree.root_node();
1202    let mut cursor = root.walk();
1203    for child in root.children(&mut cursor) {
1204        if child.kind() == "import_statement" {
1205            collect_import_statement(child, src_bytes, file, &mut out);
1206        }
1207    }
1208    out
1209}
1210
1211fn collect_import_statement(
1212    node: Node<'_>,
1213    src: &[u8],
1214    file: &str,
1215    out: &mut HashMap<String, ImportBinding>,
1216) {
1217    // `source` field holds the module specifier — a `string` node. Its text
1218    // is quoted; strip quotes then resolve relative to the current file.
1219    let source_node = match node.child_by_field_name("source") {
1220        Some(n) => n,
1221        None => return,
1222    };
1223    let raw = match node_text(source_node, src) {
1224        Some(s) => s,
1225        None => return,
1226    };
1227    let specifier = raw
1228        .trim()
1229        .trim_matches(|c| c == '"' || c == '\'' || c == '`');
1230    let module_qname = resolve_module_specifier(specifier, file);
1231
1232    // Find the import_clause child (if any — `import 'mod'` has none).
1233    let clause = find_child_by_kind(node, "import_clause");
1234    let clause = match clause {
1235        Some(c) => c,
1236        None => return,
1237    };
1238
1239    let mut cursor = clause.walk();
1240    for child in clause.children(&mut cursor) {
1241        match child.kind() {
1242            "identifier" => {
1243                // Default import: `import foo from 'mod'`
1244                let name = match node_text(child, src) {
1245                    Some(n) => n,
1246                    None => continue,
1247                };
1248                // ASD doesn't index `default` symbols, so bind the local
1249                // name to a module handle; callers that use the bare name
1250                // will resolve via `by_simple`/imports anyway. This keeps
1251                // `foo.something` callable if `mod` is a namespace-like
1252                // thing, which aligns with how CommonJS default objects work.
1253                out.insert(
1254                    name,
1255                    ImportBinding {
1256                        qname_prefix: module_qname.clone(),
1257                        is_module: true,
1258                    },
1259                );
1260            }
1261            "namespace_import" => {
1262                // `import * as foo from 'mod'`
1263                // The identifier child holds the local name.
1264                if let Some(ident) = find_child_by_kind(child, "identifier") {
1265                    if let Some(name) = node_text(ident, src) {
1266                        out.insert(
1267                            name,
1268                            ImportBinding {
1269                                qname_prefix: module_qname.clone(),
1270                                is_module: true,
1271                            },
1272                        );
1273                    }
1274                }
1275            }
1276            "named_imports" => {
1277                // `{ foo, bar as b }`
1278                let mut c2 = child.walk();
1279                for spec in child.children(&mut c2) {
1280                    if spec.kind() != "import_specifier" {
1281                        continue;
1282                    }
1283                    let name = spec
1284                        .child_by_field_name("name")
1285                        .and_then(|n| node_text(n, src));
1286                    let alias = spec
1287                        .child_by_field_name("alias")
1288                        .and_then(|n| node_text(n, src));
1289                    let name = match name {
1290                        Some(n) => n,
1291                        None => continue,
1292                    };
1293                    let local = alias.unwrap_or_else(|| name.clone());
1294                    let qname = if module_qname.is_empty() {
1295                        name.clone()
1296                    } else {
1297                        format!("{module_qname}.{name}")
1298                    };
1299                    out.insert(
1300                        local,
1301                        ImportBinding {
1302                            qname_prefix: qname,
1303                            is_module: false,
1304                        },
1305                    );
1306                }
1307            }
1308            _ => {}
1309        }
1310    }
1311}
1312
1313/// Resolve a module specifier relative to the importing file's directory.
1314/// Returns a dotted qname prefix suitable for matching workspace symbols.
1315///
1316/// - `./foo` in `src/a.ts` -> `src.foo`
1317/// - `./sub/bar` in `src/a.ts` -> `src.sub.bar`
1318/// - `../x` in `src/a/b.ts` -> `src.x`
1319/// - bare `'react'` stays `react` (won't match the workspace; dropped later)
1320fn resolve_module_specifier(spec: &str, importing_file: &str) -> String {
1321    // Strip common extensions — callers may or may not include them.
1322    let mut s = spec.trim();
1323    for ext in [".tsx", ".ts", ".mts", ".cts", ".jsx", ".js", ".mjs", ".cjs"] {
1324        if let Some(stripped) = s.strip_suffix(ext) {
1325            s = stripped;
1326            break;
1327        }
1328    }
1329    if !(s.starts_with("./") || s.starts_with("../") || s.starts_with('/')) {
1330        // Bare specifier — leave alone. It won't match workspace qnames
1331        // unless some other file happens to live at that qname, which is
1332        // correct behavior.
1333        return s.replace('/', ".");
1334    }
1335
1336    // Compute the importing file's directory as a list of segments.
1337    let normalized = importing_file.replace('\\', "/");
1338    let mut dir_parts: Vec<&str> = normalized
1339        .strip_prefix("./")
1340        .unwrap_or(normalized.as_str())
1341        .split('/')
1342        .collect();
1343    dir_parts.pop(); // drop the filename itself
1344
1345    // Collapse the spec against the directory.
1346    let spec_path = s.strip_prefix('/').unwrap_or(s);
1347    let mut parts: Vec<String> = dir_parts.iter().map(|s| s.to_string()).collect();
1348    for seg in spec_path.split('/') {
1349        match seg {
1350            "" | "." => {}
1351            ".." => {
1352                parts.pop();
1353            }
1354            other => parts.push(other.to_string()),
1355        }
1356    }
1357    parts.retain(|p| !p.is_empty());
1358    parts.join(".")
1359}
1360
1361fn enclosing_class_qname(
1362    qname: &str,
1363    module_prefix: &str,
1364    known: &HashSet<&str>,
1365) -> Option<String> {
1366    let parts: Vec<&str> = qname.split('.').collect();
1367    if parts.len() < 2 {
1368        return None;
1369    }
1370    for end in (1..parts.len()).rev() {
1371        let candidate = parts[..end].join(".");
1372        if !module_prefix.is_empty() && !candidate.starts_with(module_prefix) {
1373            continue;
1374        }
1375        if known.contains(candidate.as_str()) {
1376            return Some(candidate);
1377        }
1378    }
1379    None
1380}
1381
1382#[allow(clippy::too_many_arguments)]
1383fn collect_calls(
1384    node: Node<'_>,
1385    src: &[u8],
1386    sym: &ParsedSymbol,
1387    module_prefix: &str,
1388    by_simple: &HashMap<String, String>,
1389    known: &HashSet<&str>,
1390    imports: &HashMap<String, ImportBinding>,
1391    workspace: &WorkspaceSymbols,
1392    enclosing_class: Option<&str>,
1393    out: &mut HashSet<CallEdge>,
1394) {
1395    if node.kind() == "call_expression" {
1396        if let Some(func) = node.child_by_field_name("function") {
1397            if let Some(callee) = resolve_callee(
1398                func,
1399                src,
1400                module_prefix,
1401                by_simple,
1402                known,
1403                imports,
1404                workspace,
1405                enclosing_class,
1406            ) {
1407                out.insert(CallEdge {
1408                    caller_qname: sym.qname.clone(),
1409                    callee_qname: callee,
1410                });
1411            }
1412        }
1413    }
1414    if node.kind() == "new_expression" {
1415        // `new Foo(...)` — treat as a call to `Foo`.
1416        if let Some(cons) = node.child_by_field_name("constructor") {
1417            if let Some(callee) = resolve_callee(
1418                cons,
1419                src,
1420                module_prefix,
1421                by_simple,
1422                known,
1423                imports,
1424                workspace,
1425                enclosing_class,
1426            ) {
1427                out.insert(CallEdge {
1428                    caller_qname: sym.qname.clone(),
1429                    callee_qname: callee,
1430                });
1431            }
1432        }
1433    }
1434    let mut cursor = node.walk();
1435    for child in node.children(&mut cursor) {
1436        collect_calls(
1437            child,
1438            src,
1439            sym,
1440            module_prefix,
1441            by_simple,
1442            known,
1443            imports,
1444            workspace,
1445            enclosing_class,
1446            out,
1447        );
1448    }
1449}
1450
1451#[allow(clippy::too_many_arguments)]
1452fn resolve_callee(
1453    func: Node<'_>,
1454    src: &[u8],
1455    module_prefix: &str,
1456    by_simple: &HashMap<String, String>,
1457    known: &HashSet<&str>,
1458    imports: &HashMap<String, ImportBinding>,
1459    workspace: &WorkspaceSymbols,
1460    enclosing_class: Option<&str>,
1461) -> Option<String> {
1462    match func.kind() {
1463        "identifier" => {
1464            let name = node_text(func, src)?;
1465            // 1) intra-module first (shadows imports)
1466            if let Some(q) = by_simple.get(&name) {
1467                return Some(q.clone());
1468            }
1469            // 2) import map (e.g., `import { foo } from './mod'`)
1470            if let Some(binding) = imports.get(&name) {
1471                if workspace.contains(&binding.qname_prefix) {
1472                    return Some(binding.qname_prefix.clone());
1473                }
1474            }
1475            None
1476        }
1477        "member_expression" => {
1478            // `<object>.<property>`
1479            let object = func.child_by_field_name("object")?;
1480            let property = func.child_by_field_name("property")?;
1481            let prop_name = node_text(property, src)?;
1482
1483            match object.kind() {
1484                "this" => {
1485                    let class = enclosing_class?;
1486                    let candidate = format!("{class}.{prop_name}");
1487                    if known.contains(candidate.as_str()) {
1488                        return Some(candidate);
1489                    }
1490                    None
1491                }
1492                "identifier" => {
1493                    let obj_name = node_text(object, src)?;
1494
1495                    // Intra-module class reference: `Foo.bar` -> `mod.Foo.bar`
1496                    let class_qname = if module_prefix.is_empty() {
1497                        obj_name.clone()
1498                    } else {
1499                        format!("{module_prefix}.{obj_name}")
1500                    };
1501                    if known.contains(class_qname.as_str()) {
1502                        let candidate = format!("{class_qname}.{prop_name}");
1503                        if known.contains(candidate.as_str()) {
1504                            return Some(candidate);
1505                        }
1506                    }
1507
1508                    // Imported module or symbol: `logger.writeLog` ->
1509                    // `<prefix>.writeLog`, validated against workspace.
1510                    if let Some(binding) = imports.get(&obj_name) {
1511                        let candidate = format!("{}.{}", binding.qname_prefix, prop_name);
1512                        if workspace.contains(&candidate) {
1513                            return Some(candidate);
1514                        }
1515                    }
1516                    None
1517                }
1518                "member_expression" => {
1519                    // Chained: `foo.bar.baz()`. Flatten and try imports.
1520                    let chain = flatten_member(object, src)?;
1521                    if let Some(binding) = imports.get(&chain) {
1522                        let candidate = format!("{}.{}", binding.qname_prefix, prop_name);
1523                        if workspace.contains(&candidate) {
1524                            return Some(candidate);
1525                        }
1526                    }
1527                    None
1528                }
1529                _ => None,
1530            }
1531        }
1532        _ => None,
1533    }
1534}
1535
1536fn flatten_member(node: Node<'_>, src: &[u8]) -> Option<String> {
1537    match node.kind() {
1538        "identifier" => node_text(node, src),
1539        "member_expression" => {
1540            let object = node.child_by_field_name("object")?;
1541            let property = node.child_by_field_name("property")?;
1542            let head = flatten_member(object, src)?;
1543            let tail = node_text(property, src)?;
1544            Some(format!("{head}.{tail}"))
1545        }
1546        _ => None,
1547    }
1548}
1549
1550#[cfg(test)]
1551mod tests {
1552    use super::*;
1553
1554    #[test]
1555    fn module_prefix_strips_extensions_and_leading_dot_slash() {
1556        // src/ anchor — stable regardless of index root
1557        assert_eq!(
1558            module_qname_prefix("src/components/Button.tsx"),
1559            "components.Button"
1560        );
1561        assert_eq!(module_qname_prefix("packages/ui/src/index.ts"), "ui.index");
1562        assert_eq!(
1563            module_qname_prefix("packages/my-pkg/src/index.tsx"),
1564            "my_pkg.index"
1565        );
1566        assert_eq!(module_qname_prefix("src/util.mts"), "util");
1567        // no src segment — full relative path (fallback)
1568        assert_eq!(module_qname_prefix("foo/bar.ts"), "foo.bar");
1569        assert_eq!(module_qname_prefix("./foo/bar.tsx"), "foo.bar");
1570        assert_eq!(module_qname_prefix("bar.mts"), "bar");
1571        assert_eq!(module_qname_prefix("a/b/c.cts"), "a.b.c");
1572    }
1573
1574    #[test]
1575    fn strip_src_prefix_variants() {
1576        assert_eq!(strip_src_prefix("src/util.ts"), "util.ts");
1577        assert_eq!(strip_src_prefix("packages/ui/src/index.ts"), "index.ts");
1578        assert_eq!(strip_src_prefix("lib/util.ts"), "lib/util.ts");
1579        assert_eq!(strip_src_prefix("nosrc/foo.ts"), "nosrc/foo.ts");
1580    }
1581
1582    #[test]
1583    fn parses_function_method_and_class() {
1584        let src = r#"
1585export function top(): number {
1586    return 1;
1587}
1588
1589export class C {
1590    m(): number {
1591        return 2;
1592    }
1593}
1594
1595export const arrow = (x: number) => x + 1;
1596"#;
1597        let a = TypeScriptAdapter::new();
1598        let syms = a.parse_symbols("x.ts", src).unwrap();
1599        let qnames: Vec<_> = syms.iter().map(|s| s.qname.clone()).collect();
1600        assert!(qnames.contains(&"x.top".to_string()), "got {qnames:?}");
1601        assert!(qnames.contains(&"x.C".to_string()), "got {qnames:?}");
1602        assert!(qnames.contains(&"x.C.m".to_string()), "got {qnames:?}");
1603        assert!(qnames.contains(&"x.arrow".to_string()), "got {qnames:?}");
1604        let top = syms.iter().find(|s| s.qname == "x.top").unwrap();
1605        assert_eq!(top.kind, SymbolKind::Function);
1606        let m = syms.iter().find(|s| s.qname == "x.C.m").unwrap();
1607        assert_eq!(m.kind, SymbolKind::Method);
1608        let arrow = syms.iter().find(|s| s.qname == "x.arrow").unwrap();
1609        assert_eq!(arrow.kind, SymbolKind::Function);
1610    }
1611
1612    #[test]
1613    #[allow(non_snake_case)]
1614    fn infers_fs_write_from_writeFileSync() {
1615        let body = r#"
1616function f() {
1617    fs.writeFileSync("/tmp/a", "hi");
1618}
1619"#;
1620        let effects = infer_effects_from_body(body);
1621        let cats: Vec<_> = effects.iter().map(|e| e.effect.clone()).collect();
1622        assert!(cats.contains(&EffectCategory::IoFsWrite));
1623    }
1624
1625    #[test]
1626    fn infers_log_from_console_log() {
1627        let body = r#"
1628function f() {
1629    console.log("hi");
1630}
1631"#;
1632        let effects = infer_effects_from_body(body);
1633        let cats: Vec<_> = effects.iter().map(|e| e.effect.clone()).collect();
1634        assert!(cats.contains(&EffectCategory::Log));
1635    }
1636
1637    #[test]
1638    fn empty_when_no_patterns() {
1639        let body = "function f(x) { return x + 1; }";
1640        assert!(infer_effects_from_body(body).is_empty());
1641    }
1642
1643    fn workspace_with(qnames: &[&str]) -> WorkspaceSymbols {
1644        let mut ws = WorkspaceSymbols::default();
1645        for q in qnames {
1646            ws.qnames.insert((*q).to_string());
1647            ws.kinds.insert((*q).to_string(), SymbolKind::Function);
1648        }
1649        ws
1650    }
1651
1652    #[test]
1653    fn extracts_intra_module_call_edges() {
1654        let src = r#"
1655function helper(x: number): number {
1656    return x;
1657}
1658
1659function caller(): number {
1660    return helper(1);
1661}
1662
1663class C {
1664    init(): number {
1665        return helper(2);
1666    }
1667
1668    m(): number {
1669        return this.init();
1670    }
1671}
1672"#;
1673        let a = TypeScriptAdapter::new();
1674        let syms = a.parse_symbols("m.ts", src).unwrap();
1675        let ws = workspace_with(&["m.helper", "m.caller", "m.C", "m.C.init", "m.C.m"]);
1676        let edges = a.extract_call_edges("m.ts", src, &syms, &ws);
1677        let pairs: Vec<(String, String)> = edges
1678            .iter()
1679            .map(|e| (e.caller_qname.clone(), e.callee_qname.clone()))
1680            .collect();
1681        assert!(
1682            pairs.contains(&("m.caller".to_string(), "m.helper".to_string())),
1683            "missing caller -> helper; got {pairs:?}",
1684        );
1685        assert!(
1686            pairs.contains(&("m.C.init".to_string(), "m.helper".to_string())),
1687            "missing C.init -> helper; got {pairs:?}",
1688        );
1689        assert!(
1690            pairs.contains(&("m.C.m".to_string(), "m.C.init".to_string())),
1691            "missing C.m -> C.init; got {pairs:?}",
1692        );
1693    }
1694
1695    #[test]
1696    fn extracts_cross_module_edges_via_named_import() {
1697        let src = r#"
1698import { y } from './other';
1699
1700function x() {
1701    y();
1702}
1703"#;
1704        let a = TypeScriptAdapter::new();
1705        let syms = a.parse_symbols("caller.ts", src).unwrap();
1706        let ws = workspace_with(&["other.y", "caller.x"]);
1707        let edges = a.extract_call_edges("caller.ts", src, &syms, &ws);
1708        let pairs: Vec<(String, String)> = edges
1709            .iter()
1710            .map(|e| (e.caller_qname.clone(), e.callee_qname.clone()))
1711            .collect();
1712        assert!(
1713            pairs.contains(&("caller.x".to_string(), "other.y".to_string())),
1714            "missing x -> other.y; got {pairs:?}",
1715        );
1716    }
1717
1718    #[test]
1719    fn extracts_cross_module_edges_via_namespace_import() {
1720        let src = r#"
1721import * as logger from './logger';
1722
1723function foo() {
1724    logger.writeLog("hi");
1725}
1726"#;
1727        let a = TypeScriptAdapter::new();
1728        let syms = a.parse_symbols("caller.ts", src).unwrap();
1729        let ws = workspace_with(&["logger.writeLog", "caller.foo"]);
1730        let edges = a.extract_call_edges("caller.ts", src, &syms, &ws);
1731        let pairs: Vec<(String, String)> = edges
1732            .iter()
1733            .map(|e| (e.caller_qname.clone(), e.callee_qname.clone()))
1734            .collect();
1735        assert!(
1736            pairs.contains(&("caller.foo".to_string(), "logger.writeLog".to_string())),
1737            "missing foo -> logger.writeLog; got {pairs:?}",
1738        );
1739    }
1740
1741    #[test]
1742    fn drops_unknown_imports() {
1743        let src = r#"
1744import { thing } from 'stdlib-mod';
1745
1746function foo() {
1747    thing();
1748}
1749"#;
1750        let a = TypeScriptAdapter::new();
1751        let syms = a.parse_symbols("caller.ts", src).unwrap();
1752        let ws = workspace_with(&["caller.foo"]);
1753        let edges = a.extract_call_edges("caller.ts", src, &syms, &ws);
1754        let pairs: Vec<(String, String)> = edges
1755            .iter()
1756            .map(|e| (e.caller_qname.clone(), e.callee_qname.clone()))
1757            .collect();
1758        assert!(pairs.is_empty(), "expected no edges; got {pairs:?}");
1759    }
1760
1761    #[test]
1762    fn infers_throw() {
1763        let body = r#"
1764function f() {
1765    throw new Error("bad");
1766}
1767"#;
1768        let effects = infer_effects_from_body(body);
1769        let cats: Vec<_> = effects.iter().map(|e| e.effect.clone()).collect();
1770        assert!(cats.contains(&EffectCategory::Throw));
1771    }
1772
1773    #[test]
1774    fn resolve_relative_specifier() {
1775        // './other' imported from 'src/a.ts' -> 'src.other'
1776        assert_eq!(resolve_module_specifier("./other", "src/a.ts"), "src.other");
1777        // '../x' imported from 'src/a/b.ts' -> 'src.x'
1778        assert_eq!(resolve_module_specifier("../x", "src/a/b.ts"), "src.x");
1779        // bare 'react' stays 'react'
1780        assert_eq!(resolve_module_specifier("react", "src/a.ts"), "react");
1781    }
1782}
1783
1784#[cfg(test)]
1785mod service_endpoint_tests {
1786    use super::*;
1787
1788    fn detect(src: &str) -> Vec<DetectedEndpoint> {
1789        let a = TypeScriptAdapter;
1790        let symbols = a.parse_symbols("app.ts", src).unwrap();
1791        a.infer_service_endpoints("app.ts", src, &symbols)
1792    }
1793    fn inbound(eps: &[DetectedEndpoint]) -> Vec<&DetectedEndpoint> {
1794        eps.iter()
1795            .filter(|e| e.direction == Direction::Inbound)
1796            .collect()
1797    }
1798    fn outbound(eps: &[DetectedEndpoint]) -> Vec<&DetectedEndpoint> {
1799        eps.iter()
1800            .filter(|e| e.direction == Direction::Outbound)
1801            .collect()
1802    }
1803
1804    #[test]
1805    fn express_route_is_inbound() {
1806        let src = "function setup() {\n  app.get('/users/:id', (req, res) => res.send(1));\n}\n";
1807        let eps = detect(src);
1808        let inb = inbound(&eps);
1809        assert_eq!(inb.len(), 1, "{eps:?}");
1810        assert_eq!(inb[0].contract, "http:GET /users/{}");
1811    }
1812
1813    #[test]
1814    fn router_post_route() {
1815        let src = "function reg() {\n  router.post('/charge', handler);\n}\n";
1816        let inb_eps = detect(src);
1817        let inb = inbound(&inb_eps);
1818        assert_eq!(inb.len(), 1);
1819        assert_eq!(inb[0].contract, "http:POST /charge");
1820    }
1821
1822    #[test]
1823    fn non_route_dot_get_is_ignored() {
1824        // A map/cache .get whose arg isn't a path must not be a route.
1825        let src = "function f() {\n  const v = cache.get('key');\n  store.delete('id123');\n}\n";
1826        assert!(detect(src).is_empty(), "{:?}", detect(src));
1827    }
1828
1829    #[test]
1830    fn axios_and_fetch_clients_are_outbound() {
1831        let src = "function call() {\n  axios.get('https://users.svc/users/:id');\n  fetch('/health');\n  fetch('/charge', { method: 'POST' });\n}\n";
1832        let eps = detect(src);
1833        let mut got: Vec<String> = outbound(&eps).iter().map(|e| e.contract.clone()).collect();
1834        got.sort();
1835        assert_eq!(
1836            got,
1837            vec![
1838                "http:GET /health".to_string(),
1839                "http:GET /users/{}".to_string(),
1840                "http:POST /charge".to_string(),
1841            ]
1842        );
1843    }
1844
1845    #[test]
1846    fn template_literal_url_is_skipped() {
1847        let src = "function f() {\n  fetch(`/users/${id}`);\n  axios.get(`/x/${y}`);\n}\n";
1848        assert!(outbound(&detect(src)).is_empty(), "{:?}", detect(src));
1849    }
1850
1851    #[test]
1852    fn client_and_server_contracts_match() {
1853        let server = detect("function s() { app.get('/users/:id', h); }");
1854        let client = detect("function c() { axios.get('https://svc/users/{id}'); }");
1855        assert_eq!(inbound(&server)[0].contract, outbound(&client)[0].contract);
1856        assert_eq!(inbound(&server)[0].contract, "http:GET /users/{}");
1857    }
1858}