1use std::collections::{HashMap, HashSet};
8
9use agentstatedeveloper_core::adapter::{
10 CallEdge, DynamicDispatchHint, LanguageAdapter, ParsedSymbol, UnresolvedCall, WorkspaceSymbols,
11};
12use agentstatedeveloper_core::cross_service::{
13 DetectedEndpoint, Direction, ServiceEndpoint, Transport, http_contract, pubsub_contract,
14};
15use agentstatedeveloper_core::dataflow::DetectedDataFlow;
16use agentstatedeveloper_core::error::{AsdError, Result};
17use agentstatedeveloper_core::schema::{Effect, EffectCategory, SymbolKind};
18use serde_json::json;
19use tree_sitter::{Node, Parser};
20
21#[derive(Debug, Default, Clone, Copy)]
23pub struct PythonAdapter;
24
25impl PythonAdapter {
26 pub fn new() -> Self {
27 Self
28 }
29}
30
31impl LanguageAdapter for PythonAdapter {
32 fn language(&self) -> &str {
33 "python"
34 }
35
36 fn file_extensions(&self) -> &'static [&'static str] {
37 &["py"]
38 }
39
40 fn parse_symbols(&self, file: &str, source: &str) -> Result<Vec<ParsedSymbol>> {
41 let mut parser = Parser::new();
42 parser
43 .set_language(&tree_sitter_python::LANGUAGE.into())
44 .map_err(|e| AsdError::Parse(format!("failed to set python language: {e}")))?;
45
46 let src_bytes = source.as_bytes();
47 let tree = parser
48 .parse(src_bytes, None)
49 .ok_or_else(|| AsdError::Parse(format!("failed to parse {file}")))?;
50
51 let module_prefix = module_qname_prefix(file);
52 let root = tree.root_node();
53 let mut out = Vec::new();
54 walk(root, src_bytes, &module_prefix, &[], &mut out);
55 Ok(out)
56 }
57
58 fn infer_effects(&self, _source: &str, symbol: &ParsedSymbol) -> Vec<Effect> {
59 infer_effects_from_body(&symbol.body)
60 }
61
62 fn infer_service_endpoints(
63 &self,
64 file: &str,
65 source: &str,
66 symbols: &[ParsedSymbol],
67 ) -> Vec<DetectedEndpoint> {
68 infer_service_endpoints_in_python(file, source, symbols)
69 }
70
71 fn resolve_endpoint_prefixes(
72 &self,
73 files: &[(String, String)],
74 endpoints: &mut Vec<ServiceEndpoint>,
75 ) {
76 resolve_python_endpoint_prefixes(files, endpoints);
77 }
78
79 fn extract_dataflow(
80 &self,
81 file: &str,
82 source: &str,
83 symbols: &[ParsedSymbol],
84 _workspace: &WorkspaceSymbols,
85 ) -> Vec<DetectedDataFlow> {
86 infer_dataflow_in_python(file, source, symbols)
87 }
88
89 fn extract_call_edges(
90 &self,
91 file: &str,
92 source: &str,
93 symbols: &[ParsedSymbol],
94 workspace: &WorkspaceSymbols,
95 ) -> Vec<CallEdge> {
96 extract_call_edges_impl(file, source, symbols, workspace)
97 }
98
99 fn scan_dynamic_dispatch(&self, file: &str, source: &str) -> Vec<DynamicDispatchHint> {
100 scan_dynamic_dispatch_in_python(file, source)
101 }
102
103 fn report_unresolved_calls(
104 &self,
105 file: &str,
106 source: &str,
107 symbols: &[ParsedSymbol],
108 workspace: &WorkspaceSymbols,
109 ) -> Vec<UnresolvedCall> {
110 report_unresolved_calls_in_python(file, source, symbols, workspace)
111 }
112}
113
114pub fn report_unresolved_calls_in_python(
124 file: &str,
125 source: &str,
126 symbols: &[ParsedSymbol],
127 workspace: &WorkspaceSymbols,
128) -> Vec<UnresolvedCall> {
129 use std::collections::HashSet;
130
131 let stdlib_allowlist: HashSet<&str> = [
136 "print",
137 "len",
138 "range",
139 "type",
140 "isinstance",
141 "super",
142 "hasattr",
143 "getattr",
144 "setattr",
145 "delattr",
146 "callable",
147 "iter",
148 "next",
149 "list",
150 "dict",
151 "set",
152 "tuple",
153 "str",
154 "int",
155 "float",
156 "bool",
157 "bytes",
158 "bytearray",
159 "frozenset",
160 "min",
161 "max",
162 "sum",
163 "abs",
164 "round",
165 "sorted",
166 "reversed",
167 "enumerate",
168 "zip",
169 "map",
170 "filter",
171 "any",
172 "all",
173 "open",
174 "repr",
175 "hash",
176 "id",
177 "vars",
178 "dir",
179 "format",
180 "input",
181 "ord",
182 "chr",
183 "hex",
184 "oct",
185 "bin",
186 "pow",
187 "divmod",
188 "compile",
189 "eval",
190 "exec",
191 ]
192 .into_iter()
193 .collect();
194
195 let mut parser = Parser::new();
196 if parser
197 .set_language(&tree_sitter_python::LANGUAGE.into())
198 .is_err()
199 {
200 return Vec::new();
201 }
202 let module_prefix = module_qname_prefix(file);
203 let src_bytes = source.as_bytes();
204
205 let mut by_simple: HashMap<String, String> = HashMap::new();
207 let mut known: HashSet<&str> = HashSet::new();
208 for sym in symbols {
209 known.insert(sym.qname.as_str());
210 if let Some(simple) = sym.qname.rsplit_once('.').map(|(_, s)| s) {
211 by_simple.insert(simple.to_string(), sym.qname.clone());
212 }
213 }
214 let imports = parse_imports(source, &mut parser, &module_prefix);
215
216 let tree = match parser.parse(src_bytes, None) {
217 Some(t) => t,
218 None => return Vec::new(),
219 };
220
221 let mut line_starts: Vec<usize> = vec![0];
222 for (i, b) in src_bytes.iter().enumerate() {
223 if *b == b'\n' {
224 line_starts.push(i + 1);
225 }
226 }
227 let line_of = |off: usize| -> u32 {
228 match line_starts.binary_search(&off) {
229 Ok(i) => i as u32 + 1,
230 Err(i) => i.max(1) as u32,
231 }
232 };
233
234 let mut out = Vec::new();
235 walk_unresolved_calls(
236 tree.root_node(),
237 src_bytes,
238 &module_prefix,
239 &by_simple,
240 &known,
241 &imports,
242 workspace,
243 &stdlib_allowlist,
244 file,
245 &line_of,
246 &mut out,
247 );
248 out
249}
250
251#[allow(clippy::too_many_arguments)]
252fn walk_unresolved_calls(
253 node: Node<'_>,
254 src: &[u8],
255 module_prefix: &str,
256 by_simple: &HashMap<String, String>,
257 known: &HashSet<&str>,
258 imports: &HashMap<String, ImportBinding>,
259 workspace: &WorkspaceSymbols,
260 stdlib_allowlist: &std::collections::HashSet<&str>,
261 file: &str,
262 line_of: &dyn Fn(usize) -> u32,
263 out: &mut Vec<UnresolvedCall>,
264) {
265 if node.kind() == "call" {
266 if let Some(func) = node.child_by_field_name("function") {
267 let callee_text = node_text(func, src).unwrap_or_default();
268 let resolved = resolve_callee(
269 func,
270 src,
271 module_prefix,
272 by_simple,
273 known,
274 imports,
275 workspace,
276 None, );
278 if resolved.is_none() {
279 let head = callee_text.split('.').next().unwrap_or(&callee_text).trim();
284 if !stdlib_allowlist.contains(head) {
285 out.push(UnresolvedCall {
286 file: file.to_string(),
287 line: line_of(func.start_byte()),
288 callee_text: callee_text.trim().to_string(),
289 });
290 }
291 }
292 }
293 }
294 let mut cursor = node.walk();
295 for child in node.children(&mut cursor) {
296 walk_unresolved_calls(
297 child,
298 src,
299 module_prefix,
300 by_simple,
301 known,
302 imports,
303 workspace,
304 stdlib_allowlist,
305 file,
306 line_of,
307 out,
308 );
309 }
310}
311
312fn strip_src_prefix(path: &str) -> &str {
320 let mut offset = 0usize;
321 for part in path.split('/') {
322 if part == "src" {
323 let after = offset + part.len() + 1;
324 if after < path.len() {
325 return &path[after..];
326 }
327 }
328 offset += part.len() + 1;
329 }
330 path
331}
332
333fn module_qname_prefix(file: &str) -> String {
345 let mut s = file;
346 if let Some(stripped) = s.strip_prefix("./") {
347 s = stripped;
348 }
349 let s = s.strip_suffix(".py").unwrap_or(s);
350 let after_src = strip_src_prefix(s);
351 if after_src == "__init__" {
354 if let Some(pkg_name) = package_name_from_path(s) {
355 return format!("{}.__init__", pkg_name.replace('-', "_"));
356 }
357 }
358 after_src.replace('\\', "/").replace('/', ".")
359}
360
361fn package_name_from_path(path: &str) -> Option<&str> {
364 let parts: Vec<&str> = path.split('/').collect();
365 for (i, &part) in parts.iter().enumerate() {
366 if part == "src" && i > 0 {
367 return Some(parts[i - 1]);
368 }
369 }
370 None
371}
372
373fn walk(
379 node: Node<'_>,
380 src: &[u8],
381 module_prefix: &str,
382 scope: &[(String, ScopeKind)],
383 out: &mut Vec<ParsedSymbol>,
384) {
385 let kind = node.kind();
386 match kind {
387 "function_definition" => {
388 let name = node_field_text(node, "name", src).unwrap_or_else(|| "<anon>".to_string());
389 let qname = build_qname(module_prefix, scope, &name);
390 let symbol_kind = if scope.last().map(|s| s.1) == Some(ScopeKind::Class) {
391 SymbolKind::Method
392 } else {
393 SymbolKind::Function
394 };
395 let signature = extract_function_signature(node, src, &name);
396 out.push(make_parsed_symbol(node, src, qname, symbol_kind, signature));
397
398 let mut new_scope = scope.to_vec();
400 new_scope.push((name, ScopeKind::Function));
401 if let Some(body) = node.child_by_field_name("body") {
402 let mut cursor = body.walk();
403 for child in body.children(&mut cursor) {
404 walk(child, src, module_prefix, &new_scope, out);
405 }
406 }
407 }
408 "class_definition" => {
409 let name = node_field_text(node, "name", src).unwrap_or_else(|| "<anon>".to_string());
410 let qname = build_qname(module_prefix, scope, &name);
411 let signature = extract_class_signature(node, src, &name);
412 out.push(make_parsed_symbol(
413 node,
414 src,
415 qname,
416 SymbolKind::Class,
417 signature,
418 ));
419
420 let mut new_scope = scope.to_vec();
421 new_scope.push((name, ScopeKind::Class));
422 if let Some(body) = node.child_by_field_name("body") {
423 let mut cursor = body.walk();
424 for child in body.children(&mut cursor) {
425 walk(child, src, module_prefix, &new_scope, out);
426 }
427 }
428 }
429 _ => {
430 let mut cursor = node.walk();
432 for child in node.children(&mut cursor) {
433 walk(child, src, module_prefix, scope, out);
434 }
435 }
436 }
437}
438
439#[derive(Debug, Clone, Copy, PartialEq, Eq)]
440enum ScopeKind {
441 Class,
442 Function,
443}
444
445fn build_qname(module_prefix: &str, scope: &[(String, ScopeKind)], name: &str) -> String {
446 let mut parts = Vec::with_capacity(scope.len() + 2);
447 if !module_prefix.is_empty() {
448 parts.push(module_prefix.to_string());
449 }
450 for (s, _) in scope {
451 parts.push(s.clone());
452 }
453 parts.push(name.to_string());
454 parts.join(".")
455}
456
457fn node_field_text(node: Node<'_>, field: &str, src: &[u8]) -> Option<String> {
458 node.child_by_field_name(field)
459 .and_then(|n| node_text(n, src))
460}
461
462fn node_text(node: Node<'_>, src: &[u8]) -> Option<String> {
463 src.get(node.byte_range())
464 .map(|b| String::from_utf8_lossy(b).into_owned())
465}
466
467fn make_parsed_symbol(
468 node: Node<'_>,
469 src: &[u8],
470 qname: String,
471 kind: SymbolKind,
472 signature: Option<String>,
473) -> ParsedSymbol {
474 let start = node.start_position();
475 let end = node.end_position();
476 let body = node_text(node, src).unwrap_or_default();
477 let doc = extract_python_doc(&body);
478 ParsedSymbol {
479 qname,
480 kind,
481 start_line: (start.row as u32) + 1,
483 start_col: (start.column as u32) + 1,
484 end_line: (end.row as u32) + 1,
485 end_col: (end.column as u32) + 1,
486 body,
487 signature,
488 doc,
489 }
490}
491
492fn extract_python_doc(body: &str) -> Option<String> {
493 for quote in &[r#"""""#, "'''"] {
494 if let Some(start) = body.find(quote) {
495 let after = start + quote.len();
496 if let Some(end_rel) = body[after..].find(quote) {
497 let content = &body[after..after + end_rel];
498 let cleaned = content
499 .lines()
500 .map(|l| l.trim())
501 .filter(|l| !l.is_empty())
502 .collect::<Vec<_>>()
503 .join(" ");
504 if !cleaned.is_empty() {
505 return Some(truncate_doc(&cleaned));
506 }
507 }
508 }
509 }
510 None
511}
512
513fn extract_function_signature(node: Node<'_>, src: &[u8], name: &str) -> Option<String> {
514 let params = node.child_by_field_name("parameters")?;
515 let params_text = node_text(params, src)?;
516 Some(format!("def {name}{params_text}"))
517}
518
519fn extract_class_signature(node: Node<'_>, src: &[u8], name: &str) -> Option<String> {
520 if let Some(sup) = node.child_by_field_name("superclasses") {
522 if let Some(sup_text) = node_text(sup, src) {
523 return Some(format!("class {name}{sup_text}"));
524 }
525 }
526 Some(format!("class {name}"))
527}
528
529const HTTP_VERBS: &[&str] = &["get", "post", "put", "patch", "delete", "head", "options"];
540
541fn infer_service_endpoints_in_python(
542 file: &str,
543 source: &str,
544 symbols: &[ParsedSymbol],
545) -> Vec<DetectedEndpoint> {
546 let mut out = Vec::new();
547 let router_prefixes = collect_router_prefixes(source);
553 for (idx, raw) in source.lines().enumerate() {
554 let line_no = (idx + 1) as u32;
555 let line = raw.trim();
556
557 let receiver_prefix = route_receiver(line).and_then(|r| router_prefixes.get(&r).cloned());
559 for (method, path, conf) in parse_route_decorator(line) {
560 if let Some(owner) = owner_for_decorator(symbols, line_no) {
561 let full = match &receiver_prefix {
562 Some(pfx) => join_route(pfx, &path),
563 None => path.clone(),
564 };
565 out.push(DetectedEndpoint {
566 transport: Transport::Http,
567 direction: Direction::Inbound,
568 contract: http_contract(&method, &full),
569 owner_qname: owner.qname.clone(),
570 file: file.to_string(),
571 line: line_no,
572 confidence: conf,
573 note: None,
574 });
575 }
576 }
577
578 for (method, url, conf) in parse_client_calls(line) {
580 if let Some(owner) = owner_for_body_line(symbols, line_no) {
581 out.push(DetectedEndpoint {
582 transport: Transport::Http,
583 direction: Direction::Outbound,
584 contract: http_contract(&method, &url),
585 owner_qname: owner.qname.clone(),
586 file: file.to_string(),
587 line: line_no,
588 confidence: conf,
589 note: None,
590 });
591 }
592 }
593
594 if is_celery_task_decorator(line) {
598 if let Some(owner) = owner_for_decorator(symbols, line_no) {
599 let task = simple_name(&owner.qname);
600 out.push(DetectedEndpoint {
601 transport: Transport::PubSub,
602 direction: Direction::Inbound,
603 contract: pubsub_contract(task),
604 owner_qname: owner.qname.clone(),
605 file: file.to_string(),
606 line: line_no,
607 confidence: 0.85,
608 note: Some("celery task".into()),
609 });
610 }
611 }
612 for task in parse_celery_emits(line) {
613 if let Some(owner) = owner_for_body_line(symbols, line_no) {
614 out.push(DetectedEndpoint {
615 transport: Transport::PubSub,
616 direction: Direction::Outbound,
617 contract: pubsub_contract(&task),
618 owner_qname: owner.qname.clone(),
619 file: file.to_string(),
620 line: line_no,
621 confidence: 0.6,
622 note: Some("celery emit".into()),
623 });
624 }
625 }
626 }
627 out
628}
629
630fn simple_name(qname: &str) -> &str {
632 qname.rsplit('.').next().unwrap_or(qname)
633}
634
635fn is_celery_task_decorator(line: &str) -> bool {
638 let Some(body) = line.strip_prefix('@') else {
639 return false;
640 };
641 let head = body.split('(').next().unwrap_or(body).trim();
642 head == "shared_task" || head == "task" || head.ends_with(".task")
643}
644
645fn parse_celery_emits(line: &str) -> Vec<String> {
647 let mut out = Vec::new();
648 for suffix in [".delay(", ".apply_async("] {
649 let mut from = 0;
650 while let Some(rel) = line[from..].find(suffix) {
651 let pos = from + rel;
652 from = pos + suffix.len();
653 let name: String = line[..pos]
654 .chars()
655 .rev()
656 .take_while(|c| c.is_alphanumeric() || *c == '_')
657 .collect::<Vec<_>>()
658 .into_iter()
659 .rev()
660 .collect();
661 if !name.is_empty() && !out.contains(&name) {
662 out.push(name);
663 }
664 }
665 }
666 out
667}
668
669fn owner_for_body_line(symbols: &[ParsedSymbol], line: u32) -> Option<&ParsedSymbol> {
672 symbols
673 .iter()
674 .filter(|s| s.start_line <= line && line <= s.end_line)
675 .max_by_key(|s| s.start_line)
676}
677
678fn owner_for_decorator(symbols: &[ParsedSymbol], line: u32) -> Option<&ParsedSymbol> {
682 if let Some(s) = owner_for_body_line(symbols, line) {
683 return Some(s);
684 }
685 symbols
686 .iter()
687 .filter(|s| s.start_line > line && s.start_line <= line + 6)
688 .min_by_key(|s| s.start_line)
689}
690
691fn parse_route_decorator(line: &str) -> Vec<(String, String, f64)> {
695 let Some(rest) = line.strip_prefix('@') else {
696 return Vec::new();
697 };
698 let Some(paren) = rest.find('(') else {
700 return Vec::new();
701 };
702 let callee = rest[..paren].trim();
703 let args = &rest[paren + 1..];
704 let Some(attr) = callee.rsplit('.').next() else {
705 return Vec::new();
706 };
707 if !callee.contains('.') {
709 return Vec::new();
710 }
711 let attr = attr.trim();
712
713 if HTTP_VERBS.contains(&attr) {
714 if let Some(path) = first_string_arg(args) {
715 return vec![(attr.to_uppercase(), path, 0.95)];
716 }
717 return Vec::new();
718 }
719 if attr == "route" {
720 let Some(path) = first_string_arg(args) else {
721 return Vec::new();
722 };
723 let methods = methods_kwarg(args);
724 let methods = if methods.is_empty() {
725 vec!["GET".to_string()]
726 } else {
727 methods
728 };
729 return methods
730 .into_iter()
731 .map(|m| (m, path.clone(), 0.9))
732 .collect();
733 }
734 Vec::new()
735}
736
737fn route_receiver(line: &str) -> Option<String> {
742 let rest = line.trim().strip_prefix('@')?;
743 let paren = rest.find('(')?;
744 let callee = rest[..paren].trim();
745 let dot = callee.rfind('.')?;
746 let recv = callee[..dot].trim();
747 if recv.is_empty() {
748 None
749 } else {
750 Some(recv.to_string())
751 }
752}
753
754fn collect_router_prefixes(source: &str) -> HashMap<String, String> {
760 let mut self_p: HashMap<String, String> = HashMap::new();
761 let mut mount_p: HashMap<String, String> = HashMap::new();
762 for raw in source.lines() {
763 let line = raw.trim();
764 if let Some(eq) = line.find('=') {
766 let var = line[..eq].trim();
767 if is_ident(var) {
768 let rhs = &line[eq + 1..];
769 if let Some(a) = ctor_args(rhs, "APIRouter") {
770 if let Some(p) = string_kwarg(a, "prefix") {
771 self_p.insert(var.to_string(), p);
772 }
773 } else if let Some(a) = ctor_args(rhs, "Blueprint") {
774 if let Some(p) = string_kwarg(a, "url_prefix") {
775 self_p.insert(var.to_string(), p);
776 }
777 }
778 }
779 }
780 if let Some(a) = ctor_args(line, "include_router") {
782 if let (Some(child), Some(q)) = (first_ident_arg(a), string_kwarg(a, "prefix")) {
783 mount_p.entry(child).or_insert(q);
784 }
785 }
786 if let Some(a) = ctor_args(line, "register_blueprint") {
787 if let (Some(child), Some(q)) = (first_ident_arg(a), string_kwarg(a, "url_prefix")) {
788 mount_p.entry(child).or_insert(q);
789 }
790 }
791 }
792 let keys: HashSet<String> = self_p.keys().chain(mount_p.keys()).cloned().collect();
793 let mut out = HashMap::new();
794 for k in keys {
795 let full = normalize_prefix(&format!(
796 "{}{}",
797 mount_p.get(&k).map(String::as_str).unwrap_or(""),
798 self_p.get(&k).map(String::as_str).unwrap_or("")
799 ));
800 if !full.is_empty() {
801 out.insert(k, full);
802 }
803 }
804 out
805}
806
807fn ctor_args<'a>(s: &'a str, name: &str) -> Option<&'a str> {
810 let mut from = 0;
811 while let Some(rel) = s[from..].find(name) {
812 let pos = from + rel;
813 from = pos + name.len();
814 let before_ok = pos == 0
815 || !s[..pos]
816 .chars()
817 .next_back()
818 .map(|c| c.is_alphanumeric() || c == '_')
819 .unwrap_or(false);
820 let after = s[pos + name.len()..].trim_start();
821 if before_ok {
822 if let Some(args) = after.strip_prefix('(') {
823 return Some(args);
824 }
825 }
826 }
827 None
828}
829
830fn string_kwarg(args: &str, key: &str) -> Option<String> {
833 let mut from = 0;
834 while let Some(rel) = args[from..].find(key) {
835 let pos = from + rel;
836 from = pos + key.len();
837 let before_ok = pos == 0
838 || !args[..pos]
839 .chars()
840 .next_back()
841 .map(|c| c.is_alphanumeric() || c == '_')
842 .unwrap_or(false);
843 if !before_ok {
844 continue;
845 }
846 let after = args[pos + key.len()..].trim_start();
847 if let Some(after) = after.strip_prefix('=') {
848 if let Some(s) = read_string_literal(after.trim_start()) {
849 return Some(s);
850 }
851 }
852 }
853 None
854}
855
856fn first_ident_arg(args: &str) -> Option<String> {
858 let s = args.trim_start();
859 let end = s
860 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '.'))
861 .unwrap_or(s.len());
862 let id = &s[..end];
863 if id.is_empty() || id.starts_with('.') {
864 None
865 } else {
866 Some(id.to_string())
867 }
868}
869
870fn is_ident(s: &str) -> bool {
871 !s.is_empty() && s.chars().all(|c| c.is_alphanumeric() || c == '_')
872}
873
874fn normalize_prefix(p: &str) -> String {
876 let p = p.trim();
877 if p.is_empty() {
878 return String::new();
879 }
880 let mut s = p.to_string();
881 while s.contains("//") {
882 s = s.replace("//", "/");
883 }
884 if !s.starts_with('/') {
885 s.insert(0, '/');
886 }
887 while s.len() > 1 && s.ends_with('/') {
888 s.pop();
889 }
890 s
891}
892
893fn join_route(prefix: &str, path: &str) -> String {
895 if prefix.is_empty() {
896 return path.to_string();
897 }
898 let p = prefix.trim_end_matches('/');
899 if path.is_empty() || path == "/" {
900 return p.to_string();
901 }
902 if path.starts_with('/') {
903 format!("{p}{path}")
904 } else {
905 format!("{p}/{path}")
906 }
907}
908
909fn resolve_python_endpoint_prefixes(
919 files: &[(String, String)],
920 endpoints: &mut Vec<ServiceEndpoint>,
921) {
922 let module_by_path: HashMap<&str, String> = files
924 .iter()
925 .map(|(p, _)| (p.as_str(), module_qname_prefix(p)))
926 .collect();
927 let imports_by_path: HashMap<&str, HashMap<String, String>> = files
928 .iter()
929 .map(|(p, s)| {
930 (
931 p.as_str(),
932 file_import_qnames(s, &module_by_path[p.as_str()]),
933 )
934 })
935 .collect();
936
937 let mut self_by_q: HashMap<String, String> = HashMap::new();
939 let mut parent_of: HashMap<String, Vec<(String, String)>> = HashMap::new(); let mut app_qs: HashSet<String> = HashSet::new();
941 let mut known: HashSet<String> = HashSet::new();
942
943 for (path, source) in files {
944 let module = &module_by_path[path.as_str()];
945 let imports = &imports_by_path[path.as_str()];
946 for raw in source.lines() {
947 let line = raw.trim();
948 if let Some(eq) = line.find('=') {
950 let var = line[..eq].trim();
951 if is_ident(var) {
952 let rhs = &line[eq + 1..];
953 let q = qualify(module, var); if let Some(a) = ctor_args(rhs, "APIRouter") {
955 if let Some(p) = string_kwarg(a, "prefix") {
956 self_by_q.insert(q.clone(), normalize_prefix(&p));
957 }
958 known.insert(q);
959 } else if let Some(a) = ctor_args(rhs, "Blueprint") {
960 if let Some(p) = string_kwarg(a, "url_prefix") {
961 self_by_q.insert(q.clone(), normalize_prefix(&p));
962 }
963 known.insert(q);
964 } else if ctor_args(rhs, "FastAPI").is_some()
965 || ctor_args(rhs, "Flask").is_some()
966 {
967 app_qs.insert(q.clone());
968 known.insert(q);
969 }
970 }
971 }
972 for (fname, pkey) in [
974 ("include_router", "prefix"),
975 ("register_blueprint", "url_prefix"),
976 ] {
977 if let Some(a) = ctor_args(line, fname) {
978 if let (Some(child), Some(parent)) =
979 (first_ident_arg(a), mount_receiver(line, fname))
980 {
981 let m = string_kwarg(a, pkey)
982 .map(|s| normalize_prefix(&s))
983 .unwrap_or_default();
984 let child_q = resolve_name(&child, module, imports);
985 let parent_q = resolve_name(&parent, module, imports);
986 known.insert(child_q.clone());
987 known.insert(parent_q.clone());
988 let mounts = parent_of.entry(child_q).or_default();
989 let edge = (parent_q, m);
990 if !mounts.contains(&edge) {
991 mounts.push(edge);
992 }
993 }
994 }
995 }
996 }
997 }
998
999 let mut cache: HashMap<String, Option<Vec<String>>> = HashMap::new();
1000 let mut local: HashMap<String, HashMap<String, String>> = HashMap::new();
1001 let src_by_path: HashMap<&str, &str> = files
1002 .iter()
1003 .map(|(p, s)| (p.as_str(), s.as_str()))
1004 .collect();
1005 let mut additions: Vec<ServiceEndpoint> = Vec::new();
1008
1009 for ep in endpoints.iter_mut() {
1010 if ep.transport != Transport::Http || ep.direction != Direction::Inbound {
1011 continue;
1012 }
1013 let Some(src) = src_by_path.get(ep.file.as_str()).copied() else {
1014 continue;
1015 };
1016 let Some(module) = module_by_path.get(ep.file.as_str()) else {
1017 continue;
1018 };
1019 let imports = &imports_by_path[ep.file.as_str()];
1020 let Some(line) = src.lines().nth(ep.line.saturating_sub(1) as usize) else {
1021 continue;
1022 };
1023 let line = line.trim();
1024 let Some(recv) = route_receiver(line) else {
1025 continue;
1026 };
1027 let decos = parse_route_decorator(line);
1028 let Some((_, raw_path, _)) = decos.first() else {
1029 continue;
1030 };
1031 let method = ep
1032 .contract
1033 .strip_prefix("http:")
1034 .and_then(|s| s.split(' ').next())
1035 .unwrap_or("GET")
1036 .to_string();
1037
1038 let recv_q = resolve_name(&recv, module, imports);
1039 let prefixes: Vec<String> = if known.contains(&recv_q) {
1042 resolve_full_q(&recv_q, &self_by_q, &parent_of, &app_qs, &mut cache)
1043 } else {
1044 vec![
1045 local
1046 .entry(ep.file.clone())
1047 .or_insert_with(|| collect_router_prefixes(src))
1048 .get(&recv)
1049 .cloned()
1050 .unwrap_or_default(),
1051 ]
1052 };
1053 let mut paths: Vec<String> = prefixes.iter().map(|p| join_route(p, raw_path)).collect();
1054 paths.sort();
1055 paths.dedup();
1056 if paths.is_empty() {
1057 paths.push(raw_path.clone());
1058 }
1059 ep.contract = http_contract(&method, &paths[0]);
1060 for extra in paths.iter().skip(1) {
1061 let mut dup = ep.clone();
1062 dup.contract = http_contract(&method, extra);
1063 additions.push(dup);
1064 }
1065 }
1066 endpoints.extend(additions);
1067}
1068
1069fn qualify(module: &str, name: &str) -> String {
1071 if module.is_empty() {
1072 name.to_string()
1073 } else {
1074 format!("{module}.{name}")
1075 }
1076}
1077
1078fn resolve_name(name: &str, module: &str, imports: &HashMap<String, String>) -> String {
1081 imports
1082 .get(name)
1083 .cloned()
1084 .unwrap_or_else(|| qualify(module, name))
1085}
1086
1087fn file_import_qnames(source: &str, module: &str) -> HashMap<String, String> {
1091 let mut out = HashMap::new();
1092 for raw in source.lines() {
1093 let line = raw.trim();
1094 let Some(rest) = line.strip_prefix("from ") else {
1095 continue;
1096 };
1097 let Some(imp) = rest.find(" import ") else {
1098 continue;
1099 };
1100 let module_raw = rest[..imp].trim();
1101 let resolved = if module_raw.starts_with('.') {
1102 match resolve_relative_import(module, module_raw) {
1103 Some(m) => m,
1104 None => continue,
1105 }
1106 } else {
1107 module_raw.to_string()
1108 };
1109 let names = rest[imp + " import ".len()..]
1110 .trim()
1111 .trim_start_matches('(')
1112 .trim_end_matches(')')
1113 .trim_end_matches('\\')
1114 .trim();
1115 for part in names.split(',') {
1116 let part = part.trim();
1117 if part.is_empty() || part == "*" {
1118 continue;
1119 }
1120 let (orig, local) = match part.split_once(" as ") {
1121 Some((o, l)) => (o.trim(), l.trim()),
1122 None => (part, part),
1123 };
1124 if is_ident(orig) && is_ident(local) {
1125 out.insert(local.to_string(), qualify(&resolved, orig));
1126 }
1127 }
1128 }
1129 out
1130}
1131
1132fn resolve_full_q(
1137 q: &str,
1138 self_by_q: &HashMap<String, String>,
1139 parent_of: &HashMap<String, Vec<(String, String)>>,
1140 app_qs: &HashSet<String>,
1141 cache: &mut HashMap<String, Option<Vec<String>>>,
1142) -> Vec<String> {
1143 if let Some(c) = cache.get(q) {
1144 return c.clone().unwrap_or_default(); }
1146 cache.insert(q.to_string(), None); let self_p = self_by_q.get(q).cloned().unwrap_or_default();
1148 let mut out: Vec<String> = if app_qs.contains(q) {
1149 vec![self_p.clone()]
1150 } else if let Some(parents) = parent_of.get(q) {
1151 let mut acc = Vec::new();
1152 for (parent, mount) in parents {
1153 let mut pfulls = resolve_full_q(parent, self_by_q, parent_of, app_qs, cache);
1154 if pfulls.is_empty() {
1155 pfulls.push(String::new());
1156 }
1157 for pf in pfulls {
1158 acc.push(normalize_prefix(&format!("{pf}{mount}{self_p}")));
1159 }
1160 }
1161 if acc.is_empty() {
1162 vec![self_p.clone()]
1163 } else {
1164 acc
1165 }
1166 } else {
1167 vec![self_p.clone()]
1168 };
1169 out.sort();
1170 out.dedup();
1171 cache.insert(q.to_string(), Some(out.clone()));
1172 out
1173}
1174
1175fn mount_receiver(line: &str, fname: &str) -> Option<String> {
1178 let pos = line.find(fname)?;
1179 let before = line[..pos].trim_end().strip_suffix('.')?;
1180 let start = before
1181 .rfind(|c: char| !(c.is_alphanumeric() || c == '_'))
1182 .map(|i| i + 1)
1183 .unwrap_or(0);
1184 let recv = &before[start..];
1185 if recv.is_empty() {
1186 None
1187 } else {
1188 Some(recv.to_string())
1189 }
1190}
1191
1192fn parse_client_calls(line: &str) -> Vec<(String, String, f64)> {
1196 let mut out = Vec::new();
1197 for module in ["requests.", "httpx."] {
1198 let mut search_from = 0usize;
1199 while let Some(rel) = line[search_from..].find(module) {
1200 let start = search_from + rel;
1201 let preceded_ok = start == 0
1203 || !line[..start]
1204 .chars()
1205 .next_back()
1206 .map(|c| c.is_alphanumeric() || c == '_' || c == '.')
1207 .unwrap_or(false);
1208 let after = &line[start + module.len()..];
1209 search_from = start + module.len();
1210 if !preceded_ok {
1211 continue;
1212 }
1213 let Some(paren) = after.find('(') else {
1214 continue;
1215 };
1216 let attr = after[..paren].trim();
1217 let args = &after[paren + 1..];
1218 if HTTP_VERBS.contains(&attr) {
1219 if let Some(url) = first_string_arg(args) {
1220 out.push((attr.to_uppercase(), url, 0.9));
1221 }
1222 } else if attr == "request" {
1223 let parts = first_two_string_args(args);
1225 if let (Some(method), Some(url)) = parts {
1226 out.push((method.to_uppercase(), url, 0.9));
1227 }
1228 }
1229 }
1230 }
1231 out
1232}
1233
1234fn first_string_arg(args: &str) -> Option<String> {
1238 let s = args.trim_start();
1239 let s = match s.find('=') {
1241 Some(eq)
1242 if s[..eq].chars().all(|c| c.is_alphanumeric() || c == '_')
1243 && !s[..eq].is_empty()
1244 && s.as_bytes().get(eq + 1) != Some(&b'=') =>
1245 {
1246 s[eq + 1..].trim_start()
1247 }
1248 _ => s,
1249 };
1250 read_string_literal(s)
1251}
1252
1253fn first_two_string_args(args: &str) -> (Option<String>, Option<String>) {
1254 let first = read_string_literal(args.trim_start());
1255 let Some(comma) = args.find(',') else {
1257 return (first, None);
1258 };
1259 let second = first_string_arg(&args[comma + 1..]);
1260 (first, second)
1261}
1262
1263fn read_string_literal(s: &str) -> Option<String> {
1266 let bytes = s.as_bytes();
1267 let mut i = 0;
1268 if let Some(&c) = bytes.first() {
1270 match c {
1271 b'r' | b'R' => i = 1,
1272 b'f' | b'F' | b'b' | b'B' => return None,
1273 _ => {}
1274 }
1275 }
1276 let quote = *bytes.get(i)?;
1277 if quote != b'"' && quote != b'\'' {
1278 return None;
1279 }
1280 let inner = &s[i + 1..];
1281 let end = inner.find(quote as char)?;
1282 Some(inner[..end].to_string())
1283}
1284
1285fn methods_kwarg(args: &str) -> Vec<String> {
1288 let Some(pos) = args.find("methods") else {
1289 return Vec::new();
1290 };
1291 let after = &args[pos + "methods".len()..];
1292 let after = after.trim_start();
1293 let Some(after) = after.strip_prefix('=') else {
1294 return Vec::new();
1295 };
1296 let after = after.trim_start();
1298 let open = after.chars().next();
1299 let close = match open {
1300 Some('[') => ']',
1301 Some('(') => ')',
1302 _ => return Vec::new(),
1303 };
1304 let Some(end) = after.find(close) else {
1305 return Vec::new();
1306 };
1307 let list = &after[1..end];
1308 let mut out = Vec::new();
1309 let mut rest = list;
1310 while let Some(m) = read_string_literal(rest.trim_start()) {
1311 out.push(m.to_uppercase());
1312 match rest.find(',') {
1313 Some(c) => rest = &rest[c + 1..],
1314 None => break,
1315 }
1316 }
1317 out
1318}
1319
1320fn infer_dataflow_in_python(
1331 file: &str,
1332 source: &str,
1333 symbols: &[ParsedSymbol],
1334) -> Vec<DetectedDataFlow> {
1335 use std::collections::HashMap;
1336
1337 let mut name_to_qname: HashMap<&str, Option<String>> = HashMap::new();
1340 for s in symbols {
1341 if s.kind == SymbolKind::Function {
1342 let last = s.qname.rsplit('.').next().unwrap_or(s.qname.as_str());
1343 name_to_qname
1344 .entry(last)
1345 .and_modify(|e| *e = None)
1346 .or_insert_with(|| Some(s.qname.clone()));
1347 }
1348 }
1349
1350 let mut out = Vec::new();
1351 for (idx, raw) in source.lines().enumerate() {
1352 let line_no = (idx + 1) as u32;
1353 for (callee_name, args) in simple_calls(raw) {
1354 let Some(Some(callee_qname)) = name_to_qname.get(callee_name.as_str()) else {
1355 continue;
1356 };
1357 let Some(owner) = owner_for_body_line(symbols, line_no) else {
1358 continue;
1359 };
1360 for (i, arg) in args.iter().enumerate() {
1361 out.push(DetectedDataFlow {
1362 caller_qname: owner.qname.clone(),
1363 callee_qname: callee_qname.clone(),
1364 arg_index: i,
1365 arg: arg.clone(),
1366 file: file.to_string(),
1367 line: line_no,
1368 confidence: 0.7,
1369 });
1370 }
1371 }
1372 }
1373 out
1374}
1375
1376fn simple_calls(line: &str) -> Vec<(String, Vec<String>)> {
1380 let t = line.trim_start();
1381 if t.starts_with("def ")
1382 || t.starts_with("async def ")
1383 || t.starts_with("class ")
1384 || t.starts_with('@')
1385 || t.starts_with('#')
1386 {
1387 return Vec::new();
1388 }
1389 let chars: Vec<char> = line.chars().collect();
1390 let mut out = Vec::new();
1391 let mut i = 0;
1392 while i < chars.len() {
1393 if chars[i] == '(' {
1394 let mut start = i;
1396 while start > 0 && (chars[start - 1].is_alphanumeric() || chars[start - 1] == '_') {
1397 start -= 1;
1398 }
1399 let is_method = start > 0 && chars[start - 1] == '.';
1400 let name: String = chars[start..i].iter().collect();
1401 let valid = name
1402 .chars()
1403 .next()
1404 .is_some_and(|c| c.is_alphabetic() || c == '_');
1405 if !is_method && valid {
1406 if let Some(close) = matching_close(&chars, i) {
1407 let inner: String = chars[i + 1..close].iter().collect();
1408 if let Some(args) = simple_args(&inner) {
1409 out.push((name, args));
1410 }
1411 i = close;
1412 }
1413 }
1414 }
1415 i += 1;
1416 }
1417 out
1418}
1419
1420fn matching_close(chars: &[char], open: usize) -> Option<usize> {
1421 let mut depth = 0i32;
1422 let mut i = open;
1423 while i < chars.len() {
1424 match chars[i] {
1425 '(' => depth += 1,
1426 ')' => {
1427 depth -= 1;
1428 if depth == 0 {
1429 return Some(i);
1430 }
1431 }
1432 _ => {}
1433 }
1434 i += 1;
1435 }
1436 None
1437}
1438
1439fn simple_args(inner: &str) -> Option<Vec<String>> {
1442 let inner = inner.trim();
1443 if inner.is_empty() {
1444 return None;
1445 }
1446 let mut args = Vec::new();
1447 for part in inner.split(',') {
1448 let p = part.trim();
1449 let is_ident = p
1450 .chars()
1451 .next()
1452 .is_some_and(|c| c.is_alphabetic() || c == '_')
1453 && p.chars().all(|c| c.is_alphanumeric() || c == '_');
1454 if !is_ident || matches!(p, "True" | "False" | "None" | "self" | "cls") {
1455 return None;
1456 }
1457 args.push(p.to_string());
1458 }
1459 Some(args)
1460}
1461
1462fn infer_effects_from_body(body: &str) -> Vec<Effect> {
1467 let mut effects: Vec<Effect> = Vec::new();
1468
1469 let masked = mask_comments_and_literals(body);
1477 let scan = masked.as_str();
1478
1479 for call_site in find_calls(scan, "open(") {
1481 let args = &body[call_site.args_start..call_site.args_end];
1482 effects.push(Effect {
1483 effect: EffectCategory::IoFsRead,
1484 qualifiers: extract_open_path(args)
1485 .map(|p| json!({ "paths": [p] }))
1486 .unwrap_or(serde_json::Value::Null),
1487 note: Some(trim_note(
1488 &body[call_site.call_start..call_site.args_end + 1],
1489 )),
1490 ..Default::default()
1491 });
1492 if mentions_write_mode(args) {
1493 effects.push(Effect {
1494 effect: EffectCategory::IoFsWrite,
1495 qualifiers: extract_open_path(args)
1496 .map(|p| json!({ "paths": [p] }))
1497 .unwrap_or(serde_json::Value::Null),
1498 note: Some(trim_note(
1499 &body[call_site.call_start..call_site.args_end + 1],
1500 )),
1501 ..Default::default()
1502 });
1503 }
1504 }
1505
1506 let log_needles = [
1508 "print(",
1509 "sys.stdout",
1510 "sys.stderr",
1511 "logging.debug",
1512 "logging.info",
1513 "logging.warning",
1514 "logging.warn",
1515 "logging.error",
1516 "logging.critical",
1517 "logging.exception",
1518 "log.debug",
1519 "log.info",
1520 "log.warning",
1521 "log.warn",
1522 "log.error",
1523 "log.critical",
1524 "log.exception",
1525 "logger.debug",
1526 "logger.info",
1527 "logger.warning",
1528 "logger.warn",
1529 "logger.error",
1530 "logger.critical",
1531 "logger.exception",
1532 ];
1533 if log_needles.iter().any(|n| scan.contains(n)) {
1534 let note = first_matching_line(scan, body, &log_needles);
1535 effects.push(Effect {
1536 effect: EffectCategory::Log,
1537 qualifiers: serde_json::Value::Null,
1538 note,
1539 ..Default::default()
1540 });
1541 }
1542
1543 let db_receivers = [
1547 "db.execute(",
1548 "conn.execute(",
1549 "cursor.execute(",
1550 "cur.execute(",
1551 "session.execute(",
1552 "c.execute(",
1553 "self.db.execute(",
1554 "self.conn.execute(",
1555 ];
1556 let mut seen_db_read = false;
1557 let mut seen_db_write = false;
1558 for recv in db_receivers {
1559 for call_site in find_calls(scan, recv) {
1560 let args = &body[call_site.args_start..call_site.args_end];
1561 let sql = args.trim_start_matches(|c: char| {
1562 c.is_whitespace() || c == '"' || c == '\'' || c == 'f' || c == 'r' || c == 'b'
1563 });
1564 let upper: String = sql.chars().take(16).collect::<String>().to_uppercase();
1565 let is_write = upper.starts_with("INSERT")
1566 || upper.starts_with("UPDATE")
1567 || upper.starts_with("DELETE")
1568 || upper.starts_with("REPLACE")
1569 || upper.starts_with("CREATE")
1570 || upper.starts_with("DROP")
1571 || upper.starts_with("ALTER")
1572 || upper.starts_with("TRUNCATE");
1573 let is_read = upper.starts_with("SELECT")
1574 || upper.starts_with("WITH")
1575 || upper.starts_with("SHOW");
1576 let note = Some(trim_note(
1577 &body[call_site.call_start..call_site.args_end + 1],
1578 ));
1579 if is_write && !seen_db_write {
1580 effects.push(Effect {
1581 effect: EffectCategory::IoDbWrite,
1582 qualifiers: serde_json::Value::Null,
1583 note,
1584 ..Default::default()
1585 });
1586 seen_db_write = true;
1587 } else if is_read && !seen_db_read {
1588 effects.push(Effect {
1589 effect: EffectCategory::IoDbRead,
1590 qualifiers: serde_json::Value::Null,
1591 note,
1592 ..Default::default()
1593 });
1594 seen_db_read = true;
1595 } else if !is_write && !is_read && !seen_db_read && !seen_db_write {
1596 effects.push(Effect {
1598 effect: EffectCategory::IoDbRead,
1599 qualifiers: serde_json::Value::Null,
1600 note: note.clone(),
1601 ..Default::default()
1602 });
1603 effects.push(Effect {
1604 effect: EffectCategory::IoDbWrite,
1605 qualifiers: serde_json::Value::Null,
1606 note,
1607 ..Default::default()
1608 });
1609 seen_db_read = true;
1610 seen_db_write = true;
1611 }
1612 }
1613 }
1614 if !seen_db_write && (scan.contains(".commit()") || scan.contains("conn.commit")) {
1616 effects.push(Effect {
1617 effect: EffectCategory::IoDbWrite,
1618 qualifiers: serde_json::Value::Null,
1619 note: first_matching_line(scan, body, &[".commit()", "conn.commit"]),
1620 ..Default::default()
1621 });
1622 }
1623
1624 let net_prefixes = ["requests.", "urllib.", "httpx.", "aiohttp."];
1626 let mut net_hosts: Vec<String> = Vec::new();
1627 let mut net_note: Option<String> = None;
1628 for prefix in net_prefixes {
1629 for call_site in find_prefixed_calls(scan, prefix) {
1630 let args = &body[call_site.args_start..call_site.args_end];
1631 if let Some(host) = extract_url_host(args) {
1632 if !net_hosts.contains(&host) {
1633 net_hosts.push(host);
1634 }
1635 }
1636 if net_note.is_none() {
1637 net_note = Some(trim_note(
1638 &body[call_site.call_start..call_site.args_end + 1],
1639 ));
1640 }
1641 }
1642 }
1643 if net_note.is_some() {
1644 let qualifiers = if net_hosts.is_empty() {
1645 serde_json::Value::Null
1646 } else {
1647 json!({ "hosts": net_hosts })
1648 };
1649 effects.push(Effect {
1650 effect: EffectCategory::IoNetOut,
1651 qualifiers,
1652 note: net_note,
1653 ..Default::default()
1654 });
1655 }
1656
1657 let proc_patterns = ["subprocess.", "os.system(", "os.exec"];
1659 if let Some(note) = first_match_note(scan, body, &proc_patterns) {
1660 effects.push(Effect {
1661 effect: EffectCategory::ProcSpawn,
1662 qualifiers: serde_json::Value::Null,
1663 note: Some(note),
1664 ..Default::default()
1665 });
1666 }
1667
1668 let env_patterns = ["os.environ", "os.getenv"];
1670 if let Some(note) = first_match_note(scan, body, &env_patterns) {
1671 let mut vars: Vec<String> = Vec::new();
1672 for call_site in find_calls(scan, "os.getenv(") {
1673 let args = &body[call_site.args_start..call_site.args_end];
1674 if let Some(v) = extract_first_string_literal(args) {
1675 if !vars.contains(&v) {
1676 vars.push(v);
1677 }
1678 }
1679 }
1680 let qualifiers = if vars.is_empty() {
1681 serde_json::Value::Null
1682 } else {
1683 json!({ "vars": vars })
1684 };
1685 effects.push(Effect {
1686 effect: EffectCategory::EnvRead,
1687 qualifiers,
1688 note: Some(note),
1689 ..Default::default()
1690 });
1691 }
1692
1693 if let Some(note) = first_match_note(scan, body, &["time.sleep"]) {
1695 effects.push(Effect {
1696 effect: EffectCategory::TimeSleep,
1697 qualifiers: serde_json::Value::Null,
1698 note: Some(note),
1699 ..Default::default()
1700 });
1701 }
1702
1703 let time_read_patterns = [
1705 "time.time",
1706 "time.monotonic",
1707 "time.perf_counter",
1708 "datetime.now",
1709 "datetime.utcnow",
1710 "datetime.today",
1711 ];
1712 if let Some(note) = first_match_note(scan, body, &time_read_patterns) {
1713 effects.push(Effect {
1714 effect: EffectCategory::TimeRead,
1715 qualifiers: serde_json::Value::Null,
1716 note: Some(note),
1717 ..Default::default()
1718 });
1719 }
1720
1721 let random_patterns = ["random.", "secrets.", "os.urandom("];
1723 if let Some(note) = first_match_note(scan, body, &random_patterns) {
1724 effects.push(Effect {
1725 effect: EffectCategory::Random,
1726 qualifiers: serde_json::Value::Null,
1727 note: Some(note),
1728 ..Default::default()
1729 });
1730 }
1731
1732 if has_raise_statement(scan) {
1734 let note = first_matching_line(scan, body, &["raise "]);
1735 effects.push(Effect {
1736 effect: EffectCategory::Throw,
1737 qualifiers: serde_json::Value::Null,
1738 note,
1739 ..Default::default()
1740 });
1741 }
1742
1743 effects
1744}
1745
1746#[derive(Debug, Clone, Copy)]
1750struct CallSite {
1751 call_start: usize,
1752 args_start: usize,
1753 args_end: usize,
1754}
1755
1756fn find_calls(body: &str, needle: &str) -> Vec<CallSite> {
1760 let mut out = Vec::new();
1761 if !needle.ends_with('(') {
1762 return out;
1763 }
1764 let bytes = body.as_bytes();
1765 let mut search_from = 0;
1766 while let Some(idx) = body[search_from..].find(needle) {
1767 let abs = search_from + idx;
1768 let args_start = abs + needle.len();
1769 if let Some(args_end) = find_matching_paren(bytes, args_start) {
1770 out.push(CallSite {
1771 call_start: abs,
1772 args_start,
1773 args_end,
1774 });
1775 search_from = args_end + 1;
1776 } else {
1777 search_from = args_start;
1778 }
1779 }
1780 out
1781}
1782
1783#[allow(dead_code)]
1784fn contains_call(body: &str, needle: &str) -> bool {
1785 !find_calls(body, needle).is_empty()
1786}
1787
1788fn find_prefixed_calls(body: &str, prefix: &str) -> Vec<CallSite> {
1800 let mut out = Vec::new();
1801 let bytes = body.as_bytes();
1802 let mut from = 0;
1803 while let Some(rel) = body[from..].find(prefix) {
1804 let abs = from + rel;
1805 let after = abs + prefix.len();
1806 from = after;
1807 let rest = &body[after..];
1809 let name_len = rest
1810 .find(|c: char| !(c.is_alphanumeric() || c == '_'))
1811 .unwrap_or(rest.len());
1812 if name_len == 0 || rest.as_bytes().get(name_len) != Some(&b'(') {
1813 continue;
1814 }
1815 let args_start = after + name_len + 1;
1816 if let Some(args_end) = find_matching_paren(bytes, args_start) {
1817 out.push(CallSite {
1818 call_start: abs,
1819 args_start,
1820 args_end,
1821 });
1822 from = args_end + 1;
1823 }
1824 }
1825 out
1826}
1827
1828fn find_matching_paren(bytes: &[u8], start: usize) -> Option<usize> {
1832 let mut depth: i32 = 1;
1833 let mut i = start;
1834 let mut in_string: Option<u8> = None;
1835 while i < bytes.len() {
1836 let c = bytes[i];
1837 if let Some(q) = in_string {
1838 if c == b'\\' {
1839 i += 2;
1840 continue;
1841 }
1842 if c == q {
1843 in_string = None;
1844 }
1845 } else {
1846 match c {
1847 b'"' | b'\'' => in_string = Some(c),
1848 b'(' => depth += 1,
1849 b')' => {
1850 depth -= 1;
1851 if depth == 0 {
1852 return Some(i);
1853 }
1854 }
1855 _ => {}
1856 }
1857 }
1858 i += 1;
1859 }
1860 None
1861}
1862
1863fn mentions_write_mode(args: &str) -> bool {
1864 let bytes = args.as_bytes();
1867 let mut i = 0;
1868 while i < bytes.len() {
1869 let c = bytes[i];
1870 if c == b'"' || c == b'\'' {
1871 let q = c;
1872 let start = i + 1;
1873 let mut j = start;
1874 while j < bytes.len() && bytes[j] != q {
1875 if bytes[j] == b'\\' {
1876 j += 2;
1877 continue;
1878 }
1879 j += 1;
1880 }
1881 if j <= bytes.len() {
1882 let lit = &args[start..j.min(bytes.len())];
1883 if lit.len() <= 4 && (lit.contains('w') || lit.contains('a') || lit.contains('x')) {
1884 return true;
1885 }
1886 i = j + 1;
1887 continue;
1888 }
1889 }
1890 i += 1;
1891 }
1892 false
1893}
1894
1895fn extract_open_path(args: &str) -> Option<String> {
1897 extract_first_string_literal(args)
1898}
1899
1900fn extract_first_string_literal(args: &str) -> Option<String> {
1902 let trimmed = args.trim_start();
1903 let bytes = trimmed.as_bytes();
1904 if bytes.is_empty() {
1905 return None;
1906 }
1907 let q = bytes[0];
1908 if q != b'"' && q != b'\'' {
1909 return None;
1910 }
1911 let mut j = 1;
1912 let mut out = String::new();
1913 while j < bytes.len() {
1914 let c = bytes[j];
1915 if c == b'\\' && j + 1 < bytes.len() {
1916 out.push(bytes[j + 1] as char);
1917 j += 2;
1918 continue;
1919 }
1920 if c == q {
1921 return Some(out);
1922 }
1923 out.push(c as char);
1924 j += 1;
1925 }
1926 None
1927}
1928
1929fn extract_url_host(args: &str) -> Option<String> {
1931 let lower = args;
1933 for scheme in ["https://", "http://"] {
1934 if let Some(idx) = lower.find(scheme) {
1935 let tail = &lower[idx + scheme.len()..];
1936 let end = tail
1937 .find(|c: char| {
1938 c == '/' || c == '"' || c == '\'' || c == ')' || c == '?' || c.is_whitespace()
1939 })
1940 .unwrap_or(tail.len());
1941 let host = &tail[..end];
1942 if !host.is_empty() {
1943 return Some(host.to_string());
1944 }
1945 }
1946 }
1947 None
1948}
1949
1950fn truncate_doc(s: &str) -> String {
1953 const MAX: usize = 512;
1954 if s.len() <= MAX {
1955 return s.to_string();
1956 }
1957 let truncated = &s[..MAX];
1959 match truncated.rfind(' ') {
1960 Some(pos) => format!("{}…", &truncated[..pos]),
1961 None => format!("{}…", truncated),
1962 }
1963}
1964
1965fn trim_note(s: &str) -> String {
1966 let first_line = s.lines().next().unwrap_or("").trim();
1967 if first_line.len() > 120 {
1968 format!("{}...", &first_line[..120])
1969 } else {
1970 first_line.to_string()
1971 }
1972}
1973
1974fn first_matching_line(scan: &str, body: &str, needles: &[&str]) -> Option<String> {
1980 for (i, scan_line) in scan.lines().enumerate() {
1981 for n in needles {
1982 if scan_line.contains(n) {
1983 return body.lines().nth(i).map(|l| l.trim().to_string());
1984 }
1985 }
1986 }
1987 None
1988}
1989
1990fn first_match_note(scan: &str, body: &str, needles: &[&str]) -> Option<String> {
1993 for n in needles {
1994 if scan.contains(n) {
1995 return first_matching_line(scan, body, needles);
1996 }
1997 }
1998 None
1999}
2000
2001fn has_raise_statement(scan: &str) -> bool {
2006 for line in scan.lines() {
2007 let trimmed = line.trim_start();
2008 if trimmed.starts_with("raise ") || trimmed == "raise" {
2009 return true;
2010 }
2011 }
2012 false
2013}
2014
2015pub fn scan_dynamic_dispatch_in_python(file: &str, source: &str) -> Vec<DynamicDispatchHint> {
2033 let masked = mask_comments_and_literals(source);
2034 let mut hints = Vec::new();
2035 let bytes = masked.as_bytes();
2036 let body = source.as_bytes();
2037 let mut line_starts: Vec<usize> = vec![0];
2038 for (i, b) in body.iter().enumerate() {
2039 if *b == b'\n' {
2040 line_starts.push(i + 1);
2041 }
2042 }
2043 let line_of = |off: usize| -> u32 {
2044 match line_starts.binary_search(&off) {
2045 Ok(i) => i as u32 + 1,
2046 Err(i) => i.max(1) as u32,
2047 }
2048 };
2049 let snippet_at = |off: usize| -> String {
2050 let line_idx = line_of(off).saturating_sub(1) as usize;
2051 let start = line_starts.get(line_idx).copied().unwrap_or(0);
2052 let end = line_starts
2053 .get(line_idx + 1)
2054 .copied()
2055 .unwrap_or(body.len())
2056 .saturating_sub(1);
2057 std::str::from_utf8(&body[start..end])
2058 .map(|s| s.trim().to_string())
2059 .unwrap_or_default()
2060 };
2061
2062 for site in find_calls(&masked, "getattr(") {
2066 let mut j = site.args_end + 1;
2070 while j < bytes.len() && (bytes[j] == b' ' || bytes[j] == b'\t') {
2071 j += 1;
2072 }
2073 if j < bytes.len() && bytes[j] == b'(' {
2074 hints.push(DynamicDispatchHint {
2075 file: file.to_string(),
2076 line: line_of(site.call_start),
2077 pattern: "getattr".into(),
2078 snippet: snippet_at(site.call_start),
2079 });
2080 }
2081 }
2082
2083 for (i, line) in masked.lines().enumerate() {
2087 let trimmed = line.trim_start();
2088 if trimmed.starts_with("def __getattr__(") || trimmed.starts_with("def __getattribute__(") {
2089 let off = line_starts.get(i).copied().unwrap_or(0);
2090 hints.push(DynamicDispatchHint {
2091 file: file.to_string(),
2092 line: (i + 1) as u32,
2093 pattern: "__getattr__".into(),
2094 snippet: snippet_at(off),
2095 });
2096 }
2097 }
2098
2099 hints
2100}
2101
2102fn mask_comments_and_literals(body: &str) -> String {
2119 let bytes = body.as_bytes();
2120 let mut out: Vec<u8> = bytes.to_vec();
2121 let n = bytes.len();
2122 let mut i = 0;
2123 while i < n {
2124 let c = bytes[i];
2125 if c == b'#' {
2129 let mut j = i;
2130 while j < n && bytes[j] != b'\n' {
2131 out[j] = b' ';
2132 j += 1;
2133 }
2134 i = j;
2135 continue;
2136 }
2137 let quote_start = find_quote_after_optional_prefix(bytes, i);
2145 if let Some(qi) = quote_start {
2146 let q = bytes[qi];
2147 let triple = qi + 2 < n && bytes[qi + 1] == q && bytes[qi + 2] == q;
2149 let (literal_start, end_pos) = if triple {
2150 let inner_start = qi + 3;
2151 let end = find_triple_close(bytes, inner_start, q);
2152 (inner_start, end)
2153 } else {
2154 let inner_start = qi + 1;
2155 let end = find_single_close(bytes, inner_start, q);
2156 (inner_start, end)
2157 };
2158 for k in literal_start..end_pos.min(n) {
2162 if out[k] != b'\n' {
2163 out[k] = b' ';
2164 }
2165 }
2166 i = end_pos + if triple { 3 } else { 1 };
2168 continue;
2169 }
2170 i += 1;
2171 }
2172 debug_assert_eq!(out.len(), bytes.len());
2178 String::from_utf8(out).unwrap_or_else(|_| body.to_string())
2179}
2180
2181fn find_quote_after_optional_prefix(bytes: &[u8], i: usize) -> Option<usize> {
2188 let n = bytes.len();
2189 if i >= n {
2190 return None;
2191 }
2192 let c = bytes[i];
2193 if c == b'"' || c == b'\'' {
2194 return Some(i);
2195 }
2196 let is_prefix = |b: u8| matches!(b, b'r' | b'R' | b'b' | b'B' | b'f' | b'F');
2197 if !is_prefix(c) {
2198 return None;
2199 }
2200 if i > 0 {
2205 let prev = bytes[i - 1];
2206 if prev.is_ascii_alphanumeric() || prev == b'_' {
2207 return None;
2208 }
2209 }
2210 if i + 1 < n && (bytes[i + 1] == b'"' || bytes[i + 1] == b'\'') {
2212 return Some(i + 1);
2213 }
2214 if i + 2 < n && is_prefix(bytes[i + 1]) && (bytes[i + 2] == b'"' || bytes[i + 2] == b'\'') {
2216 return Some(i + 2);
2217 }
2218 None
2219}
2220
2221fn find_single_close(bytes: &[u8], start: usize, q: u8) -> usize {
2227 let n = bytes.len();
2228 let mut j = start;
2229 while j < n {
2230 let b = bytes[j];
2231 if b == b'\\' {
2232 j += 2;
2233 continue;
2234 }
2235 if b == b'\n' {
2236 return j; }
2238 if b == q {
2239 return j;
2240 }
2241 j += 1;
2242 }
2243 n
2244}
2245
2246fn find_triple_close(bytes: &[u8], start: usize, q: u8) -> usize {
2250 let n = bytes.len();
2251 let mut j = start;
2252 while j + 2 < n {
2253 if bytes[j] == b'\\' {
2254 j += 2;
2255 continue;
2256 }
2257 if bytes[j] == q && bytes[j + 1] == q && bytes[j + 2] == q {
2258 return j;
2259 }
2260 j += 1;
2261 }
2262 n
2263}
2264
2265fn extract_call_edges_impl(
2284 file: &str,
2285 source: &str,
2286 symbols: &[ParsedSymbol],
2287 workspace: &WorkspaceSymbols,
2288) -> Vec<CallEdge> {
2289 let module_prefix = module_qname_prefix(file);
2290
2291 let known: HashSet<&str> = symbols.iter().map(|s| s.qname.as_str()).collect();
2293
2294 let mut by_simple: HashMap<String, String> = HashMap::new();
2298 for s in symbols {
2299 let simple = s.qname.rsplit('.').next().unwrap_or(&s.qname).to_string();
2300 let module_level = if module_prefix.is_empty() {
2303 s.qname == simple
2304 } else {
2305 s.qname == format!("{}.{}", module_prefix, simple)
2306 };
2307 if module_level {
2308 by_simple.insert(simple, s.qname.clone());
2309 } else {
2310 by_simple.entry(simple).or_insert_with(|| s.qname.clone());
2311 }
2312 }
2313
2314 let mut parser = Parser::new();
2315 if parser
2316 .set_language(&tree_sitter_python::LANGUAGE.into())
2317 .is_err()
2318 {
2319 return Vec::new();
2320 }
2321
2322 let imports = parse_imports(source, &mut parser, &module_prefix);
2328
2329 let mut edges: HashSet<CallEdge> = HashSet::new();
2330
2331 for sym in symbols {
2332 if !matches!(sym.kind, SymbolKind::Function | SymbolKind::Method) {
2336 continue;
2337 }
2338
2339 let src_bytes = sym.body.as_bytes();
2340 let tree = match parser.parse(src_bytes, None) {
2341 Some(t) => t,
2342 None => continue,
2343 };
2344
2345 let enclosing_class = enclosing_class_qname(&sym.qname, &module_prefix, &known);
2348 collect_calls(
2349 tree.root_node(),
2350 src_bytes,
2351 sym,
2352 &module_prefix,
2353 &by_simple,
2354 &known,
2355 &imports,
2356 workspace,
2357 enclosing_class.as_deref(),
2358 &mut edges,
2359 );
2360 }
2361
2362 let mut out: Vec<CallEdge> = edges.into_iter().collect();
2363 out.sort_by(|a, b| {
2364 a.caller_qname
2365 .cmp(&b.caller_qname)
2366 .then_with(|| a.callee_qname.cmp(&b.callee_qname))
2367 });
2368 out
2369}
2370
2371#[derive(Debug, Clone)]
2380struct ImportBinding {
2381 qname_prefix: String,
2382 #[allow(dead_code)]
2388 is_module: bool,
2389}
2390
2391fn parse_imports(
2421 source: &str,
2422 parser: &mut Parser,
2423 module_prefix: &str,
2424) -> HashMap<String, ImportBinding> {
2425 let mut out: HashMap<String, ImportBinding> = HashMap::new();
2426 let src_bytes = source.as_bytes();
2427 let tree = match parser.parse(src_bytes, None) {
2428 Some(t) => t,
2429 None => return out,
2430 };
2431 walk_imports(tree.root_node(), src_bytes, module_prefix, &mut out);
2432 out
2433}
2434
2435fn walk_imports(
2446 node: Node<'_>,
2447 src: &[u8],
2448 module_prefix: &str,
2449 out: &mut HashMap<String, ImportBinding>,
2450) {
2451 match node.kind() {
2452 "import_statement" => {
2453 collect_import_statement(node, src, out);
2454 return;
2455 }
2456 "import_from_statement" => {
2457 collect_import_from_statement(node, src, module_prefix, out);
2458 return;
2459 }
2460 _ => {}
2461 }
2462 let mut cursor = node.walk();
2463 for child in node.children(&mut cursor) {
2464 walk_imports(child, src, module_prefix, out);
2465 }
2466}
2467
2468fn resolve_relative_import(module_prefix: &str, raw: &str) -> Option<String> {
2473 let dot_count = raw.chars().take_while(|c| *c == '.').count();
2474 if dot_count == 0 {
2475 return None;
2476 }
2477 let suffix = raw[dot_count..].trim().trim_matches('.');
2478 let parts: Vec<&str> = if module_prefix.is_empty() {
2479 Vec::new()
2480 } else {
2481 module_prefix.split('.').collect()
2482 };
2483 if dot_count > parts.len() {
2484 return None;
2485 }
2486 let keep = parts.len() - dot_count;
2487 let mut base = parts[..keep].join(".");
2488 if !suffix.is_empty() {
2489 if !base.is_empty() {
2490 base.push('.');
2491 }
2492 base.push_str(suffix);
2493 }
2494 if base.is_empty() { None } else { Some(base) }
2495}
2496
2497fn collect_import_statement(node: Node<'_>, src: &[u8], out: &mut HashMap<String, ImportBinding>) {
2499 let mut cursor = node.walk();
2503 for child in node.children_by_field_name("name", &mut cursor) {
2504 match child.kind() {
2505 "dotted_name" => {
2506 let full = node_text(child, src).unwrap_or_default();
2510 let full = full.trim().to_string();
2511 if full.is_empty() {
2512 continue;
2513 }
2514 let first = full.split('.').next().unwrap_or(&full).to_string();
2515 out.insert(
2516 first.clone(),
2517 ImportBinding {
2518 qname_prefix: first,
2519 is_module: true,
2520 },
2521 );
2522 if full.contains('.') {
2525 out.insert(
2526 full.clone(),
2527 ImportBinding {
2528 qname_prefix: full,
2529 is_module: true,
2530 },
2531 );
2532 }
2533 }
2534 "aliased_import" => {
2535 let name = child
2537 .child_by_field_name("name")
2538 .and_then(|n| node_text(n, src))
2539 .unwrap_or_default();
2540 let alias = child
2541 .child_by_field_name("alias")
2542 .and_then(|n| node_text(n, src))
2543 .unwrap_or_default();
2544 let name = name.trim().to_string();
2545 let alias = alias.trim().to_string();
2546 if name.is_empty() || alias.is_empty() {
2547 continue;
2548 }
2549 out.insert(
2550 alias,
2551 ImportBinding {
2552 qname_prefix: name,
2553 is_module: true,
2554 },
2555 );
2556 }
2557 _ => {}
2558 }
2559 }
2560}
2561
2562fn collect_import_from_statement(
2564 node: Node<'_>,
2565 src: &[u8],
2566 module_prefix: &str,
2567 out: &mut HashMap<String, ImportBinding>,
2568) {
2569 let module_node = node.child_by_field_name("module_name");
2571 let module_kind = module_node.map(|n| n.kind()).unwrap_or("");
2572 let module = if module_kind == "relative_import" {
2573 let raw = module_node
2577 .and_then(|n| node_text(n, src))
2578 .unwrap_or_default();
2579 match resolve_relative_import(module_prefix, raw.trim()) {
2580 Some(m) => m,
2581 None => return,
2582 }
2583 } else {
2584 let m = module_node
2585 .and_then(|n| node_text(n, src))
2586 .unwrap_or_default()
2587 .trim()
2588 .to_string();
2589 if m.is_empty() {
2590 return;
2591 }
2592 m
2593 };
2594
2595 let mut cursor_all = node.walk();
2598 for child in node.children(&mut cursor_all) {
2599 if child.kind() == "wildcard_import" {
2600 return;
2601 }
2602 }
2603
2604 let mut cursor = node.walk();
2605 for child in node.children_by_field_name("name", &mut cursor) {
2606 match child.kind() {
2607 "dotted_name" => {
2608 let name = node_text(child, src).unwrap_or_default();
2609 let name = name.trim().to_string();
2610 if name.is_empty() {
2611 continue;
2612 }
2613 let qname = format!("{module}.{name}");
2614 out.insert(
2615 name,
2616 ImportBinding {
2617 qname_prefix: qname,
2618 is_module: false,
2619 },
2620 );
2621 }
2622 "aliased_import" => {
2623 let name = child
2624 .child_by_field_name("name")
2625 .and_then(|n| node_text(n, src))
2626 .unwrap_or_default();
2627 let alias = child
2628 .child_by_field_name("alias")
2629 .and_then(|n| node_text(n, src))
2630 .unwrap_or_default();
2631 let name = name.trim().to_string();
2632 let alias = alias.trim().to_string();
2633 if name.is_empty() || alias.is_empty() {
2634 continue;
2635 }
2636 let qname = format!("{module}.{name}");
2637 out.insert(
2638 alias,
2639 ImportBinding {
2640 qname_prefix: qname,
2641 is_module: false,
2642 },
2643 );
2644 }
2645 _ => {}
2646 }
2647 }
2648}
2649
2650fn enclosing_class_qname(
2653 qname: &str,
2654 module_prefix: &str,
2655 known: &HashSet<&str>,
2656) -> Option<String> {
2657 let parts: Vec<&str> = qname.split('.').collect();
2658 if parts.len() < 2 {
2659 return None;
2660 }
2661 for end in (1..parts.len()).rev() {
2664 let candidate = parts[..end].join(".");
2665 if !module_prefix.is_empty() && !candidate.starts_with(module_prefix) {
2666 continue;
2667 }
2668 if known.contains(candidate.as_str()) {
2669 return Some(candidate);
2670 }
2671 }
2672 None
2673}
2674
2675#[allow(clippy::too_many_arguments)]
2676fn collect_calls(
2677 node: Node<'_>,
2678 src: &[u8],
2679 sym: &ParsedSymbol,
2680 module_prefix: &str,
2681 by_simple: &HashMap<String, String>,
2682 known: &HashSet<&str>,
2683 imports: &HashMap<String, ImportBinding>,
2684 workspace: &WorkspaceSymbols,
2685 enclosing_class: Option<&str>,
2686 out: &mut HashSet<CallEdge>,
2687) {
2688 if node.kind() == "call" {
2689 if let Some(func) = node.child_by_field_name("function") {
2690 if let Some(callee) = resolve_callee(
2691 func,
2692 src,
2693 module_prefix,
2694 by_simple,
2695 known,
2696 imports,
2697 workspace,
2698 enclosing_class,
2699 ) {
2700 out.insert(CallEdge {
2701 caller_qname: sym.qname.clone(),
2702 callee_qname: callee,
2703 });
2704 }
2705 }
2706 }
2707 let mut cursor = node.walk();
2708 for child in node.children(&mut cursor) {
2709 collect_calls(
2710 child,
2711 src,
2712 sym,
2713 module_prefix,
2714 by_simple,
2715 known,
2716 imports,
2717 workspace,
2718 enclosing_class,
2719 out,
2720 );
2721 }
2722}
2723
2724#[allow(clippy::too_many_arguments)]
2735fn resolve_callee(
2736 func: Node<'_>,
2737 src: &[u8],
2738 module_prefix: &str,
2739 by_simple: &HashMap<String, String>,
2740 known: &HashSet<&str>,
2741 imports: &HashMap<String, ImportBinding>,
2742 workspace: &WorkspaceSymbols,
2743 enclosing_class: Option<&str>,
2744) -> Option<String> {
2745 match func.kind() {
2746 "identifier" => {
2747 let name = node_text(func, src)?;
2748 if let Some(q) = by_simple.get(&name) {
2750 return Some(q.clone());
2751 }
2752 if let Some(binding) = imports.get(&name) {
2754 if workspace.contains(&binding.qname_prefix) {
2755 return Some(binding.qname_prefix.clone());
2756 }
2757 }
2758 None
2759 }
2760 "attribute" => {
2761 let object = func.child_by_field_name("object")?;
2764 let attr = func.child_by_field_name("attribute")?;
2765 let attr_name = node_text(attr, src)?;
2766
2767 match object.kind() {
2768 "identifier" => {
2769 let obj_name = node_text(object, src)?;
2770 if obj_name == "self" {
2771 let class = enclosing_class?;
2772 let candidate = format!("{class}.{attr_name}");
2773 if known.contains(candidate.as_str()) {
2774 return Some(candidate);
2775 }
2776 return None;
2777 }
2778
2779 let class_qname = if module_prefix.is_empty() {
2781 obj_name.clone()
2782 } else {
2783 format!("{module_prefix}.{obj_name}")
2784 };
2785 if known.contains(class_qname.as_str()) {
2786 let candidate = format!("{class_qname}.{attr_name}");
2787 if known.contains(candidate.as_str()) {
2788 return Some(candidate);
2789 }
2790 }
2791
2792 if let Some(binding) = imports.get(&obj_name) {
2795 let candidate = format!("{}.{}", binding.qname_prefix, attr_name);
2796 if workspace.contains(&candidate) {
2797 return Some(candidate);
2798 }
2799 }
2800 None
2801 }
2802 "attribute" => {
2803 let chain = flatten_attribute(object, src)?;
2806 if let Some(binding) = imports.get(&chain) {
2807 let candidate = format!("{}.{}", binding.qname_prefix, attr_name);
2808 if workspace.contains(&candidate) {
2809 return Some(candidate);
2810 }
2811 }
2812 None
2813 }
2814 _ => None,
2815 }
2816 }
2817 _ => None,
2818 }
2819}
2820
2821fn flatten_attribute(node: Node<'_>, src: &[u8]) -> Option<String> {
2824 match node.kind() {
2825 "identifier" => node_text(node, src),
2826 "attribute" => {
2827 let object = node.child_by_field_name("object")?;
2828 let attr = node.child_by_field_name("attribute")?;
2829 let head = flatten_attribute(object, src)?;
2830 let tail = node_text(attr, src)?;
2831 Some(format!("{head}.{tail}"))
2832 }
2833 _ => None,
2834 }
2835}
2836
2837#[cfg(test)]
2838mod tests {
2839 use super::*;
2840
2841 #[test]
2842 fn router_prefix_resolution_fastapi_and_flask() {
2843 let src = r#"
2845router = APIRouter(prefix="/api/notifications", tags=["x"])
2846
2847@router.get("/unread")
2848def unread(): ...
2849"#;
2850 let p = collect_router_prefixes(src);
2851 assert_eq!(
2852 p.get("router").map(String::as_str),
2853 Some("/api/notifications")
2854 );
2855 assert_eq!(
2856 route_receiver("@router.get(\"/unread\")").as_deref(),
2857 Some("router")
2858 );
2859 assert_eq!(
2860 join_route("/api/notifications", "/unread"),
2861 "/api/notifications/unread"
2862 );
2863
2864 let flask = r#"bp = Blueprint("acct", __name__, url_prefix="/accounts")"#;
2866 assert_eq!(
2867 collect_router_prefixes(flask).get("bp").map(String::as_str),
2868 Some("/accounts")
2869 );
2870
2871 let mounted = r#"
2873sub = APIRouter(prefix="/items")
2874app.include_router(sub, prefix="/api")
2875"#;
2876 assert_eq!(
2877 collect_router_prefixes(mounted)
2878 .get("sub")
2879 .map(String::as_str),
2880 Some("/api/items")
2881 );
2882 }
2883
2884 #[test]
2885 fn router_prefix_edge_cases() {
2886 assert_eq!(string_kwarg(r#"url_prefix="/z""#, "prefix"), None);
2889 assert_eq!(
2890 string_kwarg(r#"url_prefix="/z""#, "url_prefix").as_deref(),
2891 Some("/z")
2892 );
2893 assert!(collect_router_prefixes("app = FastAPI()").is_empty());
2894 assert_eq!(join_route("/api", "items"), "/api/items");
2896 assert_eq!(join_route("/api/", "/items"), "/api/items");
2897 assert_eq!(join_route("/api", "/"), "/api");
2898 assert_eq!(join_route("", "/items"), "/items");
2899 assert!(ctor_args("x = MyAPIRouter(prefix=\"/a\")", "APIRouter").is_none());
2901 }
2902
2903 fn ep(contract: &str, file: &str, line: u32) -> ServiceEndpoint {
2904 ServiceEndpoint {
2905 transport: Transport::Http,
2906 direction: Direction::Inbound,
2907 contract: contract.to_string(),
2908 repo_id: "r".into(),
2909 symbol_id: "s".into(),
2910 qname: "q".into(),
2911 file: file.into(),
2912 line,
2913 confidence: 0.95,
2914 note: None,
2915 }
2916 }
2917
2918 #[test]
2919 fn cross_file_aliased_mount_tree_resolves_nested_prefix() {
2920 let files = vec![
2928 (
2929 "main.py".to_string(),
2930 "from api import api_router\napp = FastAPI()\napp.include_router(api_router, prefix=\"/api\")\n".to_string(),
2931 ),
2932 (
2933 "api.py".to_string(),
2934 "from calc import router as calculators_router\napi_router = APIRouter()\napi_router.include_router(calculators_router, prefix=\"/calculators\")\n".to_string(),
2935 ),
2936 (
2937 "calc.py".to_string(),
2938 "router = APIRouter()\n\n@router.get(\"/budget\")\ndef budget(): ...\n".to_string(),
2939 ),
2940 ];
2941 let mut eps = vec![ep(&http_contract("GET", "/budget"), "calc.py", 3)];
2942 resolve_python_endpoint_prefixes(&files, &mut eps);
2943 assert_eq!(eps[0].contract, "http:GET /api/calculators/budget");
2944 }
2945
2946 #[test]
2947 fn same_router_name_distinct_modules_resolve_independently() {
2948 let files = vec![
2951 (
2952 "a.py".to_string(),
2953 "router = APIRouter(prefix=\"/api/notif\")\n\n@router.get(\"/x\")\ndef x(): ...\n"
2954 .to_string(),
2955 ),
2956 (
2957 "b.py".to_string(),
2958 "router = APIRouter(prefix=\"/api/inst\")\n\n@router.get(\"/y\")\ndef y(): ...\n"
2959 .to_string(),
2960 ),
2961 ];
2962 let mut eps = vec![
2963 ep(&http_contract("GET", "/x"), "a.py", 3),
2964 ep(&http_contract("GET", "/y"), "b.py", 3),
2965 ];
2966 resolve_python_endpoint_prefixes(&files, &mut eps);
2967 assert_eq!(eps[0].contract, "http:GET /api/notif/x");
2968 assert_eq!(eps[1].contract, "http:GET /api/inst/y");
2969 }
2970
2971 #[test]
2972 fn multi_mount_emits_route_under_each_prefix() {
2973 let files = vec![
2975 (
2976 "main.py".to_string(),
2977 "from api import api_router\napp = FastAPI()\napp.include_router(api_router, prefix=\"/api/v1\")\napp.include_router(api_router, prefix=\"/api\")\n".to_string(),
2978 ),
2979 (
2980 "api.py".to_string(),
2981 "api_router = APIRouter()\n\n@api_router.get(\"/health\")\ndef health(): ...\n".to_string(),
2982 ),
2983 ];
2984 let mut eps = vec![ep(&http_contract("GET", "/health"), "api.py", 3)];
2985 resolve_python_endpoint_prefixes(&files, &mut eps);
2986 let contracts: std::collections::HashSet<String> =
2987 eps.iter().map(|e| e.contract.clone()).collect();
2988 assert_eq!(eps.len(), 2, "{contracts:?}");
2989 assert!(
2990 contracts.contains("http:GET /api/v1/health"),
2991 "{contracts:?}"
2992 );
2993 assert!(contracts.contains("http:GET /api/health"), "{contracts:?}");
2994 }
2995
2996 #[test]
2997 fn module_prefix_strips_py_and_leading_dot_slash() {
2998 assert_eq!(
3000 module_qname_prefix("src/mypackage/module.py"),
3001 "mypackage.module"
3002 );
3003 assert_eq!(module_qname_prefix("src/utils.py"), "utils");
3004 assert_eq!(
3006 module_qname_prefix("packages/mypkg/src/__init__.py"),
3007 "mypkg.__init__"
3008 );
3009 assert_eq!(
3010 module_qname_prefix("packages/my-pkg/src/__init__.py"),
3011 "my_pkg.__init__"
3012 );
3013 assert_eq!(module_qname_prefix("foo/bar.py"), "foo.bar");
3015 assert_eq!(module_qname_prefix("./foo/bar.py"), "foo.bar");
3016 assert_eq!(module_qname_prefix("bar.py"), "bar");
3017 }
3018
3019 #[test]
3020 fn strip_src_prefix_variants() {
3021 assert_eq!(strip_src_prefix("src/pkg/mod.py"), "pkg/mod.py");
3022 assert_eq!(strip_src_prefix("lib/src/utils.py"), "utils.py");
3023 assert_eq!(strip_src_prefix("pkg/mod.py"), "pkg/mod.py");
3024 }
3025
3026 #[test]
3027 fn parses_module_function_and_class() {
3028 let src = "def top():\n return 1\n\nclass C:\n def m(self):\n return 2\n";
3029 let a = PythonAdapter::new();
3030 let syms = a.parse_symbols("x.py", src).unwrap();
3031 let qnames: Vec<_> = syms.iter().map(|s| s.qname.clone()).collect();
3032 assert!(qnames.contains(&"x.top".to_string()));
3033 assert!(qnames.contains(&"x.C".to_string()));
3034 assert!(qnames.contains(&"x.C.m".to_string()));
3035 let top = syms.iter().find(|s| s.qname == "x.top").unwrap();
3036 assert_eq!(top.kind, SymbolKind::Function);
3037 let m = syms.iter().find(|s| s.qname == "x.C.m").unwrap();
3038 assert_eq!(m.kind, SymbolKind::Method);
3039 }
3040
3041 #[test]
3042 fn infers_fs_read_and_write_from_open() {
3043 let body = "def f():\n with open('/tmp/a.txt', 'w') as f:\n f.write('hi')\n";
3044 let effects = infer_effects_from_body(body);
3045 let cats: Vec<_> = effects.iter().map(|e| e.effect.clone()).collect();
3046 assert!(cats.contains(&EffectCategory::IoFsRead));
3047 assert!(cats.contains(&EffectCategory::IoFsWrite));
3048 }
3049
3050 #[test]
3051 fn empty_when_no_patterns() {
3052 let body = "def f(x):\n return x + 1\n";
3053 assert!(infer_effects_from_body(body).is_empty());
3054 }
3055
3056 #[test]
3059 fn comment_mentioning_open_does_not_infer_fs_effects() {
3060 let body = "def f(x):\n # consider open('/tmp/x') for cache\n return x\n";
3062 let effects = infer_effects_from_body(body);
3063 let cats: Vec<_> = effects.iter().map(|e| e.effect.clone()).collect();
3064 assert!(
3065 !cats.contains(&EffectCategory::IoFsRead),
3066 "open() in a comment must not infer IoFsRead; got {cats:?}"
3067 );
3068 }
3069
3070 #[test]
3071 fn string_literal_mentioning_open_does_not_infer_fs_effects() {
3072 let body = "def f():\n msg = 'use open(path) to load'\n return msg\n";
3074 let effects = infer_effects_from_body(body);
3075 assert!(
3076 effects.is_empty(),
3077 "open() inside a string literal must not infer any effect; got {effects:?}"
3078 );
3079 }
3080
3081 #[test]
3082 fn triple_string_docstring_mentioning_requests_does_not_infer_net() {
3083 let body = r#"def f():
3086 """Pretend we call requests.get('https://example.com') here.
3087
3088 But we don't actually.
3089 """
3090 return 42
3091"#;
3092 let effects = infer_effects_from_body(body);
3093 let cats: Vec<_> = effects.iter().map(|e| e.effect.clone()).collect();
3094 assert!(
3095 !cats.contains(&EffectCategory::IoNetOut),
3096 "requests.* in a docstring must not infer IoNetOut; got {cats:?}"
3097 );
3098 }
3099
3100 #[test]
3101 fn real_requests_call_infers_net_out() {
3102 for body in [
3108 "def f():\n return requests.get('https://api.svc/x')\n",
3109 "def f():\n return httpx.post('https://api.svc/x', json={})\n",
3110 "def f():\n return requests.request('POST', 'https://api.svc/x')\n",
3111 ] {
3112 let cats: Vec<_> = infer_effects_from_body(body)
3113 .iter()
3114 .map(|e| e.effect.clone())
3115 .collect();
3116 assert!(
3117 cats.contains(&EffectCategory::IoNetOut),
3118 "expected IoNetOut for `{body}`; got {cats:?}"
3119 );
3120 }
3121 }
3122
3123 #[test]
3124 fn real_open_still_fires_when_comment_also_mentions_it() {
3125 let body = "def f():\n # open('/tmp/a') would also work\n open('/tmp/b')\n";
3128 let effects = infer_effects_from_body(body);
3129 let cats: Vec<_> = effects.iter().map(|e| e.effect.clone()).collect();
3130 assert!(
3131 cats.contains(&EffectCategory::IoFsRead),
3132 "real open() must still infer IoFsRead even when a comment also mentions it; got {cats:?}"
3133 );
3134 }
3135
3136 #[test]
3137 fn raise_inside_string_literal_does_not_infer_throw() {
3138 let body = "def f():\n msg = 'do not raise here'\n return msg\n";
3139 let effects = infer_effects_from_body(body);
3140 let cats: Vec<_> = effects.iter().map(|e| e.effect.clone()).collect();
3141 assert!(
3142 !cats.contains(&EffectCategory::Throw),
3143 "raise inside a string literal must not infer Throw; got {cats:?}"
3144 );
3145 }
3146
3147 #[test]
3148 fn mask_preserves_byte_length_and_line_count() {
3149 let body = "x = 'open(\"a\")' # open(\"b\")\ny = 1\n";
3152 let masked = mask_comments_and_literals(body);
3153 assert_eq!(masked.len(), body.len(), "mask must preserve byte length");
3154 assert_eq!(
3155 masked.lines().count(),
3156 body.lines().count(),
3157 "mask must preserve line count"
3158 );
3159 assert!(
3162 !masked.contains("open("),
3163 "masked source must not contain `open(` from literal or comment"
3164 );
3165 }
3166
3167 #[test]
3168 fn mask_leaves_identifier_chars_alone() {
3169 let body = "request_open = 1\n";
3172 let masked = mask_comments_and_literals(body);
3173 assert_eq!(
3174 masked, body,
3175 "no comments/literals here; masked must equal original"
3176 );
3177 }
3178
3179 #[test]
3180 fn mask_handles_raw_and_byte_string_prefixes() {
3181 let body = "x = r\"open('/tmp/a')\"\ny = b'open(\"b\")'\nz = rb\"open(\"c\")\"\n";
3182 let effects = infer_effects_from_body(body);
3183 assert!(
3184 effects.is_empty(),
3185 "raw/byte/raw-byte literals must mask their contents; got {effects:?}"
3186 );
3187 }
3188
3189 fn workspace_with(qnames: &[&str]) -> WorkspaceSymbols {
3192 let mut ws = WorkspaceSymbols::default();
3193 for q in qnames {
3194 ws.qnames.insert((*q).to_string());
3195 ws.kinds.insert((*q).to_string(), SymbolKind::Function);
3196 }
3197 ws
3198 }
3199
3200 #[test]
3201 fn extracts_intra_module_call_edges() {
3202 let src = r#"
3203def helper(x):
3204 return x
3205
3206def caller():
3207 return helper(1)
3208
3209class C:
3210 def __init__(self):
3211 self.x = helper(2)
3212
3213 def m(self):
3214 return self.__init__()
3215"#;
3216 let a = PythonAdapter::new();
3217 let syms = a.parse_symbols("m.py", src).unwrap();
3218 let ws = workspace_with(&["m.helper", "m.caller", "m.C", "m.C.__init__", "m.C.m"]);
3219 let edges = a.extract_call_edges("m.py", src, &syms, &ws);
3220 let pairs: Vec<(String, String)> = edges
3221 .iter()
3222 .map(|e| (e.caller_qname.clone(), e.callee_qname.clone()))
3223 .collect();
3224 assert!(
3225 pairs.contains(&("m.caller".to_string(), "m.helper".to_string())),
3226 "missing caller -> helper edge; got {pairs:?}",
3227 );
3228 assert!(
3229 pairs.contains(&("m.C.__init__".to_string(), "m.helper".to_string())),
3230 "missing C.__init__ -> helper edge; got {pairs:?}",
3231 );
3232 assert!(
3233 pairs.contains(&("m.C.m".to_string(), "m.C.__init__".to_string())),
3234 "missing C.m -> C.__init__ edge; got {pairs:?}",
3235 );
3236 }
3237
3238 #[test]
3239 fn extracts_cross_module_edges_via_from_import() {
3240 let src = r#"
3243from logger import write_log
3244
3245def foo():
3246 write_log("/tmp/x", "hi")
3247"#;
3248 let a = PythonAdapter::new();
3249 let syms = a.parse_symbols("caller.py", src).unwrap();
3250 let ws = workspace_with(&["logger.write_log", "caller.foo"]);
3251 let edges = a.extract_call_edges("caller.py", src, &syms, &ws);
3252 let pairs: Vec<(String, String)> = edges
3253 .iter()
3254 .map(|e| (e.caller_qname.clone(), e.callee_qname.clone()))
3255 .collect();
3256 assert!(
3257 pairs.contains(&("caller.foo".to_string(), "logger.write_log".to_string())),
3258 "missing foo -> logger.write_log edge; got {pairs:?}",
3259 );
3260 }
3261
3262 #[test]
3263 fn extracts_cross_module_edges_via_module_import() {
3264 let src = r#"
3266import logger
3267
3268def foo():
3269 logger.write_log("/tmp/x", "hi")
3270"#;
3271 let a = PythonAdapter::new();
3272 let syms = a.parse_symbols("caller.py", src).unwrap();
3273 let ws = workspace_with(&["logger.write_log", "caller.foo"]);
3274 let edges = a.extract_call_edges("caller.py", src, &syms, &ws);
3275 let pairs: Vec<(String, String)> = edges
3276 .iter()
3277 .map(|e| (e.caller_qname.clone(), e.callee_qname.clone()))
3278 .collect();
3279 assert!(
3280 pairs.contains(&("caller.foo".to_string(), "logger.write_log".to_string())),
3281 "missing foo -> logger.write_log edge; got {pairs:?}",
3282 );
3283 }
3284
3285 #[test]
3286 fn drops_unknown_imports() {
3287 let src = r#"
3289import stdlib_mod
3290
3291def foo():
3292 stdlib_mod.thing()
3293"#;
3294 let a = PythonAdapter::new();
3295 let syms = a.parse_symbols("caller.py", src).unwrap();
3296 let ws = workspace_with(&["caller.foo"]);
3297 let edges = a.extract_call_edges("caller.py", src, &syms, &ws);
3298 let pairs: Vec<(String, String)> = edges
3299 .iter()
3300 .map(|e| (e.caller_qname.clone(), e.callee_qname.clone()))
3301 .collect();
3302 assert!(
3303 pairs.is_empty(),
3304 "expected no edges for unknown imports; got {pairs:?}",
3305 );
3306 }
3307
3308 #[test]
3309 fn import_alias_and_from_import_with_alias() {
3310 let src = r#"
3311import logger as lg
3312from greetings import hello as h
3313
3314def foo():
3315 lg.write_log("/tmp/x", "hi")
3316 h("world")
3317"#;
3318 let a = PythonAdapter::new();
3319 let syms = a.parse_symbols("caller.py", src).unwrap();
3320 let ws = workspace_with(&["logger.write_log", "greetings.hello", "caller.foo"]);
3321 let edges = a.extract_call_edges("caller.py", src, &syms, &ws);
3322 let pairs: Vec<(String, String)> = edges
3323 .iter()
3324 .map(|e| (e.caller_qname.clone(), e.callee_qname.clone()))
3325 .collect();
3326 assert!(
3327 pairs.contains(&("caller.foo".to_string(), "logger.write_log".to_string())),
3328 "missing lg.write_log edge; got {pairs:?}",
3329 );
3330 assert!(
3331 pairs.contains(&("caller.foo".to_string(), "greetings.hello".to_string())),
3332 "missing h() edge; got {pairs:?}",
3333 );
3334 }
3335
3336 #[test]
3337 fn local_definition_shadows_import() {
3338 let src = r#"
3340from other import helper
3341
3342def helper(x):
3343 return x
3344
3345def foo():
3346 helper(1)
3347"#;
3348 let a = PythonAdapter::new();
3349 let syms = a.parse_symbols("m.py", src).unwrap();
3350 let ws = workspace_with(&["other.helper", "m.helper", "m.foo"]);
3351 let edges = a.extract_call_edges("m.py", src, &syms, &ws);
3352 let pairs: Vec<(String, String)> = edges
3353 .iter()
3354 .map(|e| (e.caller_qname.clone(), e.callee_qname.clone()))
3355 .collect();
3356 assert!(
3357 pairs.contains(&("m.foo".to_string(), "m.helper".to_string())),
3358 "expected local helper to shadow import; got {pairs:?}",
3359 );
3360 assert!(
3361 !pairs.contains(&("m.foo".to_string(), "other.helper".to_string())),
3362 "expected imported helper to be shadowed; got {pairs:?}",
3363 );
3364 }
3365
3366 #[test]
3367 fn default_extract_call_edges_returns_empty_for_unrelated_adapter() {
3368 struct Dummy;
3371 impl LanguageAdapter for Dummy {
3372 fn language(&self) -> &str {
3373 "dummy"
3374 }
3375 fn parse_symbols(&self, _f: &str, _s: &str) -> Result<Vec<ParsedSymbol>> {
3376 Ok(Vec::new())
3377 }
3378 fn infer_effects(&self, _s: &str, _p: &ParsedSymbol) -> Vec<Effect> {
3379 Vec::new()
3380 }
3381 }
3382 let d = Dummy;
3383 let ws = WorkspaceSymbols::default();
3384 assert!(d.extract_call_edges("x", "", &[], &ws).is_empty());
3385 }
3386
3387 #[test]
3390 fn relative_dot_resolves_to_current_package() {
3391 let r = super::resolve_relative_import("crucible.agents.litellm_agent", ".").unwrap();
3394 assert_eq!(r, "crucible.agents");
3395 }
3396
3397 #[test]
3398 fn relative_dot_with_suffix_appends_module() {
3399 let r = super::resolve_relative_import("crucible.agents.litellm_agent", ".base").unwrap();
3400 assert_eq!(r, "crucible.agents.base");
3401 }
3402
3403 #[test]
3404 fn relative_double_dot_goes_up_one_more() {
3405 let r = super::resolve_relative_import("crucible.agents.litellm_agent", "..util").unwrap();
3408 assert_eq!(r, "crucible.util");
3409 }
3410
3411 #[test]
3412 fn relative_from_init_resolves_against_package() {
3413 let r = super::resolve_relative_import("crucible.agents.__init__", ".base").unwrap();
3416 assert_eq!(r, "crucible.agents.base");
3417 }
3418
3419 #[test]
3420 fn relative_escaping_workspace_returns_none() {
3421 assert!(super::resolve_relative_import("crucible", "..foo").is_none());
3423 }
3424
3425 #[test]
3428 fn unresolved_calls_reports_third_party_call() {
3429 use agentstatedeveloper_core::WorkspaceSymbols;
3432 let src = "import requests\n\ndef fetch(url):\n return requests.get(url)\n";
3433 let parsed = vec![ParsedSymbol {
3434 qname: "app.client.fetch".into(),
3435 kind: SymbolKind::Function,
3436 start_line: 3,
3437 start_col: 0,
3438 end_line: 4,
3439 end_col: 30,
3440 signature: Some("def fetch(url)".into()),
3441 body: src.into(),
3442 doc: None,
3443 }];
3444 let mut ws = WorkspaceSymbols::default();
3445 ws.qnames.insert("app.client.fetch".into());
3446 ws.kinds
3447 .insert("app.client.fetch".into(), SymbolKind::Function);
3448 ws.build_suffix_index();
3449
3450 let hits = report_unresolved_calls_in_python("app/client.py", src, &parsed, &ws);
3451 assert!(
3452 hits.iter().any(|h| h.callee_text.contains("requests")),
3453 "expected unresolved hint for requests.get; got {hits:?}"
3454 );
3455 }
3456
3457 #[test]
3458 fn unresolved_calls_skips_stdlib_allowlist() {
3459 use agentstatedeveloper_core::WorkspaceSymbols;
3462 let src = "def show(items):\n print(len(items))\n for i in range(3): pass\n";
3463 let parsed = vec![ParsedSymbol {
3464 qname: "app.show.show".into(),
3465 kind: SymbolKind::Function,
3466 start_line: 1,
3467 start_col: 0,
3468 end_line: 3,
3469 end_col: 30,
3470 signature: Some("def show(items)".into()),
3471 body: src.into(),
3472 doc: None,
3473 }];
3474 let mut ws = WorkspaceSymbols::default();
3475 ws.qnames.insert("app.show.show".into());
3476 ws.kinds
3477 .insert("app.show.show".into(), SymbolKind::Function);
3478 ws.build_suffix_index();
3479
3480 let hits = report_unresolved_calls_in_python("app/show.py", src, &parsed, &ws);
3481 assert!(
3482 hits.is_empty(),
3483 "stdlib allowlist calls must not be reported; got {hits:?}"
3484 );
3485 }
3486
3487 #[test]
3488 fn unresolved_calls_skips_resolved_internal_calls() {
3489 use agentstatedeveloper_core::WorkspaceSymbols;
3492 let src = "def helper(): return 1\n\ndef use(): return helper()\n";
3493 let parsed = vec![
3494 ParsedSymbol {
3495 qname: "app.lib.helper".into(),
3496 kind: SymbolKind::Function,
3497 start_line: 1,
3498 start_col: 0,
3499 end_line: 1,
3500 end_col: 22,
3501 signature: Some("def helper()".into()),
3502 body: src.into(),
3503 doc: None,
3504 },
3505 ParsedSymbol {
3506 qname: "app.lib.use".into(),
3507 kind: SymbolKind::Function,
3508 start_line: 3,
3509 start_col: 0,
3510 end_line: 3,
3511 end_col: 25,
3512 signature: Some("def use()".into()),
3513 body: src.into(),
3514 doc: None,
3515 },
3516 ];
3517 let mut ws = WorkspaceSymbols::default();
3518 ws.qnames.insert("app.lib.helper".into());
3519 ws.kinds
3520 .insert("app.lib.helper".into(), SymbolKind::Function);
3521 ws.qnames.insert("app.lib.use".into());
3522 ws.kinds.insert("app.lib.use".into(), SymbolKind::Function);
3523 ws.build_suffix_index();
3524
3525 let hits = report_unresolved_calls_in_python("app/lib.py", src, &parsed, &ws);
3526 assert!(
3527 !hits.iter().any(|h| h.callee_text == "helper"),
3528 "internal resolved call must not appear in unresolved list; got {hits:?}"
3529 );
3530 }
3531
3532 #[test]
3535 fn detects_getattr_call_pattern() {
3536 let src = "def dispatch(obj, name):\n return getattr(obj, name)(42)\n";
3537 let hints = scan_dynamic_dispatch_in_python("app/dispatch.py", src);
3538 assert_eq!(hints.len(), 1, "expected 1 getattr hint; got {hints:?}");
3539 assert_eq!(hints[0].pattern, "getattr");
3540 assert_eq!(hints[0].line, 2);
3541 assert!(hints[0].snippet.contains("getattr(obj, name)(42)"));
3542 }
3543
3544 #[test]
3545 fn getattr_without_trailing_call_is_not_flagged() {
3546 let src = "def read(obj, name):\n return getattr(obj, name, None)\n";
3548 let hints = scan_dynamic_dispatch_in_python("app/read.py", src);
3549 assert!(
3550 hints.is_empty(),
3551 "bare getattr (no trailing call) must not be flagged; got {hints:?}"
3552 );
3553 }
3554
3555 #[test]
3556 fn detects_getattr_with_string_arg_then_call() {
3557 let src = "def go(obj):\n return getattr(obj, 'method')(1, 2)\n";
3558 let hints = scan_dynamic_dispatch_in_python("app/go.py", src);
3559 assert_eq!(hints.len(), 1);
3560 assert_eq!(hints[0].pattern, "getattr");
3561 }
3562
3563 #[test]
3564 fn detects_dunder_getattr_method_definition() {
3565 let src =
3566 "class Proxy:\n def __getattr__(self, name):\n return self._lookup(name)\n";
3567 let hints = scan_dynamic_dispatch_in_python("app/proxy.py", src);
3568 assert_eq!(hints.len(), 1);
3569 assert_eq!(hints[0].pattern, "__getattr__");
3570 assert_eq!(hints[0].line, 2);
3571 }
3572
3573 #[test]
3574 fn detects_dunder_getattribute_method_definition() {
3575 let src = "class Strict:\n def __getattribute__(self, name):\n return super().__getattribute__(name)\n";
3576 let hints = scan_dynamic_dispatch_in_python("app/strict.py", src);
3577 assert_eq!(hints.len(), 1);
3578 assert_eq!(hints[0].pattern, "__getattr__");
3579 }
3580
3581 #[test]
3582 fn getattr_inside_comment_or_string_is_masked() {
3583 let src = "def f(obj):\n # consider getattr(obj, 'x')(1) here\n msg = 'use getattr(obj, x)(args) at runtime'\n return msg\n";
3586 let hints = scan_dynamic_dispatch_in_python("app/f.py", src);
3587 assert!(
3588 hints.is_empty(),
3589 "getattr inside comment/string must not be flagged; got {hints:?}"
3590 );
3591 }
3592
3593 #[test]
3596 fn extract_call_edges_resolves_function_body_import() {
3597 use crate::PythonAdapter;
3601 use agentstatedeveloper_core::{
3602 LanguageAdapter, ParsedSymbol, SymbolKind, WorkspaceSymbols,
3603 };
3604
3605 let adapter = PythonAdapter::new();
3606 let src = "def f():\n from pkg.util import helper\n return helper()\n";
3607
3608 let syms = vec![ParsedSymbol {
3609 qname: "app.main.f".into(),
3610 kind: SymbolKind::Function,
3611 start_line: 1,
3612 start_col: 0,
3613 end_line: 3,
3614 end_col: 22,
3615 signature: Some("def f()".into()),
3616 body: src.into(),
3617 doc: None,
3618 }];
3619
3620 let mut ws = WorkspaceSymbols::default();
3621 ws.qnames.insert("pkg.util.helper".into());
3622 ws.kinds
3623 .insert("pkg.util.helper".into(), SymbolKind::Function);
3624 ws.qnames.insert("app.main.f".into());
3625 ws.kinds.insert("app.main.f".into(), SymbolKind::Function);
3626 ws.build_suffix_index();
3627
3628 let edges = adapter.extract_call_edges("app/main.py", src, &syms, &ws);
3629 assert!(
3630 edges.iter().any(|e| e.callee_qname == "pkg.util.helper"),
3631 "function-body import must produce cross-module edge; got {edges:?}"
3632 );
3633 }
3634
3635 #[test]
3636 fn extract_call_edges_resolves_if_typechecking_import() {
3637 use crate::PythonAdapter;
3640 use agentstatedeveloper_core::{
3641 LanguageAdapter, ParsedSymbol, SymbolKind, WorkspaceSymbols,
3642 };
3643
3644 let adapter = PythonAdapter::new();
3645 let src = "from typing import TYPE_CHECKING\n\nif TYPE_CHECKING:\n from pkg.api import Client\n\ndef use():\n return Client()\n";
3646
3647 let syms = vec![ParsedSymbol {
3648 qname: "app.main.use".into(),
3649 kind: SymbolKind::Function,
3650 start_line: 6,
3651 start_col: 0,
3652 end_line: 7,
3653 end_col: 20,
3654 signature: Some("def use()".into()),
3655 body: src.into(),
3656 doc: None,
3657 }];
3658
3659 let mut ws = WorkspaceSymbols::default();
3660 ws.qnames.insert("pkg.api.Client".into());
3661 ws.kinds.insert("pkg.api.Client".into(), SymbolKind::Class);
3662 ws.qnames.insert("app.main.use".into());
3663 ws.kinds.insert("app.main.use".into(), SymbolKind::Function);
3664 ws.build_suffix_index();
3665
3666 let edges = adapter.extract_call_edges("app/main.py", src, &syms, &ws);
3667 assert!(
3668 edges.iter().any(|e| e.callee_qname == "pkg.api.Client"),
3669 "if TYPE_CHECKING import must resolve; got {edges:?}"
3670 );
3671 }
3672
3673 #[test]
3674 fn extract_call_edges_resolves_try_except_import() {
3675 use crate::PythonAdapter;
3679 use agentstatedeveloper_core::{
3680 LanguageAdapter, ParsedSymbol, SymbolKind, WorkspaceSymbols,
3681 };
3682
3683 let adapter = PythonAdapter::new();
3684 let src = "try:\n from pkg.fast import loader\nexcept ImportError:\n from pkg.slow import loader\n\ndef boot():\n return loader()\n";
3685
3686 let syms = vec![ParsedSymbol {
3687 qname: "app.main.boot".into(),
3688 kind: SymbolKind::Function,
3689 start_line: 6,
3690 start_col: 0,
3691 end_line: 7,
3692 end_col: 20,
3693 signature: Some("def boot()".into()),
3694 body: src.into(),
3695 doc: None,
3696 }];
3697
3698 let mut ws = WorkspaceSymbols::default();
3699 ws.qnames.insert("pkg.fast.loader".into());
3700 ws.kinds
3701 .insert("pkg.fast.loader".into(), SymbolKind::Function);
3702 ws.qnames.insert("pkg.slow.loader".into());
3703 ws.kinds
3704 .insert("pkg.slow.loader".into(), SymbolKind::Function);
3705 ws.qnames.insert("app.main.boot".into());
3706 ws.kinds
3707 .insert("app.main.boot".into(), SymbolKind::Function);
3708 ws.build_suffix_index();
3709
3710 let edges = adapter.extract_call_edges("app/main.py", src, &syms, &ws);
3711 let resolved = edges
3714 .iter()
3715 .any(|e| e.callee_qname == "pkg.fast.loader" || e.callee_qname == "pkg.slow.loader");
3716 assert!(
3717 resolved,
3718 "try/except import must resolve to one of the branches; got {edges:?}"
3719 );
3720 }
3721
3722 #[test]
3723 fn module_scope_imports_still_resolve_after_nested_walker() {
3724 use crate::PythonAdapter;
3727 use agentstatedeveloper_core::{
3728 LanguageAdapter, ParsedSymbol, SymbolKind, WorkspaceSymbols,
3729 };
3730
3731 let adapter = PythonAdapter::new();
3732 let src = "from pkg.util import helper\n\ndef f():\n return helper()\n";
3733
3734 let syms = vec![ParsedSymbol {
3735 qname: "app.main.f".into(),
3736 kind: SymbolKind::Function,
3737 start_line: 3,
3738 start_col: 0,
3739 end_line: 4,
3740 end_col: 22,
3741 signature: Some("def f()".into()),
3742 body: src.into(),
3743 doc: None,
3744 }];
3745
3746 let mut ws = WorkspaceSymbols::default();
3747 ws.qnames.insert("pkg.util.helper".into());
3748 ws.kinds
3749 .insert("pkg.util.helper".into(), SymbolKind::Function);
3750 ws.qnames.insert("app.main.f".into());
3751 ws.kinds.insert("app.main.f".into(), SymbolKind::Function);
3752 ws.build_suffix_index();
3753
3754 let edges = adapter.extract_call_edges("app/main.py", src, &syms, &ws);
3755 assert!(
3756 edges.iter().any(|e| e.callee_qname == "pkg.util.helper"),
3757 "module-scope import must still resolve; got {edges:?}"
3758 );
3759 }
3760
3761 #[test]
3762 fn extract_call_edges_resolves_double_dot_relative_import() {
3763 use crate::PythonAdapter;
3769 use agentstatedeveloper_core::{
3770 LanguageAdapter, ParsedSymbol, SymbolKind, WorkspaceSymbols,
3771 };
3772
3773 let adapter = PythonAdapter::new();
3774 let caller_src = "from ..util import helper\n\ndef act():\n return helper()\n";
3777
3778 let caller_syms = vec![ParsedSymbol {
3779 qname: "crucible.agents.litellm_agent.act".into(),
3780 kind: SymbolKind::Function,
3781 start_line: 3,
3782 start_col: 0,
3783 end_line: 4,
3784 end_col: 22,
3785 signature: Some("def act()".into()),
3786 body: caller_src.into(),
3787 doc: None,
3788 }];
3789
3790 let mut workspace = WorkspaceSymbols::default();
3791 workspace.qnames.insert("crucible.util.helper".into());
3792 workspace
3793 .kinds
3794 .insert("crucible.util.helper".into(), SymbolKind::Function);
3795 workspace
3796 .qnames
3797 .insert("crucible.agents.litellm_agent.act".into());
3798 workspace.kinds.insert(
3799 "crucible.agents.litellm_agent.act".into(),
3800 SymbolKind::Function,
3801 );
3802 workspace.build_suffix_index();
3803
3804 let edges = adapter.extract_call_edges(
3805 "crucible/agents/litellm_agent.py",
3806 caller_src,
3807 &caller_syms,
3808 &workspace,
3809 );
3810
3811 assert!(
3812 edges
3813 .iter()
3814 .any(|e| e.callee_qname == "crucible.util.helper"),
3815 "expected double-dot relative import to produce cross-module edge to crucible.util.helper; got {edges:?}"
3816 );
3817 }
3818
3819 #[test]
3820 fn extract_call_edges_resolves_relative_import_to_cross_module() {
3821 use crate::PythonAdapter;
3824 use agentstatedeveloper_core::{
3825 LanguageAdapter, ParsedSymbol, SymbolKind, WorkspaceSymbols,
3826 };
3827
3828 let adapter = PythonAdapter::new();
3829 let caller_src =
3830 "from .base import make_decision\n\ndef act():\n return make_decision()\n";
3831
3832 let caller_syms = vec![ParsedSymbol {
3833 qname: "crucible.agents.litellm_agent.act".into(),
3834 kind: SymbolKind::Function,
3835 start_line: 3,
3836 start_col: 0,
3837 end_line: 4,
3838 end_col: 27,
3839 signature: Some("def act()".into()),
3840 body: caller_src.into(),
3841 doc: None,
3842 }];
3843
3844 let mut workspace = WorkspaceSymbols::default();
3845 workspace
3846 .qnames
3847 .insert("crucible.agents.base.make_decision".into());
3848 workspace.kinds.insert(
3849 "crucible.agents.base.make_decision".into(),
3850 SymbolKind::Function,
3851 );
3852 workspace
3853 .qnames
3854 .insert("crucible.agents.litellm_agent.act".into());
3855 workspace.kinds.insert(
3856 "crucible.agents.litellm_agent.act".into(),
3857 SymbolKind::Function,
3858 );
3859 workspace.build_suffix_index();
3860
3861 let edges = adapter.extract_call_edges(
3862 "crucible/agents/litellm_agent.py",
3863 caller_src,
3864 &caller_syms,
3865 &workspace,
3866 );
3867
3868 assert!(
3869 edges
3870 .iter()
3871 .any(|e| e.callee_qname == "crucible.agents.base.make_decision"),
3872 "expected cross-module edge to crucible.agents.base.make_decision; got {edges:?}"
3873 );
3874 }
3875}
3876
3877#[cfg(test)]
3878mod service_endpoint_tests {
3879 use super::*;
3880 use agentstatedeveloper_core::adapter::LanguageAdapter;
3881
3882 fn detect(src: &str) -> Vec<DetectedEndpoint> {
3883 let a = PythonAdapter;
3884 let symbols = a.parse_symbols("app.py", src).unwrap();
3885 a.infer_service_endpoints("app.py", src, &symbols)
3886 }
3887
3888 fn inbound(eps: &[DetectedEndpoint]) -> Vec<&DetectedEndpoint> {
3889 eps.iter()
3890 .filter(|e| e.direction == Direction::Inbound)
3891 .collect()
3892 }
3893 fn outbound(eps: &[DetectedEndpoint]) -> Vec<&DetectedEndpoint> {
3894 eps.iter()
3895 .filter(|e| e.direction == Direction::Outbound)
3896 .collect()
3897 }
3898
3899 #[test]
3900 fn fastapi_decorator_route_is_inbound() {
3901 let src = "@app.get(\"/charge\")\ndef charge():\n return 1\n";
3902 let eps = detect(src);
3903 let inb = inbound(&eps);
3904 assert_eq!(inb.len(), 1, "{eps:?}");
3905 assert_eq!(inb[0].contract, "http:GET /charge");
3906 assert!(
3907 inb[0].owner_qname.ends_with("charge"),
3908 "owner: {}",
3909 inb[0].owner_qname
3910 );
3911 assert!(inb[0].confidence > 0.9);
3912 }
3913
3914 #[test]
3915 fn flask_route_with_methods_emits_one_per_method() {
3916 let src = "@app.route(\"/items\", methods=[\"GET\", \"POST\"])\ndef items():\n pass\n";
3917 let eps = detect(src);
3918 let mut got: Vec<String> = inbound(&eps).iter().map(|e| e.contract.clone()).collect();
3919 got.sort();
3920 assert_eq!(
3921 got,
3922 vec![
3923 "http:GET /items".to_string(),
3924 "http:POST /items".to_string()
3925 ]
3926 );
3927 }
3928
3929 #[test]
3930 fn flask_route_defaults_to_get() {
3931 let src = "@app.route(\"/ping\")\ndef ping():\n pass\n";
3932 let eps = detect(src);
3933 let inb = inbound(&eps);
3934 assert_eq!(inb.len(), 1);
3935 assert_eq!(inb[0].contract, "http:GET /ping");
3936 }
3937
3938 #[test]
3939 fn requests_client_call_is_outbound() {
3940 let src = "def pay():\n requests.post(\"https://payments.svc/charge\", json=x)\n";
3941 let eps = detect(src);
3942 let out = outbound(&eps);
3943 assert_eq!(out.len(), 1, "{eps:?}");
3944 assert_eq!(out[0].contract, "http:POST /charge");
3945 assert!(
3946 out[0].owner_qname.ends_with("pay"),
3947 "owner: {}",
3948 out[0].owner_qname
3949 );
3950 }
3951
3952 #[test]
3953 fn requests_request_method_first_arg() {
3954 let src = "def f():\n httpx.request(\"DELETE\", \"https://api/things/{id}\")\n";
3955 let eps = detect(src);
3956 let out = outbound(&eps);
3957 assert_eq!(out.len(), 1);
3958 assert_eq!(out[0].contract, "http:DELETE /things/{}");
3959 }
3960
3961 #[test]
3962 fn non_literal_urls_are_skipped() {
3963 let src = "def f():\n requests.get(url)\n requests.get(f\"/u/{i}\")\n";
3965 let eps = detect(src);
3966 assert!(outbound(&eps).is_empty(), "{eps:?}");
3967 }
3968
3969 #[test]
3970 fn import_line_is_not_a_client_call() {
3971 let src = "import requests\n\ndef f():\n return 1\n";
3972 assert!(detect(src).is_empty());
3973 }
3974
3975 #[test]
3976 fn client_and_server_contracts_match_for_matching_endpoint() {
3977 let server = detect("@app.get(\"/users/{id}\")\ndef get_user(id):\n pass\n");
3980 let client = detect("def fetch():\n requests.get(\"https://users.svc/users/:id\")\n");
3981 assert_eq!(inbound(&server)[0].contract, outbound(&client)[0].contract);
3982 assert_eq!(inbound(&server)[0].contract, "http:GET /users/{}");
3983 }
3984}
3985
3986#[cfg(test)]
3987mod dataflow_tests {
3988 use super::*;
3989 use agentstatedeveloper_core::adapter::{LanguageAdapter, WorkspaceSymbols};
3990
3991 fn detect(src: &str) -> Vec<DetectedDataFlow> {
3992 let a = PythonAdapter;
3993 let symbols = a.parse_symbols("m.py", src).unwrap();
3994 a.extract_dataflow("m.py", src, &symbols, &WorkspaceSymbols::default())
3995 }
3996
3997 #[test]
3998 fn same_file_call_maps_positional_args() {
3999 let src = "def helper(a, b):\n return a\n\ndef caller():\n helper(x, y)\n";
4000 let df = detect(src);
4001 assert_eq!(df.len(), 2, "{df:?}");
4002 assert!(df.iter().all(|d| d.callee_qname.ends_with("helper")));
4003 assert!(df.iter().all(|d| d.caller_qname.ends_with("caller")));
4004 let args: Vec<(usize, String)> = df.iter().map(|d| (d.arg_index, d.arg.clone())).collect();
4005 assert!(args.contains(&(0, "x".to_string())));
4006 assert!(args.contains(&(1, "y".to_string())));
4007 }
4008
4009 #[test]
4010 fn method_calls_are_skipped() {
4011 let src = "def caller():\n obj.helper(x)\n";
4012 assert!(detect(src).is_empty(), "{:?}", detect(src));
4013 }
4014
4015 #[test]
4016 fn unknown_or_cross_file_callee_skipped() {
4017 let src = "def caller():\n helper(x)\n";
4019 assert!(detect(src).is_empty());
4020 }
4021
4022 #[test]
4023 fn non_identifier_args_are_skipped() {
4024 let src = "def helper(a):\n pass\n\ndef caller():\n helper(\"lit\")\n helper(g(x))\n helper(a + b)\n";
4025 assert!(detect(src).is_empty(), "{:?}", detect(src));
4026 }
4027
4028 #[test]
4029 fn ambiguous_callee_name_skipped() {
4030 let src = "def helper(a):\n pass\n\ndef helper(b):\n pass\n\ndef caller():\n helper(x)\n";
4031 assert!(detect(src).is_empty(), "{:?}", detect(src));
4032 }
4033}
4034
4035#[cfg(test)]
4036mod pubsub_tests {
4037 use super::*;
4038 use agentstatedeveloper_core::adapter::LanguageAdapter;
4039
4040 fn detect(src: &str) -> Vec<DetectedEndpoint> {
4041 let a = PythonAdapter;
4042 let symbols = a.parse_symbols("tasks.py", src).unwrap();
4043 a.infer_service_endpoints("tasks.py", src, &symbols)
4044 }
4045 fn ps(eps: &[DetectedEndpoint], dir: Direction) -> Vec<&DetectedEndpoint> {
4046 eps.iter()
4047 .filter(|e| e.transport == Transport::PubSub && e.direction == dir)
4048 .collect()
4049 }
4050
4051 #[test]
4052 fn shared_task_is_a_listener() {
4053 let eps = detect("@shared_task\ndef process_order(o):\n return o\n");
4054 let inb = ps(&eps, Direction::Inbound);
4055 assert_eq!(inb.len(), 1, "{eps:?}");
4056 assert_eq!(inb[0].contract, "topic:process_order");
4057 }
4058
4059 #[test]
4060 fn delay_call_is_an_emit() {
4061 let eps = detect("def caller():\n process_order.delay(payload)\n");
4062 let outb = ps(&eps, Direction::Outbound);
4063 assert_eq!(outb.len(), 1);
4064 assert_eq!(outb[0].contract, "topic:process_order");
4065 }
4066
4067 #[test]
4068 fn listener_and_emit_share_one_contract() {
4069 let src = "@app.task(bind=True)\ndef send_email(addr):\n pass\n\ndef trigger():\n send_email.apply_async((a,))\n";
4070 let eps = detect(src);
4071 assert_eq!(
4072 ps(&eps, Direction::Inbound)[0].contract,
4073 ps(&eps, Direction::Outbound)[0].contract
4074 );
4075 assert_eq!(ps(&eps, Direction::Inbound)[0].contract, "topic:send_email");
4076 }
4077
4078 #[test]
4079 fn non_celery_decorator_is_not_a_listener() {
4080 assert!(
4082 ps(
4083 &detect("@property\ndef value(self):\n return 1\n"),
4084 Direction::Inbound
4085 )
4086 .is_empty()
4087 );
4088 let http = detect("@app.get(\"/x\")\ndef h():\n pass\n");
4089 assert!(ps(&http, Direction::Inbound).is_empty());
4090 }
4091}