1use 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#[derive(Debug, Default, Clone, Copy)]
29pub struct TypeScriptAdapter;
30
31impl TypeScriptAdapter {
32 pub fn new() -> Self {
33 Self
34 }
35}
36
37fn 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
99fn 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
120fn 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 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
152fn 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
172fn 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 }
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 }
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 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 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 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 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 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
315fn 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 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 out.push(make_parsed_symbol(
348 node,
349 src,
350 qname,
351 SymbolKind::Function,
352 signature,
353 ));
354 }
355 _ => {}
356 }
357}
358
359fn 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
494const 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
548fn 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
556fn 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
576fn 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
601fn 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
612fn 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
625fn 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
633fn infer_effects_from_body(body: &str) -> Vec<Effect> {
638 let mut effects: Vec<Effect> = Vec::new();
639
640 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 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 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 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 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 if body.contains("process.env") {
764 let mut vars: Vec<String> = Vec::new();
765 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 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 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 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 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 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 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#[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
962fn 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
996fn 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
1025fn 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
1050fn 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
1060fn 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
1072fn 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
1082fn 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
1093fn 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 let known: HashSet<&str> = symbols.iter().map(|s| s.qname.as_str()).collect();
1107
1108 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#[derive(Debug, Clone)]
1170struct ImportBinding {
1171 qname_prefix: String,
1173 #[allow(dead_code)]
1177 is_module: bool,
1178}
1179
1180fn 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 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 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 let name = match node_text(child, src) {
1245 Some(n) => n,
1246 None => continue,
1247 };
1248 out.insert(
1254 name,
1255 ImportBinding {
1256 qname_prefix: module_qname.clone(),
1257 is_module: true,
1258 },
1259 );
1260 }
1261 "namespace_import" => {
1262 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 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
1313fn resolve_module_specifier(spec: &str, importing_file: &str) -> String {
1321 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 return s.replace('/', ".");
1334 }
1335
1336 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(); 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 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 if let Some(q) = by_simple.get(&name) {
1467 return Some(q.clone());
1468 }
1469 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 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 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 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 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 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 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 assert_eq!(resolve_module_specifier("./other", "src/a.ts"), "src.other");
1777 assert_eq!(resolve_module_specifier("../x", "src/a/b.ts"), "src.x");
1779 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 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}