use std::collections::{HashMap, HashSet};
use agentstatedeveloper_core::adapter::{
CallEdge, DynamicDispatchHint, LanguageAdapter, ParsedSymbol, UnresolvedCall, WorkspaceSymbols,
};
use agentstatedeveloper_core::cross_service::{
DetectedEndpoint, Direction, ServiceEndpoint, Transport, http_contract, pubsub_contract,
};
use agentstatedeveloper_core::dataflow::DetectedDataFlow;
use agentstatedeveloper_core::error::{AsdError, Result};
use agentstatedeveloper_core::schema::{Effect, EffectCategory, SymbolKind};
use serde_json::json;
use tree_sitter::{Node, Parser};
#[derive(Debug, Default, Clone, Copy)]
pub struct PythonAdapter;
impl PythonAdapter {
pub fn new() -> Self {
Self
}
}
impl LanguageAdapter for PythonAdapter {
fn language(&self) -> &str {
"python"
}
fn file_extensions(&self) -> &'static [&'static str] {
&["py"]
}
fn parse_symbols(&self, file: &str, source: &str) -> Result<Vec<ParsedSymbol>> {
let mut parser = Parser::new();
parser
.set_language(&tree_sitter_python::LANGUAGE.into())
.map_err(|e| AsdError::Parse(format!("failed to set python language: {e}")))?;
let src_bytes = source.as_bytes();
let tree = parser
.parse(src_bytes, None)
.ok_or_else(|| AsdError::Parse(format!("failed to parse {file}")))?;
let module_prefix = module_qname_prefix(file);
let root = tree.root_node();
let mut out = Vec::new();
walk(root, src_bytes, &module_prefix, &[], &mut out);
Ok(out)
}
fn infer_effects(&self, _source: &str, symbol: &ParsedSymbol) -> Vec<Effect> {
infer_effects_from_body(&symbol.body)
}
fn infer_service_endpoints(
&self,
file: &str,
source: &str,
symbols: &[ParsedSymbol],
) -> Vec<DetectedEndpoint> {
infer_service_endpoints_in_python(file, source, symbols)
}
fn resolve_endpoint_prefixes(
&self,
files: &[(String, String)],
endpoints: &mut Vec<ServiceEndpoint>,
) {
resolve_python_endpoint_prefixes(files, endpoints);
}
fn extract_dataflow(
&self,
file: &str,
source: &str,
symbols: &[ParsedSymbol],
_workspace: &WorkspaceSymbols,
) -> Vec<DetectedDataFlow> {
infer_dataflow_in_python(file, source, symbols)
}
fn extract_call_edges(
&self,
file: &str,
source: &str,
symbols: &[ParsedSymbol],
workspace: &WorkspaceSymbols,
) -> Vec<CallEdge> {
extract_call_edges_impl(file, source, symbols, workspace)
}
fn scan_dynamic_dispatch(&self, file: &str, source: &str) -> Vec<DynamicDispatchHint> {
scan_dynamic_dispatch_in_python(file, source)
}
fn report_unresolved_calls(
&self,
file: &str,
source: &str,
symbols: &[ParsedSymbol],
workspace: &WorkspaceSymbols,
) -> Vec<UnresolvedCall> {
report_unresolved_calls_in_python(file, source, symbols, workspace)
}
}
pub fn report_unresolved_calls_in_python(
file: &str,
source: &str,
symbols: &[ParsedSymbol],
workspace: &WorkspaceSymbols,
) -> Vec<UnresolvedCall> {
use std::collections::HashSet;
let stdlib_allowlist: HashSet<&str> = [
"print",
"len",
"range",
"type",
"isinstance",
"super",
"hasattr",
"getattr",
"setattr",
"delattr",
"callable",
"iter",
"next",
"list",
"dict",
"set",
"tuple",
"str",
"int",
"float",
"bool",
"bytes",
"bytearray",
"frozenset",
"min",
"max",
"sum",
"abs",
"round",
"sorted",
"reversed",
"enumerate",
"zip",
"map",
"filter",
"any",
"all",
"open",
"repr",
"hash",
"id",
"vars",
"dir",
"format",
"input",
"ord",
"chr",
"hex",
"oct",
"bin",
"pow",
"divmod",
"compile",
"eval",
"exec",
]
.into_iter()
.collect();
let mut parser = Parser::new();
if parser
.set_language(&tree_sitter_python::LANGUAGE.into())
.is_err()
{
return Vec::new();
}
let module_prefix = module_qname_prefix(file);
let src_bytes = source.as_bytes();
let mut by_simple: HashMap<String, String> = HashMap::new();
let mut known: HashSet<&str> = HashSet::new();
for sym in symbols {
known.insert(sym.qname.as_str());
if let Some(simple) = sym.qname.rsplit_once('.').map(|(_, s)| s) {
by_simple.insert(simple.to_string(), sym.qname.clone());
}
}
let imports = parse_imports(source, &mut parser, &module_prefix);
let tree = match parser.parse(src_bytes, None) {
Some(t) => t,
None => return Vec::new(),
};
let mut line_starts: Vec<usize> = vec![0];
for (i, b) in src_bytes.iter().enumerate() {
if *b == b'\n' {
line_starts.push(i + 1);
}
}
let line_of = |off: usize| -> u32 {
match line_starts.binary_search(&off) {
Ok(i) => i as u32 + 1,
Err(i) => i.max(1) as u32,
}
};
let mut out = Vec::new();
walk_unresolved_calls(
tree.root_node(),
src_bytes,
&module_prefix,
&by_simple,
&known,
&imports,
workspace,
&stdlib_allowlist,
file,
&line_of,
&mut out,
);
out
}
#[allow(clippy::too_many_arguments)]
fn walk_unresolved_calls(
node: Node<'_>,
src: &[u8],
module_prefix: &str,
by_simple: &HashMap<String, String>,
known: &HashSet<&str>,
imports: &HashMap<String, ImportBinding>,
workspace: &WorkspaceSymbols,
stdlib_allowlist: &std::collections::HashSet<&str>,
file: &str,
line_of: &dyn Fn(usize) -> u32,
out: &mut Vec<UnresolvedCall>,
) {
if node.kind() == "call" {
if let Some(func) = node.child_by_field_name("function") {
let callee_text = node_text(func, src).unwrap_or_default();
let resolved = resolve_callee(
func,
src,
module_prefix,
by_simple,
known,
imports,
workspace,
None, );
if resolved.is_none() {
let head = callee_text.split('.').next().unwrap_or(&callee_text).trim();
if !stdlib_allowlist.contains(head) {
out.push(UnresolvedCall {
file: file.to_string(),
line: line_of(func.start_byte()),
callee_text: callee_text.trim().to_string(),
});
}
}
}
}
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
walk_unresolved_calls(
child,
src,
module_prefix,
by_simple,
known,
imports,
workspace,
stdlib_allowlist,
file,
line_of,
out,
);
}
}
fn strip_src_prefix(path: &str) -> &str {
let mut offset = 0usize;
for part in path.split('/') {
if part == "src" {
let after = offset + part.len() + 1;
if after < path.len() {
return &path[after..];
}
}
offset += part.len() + 1;
}
path
}
fn module_qname_prefix(file: &str) -> String {
let mut s = file;
if let Some(stripped) = s.strip_prefix("./") {
s = stripped;
}
let s = s.strip_suffix(".py").unwrap_or(s);
let after_src = strip_src_prefix(s);
if after_src == "__init__" {
if let Some(pkg_name) = package_name_from_path(s) {
return format!("{}.__init__", pkg_name.replace('-', "_"));
}
}
after_src.replace('\\', "/").replace('/', ".")
}
fn package_name_from_path(path: &str) -> Option<&str> {
let parts: Vec<&str> = path.split('/').collect();
for (i, &part) in parts.iter().enumerate() {
if part == "src" && i > 0 {
return Some(parts[i - 1]);
}
}
None
}
fn walk(
node: Node<'_>,
src: &[u8],
module_prefix: &str,
scope: &[(String, ScopeKind)],
out: &mut Vec<ParsedSymbol>,
) {
let kind = node.kind();
match kind {
"function_definition" => {
let name = node_field_text(node, "name", src).unwrap_or_else(|| "<anon>".to_string());
let qname = build_qname(module_prefix, scope, &name);
let symbol_kind = if scope.last().map(|s| s.1) == Some(ScopeKind::Class) {
SymbolKind::Method
} else {
SymbolKind::Function
};
let signature = extract_function_signature(node, src, &name);
out.push(make_parsed_symbol(node, src, qname, symbol_kind, signature));
let mut new_scope = scope.to_vec();
new_scope.push((name, ScopeKind::Function));
if let Some(body) = node.child_by_field_name("body") {
let mut cursor = body.walk();
for child in body.children(&mut cursor) {
walk(child, src, module_prefix, &new_scope, out);
}
}
}
"class_definition" => {
let name = node_field_text(node, "name", src).unwrap_or_else(|| "<anon>".to_string());
let qname = build_qname(module_prefix, scope, &name);
let signature = extract_class_signature(node, src, &name);
out.push(make_parsed_symbol(
node,
src,
qname,
SymbolKind::Class,
signature,
));
let mut new_scope = scope.to_vec();
new_scope.push((name, ScopeKind::Class));
if let Some(body) = node.child_by_field_name("body") {
let mut cursor = body.walk();
for child in body.children(&mut cursor) {
walk(child, src, module_prefix, &new_scope, out);
}
}
}
_ => {
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
walk(child, src, module_prefix, scope, out);
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ScopeKind {
Class,
Function,
}
fn build_qname(module_prefix: &str, scope: &[(String, ScopeKind)], name: &str) -> String {
let mut parts = Vec::with_capacity(scope.len() + 2);
if !module_prefix.is_empty() {
parts.push(module_prefix.to_string());
}
for (s, _) in scope {
parts.push(s.clone());
}
parts.push(name.to_string());
parts.join(".")
}
fn node_field_text(node: Node<'_>, field: &str, src: &[u8]) -> Option<String> {
node.child_by_field_name(field)
.and_then(|n| node_text(n, src))
}
fn node_text(node: Node<'_>, src: &[u8]) -> Option<String> {
src.get(node.byte_range())
.map(|b| String::from_utf8_lossy(b).into_owned())
}
fn make_parsed_symbol(
node: Node<'_>,
src: &[u8],
qname: String,
kind: SymbolKind,
signature: Option<String>,
) -> ParsedSymbol {
let start = node.start_position();
let end = node.end_position();
let body = node_text(node, src).unwrap_or_default();
let doc = extract_python_doc(&body);
ParsedSymbol {
qname,
kind,
start_line: (start.row as u32) + 1,
start_col: (start.column as u32) + 1,
end_line: (end.row as u32) + 1,
end_col: (end.column as u32) + 1,
body,
signature,
doc,
}
}
fn extract_python_doc(body: &str) -> Option<String> {
for quote in &[r#"""""#, "'''"] {
if let Some(start) = body.find(quote) {
let after = start + quote.len();
if let Some(end_rel) = body[after..].find(quote) {
let content = &body[after..after + end_rel];
let cleaned = content
.lines()
.map(|l| l.trim())
.filter(|l| !l.is_empty())
.collect::<Vec<_>>()
.join(" ");
if !cleaned.is_empty() {
return Some(truncate_doc(&cleaned));
}
}
}
}
None
}
fn extract_function_signature(node: Node<'_>, src: &[u8], name: &str) -> Option<String> {
let params = node.child_by_field_name("parameters")?;
let params_text = node_text(params, src)?;
Some(format!("def {name}{params_text}"))
}
fn extract_class_signature(node: Node<'_>, src: &[u8], name: &str) -> Option<String> {
if let Some(sup) = node.child_by_field_name("superclasses") {
if let Some(sup_text) = node_text(sup, src) {
return Some(format!("class {name}{sup_text}"));
}
}
Some(format!("class {name}"))
}
const HTTP_VERBS: &[&str] = &["get", "post", "put", "patch", "delete", "head", "options"];
fn infer_service_endpoints_in_python(
file: &str,
source: &str,
symbols: &[ParsedSymbol],
) -> Vec<DetectedEndpoint> {
let mut out = Vec::new();
let router_prefixes = collect_router_prefixes(source);
for (idx, raw) in source.lines().enumerate() {
let line_no = (idx + 1) as u32;
let line = raw.trim();
let receiver_prefix = route_receiver(line).and_then(|r| router_prefixes.get(&r).cloned());
for (method, path, conf) in parse_route_decorator(line) {
if let Some(owner) = owner_for_decorator(symbols, line_no) {
let full = match &receiver_prefix {
Some(pfx) => join_route(pfx, &path),
None => path.clone(),
};
out.push(DetectedEndpoint {
transport: Transport::Http,
direction: Direction::Inbound,
contract: http_contract(&method, &full),
owner_qname: owner.qname.clone(),
file: file.to_string(),
line: line_no,
confidence: conf,
note: None,
});
}
}
for (method, url, conf) in parse_client_calls(line) {
if let Some(owner) = owner_for_body_line(symbols, line_no) {
out.push(DetectedEndpoint {
transport: Transport::Http,
direction: Direction::Outbound,
contract: http_contract(&method, &url),
owner_qname: owner.qname.clone(),
file: file.to_string(),
line: line_no,
confidence: conf,
note: None,
});
}
}
if is_celery_task_decorator(line) {
if let Some(owner) = owner_for_decorator(symbols, line_no) {
let task = simple_name(&owner.qname);
out.push(DetectedEndpoint {
transport: Transport::PubSub,
direction: Direction::Inbound,
contract: pubsub_contract(task),
owner_qname: owner.qname.clone(),
file: file.to_string(),
line: line_no,
confidence: 0.85,
note: Some("celery task".into()),
});
}
}
for task in parse_celery_emits(line) {
if let Some(owner) = owner_for_body_line(symbols, line_no) {
out.push(DetectedEndpoint {
transport: Transport::PubSub,
direction: Direction::Outbound,
contract: pubsub_contract(&task),
owner_qname: owner.qname.clone(),
file: file.to_string(),
line: line_no,
confidence: 0.6,
note: Some("celery emit".into()),
});
}
}
}
out
}
fn simple_name(qname: &str) -> &str {
qname.rsplit('.').next().unwrap_or(qname)
}
fn is_celery_task_decorator(line: &str) -> bool {
let Some(body) = line.strip_prefix('@') else {
return false;
};
let head = body.split('(').next().unwrap_or(body).trim();
head == "shared_task" || head == "task" || head.ends_with(".task")
}
fn parse_celery_emits(line: &str) -> Vec<String> {
let mut out = Vec::new();
for suffix in [".delay(", ".apply_async("] {
let mut from = 0;
while let Some(rel) = line[from..].find(suffix) {
let pos = from + rel;
from = pos + suffix.len();
let name: String = line[..pos]
.chars()
.rev()
.take_while(|c| c.is_alphanumeric() || *c == '_')
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
if !name.is_empty() && !out.contains(&name) {
out.push(name);
}
}
}
out
}
fn owner_for_body_line(symbols: &[ParsedSymbol], line: u32) -> Option<&ParsedSymbol> {
symbols
.iter()
.filter(|s| s.start_line <= line && line <= s.end_line)
.max_by_key(|s| s.start_line)
}
fn owner_for_decorator(symbols: &[ParsedSymbol], line: u32) -> Option<&ParsedSymbol> {
if let Some(s) = owner_for_body_line(symbols, line) {
return Some(s);
}
symbols
.iter()
.filter(|s| s.start_line > line && s.start_line <= line + 6)
.min_by_key(|s| s.start_line)
}
fn parse_route_decorator(line: &str) -> Vec<(String, String, f64)> {
let Some(rest) = line.strip_prefix('@') else {
return Vec::new();
};
let Some(paren) = rest.find('(') else {
return Vec::new();
};
let callee = rest[..paren].trim();
let args = &rest[paren + 1..];
let Some(attr) = callee.rsplit('.').next() else {
return Vec::new();
};
if !callee.contains('.') {
return Vec::new();
}
let attr = attr.trim();
if HTTP_VERBS.contains(&attr) {
if let Some(path) = first_string_arg(args) {
return vec![(attr.to_uppercase(), path, 0.95)];
}
return Vec::new();
}
if attr == "route" {
let Some(path) = first_string_arg(args) else {
return Vec::new();
};
let methods = methods_kwarg(args);
let methods = if methods.is_empty() {
vec!["GET".to_string()]
} else {
methods
};
return methods
.into_iter()
.map(|m| (m, path.clone(), 0.9))
.collect();
}
Vec::new()
}
fn route_receiver(line: &str) -> Option<String> {
let rest = line.trim().strip_prefix('@')?;
let paren = rest.find('(')?;
let callee = rest[..paren].trim();
let dot = callee.rfind('.')?;
let recv = callee[..dot].trim();
if recv.is_empty() {
None
} else {
Some(recv.to_string())
}
}
fn collect_router_prefixes(source: &str) -> HashMap<String, String> {
let mut self_p: HashMap<String, String> = HashMap::new();
let mut mount_p: HashMap<String, String> = HashMap::new();
for raw in source.lines() {
let line = raw.trim();
if let Some(eq) = line.find('=') {
let var = line[..eq].trim();
if is_ident(var) {
let rhs = &line[eq + 1..];
if let Some(a) = ctor_args(rhs, "APIRouter") {
if let Some(p) = string_kwarg(a, "prefix") {
self_p.insert(var.to_string(), p);
}
} else if let Some(a) = ctor_args(rhs, "Blueprint") {
if let Some(p) = string_kwarg(a, "url_prefix") {
self_p.insert(var.to_string(), p);
}
}
}
}
if let Some(a) = ctor_args(line, "include_router") {
if let (Some(child), Some(q)) = (first_ident_arg(a), string_kwarg(a, "prefix")) {
mount_p.entry(child).or_insert(q);
}
}
if let Some(a) = ctor_args(line, "register_blueprint") {
if let (Some(child), Some(q)) = (first_ident_arg(a), string_kwarg(a, "url_prefix")) {
mount_p.entry(child).or_insert(q);
}
}
}
let keys: HashSet<String> = self_p.keys().chain(mount_p.keys()).cloned().collect();
let mut out = HashMap::new();
for k in keys {
let full = normalize_prefix(&format!(
"{}{}",
mount_p.get(&k).map(String::as_str).unwrap_or(""),
self_p.get(&k).map(String::as_str).unwrap_or("")
));
if !full.is_empty() {
out.insert(k, full);
}
}
out
}
fn ctor_args<'a>(s: &'a str, name: &str) -> Option<&'a str> {
let mut from = 0;
while let Some(rel) = s[from..].find(name) {
let pos = from + rel;
from = pos + name.len();
let before_ok = pos == 0
|| !s[..pos]
.chars()
.next_back()
.map(|c| c.is_alphanumeric() || c == '_')
.unwrap_or(false);
let after = s[pos + name.len()..].trim_start();
if before_ok {
if let Some(args) = after.strip_prefix('(') {
return Some(args);
}
}
}
None
}
fn string_kwarg(args: &str, key: &str) -> Option<String> {
let mut from = 0;
while let Some(rel) = args[from..].find(key) {
let pos = from + rel;
from = pos + key.len();
let before_ok = pos == 0
|| !args[..pos]
.chars()
.next_back()
.map(|c| c.is_alphanumeric() || c == '_')
.unwrap_or(false);
if !before_ok {
continue;
}
let after = args[pos + key.len()..].trim_start();
if let Some(after) = after.strip_prefix('=') {
if let Some(s) = read_string_literal(after.trim_start()) {
return Some(s);
}
}
}
None
}
fn first_ident_arg(args: &str) -> Option<String> {
let s = args.trim_start();
let end = s
.find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '.'))
.unwrap_or(s.len());
let id = &s[..end];
if id.is_empty() || id.starts_with('.') {
None
} else {
Some(id.to_string())
}
}
fn is_ident(s: &str) -> bool {
!s.is_empty() && s.chars().all(|c| c.is_alphanumeric() || c == '_')
}
fn normalize_prefix(p: &str) -> String {
let p = p.trim();
if p.is_empty() {
return String::new();
}
let mut s = p.to_string();
while s.contains("//") {
s = s.replace("//", "/");
}
if !s.starts_with('/') {
s.insert(0, '/');
}
while s.len() > 1 && s.ends_with('/') {
s.pop();
}
s
}
fn join_route(prefix: &str, path: &str) -> String {
if prefix.is_empty() {
return path.to_string();
}
let p = prefix.trim_end_matches('/');
if path.is_empty() || path == "/" {
return p.to_string();
}
if path.starts_with('/') {
format!("{p}{path}")
} else {
format!("{p}/{path}")
}
}
fn resolve_python_endpoint_prefixes(
files: &[(String, String)],
endpoints: &mut Vec<ServiceEndpoint>,
) {
let module_by_path: HashMap<&str, String> = files
.iter()
.map(|(p, _)| (p.as_str(), module_qname_prefix(p)))
.collect();
let imports_by_path: HashMap<&str, HashMap<String, String>> = files
.iter()
.map(|(p, s)| {
(
p.as_str(),
file_import_qnames(s, &module_by_path[p.as_str()]),
)
})
.collect();
let mut self_by_q: HashMap<String, String> = HashMap::new();
let mut parent_of: HashMap<String, Vec<(String, String)>> = HashMap::new(); let mut app_qs: HashSet<String> = HashSet::new();
let mut known: HashSet<String> = HashSet::new();
for (path, source) in files {
let module = &module_by_path[path.as_str()];
let imports = &imports_by_path[path.as_str()];
for raw in source.lines() {
let line = raw.trim();
if let Some(eq) = line.find('=') {
let var = line[..eq].trim();
if is_ident(var) {
let rhs = &line[eq + 1..];
let q = qualify(module, var); if let Some(a) = ctor_args(rhs, "APIRouter") {
if let Some(p) = string_kwarg(a, "prefix") {
self_by_q.insert(q.clone(), normalize_prefix(&p));
}
known.insert(q);
} else if let Some(a) = ctor_args(rhs, "Blueprint") {
if let Some(p) = string_kwarg(a, "url_prefix") {
self_by_q.insert(q.clone(), normalize_prefix(&p));
}
known.insert(q);
} else if ctor_args(rhs, "FastAPI").is_some()
|| ctor_args(rhs, "Flask").is_some()
{
app_qs.insert(q.clone());
known.insert(q);
}
}
}
for (fname, pkey) in [
("include_router", "prefix"),
("register_blueprint", "url_prefix"),
] {
if let Some(a) = ctor_args(line, fname) {
if let (Some(child), Some(parent)) =
(first_ident_arg(a), mount_receiver(line, fname))
{
let m = string_kwarg(a, pkey)
.map(|s| normalize_prefix(&s))
.unwrap_or_default();
let child_q = resolve_name(&child, module, imports);
let parent_q = resolve_name(&parent, module, imports);
known.insert(child_q.clone());
known.insert(parent_q.clone());
let mounts = parent_of.entry(child_q).or_default();
let edge = (parent_q, m);
if !mounts.contains(&edge) {
mounts.push(edge);
}
}
}
}
}
}
let mut cache: HashMap<String, Option<Vec<String>>> = HashMap::new();
let mut local: HashMap<String, HashMap<String, String>> = HashMap::new();
let src_by_path: HashMap<&str, &str> = files
.iter()
.map(|(p, s)| (p.as_str(), s.as_str()))
.collect();
let mut additions: Vec<ServiceEndpoint> = Vec::new();
for ep in endpoints.iter_mut() {
if ep.transport != Transport::Http || ep.direction != Direction::Inbound {
continue;
}
let Some(src) = src_by_path.get(ep.file.as_str()).copied() else {
continue;
};
let Some(module) = module_by_path.get(ep.file.as_str()) else {
continue;
};
let imports = &imports_by_path[ep.file.as_str()];
let Some(line) = src.lines().nth(ep.line.saturating_sub(1) as usize) else {
continue;
};
let line = line.trim();
let Some(recv) = route_receiver(line) else {
continue;
};
let decos = parse_route_decorator(line);
let Some((_, raw_path, _)) = decos.first() else {
continue;
};
let method = ep
.contract
.strip_prefix("http:")
.and_then(|s| s.split(' ').next())
.unwrap_or("GET")
.to_string();
let recv_q = resolve_name(&recv, module, imports);
let prefixes: Vec<String> = if known.contains(&recv_q) {
resolve_full_q(&recv_q, &self_by_q, &parent_of, &app_qs, &mut cache)
} else {
vec![
local
.entry(ep.file.clone())
.or_insert_with(|| collect_router_prefixes(src))
.get(&recv)
.cloned()
.unwrap_or_default(),
]
};
let mut paths: Vec<String> = prefixes.iter().map(|p| join_route(p, raw_path)).collect();
paths.sort();
paths.dedup();
if paths.is_empty() {
paths.push(raw_path.clone());
}
ep.contract = http_contract(&method, &paths[0]);
for extra in paths.iter().skip(1) {
let mut dup = ep.clone();
dup.contract = http_contract(&method, extra);
additions.push(dup);
}
}
endpoints.extend(additions);
}
fn qualify(module: &str, name: &str) -> String {
if module.is_empty() {
name.to_string()
} else {
format!("{module}.{name}")
}
}
fn resolve_name(name: &str, module: &str, imports: &HashMap<String, String>) -> String {
imports
.get(name)
.cloned()
.unwrap_or_else(|| qualify(module, name))
}
fn file_import_qnames(source: &str, module: &str) -> HashMap<String, String> {
let mut out = HashMap::new();
for raw in source.lines() {
let line = raw.trim();
let Some(rest) = line.strip_prefix("from ") else {
continue;
};
let Some(imp) = rest.find(" import ") else {
continue;
};
let module_raw = rest[..imp].trim();
let resolved = if module_raw.starts_with('.') {
match resolve_relative_import(module, module_raw) {
Some(m) => m,
None => continue,
}
} else {
module_raw.to_string()
};
let names = rest[imp + " import ".len()..]
.trim()
.trim_start_matches('(')
.trim_end_matches(')')
.trim_end_matches('\\')
.trim();
for part in names.split(',') {
let part = part.trim();
if part.is_empty() || part == "*" {
continue;
}
let (orig, local) = match part.split_once(" as ") {
Some((o, l)) => (o.trim(), l.trim()),
None => (part, part),
};
if is_ident(orig) && is_ident(local) {
out.insert(local.to_string(), qualify(&resolved, orig));
}
}
}
out
}
fn resolve_full_q(
q: &str,
self_by_q: &HashMap<String, String>,
parent_of: &HashMap<String, Vec<(String, String)>>,
app_qs: &HashSet<String>,
cache: &mut HashMap<String, Option<Vec<String>>>,
) -> Vec<String> {
if let Some(c) = cache.get(q) {
return c.clone().unwrap_or_default(); }
cache.insert(q.to_string(), None); let self_p = self_by_q.get(q).cloned().unwrap_or_default();
let mut out: Vec<String> = if app_qs.contains(q) {
vec![self_p.clone()]
} else if let Some(parents) = parent_of.get(q) {
let mut acc = Vec::new();
for (parent, mount) in parents {
let mut pfulls = resolve_full_q(parent, self_by_q, parent_of, app_qs, cache);
if pfulls.is_empty() {
pfulls.push(String::new());
}
for pf in pfulls {
acc.push(normalize_prefix(&format!("{pf}{mount}{self_p}")));
}
}
if acc.is_empty() {
vec![self_p.clone()]
} else {
acc
}
} else {
vec![self_p.clone()]
};
out.sort();
out.dedup();
cache.insert(q.to_string(), Some(out.clone()));
out
}
fn mount_receiver(line: &str, fname: &str) -> Option<String> {
let pos = line.find(fname)?;
let before = line[..pos].trim_end().strip_suffix('.')?;
let start = before
.rfind(|c: char| !(c.is_alphanumeric() || c == '_'))
.map(|i| i + 1)
.unwrap_or(0);
let recv = &before[start..];
if recv.is_empty() {
None
} else {
Some(recv.to_string())
}
}
fn parse_client_calls(line: &str) -> Vec<(String, String, f64)> {
let mut out = Vec::new();
for module in ["requests.", "httpx."] {
let mut search_from = 0usize;
while let Some(rel) = line[search_from..].find(module) {
let start = search_from + rel;
let preceded_ok = start == 0
|| !line[..start]
.chars()
.next_back()
.map(|c| c.is_alphanumeric() || c == '_' || c == '.')
.unwrap_or(false);
let after = &line[start + module.len()..];
search_from = start + module.len();
if !preceded_ok {
continue;
}
let Some(paren) = after.find('(') else {
continue;
};
let attr = after[..paren].trim();
let args = &after[paren + 1..];
if HTTP_VERBS.contains(&attr) {
if let Some(url) = first_string_arg(args) {
out.push((attr.to_uppercase(), url, 0.9));
}
} else if attr == "request" {
let parts = first_two_string_args(args);
if let (Some(method), Some(url)) = parts {
out.push((method.to_uppercase(), url, 0.9));
}
}
}
}
out
}
fn first_string_arg(args: &str) -> Option<String> {
let s = args.trim_start();
let s = match s.find('=') {
Some(eq)
if s[..eq].chars().all(|c| c.is_alphanumeric() || c == '_')
&& !s[..eq].is_empty()
&& s.as_bytes().get(eq + 1) != Some(&b'=') =>
{
s[eq + 1..].trim_start()
}
_ => s,
};
read_string_literal(s)
}
fn first_two_string_args(args: &str) -> (Option<String>, Option<String>) {
let first = read_string_literal(args.trim_start());
let Some(comma) = args.find(',') else {
return (first, None);
};
let second = first_string_arg(&args[comma + 1..]);
(first, second)
}
fn read_string_literal(s: &str) -> Option<String> {
let bytes = s.as_bytes();
let mut i = 0;
if let Some(&c) = bytes.first() {
match c {
b'r' | b'R' => i = 1,
b'f' | b'F' | b'b' | b'B' => return None,
_ => {}
}
}
let quote = *bytes.get(i)?;
if quote != b'"' && quote != b'\'' {
return None;
}
let inner = &s[i + 1..];
let end = inner.find(quote as char)?;
Some(inner[..end].to_string())
}
fn methods_kwarg(args: &str) -> Vec<String> {
let Some(pos) = args.find("methods") else {
return Vec::new();
};
let after = &args[pos + "methods".len()..];
let after = after.trim_start();
let Some(after) = after.strip_prefix('=') else {
return Vec::new();
};
let after = after.trim_start();
let open = after.chars().next();
let close = match open {
Some('[') => ']',
Some('(') => ')',
_ => return Vec::new(),
};
let Some(end) = after.find(close) else {
return Vec::new();
};
let list = &after[1..end];
let mut out = Vec::new();
let mut rest = list;
while let Some(m) = read_string_literal(rest.trim_start()) {
out.push(m.to_uppercase());
match rest.find(',') {
Some(c) => rest = &rest[c + 1..],
None => break,
}
}
out
}
fn infer_dataflow_in_python(
file: &str,
source: &str,
symbols: &[ParsedSymbol],
) -> Vec<DetectedDataFlow> {
use std::collections::HashMap;
let mut name_to_qname: HashMap<&str, Option<String>> = HashMap::new();
for s in symbols {
if s.kind == SymbolKind::Function {
let last = s.qname.rsplit('.').next().unwrap_or(s.qname.as_str());
name_to_qname
.entry(last)
.and_modify(|e| *e = None)
.or_insert_with(|| Some(s.qname.clone()));
}
}
let mut out = Vec::new();
for (idx, raw) in source.lines().enumerate() {
let line_no = (idx + 1) as u32;
for (callee_name, args) in simple_calls(raw) {
let Some(Some(callee_qname)) = name_to_qname.get(callee_name.as_str()) else {
continue;
};
let Some(owner) = owner_for_body_line(symbols, line_no) else {
continue;
};
for (i, arg) in args.iter().enumerate() {
out.push(DetectedDataFlow {
caller_qname: owner.qname.clone(),
callee_qname: callee_qname.clone(),
arg_index: i,
arg: arg.clone(),
file: file.to_string(),
line: line_no,
confidence: 0.7,
});
}
}
}
out
}
fn simple_calls(line: &str) -> Vec<(String, Vec<String>)> {
let t = line.trim_start();
if t.starts_with("def ")
|| t.starts_with("async def ")
|| t.starts_with("class ")
|| t.starts_with('@')
|| t.starts_with('#')
{
return Vec::new();
}
let chars: Vec<char> = line.chars().collect();
let mut out = Vec::new();
let mut i = 0;
while i < chars.len() {
if chars[i] == '(' {
let mut start = i;
while start > 0 && (chars[start - 1].is_alphanumeric() || chars[start - 1] == '_') {
start -= 1;
}
let is_method = start > 0 && chars[start - 1] == '.';
let name: String = chars[start..i].iter().collect();
let valid = name
.chars()
.next()
.is_some_and(|c| c.is_alphabetic() || c == '_');
if !is_method && valid {
if let Some(close) = matching_close(&chars, i) {
let inner: String = chars[i + 1..close].iter().collect();
if let Some(args) = simple_args(&inner) {
out.push((name, args));
}
i = close;
}
}
}
i += 1;
}
out
}
fn matching_close(chars: &[char], open: usize) -> Option<usize> {
let mut depth = 0i32;
let mut i = open;
while i < chars.len() {
match chars[i] {
'(' => depth += 1,
')' => {
depth -= 1;
if depth == 0 {
return Some(i);
}
}
_ => {}
}
i += 1;
}
None
}
fn simple_args(inner: &str) -> Option<Vec<String>> {
let inner = inner.trim();
if inner.is_empty() {
return None;
}
let mut args = Vec::new();
for part in inner.split(',') {
let p = part.trim();
let is_ident = p
.chars()
.next()
.is_some_and(|c| c.is_alphabetic() || c == '_')
&& p.chars().all(|c| c.is_alphanumeric() || c == '_');
if !is_ident || matches!(p, "True" | "False" | "None" | "self" | "cls") {
return None;
}
args.push(p.to_string());
}
Some(args)
}
fn infer_effects_from_body(body: &str) -> Vec<Effect> {
let mut effects: Vec<Effect> = Vec::new();
let masked = mask_comments_and_literals(body);
let scan = masked.as_str();
for call_site in find_calls(scan, "open(") {
let args = &body[call_site.args_start..call_site.args_end];
effects.push(Effect {
effect: EffectCategory::IoFsRead,
qualifiers: extract_open_path(args)
.map(|p| json!({ "paths": [p] }))
.unwrap_or(serde_json::Value::Null),
note: Some(trim_note(
&body[call_site.call_start..call_site.args_end + 1],
)),
..Default::default()
});
if mentions_write_mode(args) {
effects.push(Effect {
effect: EffectCategory::IoFsWrite,
qualifiers: extract_open_path(args)
.map(|p| json!({ "paths": [p] }))
.unwrap_or(serde_json::Value::Null),
note: Some(trim_note(
&body[call_site.call_start..call_site.args_end + 1],
)),
..Default::default()
});
}
}
let log_needles = [
"print(",
"sys.stdout",
"sys.stderr",
"logging.debug",
"logging.info",
"logging.warning",
"logging.warn",
"logging.error",
"logging.critical",
"logging.exception",
"log.debug",
"log.info",
"log.warning",
"log.warn",
"log.error",
"log.critical",
"log.exception",
"logger.debug",
"logger.info",
"logger.warning",
"logger.warn",
"logger.error",
"logger.critical",
"logger.exception",
];
if log_needles.iter().any(|n| scan.contains(n)) {
let note = first_matching_line(scan, body, &log_needles);
effects.push(Effect {
effect: EffectCategory::Log,
qualifiers: serde_json::Value::Null,
note,
..Default::default()
});
}
let db_receivers = [
"db.execute(",
"conn.execute(",
"cursor.execute(",
"cur.execute(",
"session.execute(",
"c.execute(",
"self.db.execute(",
"self.conn.execute(",
];
let mut seen_db_read = false;
let mut seen_db_write = false;
for recv in db_receivers {
for call_site in find_calls(scan, recv) {
let args = &body[call_site.args_start..call_site.args_end];
let sql = args.trim_start_matches(|c: char| {
c.is_whitespace() || c == '"' || c == '\'' || c == 'f' || c == 'r' || c == 'b'
});
let upper: String = sql.chars().take(16).collect::<String>().to_uppercase();
let is_write = upper.starts_with("INSERT")
|| upper.starts_with("UPDATE")
|| upper.starts_with("DELETE")
|| upper.starts_with("REPLACE")
|| upper.starts_with("CREATE")
|| upper.starts_with("DROP")
|| upper.starts_with("ALTER")
|| upper.starts_with("TRUNCATE");
let is_read = upper.starts_with("SELECT")
|| upper.starts_with("WITH")
|| upper.starts_with("SHOW");
let note = Some(trim_note(
&body[call_site.call_start..call_site.args_end + 1],
));
if is_write && !seen_db_write {
effects.push(Effect {
effect: EffectCategory::IoDbWrite,
qualifiers: serde_json::Value::Null,
note,
..Default::default()
});
seen_db_write = true;
} else if is_read && !seen_db_read {
effects.push(Effect {
effect: EffectCategory::IoDbRead,
qualifiers: serde_json::Value::Null,
note,
..Default::default()
});
seen_db_read = true;
} else if !is_write && !is_read && !seen_db_read && !seen_db_write {
effects.push(Effect {
effect: EffectCategory::IoDbRead,
qualifiers: serde_json::Value::Null,
note: note.clone(),
..Default::default()
});
effects.push(Effect {
effect: EffectCategory::IoDbWrite,
qualifiers: serde_json::Value::Null,
note,
..Default::default()
});
seen_db_read = true;
seen_db_write = true;
}
}
}
if !seen_db_write && (scan.contains(".commit()") || scan.contains("conn.commit")) {
effects.push(Effect {
effect: EffectCategory::IoDbWrite,
qualifiers: serde_json::Value::Null,
note: first_matching_line(scan, body, &[".commit()", "conn.commit"]),
..Default::default()
});
}
let net_prefixes = ["requests.", "urllib.", "httpx.", "aiohttp."];
let mut net_hosts: Vec<String> = Vec::new();
let mut net_note: Option<String> = None;
for prefix in net_prefixes {
for call_site in find_prefixed_calls(scan, prefix) {
let args = &body[call_site.args_start..call_site.args_end];
if let Some(host) = extract_url_host(args) {
if !net_hosts.contains(&host) {
net_hosts.push(host);
}
}
if net_note.is_none() {
net_note = Some(trim_note(
&body[call_site.call_start..call_site.args_end + 1],
));
}
}
}
if net_note.is_some() {
let qualifiers = if net_hosts.is_empty() {
serde_json::Value::Null
} else {
json!({ "hosts": net_hosts })
};
effects.push(Effect {
effect: EffectCategory::IoNetOut,
qualifiers,
note: net_note,
..Default::default()
});
}
let proc_patterns = ["subprocess.", "os.system(", "os.exec"];
if let Some(note) = first_match_note(scan, body, &proc_patterns) {
effects.push(Effect {
effect: EffectCategory::ProcSpawn,
qualifiers: serde_json::Value::Null,
note: Some(note),
..Default::default()
});
}
let env_patterns = ["os.environ", "os.getenv"];
if let Some(note) = first_match_note(scan, body, &env_patterns) {
let mut vars: Vec<String> = Vec::new();
for call_site in find_calls(scan, "os.getenv(") {
let args = &body[call_site.args_start..call_site.args_end];
if let Some(v) = extract_first_string_literal(args) {
if !vars.contains(&v) {
vars.push(v);
}
}
}
let qualifiers = if vars.is_empty() {
serde_json::Value::Null
} else {
json!({ "vars": vars })
};
effects.push(Effect {
effect: EffectCategory::EnvRead,
qualifiers,
note: Some(note),
..Default::default()
});
}
if let Some(note) = first_match_note(scan, body, &["time.sleep"]) {
effects.push(Effect {
effect: EffectCategory::TimeSleep,
qualifiers: serde_json::Value::Null,
note: Some(note),
..Default::default()
});
}
let time_read_patterns = [
"time.time",
"time.monotonic",
"time.perf_counter",
"datetime.now",
"datetime.utcnow",
"datetime.today",
];
if let Some(note) = first_match_note(scan, body, &time_read_patterns) {
effects.push(Effect {
effect: EffectCategory::TimeRead,
qualifiers: serde_json::Value::Null,
note: Some(note),
..Default::default()
});
}
let random_patterns = ["random.", "secrets.", "os.urandom("];
if let Some(note) = first_match_note(scan, body, &random_patterns) {
effects.push(Effect {
effect: EffectCategory::Random,
qualifiers: serde_json::Value::Null,
note: Some(note),
..Default::default()
});
}
if has_raise_statement(scan) {
let note = first_matching_line(scan, body, &["raise "]);
effects.push(Effect {
effect: EffectCategory::Throw,
qualifiers: serde_json::Value::Null,
note,
..Default::default()
});
}
effects
}
#[derive(Debug, Clone, Copy)]
struct CallSite {
call_start: usize,
args_start: usize,
args_end: usize,
}
fn find_calls(body: &str, needle: &str) -> Vec<CallSite> {
let mut out = Vec::new();
if !needle.ends_with('(') {
return out;
}
let bytes = body.as_bytes();
let mut search_from = 0;
while let Some(idx) = body[search_from..].find(needle) {
let abs = search_from + idx;
let args_start = abs + needle.len();
if let Some(args_end) = find_matching_paren(bytes, args_start) {
out.push(CallSite {
call_start: abs,
args_start,
args_end,
});
search_from = args_end + 1;
} else {
search_from = args_start;
}
}
out
}
#[allow(dead_code)]
fn contains_call(body: &str, needle: &str) -> bool {
!find_calls(body, needle).is_empty()
}
fn find_prefixed_calls(body: &str, prefix: &str) -> Vec<CallSite> {
let mut out = Vec::new();
let bytes = body.as_bytes();
let mut from = 0;
while let Some(rel) = body[from..].find(prefix) {
let abs = from + rel;
let after = abs + prefix.len();
from = after;
let rest = &body[after..];
let name_len = rest
.find(|c: char| !(c.is_alphanumeric() || c == '_'))
.unwrap_or(rest.len());
if name_len == 0 || rest.as_bytes().get(name_len) != Some(&b'(') {
continue;
}
let args_start = after + name_len + 1;
if let Some(args_end) = find_matching_paren(bytes, args_start) {
out.push(CallSite {
call_start: abs,
args_start,
args_end,
});
from = args_end + 1;
}
}
out
}
fn find_matching_paren(bytes: &[u8], start: usize) -> Option<usize> {
let mut depth: i32 = 1;
let mut i = start;
let mut in_string: Option<u8> = None;
while i < bytes.len() {
let c = bytes[i];
if let Some(q) = in_string {
if c == b'\\' {
i += 2;
continue;
}
if c == q {
in_string = None;
}
} else {
match c {
b'"' | b'\'' => in_string = Some(c),
b'(' => depth += 1,
b')' => {
depth -= 1;
if depth == 0 {
return Some(i);
}
}
_ => {}
}
}
i += 1;
}
None
}
fn mentions_write_mode(args: &str) -> bool {
let bytes = args.as_bytes();
let mut i = 0;
while i < bytes.len() {
let c = bytes[i];
if c == b'"' || c == b'\'' {
let q = c;
let start = i + 1;
let mut j = start;
while j < bytes.len() && bytes[j] != q {
if bytes[j] == b'\\' {
j += 2;
continue;
}
j += 1;
}
if j <= bytes.len() {
let lit = &args[start..j.min(bytes.len())];
if lit.len() <= 4 && (lit.contains('w') || lit.contains('a') || lit.contains('x')) {
return true;
}
i = j + 1;
continue;
}
}
i += 1;
}
false
}
fn extract_open_path(args: &str) -> Option<String> {
extract_first_string_literal(args)
}
fn extract_first_string_literal(args: &str) -> Option<String> {
let trimmed = args.trim_start();
let bytes = trimmed.as_bytes();
if bytes.is_empty() {
return None;
}
let q = bytes[0];
if q != b'"' && q != b'\'' {
return None;
}
let mut j = 1;
let mut out = String::new();
while j < bytes.len() {
let c = bytes[j];
if c == b'\\' && j + 1 < bytes.len() {
out.push(bytes[j + 1] as char);
j += 2;
continue;
}
if c == q {
return Some(out);
}
out.push(c as char);
j += 1;
}
None
}
fn extract_url_host(args: &str) -> Option<String> {
let lower = args;
for scheme in ["https://", "http://"] {
if let Some(idx) = lower.find(scheme) {
let tail = &lower[idx + scheme.len()..];
let end = tail
.find(|c: char| {
c == '/' || c == '"' || c == '\'' || c == ')' || c == '?' || c.is_whitespace()
})
.unwrap_or(tail.len());
let host = &tail[..end];
if !host.is_empty() {
return Some(host.to_string());
}
}
}
None
}
fn truncate_doc(s: &str) -> String {
const MAX: usize = 512;
if s.len() <= MAX {
return s.to_string();
}
let truncated = &s[..MAX];
match truncated.rfind(' ') {
Some(pos) => format!("{}…", &truncated[..pos]),
None => format!("{}…", truncated),
}
}
fn trim_note(s: &str) -> String {
let first_line = s.lines().next().unwrap_or("").trim();
if first_line.len() > 120 {
format!("{}...", &first_line[..120])
} else {
first_line.to_string()
}
}
fn first_matching_line(scan: &str, body: &str, needles: &[&str]) -> Option<String> {
for (i, scan_line) in scan.lines().enumerate() {
for n in needles {
if scan_line.contains(n) {
return body.lines().nth(i).map(|l| l.trim().to_string());
}
}
}
None
}
fn first_match_note(scan: &str, body: &str, needles: &[&str]) -> Option<String> {
for n in needles {
if scan.contains(n) {
return first_matching_line(scan, body, needles);
}
}
None
}
fn has_raise_statement(scan: &str) -> bool {
for line in scan.lines() {
let trimmed = line.trim_start();
if trimmed.starts_with("raise ") || trimmed == "raise" {
return true;
}
}
false
}
pub fn scan_dynamic_dispatch_in_python(file: &str, source: &str) -> Vec<DynamicDispatchHint> {
let masked = mask_comments_and_literals(source);
let mut hints = Vec::new();
let bytes = masked.as_bytes();
let body = source.as_bytes();
let mut line_starts: Vec<usize> = vec![0];
for (i, b) in body.iter().enumerate() {
if *b == b'\n' {
line_starts.push(i + 1);
}
}
let line_of = |off: usize| -> u32 {
match line_starts.binary_search(&off) {
Ok(i) => i as u32 + 1,
Err(i) => i.max(1) as u32,
}
};
let snippet_at = |off: usize| -> String {
let line_idx = line_of(off).saturating_sub(1) as usize;
let start = line_starts.get(line_idx).copied().unwrap_or(0);
let end = line_starts
.get(line_idx + 1)
.copied()
.unwrap_or(body.len())
.saturating_sub(1);
std::str::from_utf8(&body[start..end])
.map(|s| s.trim().to_string())
.unwrap_or_default()
};
for site in find_calls(&masked, "getattr(") {
let mut j = site.args_end + 1;
while j < bytes.len() && (bytes[j] == b' ' || bytes[j] == b'\t') {
j += 1;
}
if j < bytes.len() && bytes[j] == b'(' {
hints.push(DynamicDispatchHint {
file: file.to_string(),
line: line_of(site.call_start),
pattern: "getattr".into(),
snippet: snippet_at(site.call_start),
});
}
}
for (i, line) in masked.lines().enumerate() {
let trimmed = line.trim_start();
if trimmed.starts_with("def __getattr__(") || trimmed.starts_with("def __getattribute__(") {
let off = line_starts.get(i).copied().unwrap_or(0);
hints.push(DynamicDispatchHint {
file: file.to_string(),
line: (i + 1) as u32,
pattern: "__getattr__".into(),
snippet: snippet_at(off),
});
}
}
hints
}
fn mask_comments_and_literals(body: &str) -> String {
let bytes = body.as_bytes();
let mut out: Vec<u8> = bytes.to_vec();
let n = bytes.len();
let mut i = 0;
while i < n {
let c = bytes[i];
if c == b'#' {
let mut j = i;
while j < n && bytes[j] != b'\n' {
out[j] = b' ';
j += 1;
}
i = j;
continue;
}
let quote_start = find_quote_after_optional_prefix(bytes, i);
if let Some(qi) = quote_start {
let q = bytes[qi];
let triple = qi + 2 < n && bytes[qi + 1] == q && bytes[qi + 2] == q;
let (literal_start, end_pos) = if triple {
let inner_start = qi + 3;
let end = find_triple_close(bytes, inner_start, q);
(inner_start, end)
} else {
let inner_start = qi + 1;
let end = find_single_close(bytes, inner_start, q);
(inner_start, end)
};
for k in literal_start..end_pos.min(n) {
if out[k] != b'\n' {
out[k] = b' ';
}
}
i = end_pos + if triple { 3 } else { 1 };
continue;
}
i += 1;
}
debug_assert_eq!(out.len(), bytes.len());
String::from_utf8(out).unwrap_or_else(|_| body.to_string())
}
fn find_quote_after_optional_prefix(bytes: &[u8], i: usize) -> Option<usize> {
let n = bytes.len();
if i >= n {
return None;
}
let c = bytes[i];
if c == b'"' || c == b'\'' {
return Some(i);
}
let is_prefix = |b: u8| matches!(b, b'r' | b'R' | b'b' | b'B' | b'f' | b'F');
if !is_prefix(c) {
return None;
}
if i > 0 {
let prev = bytes[i - 1];
if prev.is_ascii_alphanumeric() || prev == b'_' {
return None;
}
}
if i + 1 < n && (bytes[i + 1] == b'"' || bytes[i + 1] == b'\'') {
return Some(i + 1);
}
if i + 2 < n && is_prefix(bytes[i + 1]) && (bytes[i + 2] == b'"' || bytes[i + 2] == b'\'') {
return Some(i + 2);
}
None
}
fn find_single_close(bytes: &[u8], start: usize, q: u8) -> usize {
let n = bytes.len();
let mut j = start;
while j < n {
let b = bytes[j];
if b == b'\\' {
j += 2;
continue;
}
if b == b'\n' {
return j; }
if b == q {
return j;
}
j += 1;
}
n
}
fn find_triple_close(bytes: &[u8], start: usize, q: u8) -> usize {
let n = bytes.len();
let mut j = start;
while j + 2 < n {
if bytes[j] == b'\\' {
j += 2;
continue;
}
if bytes[j] == q && bytes[j + 1] == q && bytes[j + 2] == q {
return j;
}
j += 1;
}
n
}
fn extract_call_edges_impl(
file: &str,
source: &str,
symbols: &[ParsedSymbol],
workspace: &WorkspaceSymbols,
) -> Vec<CallEdge> {
let module_prefix = module_qname_prefix(file);
let known: HashSet<&str> = symbols.iter().map(|s| s.qname.as_str()).collect();
let mut by_simple: HashMap<String, String> = HashMap::new();
for s in symbols {
let simple = s.qname.rsplit('.').next().unwrap_or(&s.qname).to_string();
let module_level = if module_prefix.is_empty() {
s.qname == simple
} else {
s.qname == format!("{}.{}", module_prefix, simple)
};
if module_level {
by_simple.insert(simple, s.qname.clone());
} else {
by_simple.entry(simple).or_insert_with(|| s.qname.clone());
}
}
let mut parser = Parser::new();
if parser
.set_language(&tree_sitter_python::LANGUAGE.into())
.is_err()
{
return Vec::new();
}
let imports = parse_imports(source, &mut parser, &module_prefix);
let mut edges: HashSet<CallEdge> = HashSet::new();
for sym in symbols {
if !matches!(sym.kind, SymbolKind::Function | SymbolKind::Method) {
continue;
}
let src_bytes = sym.body.as_bytes();
let tree = match parser.parse(src_bytes, None) {
Some(t) => t,
None => continue,
};
let enclosing_class = enclosing_class_qname(&sym.qname, &module_prefix, &known);
collect_calls(
tree.root_node(),
src_bytes,
sym,
&module_prefix,
&by_simple,
&known,
&imports,
workspace,
enclosing_class.as_deref(),
&mut edges,
);
}
let mut out: Vec<CallEdge> = edges.into_iter().collect();
out.sort_by(|a, b| {
a.caller_qname
.cmp(&b.caller_qname)
.then_with(|| a.callee_qname.cmp(&b.callee_qname))
});
out
}
#[derive(Debug, Clone)]
struct ImportBinding {
qname_prefix: String,
#[allow(dead_code)]
is_module: bool,
}
fn parse_imports(
source: &str,
parser: &mut Parser,
module_prefix: &str,
) -> HashMap<String, ImportBinding> {
let mut out: HashMap<String, ImportBinding> = HashMap::new();
let src_bytes = source.as_bytes();
let tree = match parser.parse(src_bytes, None) {
Some(t) => t,
None => return out,
};
walk_imports(tree.root_node(), src_bytes, module_prefix, &mut out);
out
}
fn walk_imports(
node: Node<'_>,
src: &[u8],
module_prefix: &str,
out: &mut HashMap<String, ImportBinding>,
) {
match node.kind() {
"import_statement" => {
collect_import_statement(node, src, out);
return;
}
"import_from_statement" => {
collect_import_from_statement(node, src, module_prefix, out);
return;
}
_ => {}
}
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
walk_imports(child, src, module_prefix, out);
}
}
fn resolve_relative_import(module_prefix: &str, raw: &str) -> Option<String> {
let dot_count = raw.chars().take_while(|c| *c == '.').count();
if dot_count == 0 {
return None;
}
let suffix = raw[dot_count..].trim().trim_matches('.');
let parts: Vec<&str> = if module_prefix.is_empty() {
Vec::new()
} else {
module_prefix.split('.').collect()
};
if dot_count > parts.len() {
return None;
}
let keep = parts.len() - dot_count;
let mut base = parts[..keep].join(".");
if !suffix.is_empty() {
if !base.is_empty() {
base.push('.');
}
base.push_str(suffix);
}
if base.is_empty() { None } else { Some(base) }
}
fn collect_import_statement(node: Node<'_>, src: &[u8], out: &mut HashMap<String, ImportBinding>) {
let mut cursor = node.walk();
for child in node.children_by_field_name("name", &mut cursor) {
match child.kind() {
"dotted_name" => {
let full = node_text(child, src).unwrap_or_default();
let full = full.trim().to_string();
if full.is_empty() {
continue;
}
let first = full.split('.').next().unwrap_or(&full).to_string();
out.insert(
first.clone(),
ImportBinding {
qname_prefix: first,
is_module: true,
},
);
if full.contains('.') {
out.insert(
full.clone(),
ImportBinding {
qname_prefix: full,
is_module: true,
},
);
}
}
"aliased_import" => {
let name = child
.child_by_field_name("name")
.and_then(|n| node_text(n, src))
.unwrap_or_default();
let alias = child
.child_by_field_name("alias")
.and_then(|n| node_text(n, src))
.unwrap_or_default();
let name = name.trim().to_string();
let alias = alias.trim().to_string();
if name.is_empty() || alias.is_empty() {
continue;
}
out.insert(
alias,
ImportBinding {
qname_prefix: name,
is_module: true,
},
);
}
_ => {}
}
}
}
fn collect_import_from_statement(
node: Node<'_>,
src: &[u8],
module_prefix: &str,
out: &mut HashMap<String, ImportBinding>,
) {
let module_node = node.child_by_field_name("module_name");
let module_kind = module_node.map(|n| n.kind()).unwrap_or("");
let module = if module_kind == "relative_import" {
let raw = module_node
.and_then(|n| node_text(n, src))
.unwrap_or_default();
match resolve_relative_import(module_prefix, raw.trim()) {
Some(m) => m,
None => return,
}
} else {
let m = module_node
.and_then(|n| node_text(n, src))
.unwrap_or_default()
.trim()
.to_string();
if m.is_empty() {
return;
}
m
};
let mut cursor_all = node.walk();
for child in node.children(&mut cursor_all) {
if child.kind() == "wildcard_import" {
return;
}
}
let mut cursor = node.walk();
for child in node.children_by_field_name("name", &mut cursor) {
match child.kind() {
"dotted_name" => {
let name = node_text(child, src).unwrap_or_default();
let name = name.trim().to_string();
if name.is_empty() {
continue;
}
let qname = format!("{module}.{name}");
out.insert(
name,
ImportBinding {
qname_prefix: qname,
is_module: false,
},
);
}
"aliased_import" => {
let name = child
.child_by_field_name("name")
.and_then(|n| node_text(n, src))
.unwrap_or_default();
let alias = child
.child_by_field_name("alias")
.and_then(|n| node_text(n, src))
.unwrap_or_default();
let name = name.trim().to_string();
let alias = alias.trim().to_string();
if name.is_empty() || alias.is_empty() {
continue;
}
let qname = format!("{module}.{name}");
out.insert(
alias,
ImportBinding {
qname_prefix: qname,
is_module: false,
},
);
}
_ => {}
}
}
}
fn enclosing_class_qname(
qname: &str,
module_prefix: &str,
known: &HashSet<&str>,
) -> Option<String> {
let parts: Vec<&str> = qname.split('.').collect();
if parts.len() < 2 {
return None;
}
for end in (1..parts.len()).rev() {
let candidate = parts[..end].join(".");
if !module_prefix.is_empty() && !candidate.starts_with(module_prefix) {
continue;
}
if known.contains(candidate.as_str()) {
return Some(candidate);
}
}
None
}
#[allow(clippy::too_many_arguments)]
fn collect_calls(
node: Node<'_>,
src: &[u8],
sym: &ParsedSymbol,
module_prefix: &str,
by_simple: &HashMap<String, String>,
known: &HashSet<&str>,
imports: &HashMap<String, ImportBinding>,
workspace: &WorkspaceSymbols,
enclosing_class: Option<&str>,
out: &mut HashSet<CallEdge>,
) {
if node.kind() == "call" {
if let Some(func) = node.child_by_field_name("function") {
if let Some(callee) = resolve_callee(
func,
src,
module_prefix,
by_simple,
known,
imports,
workspace,
enclosing_class,
) {
out.insert(CallEdge {
caller_qname: sym.qname.clone(),
callee_qname: callee,
});
}
}
}
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
collect_calls(
child,
src,
sym,
module_prefix,
by_simple,
known,
imports,
workspace,
enclosing_class,
out,
);
}
}
#[allow(clippy::too_many_arguments)]
fn resolve_callee(
func: Node<'_>,
src: &[u8],
module_prefix: &str,
by_simple: &HashMap<String, String>,
known: &HashSet<&str>,
imports: &HashMap<String, ImportBinding>,
workspace: &WorkspaceSymbols,
enclosing_class: Option<&str>,
) -> Option<String> {
match func.kind() {
"identifier" => {
let name = node_text(func, src)?;
if let Some(q) = by_simple.get(&name) {
return Some(q.clone());
}
if let Some(binding) = imports.get(&name) {
if workspace.contains(&binding.qname_prefix) {
return Some(binding.qname_prefix.clone());
}
}
None
}
"attribute" => {
let object = func.child_by_field_name("object")?;
let attr = func.child_by_field_name("attribute")?;
let attr_name = node_text(attr, src)?;
match object.kind() {
"identifier" => {
let obj_name = node_text(object, src)?;
if obj_name == "self" {
let class = enclosing_class?;
let candidate = format!("{class}.{attr_name}");
if known.contains(candidate.as_str()) {
return Some(candidate);
}
return None;
}
let class_qname = if module_prefix.is_empty() {
obj_name.clone()
} else {
format!("{module_prefix}.{obj_name}")
};
if known.contains(class_qname.as_str()) {
let candidate = format!("{class_qname}.{attr_name}");
if known.contains(candidate.as_str()) {
return Some(candidate);
}
}
if let Some(binding) = imports.get(&obj_name) {
let candidate = format!("{}.{}", binding.qname_prefix, attr_name);
if workspace.contains(&candidate) {
return Some(candidate);
}
}
None
}
"attribute" => {
let chain = flatten_attribute(object, src)?;
if let Some(binding) = imports.get(&chain) {
let candidate = format!("{}.{}", binding.qname_prefix, attr_name);
if workspace.contains(&candidate) {
return Some(candidate);
}
}
None
}
_ => None,
}
}
_ => None,
}
}
fn flatten_attribute(node: Node<'_>, src: &[u8]) -> Option<String> {
match node.kind() {
"identifier" => node_text(node, src),
"attribute" => {
let object = node.child_by_field_name("object")?;
let attr = node.child_by_field_name("attribute")?;
let head = flatten_attribute(object, src)?;
let tail = node_text(attr, src)?;
Some(format!("{head}.{tail}"))
}
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn router_prefix_resolution_fastapi_and_flask() {
let src = r#"
router = APIRouter(prefix="/api/notifications", tags=["x"])
@router.get("/unread")
def unread(): ...
"#;
let p = collect_router_prefixes(src);
assert_eq!(
p.get("router").map(String::as_str),
Some("/api/notifications")
);
assert_eq!(
route_receiver("@router.get(\"/unread\")").as_deref(),
Some("router")
);
assert_eq!(
join_route("/api/notifications", "/unread"),
"/api/notifications/unread"
);
let flask = r#"bp = Blueprint("acct", __name__, url_prefix="/accounts")"#;
assert_eq!(
collect_router_prefixes(flask).get("bp").map(String::as_str),
Some("/accounts")
);
let mounted = r#"
sub = APIRouter(prefix="/items")
app.include_router(sub, prefix="/api")
"#;
assert_eq!(
collect_router_prefixes(mounted)
.get("sub")
.map(String::as_str),
Some("/api/items")
);
}
#[test]
fn router_prefix_edge_cases() {
assert_eq!(string_kwarg(r#"url_prefix="/z""#, "prefix"), None);
assert_eq!(
string_kwarg(r#"url_prefix="/z""#, "url_prefix").as_deref(),
Some("/z")
);
assert!(collect_router_prefixes("app = FastAPI()").is_empty());
assert_eq!(join_route("/api", "items"), "/api/items");
assert_eq!(join_route("/api/", "/items"), "/api/items");
assert_eq!(join_route("/api", "/"), "/api");
assert_eq!(join_route("", "/items"), "/items");
assert!(ctor_args("x = MyAPIRouter(prefix=\"/a\")", "APIRouter").is_none());
}
fn ep(contract: &str, file: &str, line: u32) -> ServiceEndpoint {
ServiceEndpoint {
transport: Transport::Http,
direction: Direction::Inbound,
contract: contract.to_string(),
repo_id: "r".into(),
symbol_id: "s".into(),
qname: "q".into(),
file: file.into(),
line,
confidence: 0.95,
note: None,
}
}
#[test]
fn cross_file_aliased_mount_tree_resolves_nested_prefix() {
let files = vec![
(
"main.py".to_string(),
"from api import api_router\napp = FastAPI()\napp.include_router(api_router, prefix=\"/api\")\n".to_string(),
),
(
"api.py".to_string(),
"from calc import router as calculators_router\napi_router = APIRouter()\napi_router.include_router(calculators_router, prefix=\"/calculators\")\n".to_string(),
),
(
"calc.py".to_string(),
"router = APIRouter()\n\n@router.get(\"/budget\")\ndef budget(): ...\n".to_string(),
),
];
let mut eps = vec![ep(&http_contract("GET", "/budget"), "calc.py", 3)];
resolve_python_endpoint_prefixes(&files, &mut eps);
assert_eq!(eps[0].contract, "http:GET /api/calculators/budget");
}
#[test]
fn same_router_name_distinct_modules_resolve_independently() {
let files = vec![
(
"a.py".to_string(),
"router = APIRouter(prefix=\"/api/notif\")\n\n@router.get(\"/x\")\ndef x(): ...\n"
.to_string(),
),
(
"b.py".to_string(),
"router = APIRouter(prefix=\"/api/inst\")\n\n@router.get(\"/y\")\ndef y(): ...\n"
.to_string(),
),
];
let mut eps = vec![
ep(&http_contract("GET", "/x"), "a.py", 3),
ep(&http_contract("GET", "/y"), "b.py", 3),
];
resolve_python_endpoint_prefixes(&files, &mut eps);
assert_eq!(eps[0].contract, "http:GET /api/notif/x");
assert_eq!(eps[1].contract, "http:GET /api/inst/y");
}
#[test]
fn multi_mount_emits_route_under_each_prefix() {
let files = vec![
(
"main.py".to_string(),
"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(),
),
(
"api.py".to_string(),
"api_router = APIRouter()\n\n@api_router.get(\"/health\")\ndef health(): ...\n".to_string(),
),
];
let mut eps = vec![ep(&http_contract("GET", "/health"), "api.py", 3)];
resolve_python_endpoint_prefixes(&files, &mut eps);
let contracts: std::collections::HashSet<String> =
eps.iter().map(|e| e.contract.clone()).collect();
assert_eq!(eps.len(), 2, "{contracts:?}");
assert!(
contracts.contains("http:GET /api/v1/health"),
"{contracts:?}"
);
assert!(contracts.contains("http:GET /api/health"), "{contracts:?}");
}
#[test]
fn module_prefix_strips_py_and_leading_dot_slash() {
assert_eq!(
module_qname_prefix("src/mypackage/module.py"),
"mypackage.module"
);
assert_eq!(module_qname_prefix("src/utils.py"), "utils");
assert_eq!(
module_qname_prefix("packages/mypkg/src/__init__.py"),
"mypkg.__init__"
);
assert_eq!(
module_qname_prefix("packages/my-pkg/src/__init__.py"),
"my_pkg.__init__"
);
assert_eq!(module_qname_prefix("foo/bar.py"), "foo.bar");
assert_eq!(module_qname_prefix("./foo/bar.py"), "foo.bar");
assert_eq!(module_qname_prefix("bar.py"), "bar");
}
#[test]
fn strip_src_prefix_variants() {
assert_eq!(strip_src_prefix("src/pkg/mod.py"), "pkg/mod.py");
assert_eq!(strip_src_prefix("lib/src/utils.py"), "utils.py");
assert_eq!(strip_src_prefix("pkg/mod.py"), "pkg/mod.py");
}
#[test]
fn parses_module_function_and_class() {
let src = "def top():\n return 1\n\nclass C:\n def m(self):\n return 2\n";
let a = PythonAdapter::new();
let syms = a.parse_symbols("x.py", src).unwrap();
let qnames: Vec<_> = syms.iter().map(|s| s.qname.clone()).collect();
assert!(qnames.contains(&"x.top".to_string()));
assert!(qnames.contains(&"x.C".to_string()));
assert!(qnames.contains(&"x.C.m".to_string()));
let top = syms.iter().find(|s| s.qname == "x.top").unwrap();
assert_eq!(top.kind, SymbolKind::Function);
let m = syms.iter().find(|s| s.qname == "x.C.m").unwrap();
assert_eq!(m.kind, SymbolKind::Method);
}
#[test]
fn infers_fs_read_and_write_from_open() {
let body = "def f():\n with open('/tmp/a.txt', 'w') as f:\n f.write('hi')\n";
let effects = infer_effects_from_body(body);
let cats: Vec<_> = effects.iter().map(|e| e.effect.clone()).collect();
assert!(cats.contains(&EffectCategory::IoFsRead));
assert!(cats.contains(&EffectCategory::IoFsWrite));
}
#[test]
fn empty_when_no_patterns() {
let body = "def f(x):\n return x + 1\n";
assert!(infer_effects_from_body(body).is_empty());
}
#[test]
fn comment_mentioning_open_does_not_infer_fs_effects() {
let body = "def f(x):\n # consider open('/tmp/x') for cache\n return x\n";
let effects = infer_effects_from_body(body);
let cats: Vec<_> = effects.iter().map(|e| e.effect.clone()).collect();
assert!(
!cats.contains(&EffectCategory::IoFsRead),
"open() in a comment must not infer IoFsRead; got {cats:?}"
);
}
#[test]
fn string_literal_mentioning_open_does_not_infer_fs_effects() {
let body = "def f():\n msg = 'use open(path) to load'\n return msg\n";
let effects = infer_effects_from_body(body);
assert!(
effects.is_empty(),
"open() inside a string literal must not infer any effect; got {effects:?}"
);
}
#[test]
fn triple_string_docstring_mentioning_requests_does_not_infer_net() {
let body = r#"def f():
"""Pretend we call requests.get('https://example.com') here.
But we don't actually.
"""
return 42
"#;
let effects = infer_effects_from_body(body);
let cats: Vec<_> = effects.iter().map(|e| e.effect.clone()).collect();
assert!(
!cats.contains(&EffectCategory::IoNetOut),
"requests.* in a docstring must not infer IoNetOut; got {cats:?}"
);
}
#[test]
fn real_requests_call_infers_net_out() {
for body in [
"def f():\n return requests.get('https://api.svc/x')\n",
"def f():\n return httpx.post('https://api.svc/x', json={})\n",
"def f():\n return requests.request('POST', 'https://api.svc/x')\n",
] {
let cats: Vec<_> = infer_effects_from_body(body)
.iter()
.map(|e| e.effect.clone())
.collect();
assert!(
cats.contains(&EffectCategory::IoNetOut),
"expected IoNetOut for `{body}`; got {cats:?}"
);
}
}
#[test]
fn real_open_still_fires_when_comment_also_mentions_it() {
let body = "def f():\n # open('/tmp/a') would also work\n open('/tmp/b')\n";
let effects = infer_effects_from_body(body);
let cats: Vec<_> = effects.iter().map(|e| e.effect.clone()).collect();
assert!(
cats.contains(&EffectCategory::IoFsRead),
"real open() must still infer IoFsRead even when a comment also mentions it; got {cats:?}"
);
}
#[test]
fn raise_inside_string_literal_does_not_infer_throw() {
let body = "def f():\n msg = 'do not raise here'\n return msg\n";
let effects = infer_effects_from_body(body);
let cats: Vec<_> = effects.iter().map(|e| e.effect.clone()).collect();
assert!(
!cats.contains(&EffectCategory::Throw),
"raise inside a string literal must not infer Throw; got {cats:?}"
);
}
#[test]
fn mask_preserves_byte_length_and_line_count() {
let body = "x = 'open(\"a\")' # open(\"b\")\ny = 1\n";
let masked = mask_comments_and_literals(body);
assert_eq!(masked.len(), body.len(), "mask must preserve byte length");
assert_eq!(
masked.lines().count(),
body.lines().count(),
"mask must preserve line count"
);
assert!(
!masked.contains("open("),
"masked source must not contain `open(` from literal or comment"
);
}
#[test]
fn mask_leaves_identifier_chars_alone() {
let body = "request_open = 1\n";
let masked = mask_comments_and_literals(body);
assert_eq!(
masked, body,
"no comments/literals here; masked must equal original"
);
}
#[test]
fn mask_handles_raw_and_byte_string_prefixes() {
let body = "x = r\"open('/tmp/a')\"\ny = b'open(\"b\")'\nz = rb\"open(\"c\")\"\n";
let effects = infer_effects_from_body(body);
assert!(
effects.is_empty(),
"raw/byte/raw-byte literals must mask their contents; got {effects:?}"
);
}
fn workspace_with(qnames: &[&str]) -> WorkspaceSymbols {
let mut ws = WorkspaceSymbols::default();
for q in qnames {
ws.qnames.insert((*q).to_string());
ws.kinds.insert((*q).to_string(), SymbolKind::Function);
}
ws
}
#[test]
fn extracts_intra_module_call_edges() {
let src = r#"
def helper(x):
return x
def caller():
return helper(1)
class C:
def __init__(self):
self.x = helper(2)
def m(self):
return self.__init__()
"#;
let a = PythonAdapter::new();
let syms = a.parse_symbols("m.py", src).unwrap();
let ws = workspace_with(&["m.helper", "m.caller", "m.C", "m.C.__init__", "m.C.m"]);
let edges = a.extract_call_edges("m.py", src, &syms, &ws);
let pairs: Vec<(String, String)> = edges
.iter()
.map(|e| (e.caller_qname.clone(), e.callee_qname.clone()))
.collect();
assert!(
pairs.contains(&("m.caller".to_string(), "m.helper".to_string())),
"missing caller -> helper edge; got {pairs:?}",
);
assert!(
pairs.contains(&("m.C.__init__".to_string(), "m.helper".to_string())),
"missing C.__init__ -> helper edge; got {pairs:?}",
);
assert!(
pairs.contains(&("m.C.m".to_string(), "m.C.__init__".to_string())),
"missing C.m -> C.__init__ edge; got {pairs:?}",
);
}
#[test]
fn extracts_cross_module_edges_via_from_import() {
let src = r#"
from logger import write_log
def foo():
write_log("/tmp/x", "hi")
"#;
let a = PythonAdapter::new();
let syms = a.parse_symbols("caller.py", src).unwrap();
let ws = workspace_with(&["logger.write_log", "caller.foo"]);
let edges = a.extract_call_edges("caller.py", src, &syms, &ws);
let pairs: Vec<(String, String)> = edges
.iter()
.map(|e| (e.caller_qname.clone(), e.callee_qname.clone()))
.collect();
assert!(
pairs.contains(&("caller.foo".to_string(), "logger.write_log".to_string())),
"missing foo -> logger.write_log edge; got {pairs:?}",
);
}
#[test]
fn extracts_cross_module_edges_via_module_import() {
let src = r#"
import logger
def foo():
logger.write_log("/tmp/x", "hi")
"#;
let a = PythonAdapter::new();
let syms = a.parse_symbols("caller.py", src).unwrap();
let ws = workspace_with(&["logger.write_log", "caller.foo"]);
let edges = a.extract_call_edges("caller.py", src, &syms, &ws);
let pairs: Vec<(String, String)> = edges
.iter()
.map(|e| (e.caller_qname.clone(), e.callee_qname.clone()))
.collect();
assert!(
pairs.contains(&("caller.foo".to_string(), "logger.write_log".to_string())),
"missing foo -> logger.write_log edge; got {pairs:?}",
);
}
#[test]
fn drops_unknown_imports() {
let src = r#"
import stdlib_mod
def foo():
stdlib_mod.thing()
"#;
let a = PythonAdapter::new();
let syms = a.parse_symbols("caller.py", src).unwrap();
let ws = workspace_with(&["caller.foo"]);
let edges = a.extract_call_edges("caller.py", src, &syms, &ws);
let pairs: Vec<(String, String)> = edges
.iter()
.map(|e| (e.caller_qname.clone(), e.callee_qname.clone()))
.collect();
assert!(
pairs.is_empty(),
"expected no edges for unknown imports; got {pairs:?}",
);
}
#[test]
fn import_alias_and_from_import_with_alias() {
let src = r#"
import logger as lg
from greetings import hello as h
def foo():
lg.write_log("/tmp/x", "hi")
h("world")
"#;
let a = PythonAdapter::new();
let syms = a.parse_symbols("caller.py", src).unwrap();
let ws = workspace_with(&["logger.write_log", "greetings.hello", "caller.foo"]);
let edges = a.extract_call_edges("caller.py", src, &syms, &ws);
let pairs: Vec<(String, String)> = edges
.iter()
.map(|e| (e.caller_qname.clone(), e.callee_qname.clone()))
.collect();
assert!(
pairs.contains(&("caller.foo".to_string(), "logger.write_log".to_string())),
"missing lg.write_log edge; got {pairs:?}",
);
assert!(
pairs.contains(&("caller.foo".to_string(), "greetings.hello".to_string())),
"missing h() edge; got {pairs:?}",
);
}
#[test]
fn local_definition_shadows_import() {
let src = r#"
from other import helper
def helper(x):
return x
def foo():
helper(1)
"#;
let a = PythonAdapter::new();
let syms = a.parse_symbols("m.py", src).unwrap();
let ws = workspace_with(&["other.helper", "m.helper", "m.foo"]);
let edges = a.extract_call_edges("m.py", src, &syms, &ws);
let pairs: Vec<(String, String)> = edges
.iter()
.map(|e| (e.caller_qname.clone(), e.callee_qname.clone()))
.collect();
assert!(
pairs.contains(&("m.foo".to_string(), "m.helper".to_string())),
"expected local helper to shadow import; got {pairs:?}",
);
assert!(
!pairs.contains(&("m.foo".to_string(), "other.helper".to_string())),
"expected imported helper to be shadowed; got {pairs:?}",
);
}
#[test]
fn default_extract_call_edges_returns_empty_for_unrelated_adapter() {
struct Dummy;
impl LanguageAdapter for Dummy {
fn language(&self) -> &str {
"dummy"
}
fn parse_symbols(&self, _f: &str, _s: &str) -> Result<Vec<ParsedSymbol>> {
Ok(Vec::new())
}
fn infer_effects(&self, _s: &str, _p: &ParsedSymbol) -> Vec<Effect> {
Vec::new()
}
}
let d = Dummy;
let ws = WorkspaceSymbols::default();
assert!(d.extract_call_edges("x", "", &[], &ws).is_empty());
}
#[test]
fn relative_dot_resolves_to_current_package() {
let r = super::resolve_relative_import("crucible.agents.litellm_agent", ".").unwrap();
assert_eq!(r, "crucible.agents");
}
#[test]
fn relative_dot_with_suffix_appends_module() {
let r = super::resolve_relative_import("crucible.agents.litellm_agent", ".base").unwrap();
assert_eq!(r, "crucible.agents.base");
}
#[test]
fn relative_double_dot_goes_up_one_more() {
let r = super::resolve_relative_import("crucible.agents.litellm_agent", "..util").unwrap();
assert_eq!(r, "crucible.util");
}
#[test]
fn relative_from_init_resolves_against_package() {
let r = super::resolve_relative_import("crucible.agents.__init__", ".base").unwrap();
assert_eq!(r, "crucible.agents.base");
}
#[test]
fn relative_escaping_workspace_returns_none() {
assert!(super::resolve_relative_import("crucible", "..foo").is_none());
}
#[test]
fn unresolved_calls_reports_third_party_call() {
use agentstatedeveloper_core::WorkspaceSymbols;
let src = "import requests\n\ndef fetch(url):\n return requests.get(url)\n";
let parsed = vec![ParsedSymbol {
qname: "app.client.fetch".into(),
kind: SymbolKind::Function,
start_line: 3,
start_col: 0,
end_line: 4,
end_col: 30,
signature: Some("def fetch(url)".into()),
body: src.into(),
doc: None,
}];
let mut ws = WorkspaceSymbols::default();
ws.qnames.insert("app.client.fetch".into());
ws.kinds
.insert("app.client.fetch".into(), SymbolKind::Function);
ws.build_suffix_index();
let hits = report_unresolved_calls_in_python("app/client.py", src, &parsed, &ws);
assert!(
hits.iter().any(|h| h.callee_text.contains("requests")),
"expected unresolved hint for requests.get; got {hits:?}"
);
}
#[test]
fn unresolved_calls_skips_stdlib_allowlist() {
use agentstatedeveloper_core::WorkspaceSymbols;
let src = "def show(items):\n print(len(items))\n for i in range(3): pass\n";
let parsed = vec![ParsedSymbol {
qname: "app.show.show".into(),
kind: SymbolKind::Function,
start_line: 1,
start_col: 0,
end_line: 3,
end_col: 30,
signature: Some("def show(items)".into()),
body: src.into(),
doc: None,
}];
let mut ws = WorkspaceSymbols::default();
ws.qnames.insert("app.show.show".into());
ws.kinds
.insert("app.show.show".into(), SymbolKind::Function);
ws.build_suffix_index();
let hits = report_unresolved_calls_in_python("app/show.py", src, &parsed, &ws);
assert!(
hits.is_empty(),
"stdlib allowlist calls must not be reported; got {hits:?}"
);
}
#[test]
fn unresolved_calls_skips_resolved_internal_calls() {
use agentstatedeveloper_core::WorkspaceSymbols;
let src = "def helper(): return 1\n\ndef use(): return helper()\n";
let parsed = vec![
ParsedSymbol {
qname: "app.lib.helper".into(),
kind: SymbolKind::Function,
start_line: 1,
start_col: 0,
end_line: 1,
end_col: 22,
signature: Some("def helper()".into()),
body: src.into(),
doc: None,
},
ParsedSymbol {
qname: "app.lib.use".into(),
kind: SymbolKind::Function,
start_line: 3,
start_col: 0,
end_line: 3,
end_col: 25,
signature: Some("def use()".into()),
body: src.into(),
doc: None,
},
];
let mut ws = WorkspaceSymbols::default();
ws.qnames.insert("app.lib.helper".into());
ws.kinds
.insert("app.lib.helper".into(), SymbolKind::Function);
ws.qnames.insert("app.lib.use".into());
ws.kinds.insert("app.lib.use".into(), SymbolKind::Function);
ws.build_suffix_index();
let hits = report_unresolved_calls_in_python("app/lib.py", src, &parsed, &ws);
assert!(
!hits.iter().any(|h| h.callee_text == "helper"),
"internal resolved call must not appear in unresolved list; got {hits:?}"
);
}
#[test]
fn detects_getattr_call_pattern() {
let src = "def dispatch(obj, name):\n return getattr(obj, name)(42)\n";
let hints = scan_dynamic_dispatch_in_python("app/dispatch.py", src);
assert_eq!(hints.len(), 1, "expected 1 getattr hint; got {hints:?}");
assert_eq!(hints[0].pattern, "getattr");
assert_eq!(hints[0].line, 2);
assert!(hints[0].snippet.contains("getattr(obj, name)(42)"));
}
#[test]
fn getattr_without_trailing_call_is_not_flagged() {
let src = "def read(obj, name):\n return getattr(obj, name, None)\n";
let hints = scan_dynamic_dispatch_in_python("app/read.py", src);
assert!(
hints.is_empty(),
"bare getattr (no trailing call) must not be flagged; got {hints:?}"
);
}
#[test]
fn detects_getattr_with_string_arg_then_call() {
let src = "def go(obj):\n return getattr(obj, 'method')(1, 2)\n";
let hints = scan_dynamic_dispatch_in_python("app/go.py", src);
assert_eq!(hints.len(), 1);
assert_eq!(hints[0].pattern, "getattr");
}
#[test]
fn detects_dunder_getattr_method_definition() {
let src =
"class Proxy:\n def __getattr__(self, name):\n return self._lookup(name)\n";
let hints = scan_dynamic_dispatch_in_python("app/proxy.py", src);
assert_eq!(hints.len(), 1);
assert_eq!(hints[0].pattern, "__getattr__");
assert_eq!(hints[0].line, 2);
}
#[test]
fn detects_dunder_getattribute_method_definition() {
let src = "class Strict:\n def __getattribute__(self, name):\n return super().__getattribute__(name)\n";
let hints = scan_dynamic_dispatch_in_python("app/strict.py", src);
assert_eq!(hints.len(), 1);
assert_eq!(hints[0].pattern, "__getattr__");
}
#[test]
fn getattr_inside_comment_or_string_is_masked() {
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";
let hints = scan_dynamic_dispatch_in_python("app/f.py", src);
assert!(
hints.is_empty(),
"getattr inside comment/string must not be flagged; got {hints:?}"
);
}
#[test]
fn extract_call_edges_resolves_function_body_import() {
use crate::PythonAdapter;
use agentstatedeveloper_core::{
LanguageAdapter, ParsedSymbol, SymbolKind, WorkspaceSymbols,
};
let adapter = PythonAdapter::new();
let src = "def f():\n from pkg.util import helper\n return helper()\n";
let syms = vec![ParsedSymbol {
qname: "app.main.f".into(),
kind: SymbolKind::Function,
start_line: 1,
start_col: 0,
end_line: 3,
end_col: 22,
signature: Some("def f()".into()),
body: src.into(),
doc: None,
}];
let mut ws = WorkspaceSymbols::default();
ws.qnames.insert("pkg.util.helper".into());
ws.kinds
.insert("pkg.util.helper".into(), SymbolKind::Function);
ws.qnames.insert("app.main.f".into());
ws.kinds.insert("app.main.f".into(), SymbolKind::Function);
ws.build_suffix_index();
let edges = adapter.extract_call_edges("app/main.py", src, &syms, &ws);
assert!(
edges.iter().any(|e| e.callee_qname == "pkg.util.helper"),
"function-body import must produce cross-module edge; got {edges:?}"
);
}
#[test]
fn extract_call_edges_resolves_if_typechecking_import() {
use crate::PythonAdapter;
use agentstatedeveloper_core::{
LanguageAdapter, ParsedSymbol, SymbolKind, WorkspaceSymbols,
};
let adapter = PythonAdapter::new();
let src = "from typing import TYPE_CHECKING\n\nif TYPE_CHECKING:\n from pkg.api import Client\n\ndef use():\n return Client()\n";
let syms = vec![ParsedSymbol {
qname: "app.main.use".into(),
kind: SymbolKind::Function,
start_line: 6,
start_col: 0,
end_line: 7,
end_col: 20,
signature: Some("def use()".into()),
body: src.into(),
doc: None,
}];
let mut ws = WorkspaceSymbols::default();
ws.qnames.insert("pkg.api.Client".into());
ws.kinds.insert("pkg.api.Client".into(), SymbolKind::Class);
ws.qnames.insert("app.main.use".into());
ws.kinds.insert("app.main.use".into(), SymbolKind::Function);
ws.build_suffix_index();
let edges = adapter.extract_call_edges("app/main.py", src, &syms, &ws);
assert!(
edges.iter().any(|e| e.callee_qname == "pkg.api.Client"),
"if TYPE_CHECKING import must resolve; got {edges:?}"
);
}
#[test]
fn extract_call_edges_resolves_try_except_import() {
use crate::PythonAdapter;
use agentstatedeveloper_core::{
LanguageAdapter, ParsedSymbol, SymbolKind, WorkspaceSymbols,
};
let adapter = PythonAdapter::new();
let src = "try:\n from pkg.fast import loader\nexcept ImportError:\n from pkg.slow import loader\n\ndef boot():\n return loader()\n";
let syms = vec![ParsedSymbol {
qname: "app.main.boot".into(),
kind: SymbolKind::Function,
start_line: 6,
start_col: 0,
end_line: 7,
end_col: 20,
signature: Some("def boot()".into()),
body: src.into(),
doc: None,
}];
let mut ws = WorkspaceSymbols::default();
ws.qnames.insert("pkg.fast.loader".into());
ws.kinds
.insert("pkg.fast.loader".into(), SymbolKind::Function);
ws.qnames.insert("pkg.slow.loader".into());
ws.kinds
.insert("pkg.slow.loader".into(), SymbolKind::Function);
ws.qnames.insert("app.main.boot".into());
ws.kinds
.insert("app.main.boot".into(), SymbolKind::Function);
ws.build_suffix_index();
let edges = adapter.extract_call_edges("app/main.py", src, &syms, &ws);
let resolved = edges
.iter()
.any(|e| e.callee_qname == "pkg.fast.loader" || e.callee_qname == "pkg.slow.loader");
assert!(
resolved,
"try/except import must resolve to one of the branches; got {edges:?}"
);
}
#[test]
fn module_scope_imports_still_resolve_after_nested_walker() {
use crate::PythonAdapter;
use agentstatedeveloper_core::{
LanguageAdapter, ParsedSymbol, SymbolKind, WorkspaceSymbols,
};
let adapter = PythonAdapter::new();
let src = "from pkg.util import helper\n\ndef f():\n return helper()\n";
let syms = vec![ParsedSymbol {
qname: "app.main.f".into(),
kind: SymbolKind::Function,
start_line: 3,
start_col: 0,
end_line: 4,
end_col: 22,
signature: Some("def f()".into()),
body: src.into(),
doc: None,
}];
let mut ws = WorkspaceSymbols::default();
ws.qnames.insert("pkg.util.helper".into());
ws.kinds
.insert("pkg.util.helper".into(), SymbolKind::Function);
ws.qnames.insert("app.main.f".into());
ws.kinds.insert("app.main.f".into(), SymbolKind::Function);
ws.build_suffix_index();
let edges = adapter.extract_call_edges("app/main.py", src, &syms, &ws);
assert!(
edges.iter().any(|e| e.callee_qname == "pkg.util.helper"),
"module-scope import must still resolve; got {edges:?}"
);
}
#[test]
fn extract_call_edges_resolves_double_dot_relative_import() {
use crate::PythonAdapter;
use agentstatedeveloper_core::{
LanguageAdapter, ParsedSymbol, SymbolKind, WorkspaceSymbols,
};
let adapter = PythonAdapter::new();
let caller_src = "from ..util import helper\n\ndef act():\n return helper()\n";
let caller_syms = vec![ParsedSymbol {
qname: "crucible.agents.litellm_agent.act".into(),
kind: SymbolKind::Function,
start_line: 3,
start_col: 0,
end_line: 4,
end_col: 22,
signature: Some("def act()".into()),
body: caller_src.into(),
doc: None,
}];
let mut workspace = WorkspaceSymbols::default();
workspace.qnames.insert("crucible.util.helper".into());
workspace
.kinds
.insert("crucible.util.helper".into(), SymbolKind::Function);
workspace
.qnames
.insert("crucible.agents.litellm_agent.act".into());
workspace.kinds.insert(
"crucible.agents.litellm_agent.act".into(),
SymbolKind::Function,
);
workspace.build_suffix_index();
let edges = adapter.extract_call_edges(
"crucible/agents/litellm_agent.py",
caller_src,
&caller_syms,
&workspace,
);
assert!(
edges
.iter()
.any(|e| e.callee_qname == "crucible.util.helper"),
"expected double-dot relative import to produce cross-module edge to crucible.util.helper; got {edges:?}"
);
}
#[test]
fn extract_call_edges_resolves_relative_import_to_cross_module() {
use crate::PythonAdapter;
use agentstatedeveloper_core::{
LanguageAdapter, ParsedSymbol, SymbolKind, WorkspaceSymbols,
};
let adapter = PythonAdapter::new();
let caller_src =
"from .base import make_decision\n\ndef act():\n return make_decision()\n";
let caller_syms = vec![ParsedSymbol {
qname: "crucible.agents.litellm_agent.act".into(),
kind: SymbolKind::Function,
start_line: 3,
start_col: 0,
end_line: 4,
end_col: 27,
signature: Some("def act()".into()),
body: caller_src.into(),
doc: None,
}];
let mut workspace = WorkspaceSymbols::default();
workspace
.qnames
.insert("crucible.agents.base.make_decision".into());
workspace.kinds.insert(
"crucible.agents.base.make_decision".into(),
SymbolKind::Function,
);
workspace
.qnames
.insert("crucible.agents.litellm_agent.act".into());
workspace.kinds.insert(
"crucible.agents.litellm_agent.act".into(),
SymbolKind::Function,
);
workspace.build_suffix_index();
let edges = adapter.extract_call_edges(
"crucible/agents/litellm_agent.py",
caller_src,
&caller_syms,
&workspace,
);
assert!(
edges
.iter()
.any(|e| e.callee_qname == "crucible.agents.base.make_decision"),
"expected cross-module edge to crucible.agents.base.make_decision; got {edges:?}"
);
}
}
#[cfg(test)]
mod service_endpoint_tests {
use super::*;
use agentstatedeveloper_core::adapter::LanguageAdapter;
fn detect(src: &str) -> Vec<DetectedEndpoint> {
let a = PythonAdapter;
let symbols = a.parse_symbols("app.py", src).unwrap();
a.infer_service_endpoints("app.py", src, &symbols)
}
fn inbound(eps: &[DetectedEndpoint]) -> Vec<&DetectedEndpoint> {
eps.iter()
.filter(|e| e.direction == Direction::Inbound)
.collect()
}
fn outbound(eps: &[DetectedEndpoint]) -> Vec<&DetectedEndpoint> {
eps.iter()
.filter(|e| e.direction == Direction::Outbound)
.collect()
}
#[test]
fn fastapi_decorator_route_is_inbound() {
let src = "@app.get(\"/charge\")\ndef charge():\n return 1\n";
let eps = detect(src);
let inb = inbound(&eps);
assert_eq!(inb.len(), 1, "{eps:?}");
assert_eq!(inb[0].contract, "http:GET /charge");
assert!(
inb[0].owner_qname.ends_with("charge"),
"owner: {}",
inb[0].owner_qname
);
assert!(inb[0].confidence > 0.9);
}
#[test]
fn flask_route_with_methods_emits_one_per_method() {
let src = "@app.route(\"/items\", methods=[\"GET\", \"POST\"])\ndef items():\n pass\n";
let eps = detect(src);
let mut got: Vec<String> = inbound(&eps).iter().map(|e| e.contract.clone()).collect();
got.sort();
assert_eq!(
got,
vec![
"http:GET /items".to_string(),
"http:POST /items".to_string()
]
);
}
#[test]
fn flask_route_defaults_to_get() {
let src = "@app.route(\"/ping\")\ndef ping():\n pass\n";
let eps = detect(src);
let inb = inbound(&eps);
assert_eq!(inb.len(), 1);
assert_eq!(inb[0].contract, "http:GET /ping");
}
#[test]
fn requests_client_call_is_outbound() {
let src = "def pay():\n requests.post(\"https://payments.svc/charge\", json=x)\n";
let eps = detect(src);
let out = outbound(&eps);
assert_eq!(out.len(), 1, "{eps:?}");
assert_eq!(out[0].contract, "http:POST /charge");
assert!(
out[0].owner_qname.ends_with("pay"),
"owner: {}",
out[0].owner_qname
);
}
#[test]
fn requests_request_method_first_arg() {
let src = "def f():\n httpx.request(\"DELETE\", \"https://api/things/{id}\")\n";
let eps = detect(src);
let out = outbound(&eps);
assert_eq!(out.len(), 1);
assert_eq!(out[0].contract, "http:DELETE /things/{}");
}
#[test]
fn non_literal_urls_are_skipped() {
let src = "def f():\n requests.get(url)\n requests.get(f\"/u/{i}\")\n";
let eps = detect(src);
assert!(outbound(&eps).is_empty(), "{eps:?}");
}
#[test]
fn import_line_is_not_a_client_call() {
let src = "import requests\n\ndef f():\n return 1\n";
assert!(detect(src).is_empty());
}
#[test]
fn client_and_server_contracts_match_for_matching_endpoint() {
let server = detect("@app.get(\"/users/{id}\")\ndef get_user(id):\n pass\n");
let client = detect("def fetch():\n requests.get(\"https://users.svc/users/:id\")\n");
assert_eq!(inbound(&server)[0].contract, outbound(&client)[0].contract);
assert_eq!(inbound(&server)[0].contract, "http:GET /users/{}");
}
}
#[cfg(test)]
mod dataflow_tests {
use super::*;
use agentstatedeveloper_core::adapter::{LanguageAdapter, WorkspaceSymbols};
fn detect(src: &str) -> Vec<DetectedDataFlow> {
let a = PythonAdapter;
let symbols = a.parse_symbols("m.py", src).unwrap();
a.extract_dataflow("m.py", src, &symbols, &WorkspaceSymbols::default())
}
#[test]
fn same_file_call_maps_positional_args() {
let src = "def helper(a, b):\n return a\n\ndef caller():\n helper(x, y)\n";
let df = detect(src);
assert_eq!(df.len(), 2, "{df:?}");
assert!(df.iter().all(|d| d.callee_qname.ends_with("helper")));
assert!(df.iter().all(|d| d.caller_qname.ends_with("caller")));
let args: Vec<(usize, String)> = df.iter().map(|d| (d.arg_index, d.arg.clone())).collect();
assert!(args.contains(&(0, "x".to_string())));
assert!(args.contains(&(1, "y".to_string())));
}
#[test]
fn method_calls_are_skipped() {
let src = "def caller():\n obj.helper(x)\n";
assert!(detect(src).is_empty(), "{:?}", detect(src));
}
#[test]
fn unknown_or_cross_file_callee_skipped() {
let src = "def caller():\n helper(x)\n";
assert!(detect(src).is_empty());
}
#[test]
fn non_identifier_args_are_skipped() {
let src = "def helper(a):\n pass\n\ndef caller():\n helper(\"lit\")\n helper(g(x))\n helper(a + b)\n";
assert!(detect(src).is_empty(), "{:?}", detect(src));
}
#[test]
fn ambiguous_callee_name_skipped() {
let src = "def helper(a):\n pass\n\ndef helper(b):\n pass\n\ndef caller():\n helper(x)\n";
assert!(detect(src).is_empty(), "{:?}", detect(src));
}
}
#[cfg(test)]
mod pubsub_tests {
use super::*;
use agentstatedeveloper_core::adapter::LanguageAdapter;
fn detect(src: &str) -> Vec<DetectedEndpoint> {
let a = PythonAdapter;
let symbols = a.parse_symbols("tasks.py", src).unwrap();
a.infer_service_endpoints("tasks.py", src, &symbols)
}
fn ps(eps: &[DetectedEndpoint], dir: Direction) -> Vec<&DetectedEndpoint> {
eps.iter()
.filter(|e| e.transport == Transport::PubSub && e.direction == dir)
.collect()
}
#[test]
fn shared_task_is_a_listener() {
let eps = detect("@shared_task\ndef process_order(o):\n return o\n");
let inb = ps(&eps, Direction::Inbound);
assert_eq!(inb.len(), 1, "{eps:?}");
assert_eq!(inb[0].contract, "topic:process_order");
}
#[test]
fn delay_call_is_an_emit() {
let eps = detect("def caller():\n process_order.delay(payload)\n");
let outb = ps(&eps, Direction::Outbound);
assert_eq!(outb.len(), 1);
assert_eq!(outb[0].contract, "topic:process_order");
}
#[test]
fn listener_and_emit_share_one_contract() {
let src = "@app.task(bind=True)\ndef send_email(addr):\n pass\n\ndef trigger():\n send_email.apply_async((a,))\n";
let eps = detect(src);
assert_eq!(
ps(&eps, Direction::Inbound)[0].contract,
ps(&eps, Direction::Outbound)[0].contract
);
assert_eq!(ps(&eps, Direction::Inbound)[0].contract, "topic:send_email");
}
#[test]
fn non_celery_decorator_is_not_a_listener() {
assert!(
ps(
&detect("@property\ndef value(self):\n return 1\n"),
Direction::Inbound
)
.is_empty()
);
let http = detect("@app.get(\"/x\")\ndef h():\n pass\n");
assert!(ps(&http, Direction::Inbound).is_empty());
}
}