Skip to main content

agentstatedeveloper_python/
lib.rs

1//! Python language adapter for AgentStateDeveloper.
2//!
3//! Implements [`LanguageAdapter`](agentstatedeveloper_core::LanguageAdapter)
4//! on top of `tree-sitter-python`. Parses module-level functions, methods,
5//! and classes, and runs a small substring-based effect inference pass.
6
7use 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/// Python language adapter.
22#[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
114/// Plan L t-006: walk a Python file's call nodes and report any that
115/// the static resolver couldn't bind to a workspace qname. Includes
116/// stdlib + third-party + dynamic — callers should treat the count
117/// as "static-resolution gap," not "bug list."
118///
119/// Filters out a small known-stdlib allowlist so the output isn't
120/// drowned in `print` / `len` / `range` calls. Anything else gets
121/// reported, including third-party calls (which are useful to see —
122/// they tell you "these dependencies sit outside the indexed scope").
123pub 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    // The Python builtins that aren't worth surfacing as gaps —
132    // every program calls them and they're never going to resolve to
133    // a workspace symbol. Keep this list tight; when in doubt, leave
134    // the call OUT of the allowlist so it surfaces.
135    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    // Rebuild the same resolution context the call-graph walker uses.
206    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, // enclosing_class — best-effort; we don't replay class scope
277            );
278            if resolved.is_none() {
279                // Filter out trivial stdlib calls. Match on the FIRST
280                // segment for chained attribute access (`foo.bar` →
281                // check `foo` against the allowlist; this catches
282                // `str.format`-style chains incidentally).
283                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
312/// Walk path components and return the tail after the first `src` segment.
313/// Falls back to the full path for projects without that convention.
314///
315/// Examples:
316/// - `src/mypackage/module.py`  → `mypackage/module.py`
317/// - `lib/src/utils.py`         → `utils.py`
318/// - `mypackage/module.py`      → `mypackage/module.py`  (no `src`, unchanged)
319fn 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
333/// Derive the dotted module prefix for a file path.
334///
335/// Anchors at the `src/` boundary for src-layout projects (PEP 517/518)
336/// so the prefix is stable regardless of which directory `asd index` was
337/// invoked from. Falls back to the full relative path for flat layouts.
338///
339/// `src/mypackage/module.py`        -> `mypackage.module`
340/// `packages/mypkg/src/__init__.py` -> `mypkg.__init__`  (package-name disambiguates)
341/// `foo/bar.py`                     -> `foo.bar`          (no src segment, fallback)
342/// `./foo/bar.py`                   -> `foo.bar`
343/// `bar.py`                         -> `bar`
344fn 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    // __init__.py at the root of a package's src/ dir is not unique across packages.
352    // Prepend the package directory name (the segment before `src/`).
353    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
361/// Extract the package name from a path like `packages/my-pkg/src/__init__.py`
362/// by finding the segment immediately before a `src` component.
363fn 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
373/// Recursive descent over the Python tree. We enumerate:
374/// - `function_definition` -> Function or Method depending on scope
375/// - `class_definition` -> Class
376///
377/// Nested functions get their enclosing function/class names in their qname.
378fn 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            // Recurse into the body with this function pushed on the scope.
399            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            // Descend into other containers (module root, decorated_definition, etc.).
431            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        // tree-sitter positions are 0-based; ASD wants 1-based.
482        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    // `superclasses` is optional in the grammar.
521    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
529// -----------------------------------------------------------------------------
530// Cross-service endpoint detection (t-002)
531//
532// Heuristic, line-based, matching the substring style of effect inference.
533// Inbound: FastAPI/Flask route decorators. Outbound: requests/httpx client
534// calls with a literal URL. Non-literal URLs (f-strings, variables) are skipped
535// — without a literal we can't form a contract that would match a server route.
536// Session/client-object calls and Django url routing are known coverage gaps.
537// -----------------------------------------------------------------------------
538
539const 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    // Plan Q t-004: resolve each router's prefix once per file so route contracts
548    // are the full runtime path, not just the decorator arg. Covers same-file
549    // `APIRouter(prefix=)` / `Blueprint(url_prefix=)` and same-file
550    // `include_router`/`register_blueprint` mounts. (Cross-file/nested mount
551    // trees are increment 2.)
552    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        // Inbound: route decorators (@app.get("/x"), @router.get("/x"), @bp.route(...)).
558        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        // Outbound: requests/httpx client calls with a literal URL.
579        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        // Pub-sub (Celery): a @shared_task/@app.task def is a listener; the task
595        // function's name is the contract. `name.delay()/.apply_async()` is the
596        // emit. The two match by task name.
597        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
630/// Last dotted segment of a qname (`a.b.process_order` → `process_order`).
631fn simple_name(qname: &str) -> &str {
632    qname.rsplit('.').next().unwrap_or(qname)
633}
634
635/// `@shared_task`, `@app.task`, `@celery.task(bind=True)`, `@<x>.task` — Celery
636/// task definitions (which act as message consumers).
637fn 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
645/// Task names invoked via `<name>.delay(` / `<name>.apply_async(`.
646fn 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
669/// The symbol whose line range contains `line`, innermost (largest start_line)
670/// when nested (class + method).
671fn 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
678/// The symbol a decorator on `line` decorates: the symbol containing the line,
679/// else the nearest `def` starting just below it (decorators stack within a few
680/// lines of the definition).
681fn 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
691/// Parse a route decorator line into `(METHOD, path, confidence)` pairs.
692/// `@x.get("/p")` → one entry; `@x.route("/p", methods=["GET","POST"])` → one
693/// per method (default GET when `methods=` is absent).
694fn parse_route_decorator(line: &str) -> Vec<(String, String, f64)> {
695    let Some(rest) = line.strip_prefix('@') else {
696        return Vec::new();
697    };
698    // Need an `obj.attr(` shape.
699    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    // The attribute must be on something (a.b), not a bare `@decorator(`.
708    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
737// ── router-prefix resolution (Plan Q t-004) ────────────────────────────────
738
739/// The receiver variable of a route decorator: `@router.get("/x")` → `router`,
740/// `@app.route(...)` → `app`. `None` for bare `@decorator(` shapes.
741fn 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
754/// Map every router/blueprint variable in a file to its resolved prefix, from
755/// same-file `APIRouter(prefix=)` / `Blueprint(url_prefix=)` definitions and
756/// same-file `include_router(child, prefix=)` / `register_blueprint(child,
757/// url_prefix=)` mounts. The mount prefix precedes the router's own prefix
758/// (FastAPI/Flask ordering).
759fn 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        // `<var> = APIRouter(... prefix="P" ...)` / `Blueprint(... url_prefix="P" ...)`.
765        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        // `<recv>.include_router(<child>, prefix="Q")` / `register_blueprint(<child>, url_prefix="Q")`.
781        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
807/// Args slice after `Name(` in `s`, respecting an identifier boundary before
808/// `Name` (so `APIRouter` is not matched inside `MyAPIRouter`).
809fn 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
830/// A `key="value"` string kwarg, respecting an identifier boundary before `key`
831/// (so `prefix` is not matched inside `url_prefix`).
832fn 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
856/// First positional argument if it's a (possibly dotted) identifier.
857fn 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
874/// Normalize a prefix: leading slash, no trailing slash, collapse `//`.
875fn 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
893/// Join a resolved router prefix with a decorator path into the full route path.
894fn 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
909// ── project-level mount-tree resolution (Plan Q t-004b/c) ───────────────────
910
911/// Rewrite each inbound HTTP endpoint's contract to the full runtime path by
912/// resolving the cross-file, nested router-mount chain
913/// (`app.include_router(api, prefix="/api")` → `api.include_router(calc,
914/// prefix="/calc")` → `@calc.get("/x")` = `/api/calc/x`). Routers are keyed by
915/// their **qname** (`module.var`), so an `include_router(alias)` mount connects
916/// to the aliased router's routes even across files and generic `router` names
917/// (t-004c: aliases resolved via `from … import … as …`).
918fn resolve_python_endpoint_prefixes(
919    files: &[(String, String)],
920    endpoints: &mut Vec<ServiceEndpoint>,
921) {
922    // Per-file module qname + import alias map (local name -> imported qname).
923    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    // Global router tables keyed by qname (globally unique — no ambiguity).
938    let mut self_by_q: HashMap<String, String> = HashMap::new();
939    let mut parent_of: HashMap<String, Vec<(String, String)>> = HashMap::new(); // child -> mounts
940    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            // `<var> = APIRouter(...)` / `Blueprint(...)` / `FastAPI()` / `Flask()`.
949            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); // a definition binds locally
954                    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            // `<parent>.include_router(<child>, prefix="Q")` / register_blueprint.
973            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    // Routes served under >1 mount prefix produce extra endpoints (t-004d),
1006    // appended after the in-place rewrite pass.
1007    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        // Global mount-tree resolution for known routers; else file-local (t-004a).
1040        // A router mounted at N prefixes yields N full paths (t-004d).
1041        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
1069/// `module.name`, or just `name` when module is empty.
1070fn qualify(module: &str, name: &str) -> String {
1071    if module.is_empty() {
1072        name.to_string()
1073    } else {
1074        format!("{module}.{name}")
1075    }
1076}
1077
1078/// Resolve a local name to a qname: an imported alias resolves to the imported
1079/// symbol's qname; otherwise it's `module.name` (a local binding).
1080fn 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
1087/// Per-file import map: local name -> imported symbol qname, for
1088/// `from <mod> import a, b as c` (relative or absolute). Best-effort single-line
1089/// — enough to connect router aliases. Reuses [`resolve_relative_import`].
1090fn 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
1132/// Full mount-resolved prefixes for a router qname. A router included at several
1133/// mounts (or via a parent that is) yields several full paths — one per mount
1134/// path through the tree (`full(parent) + mount + self`). Memoized, cycle-guarded
1135/// (a cycle contributes no path), and deduped.
1136fn 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(); // in-progress (None) → empty = cycle break
1145    }
1146    cache.insert(q.to_string(), None); // mark visiting
1147    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
1175/// The receiver before `.<fname>(` — `api_router.include_router(...)` →
1176/// `api_router`, `app.register_blueprint(...)` → `app`.
1177fn 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
1192/// Parse requests/httpx client calls on a line into `(METHOD, url, confidence)`.
1193/// Handles `requests.get("u")`, `httpx.post('u')`, and
1194/// `requests.request("POST", "u")`. Only literal URLs are returned.
1195fn 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            // Must be a call boundary, not a substring of a longer identifier.
1202            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                // requests.request("POST", "url", ...)
1224                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
1234/// Extract the first string literal from an argument list. Returns `None` for
1235/// f-strings / b-strings (dynamic) so we never form a bogus contract; r-strings
1236/// (raw) are accepted. Leading whitespace and a `url=` kwarg name are skipped.
1237fn first_string_arg(args: &str) -> Option<String> {
1238    let s = args.trim_start();
1239    // Skip a leading `name=` kwarg label (e.g. url=, path=).
1240    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    // Advance past the first literal + comma, then read the second.
1256    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
1263/// Read a leading string literal, rejecting f/b-string prefixes. Returns the
1264/// inner text (without quotes).
1265fn read_string_literal(s: &str) -> Option<String> {
1266    let bytes = s.as_bytes();
1267    let mut i = 0;
1268    // Allow a raw-string prefix `r`/`R`; reject f/b (dynamic / bytes).
1269    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
1285/// Parse a `methods=["GET", "POST"]` (or `=('GET',)`) kwarg into upper-cased
1286/// method names.
1287fn 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    // Take up to the closing bracket/paren of the list.
1297    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
1320// -----------------------------------------------------------------------------
1321// Data-flow detection (t-002 slice 4)
1322//
1323// Conservative arg→param detection: only same-file function calls whose
1324// arguments are ALL bare identifiers. High precision over recall — cross-file
1325// resolution, method calls, literals, and kwargs are deliberately skipped
1326// (tracked as follow-up). The pipeline maps each arg position to the callee's
1327// parameter name from its signature.
1328// -----------------------------------------------------------------------------
1329
1330fn infer_dataflow_in_python(
1331    file: &str,
1332    source: &str,
1333    symbols: &[ParsedSymbol],
1334) -> Vec<DetectedDataFlow> {
1335    use std::collections::HashMap;
1336
1337    // Same-file function simple-name → qname. A name defined more than once is
1338    // ambiguous (mapped to None) and never resolved.
1339    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
1376/// Find `name(args)` call sites on a line where the callee is a bare function
1377/// (not a method) and every argument is a simple identifier. Returns
1378/// `(callee_name, [arg_idents])`.
1379fn 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            // Capture the identifier ending right before '('.
1395            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
1439/// `Some(args)` only when every comma-separated argument is a bare identifier
1440/// (no nested calls, literals, operators, or kwargs) and there is at least one.
1441fn 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
1462// -----------------------------------------------------------------------------
1463// Effect inference
1464// -----------------------------------------------------------------------------
1465
1466fn infer_effects_from_body(body: &str) -> Vec<Effect> {
1467    let mut effects: Vec<Effect> = Vec::new();
1468
1469    // Plan L t-002: detection runs on a copy with comments + string
1470    // literals masked to spaces, so an effect-like substring inside a
1471    // `# os.open(…)` comment or a `"os.open(…)"` literal no longer
1472    // produces a false-positive inference. We preserve byte length so
1473    // all the existing offset-based slicing (`&body[args_start..]`,
1474    // notes, path extraction) continues to read from the original
1475    // source — the mask only suppresses *matches*, not data.
1476    let masked = mask_comments_and_literals(body);
1477    let scan = masked.as_str();
1478
1479    // open(...) -> IoFsRead (+ IoFsWrite when mode contains 'w' or 'a')
1480    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    // print(...) / sys.stdout / sys.stderr / logging.* / log.* / logger.* -> Log
1507    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    // Database cursor/conn/db.execute(...) / .fetchone() / .commit()  -> IoDb*
1544    // Heuristic: inspect the SQL-like first arg of `.execute(` to decide
1545    // read vs write. Common names: db, conn, cursor, cur, session, c.
1546    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                // Unrecognized SQL — conservatively emit both.
1597                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    // .commit() implies a preceding write
1615    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    // Network libraries -> IoNetOut
1625    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    // subprocess.* / os.system / os.exec* -> ProcSpawn
1658    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    // os.environ / os.getenv -> EnvRead
1669    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    // time.sleep -> TimeSleep
1694    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    // time.time / time.monotonic / datetime.now -> TimeRead
1704    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    // random.* / secrets.* -> Random
1722    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    // `raise ` statement -> Throw
1733    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/// Scan for a literal needle and return the lines we recognize as actual call
1747/// sites. We do not attempt to skip string literals — for M1 this is good
1748/// enough and matches the "plain substring" guidance.
1749#[derive(Debug, Clone, Copy)]
1750struct CallSite {
1751    call_start: usize,
1752    args_start: usize,
1753    args_end: usize,
1754}
1755
1756/// Find all call sites where `needle` (which must end in `(`) appears in
1757/// `body`, returning the byte offsets of the opening identifier, the byte
1758/// after the `(`, and the matching `)`.
1759fn 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
1788/// Like [`find_calls`], but for a dotted module/receiver prefix that ends in
1789/// `.` (e.g. `"requests."`). Matches `prefix<identifier>(` — the dotted
1790/// prefix, a bare method identifier, then the call's parens — and returns a
1791/// [`CallSite`] whose `call_start` is the prefix start and whose
1792/// `args_start..args_end` span the parenthesised arguments.
1793///
1794/// `find_calls` requires its needle to end in `(`, so passing a varying-method
1795/// prefix like `"requests."` to it returns nothing (the guard rejects
1796/// non-`(` needles). That is exactly how the network-effect detection used to
1797/// silently no-op for `requests.get(...)` / `httpx.post(...)`; this helper
1798/// closes that gap.
1799fn 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        // The method name must be a bare identifier immediately followed by '('.
1808        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
1828/// Given the position just after `(`, find the matching `)`. Handles nested
1829/// parens and single/double quoted strings (non-raw). Returns the index of the
1830/// matching `)` byte.
1831fn 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    // Look for a mode string literal containing 'w' or 'a'. Very loose —
1865    // we just scan quoted sections.
1866    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
1895/// Extract the first positional argument if it is a plain string literal.
1896fn extract_open_path(args: &str) -> Option<String> {
1897    extract_first_string_literal(args)
1898}
1899
1900/// If the args start with a string literal, return its contents.
1901fn 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
1929/// Try to extract a host from args containing a URL literal.
1930fn extract_url_host(args: &str) -> Option<String> {
1931    // Find any `http://` or `https://` in the args text.
1932    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
1950/// Return a short, single-line note snippet.
1951/// Cap a doc string at 512 characters, trimming at a word boundary when possible.
1952fn truncate_doc(s: &str) -> String {
1953    const MAX: usize = 512;
1954    if s.len() <= MAX {
1955        return s.to_string();
1956    }
1957    // Try to break at a space before the limit.
1958    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
1974/// Plan L t-002: scan `scan` for a match (so comments / string literals
1975/// previously masked to spaces don't trigger), but return the matching
1976/// line from `body` so the note reflects the real source text. `scan`
1977/// and `body` MUST be the same byte length and split into the same
1978/// line count for the alignment to hold.
1979fn 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
1990/// Check each needle against the masked `scan`; return the first
1991/// matching line from the real `body` as a note.
1992fn 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
2001/// Detect a `raise` statement (not `raises` in a docstring or attribute).
2002/// We check for `raise ` preceded by start-of-line or whitespace.
2003/// Operates on the masked `scan` so a `raise` inside a comment or
2004/// string literal doesn't trip the detector.
2005fn 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
2015/// Plan L t-005: scan Python source for call patterns the static
2016/// call-graph walker can't resolve. Returns one hint per detected
2017/// site so the indexer can surface a warning. The patterns we
2018/// flag are deliberately conservative — only the ones that are
2019/// unambiguously dynamic dispatch:
2020///
2021/// - `getattr(obj, "name")(args)` and `getattr(obj, name_var)(args)`
2022///   — runtime attribute lookup feeding a call.
2023/// - `__getattr__` / `__getattribute__` method definitions — the
2024///   class promises to resolve unknown attributes at runtime.
2025///
2026/// We deliberately do NOT flag plain `getattr(obj, "name")` (no
2027/// trailing call) — that's a read, not a dispatch, and gets
2028/// resolved by the property/attribute pass.
2029///
2030/// Detection runs against the masked source (Plan L t-002) so a
2031/// `# getattr(...)` in a comment doesn't generate a phantom warning.
2032pub 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    // Pattern 1: getattr(obj, …)(…)  — the trailing `(` after the
2063    // outer `)` is the giveaway. We use find_calls + check the byte
2064    // right after args_end.
2065    for site in find_calls(&masked, "getattr(") {
2066        // Peek past the closing `)` for an opening `(` (allowing
2067        // whitespace / chained attribute reads in between is more
2068        // ambitious — for v1 we require an immediate call).
2069        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    // Pattern 2: `def __getattr__(self, …)` / `def __getattribute__(…)`
2084    // method definitions. Scan line by line; `mask_comments_and_literals`
2085    // preserves line splits so offsets align.
2086    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
2102/// Plan L t-002: walk Python source and replace bytes inside `#`
2103/// line comments and string literals (single/double/triple, with
2104/// optional `r` / `b` / `f` prefixes) with ASCII space. Newlines and
2105/// every other byte stay where they are, so the output has the same
2106/// byte length AND the same line split as the input. Callers can
2107/// detect call patterns against the masked output and use byte
2108/// offsets to extract real text from the original body.
2109///
2110/// Limitations:
2111/// - F-string interpolations inside `f"{expr}"` are masked along
2112///   with the rest of the literal. An effect inside an f-string
2113///   interpolation will be missed. Acceptable trade — the bug being
2114///   fixed is comments and plain strings producing false positives;
2115///   under-detecting f-string interpolations is conservative.
2116/// - Raw strings (`r"..."`) are masked normally; backslashes don't
2117///   escape the closing quote.
2118fn 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        // Line comment: `#` to end-of-line, but not inside an f-string
2126        // (we're not in a string here because we'd have entered the
2127        // literal branch below).
2128        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        // String literal start. Optional prefix: r/R/b/B/f/F (and
2138        // combinations like rb, fr, Rb…). The prefix bytes themselves
2139        // are NOT masked — they're identifiers as far as effect
2140        // detection is concerned, and erasing them could turn
2141        // `rb"…"` into `  "…"` which still parses as a literal but
2142        // changes column counts in error paths. Leave the prefix,
2143        // mask the literal body.
2144        let quote_start = find_quote_after_optional_prefix(bytes, i);
2145        if let Some(qi) = quote_start {
2146            let q = bytes[qi];
2147            // Triple quote?
2148            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            // Mask the literal body (between the open and close
2159            // quotes). Quotes themselves stay so paren-matching and
2160            // any human-eyeballing of the masked source still reads.
2161            for k in literal_start..end_pos.min(n) {
2162                if out[k] != b'\n' {
2163                    out[k] = b' ';
2164                }
2165            }
2166            // Advance past the closing quote(s).
2167            i = end_pos + if triple { 3 } else { 1 };
2168            continue;
2169        }
2170        i += 1;
2171    }
2172    // Safety: byte length must be preserved for offset-based slicing
2173    // upstream. `out` was built from `bytes.to_vec()` and only mutated
2174    // in place, so length is identical to `body.len()`. The mutations
2175    // only ever swap a byte for ASCII space (0x20) or leave it alone,
2176    // both of which keep the buffer valid UTF-8.
2177    debug_assert_eq!(out.len(), bytes.len());
2178    String::from_utf8(out).unwrap_or_else(|_| body.to_string())
2179}
2180
2181/// Skip optional Python string prefix bytes (`r`, `R`, `b`, `B`, `f`,
2182/// `F`, in 1- or 2-char combinations like `rb`/`Rb`/`fr`) starting at
2183/// `i`, returning the index of the opening quote if found. Only
2184/// activates when `bytes[i]` is one of the prefix bytes AND a quote
2185/// follows within two bytes — otherwise the caller's `i` is on a
2186/// normal identifier byte and we return None.
2187fn 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    // The byte before `i` must NOT be an identifier byte — otherwise
2201    // we're in the middle of a word like `_renderRequest`, not at a
2202    // string prefix. Treat any ASCII alphanumeric or underscore as
2203    // identifier-continuing.
2204    if i > 0 {
2205        let prev = bytes[i - 1];
2206        if prev.is_ascii_alphanumeric() || prev == b'_' {
2207            return None;
2208        }
2209    }
2210    // 1-char prefix: `r"…"`
2211    if i + 1 < n && (bytes[i + 1] == b'"' || bytes[i + 1] == b'\'') {
2212        return Some(i + 1);
2213    }
2214    // 2-char prefix: `rb"…"` / `fr"…"` / etc.
2215    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
2221/// Find the closing single-quote `q` starting from `start`. Honors
2222/// backslash escapes. Returns the index of the closing quote, or
2223/// `bytes.len()` if no close found (treat as "string runs to EOF").
2224/// A bare newline closes a non-triple string in Python — we honor
2225/// that so an unterminated literal doesn't mask the rest of the file.
2226fn 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; // unterminated; stop at newline
2237        }
2238        if b == q {
2239            return j;
2240        }
2241        j += 1;
2242    }
2243    n
2244}
2245
2246/// Find the closing `qqq` triple-quote sequence starting from `start`.
2247/// Returns the index of the FIRST quote in the closing triple, or
2248/// `bytes.len()` if the literal runs to EOF.
2249fn 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
2265// -----------------------------------------------------------------------------
2266// Call-edge extraction (intra- and cross-module)
2267// -----------------------------------------------------------------------------
2268
2269/// Build the (caller_qname -> callee_qname) edge set for one file. Resolution
2270/// is best-effort and covers:
2271///
2272/// - `identifier()` -> intra-module `simple_name -> qname`, then imports
2273///   (`from foo import bar` / `import foo as f`).
2274/// - `Class.method()` -> resolve `<module>.Class.method`.
2275/// - `self.method()` inside `<module>.Class.<fn>` -> resolve to `<module>.Class.method`.
2276/// - `foo.bar()` where `foo` is an imported module -> resolve via the import
2277///   map to `<foo_prefix>.bar`, and emit only if that qname exists in the
2278///   workspace. This is how we capture cross-module calls like
2279///   `logger.write_log` from `_driver.py`.
2280///
2281/// Anything we can't resolve (subscripts, lambdas, star imports, relative
2282/// imports, dynamic dispatch) is silently skipped.
2283fn 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    // All known qnames in this file.
2292    let known: HashSet<&str> = symbols.iter().map(|s| s.qname.as_str()).collect();
2293
2294    // simple_name -> qname for single-identifier resolution. If two symbols
2295    // share a simple name (e.g. method/function collision), we keep the first
2296    // module-level one we see; this is best-effort by design.
2297    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        // Prefer module-level functions/classes over nested entries: only fill
2301        // if the qname has exactly module_prefix + "." + simple.
2302        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    // Parse the full source once to extract top-level imports. We need the
2323    // file-level tree (not individual symbol bodies) so we can pick up
2324    // `import foo` / `from foo import bar` at module scope. module_prefix
2325    // is passed so relative imports (`from . import x`, `from .foo import y`)
2326    // can be resolved to absolute qnames — Plan D t-004.
2327    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        // Only function and method bodies can contain calls in a meaningful
2333        // sense; skip class/module symbols (their bodies' calls are usually
2334        // attributed to a wrapping function anyway).
2335        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        // The parsed body is a `function_definition` at (or near) the root —
2346        // walk the whole tree and pick up every `call` node.
2347        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/// Imports discovered at module scope, keyed by the local binding.
2372///
2373/// Each entry records:
2374/// - `qname_prefix`: the qname (or qname prefix) the local name resolves to.
2375/// - `is_module`: whether the local name refers to a *module* (resolve
2376///   `local.attr` -> `qname_prefix.attr`) vs. a single imported symbol
2377///   (resolve `local.attr` -> `qname_prefix.attr` as well, but `local()`
2378///   resolves to `qname_prefix` directly).
2379#[derive(Debug, Clone)]
2380struct ImportBinding {
2381    qname_prefix: String,
2382    /// `true` for `import foo` / `import foo as f`; `false` for
2383    /// `from foo import bar`. Currently both forms resolve attribute access
2384    /// via the same path (`qname_prefix` + `.` + `attr`), but we keep the
2385    /// distinction so future resolvers (e.g., re-exports) can tell apart
2386    /// module handles from direct symbol bindings.
2387    #[allow(dead_code)]
2388    is_module: bool,
2389}
2390
2391/// Walk the file's top-level statements and collect import bindings.
2392///
2393/// Supports:
2394/// - `import foo` -> `foo -> ("foo", module)`
2395/// - `import foo.bar` -> `foo -> ("foo", module)` (only the first segment binds)
2396/// - `import foo as f` -> `f -> ("foo", module)`
2397/// - `from foo import bar` -> `bar -> ("foo.bar", symbol)`
2398/// - `from foo import bar as b` -> `b -> ("foo.bar", symbol)`
2399/// - `from foo import (a, b, c)` -> each as above
2400/// - Plan D t-004: relative imports (`from . import x`, `from .foo import y`,
2401///   `from ..pkg import z`) — resolved against `module_prefix`.
2402///
2403/// - Plan L t-004: imports nested inside function bodies, class
2404///   bodies, and conditional blocks (`if TYPE_CHECKING:`, `try: …
2405///   except ImportError: …`) are also collected and merged into the
2406///   same flat map.
2407///
2408/// Skips:
2409/// - `from foo import *` — can't statically resolve members.
2410///
2411/// Nested-import scoping policy: we deliberately use a single flat
2412/// map rather than per-function scoping. Real Python code rarely
2413/// shadows module-scope imports inside a function under the same
2414/// local name; when it does (typically `try/except ImportError`
2415/// fallbacks), either branch resolving to the same prefix is the
2416/// behavior callers want. The trade-off: a function that genuinely
2417/// imports a different module under a colliding name would resolve
2418/// to whichever was inserted last. Acceptable for the call-edge
2419/// signal we're building.
2420fn 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
2435/// Plan L t-004: recursively walk the tree picking up every
2436/// `import_statement` / `import_from_statement` node — module-scope
2437/// AND nested. Skipping non-import nodes is implicit; we just
2438/// recurse into every child and dispatch on `kind()`.
2439///
2440/// We don't prune at function/class boundaries on purpose: a
2441/// `if TYPE_CHECKING:` block sits at module scope, a body import
2442/// sits inside a function, a `try: import cPickle / except: import
2443/// pickle` sits inside a `try_statement`. Recursing everywhere
2444/// catches all three with one walk.
2445fn 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
2468/// Plan D t-004: resolve a relative-import dot prefix against the importing
2469/// file's `module_prefix`. Returns the absolute module qname (e.g.
2470/// "crucible.agents.base") that the dots + optional suffix point to, or
2471/// None when the relative import escapes the workspace.
2472fn 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
2497/// Handle `import a, b as c, d.e`.
2498fn collect_import_statement(node: Node<'_>, src: &[u8], out: &mut HashMap<String, ImportBinding>) {
2499    // Tree-sitter-python emits each imported name as a `name` field (which
2500    // may appear multiple times), each being either `dotted_name` or
2501    // `aliased_import`.
2502    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                // `import foo.bar.baz` -> local binding is the first segment,
2507                // but resolves to just that first segment (not the tail —
2508                // Python only binds the top package name).
2509                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                // Also register the fully-dotted form so `foo.bar.baz(...)`
2523                // written explicitly can resolve to a module qname prefix.
2524                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                // `import foo as f` / `import foo.bar as fb`
2536                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
2562/// Handle `from foo import a, b as c` and `from foo import (a, b)`.
2563fn collect_import_from_statement(
2564    node: Node<'_>,
2565    src: &[u8],
2566    module_prefix: &str,
2567    out: &mut HashMap<String, ImportBinding>,
2568) {
2569    // `module_name` field holds a `dotted_name` or `relative_import`.
2570    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        // Plan D t-004: resolve `.`/`..`/etc. against the importing file's
2574        // module_prefix. `from . import x` from crucible/agents/foo.py
2575        // resolves to module "crucible.agents".
2576        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    // Detect `from foo import *` — tree-sitter-python represents the `*` as
2596    // a `wildcard_import` child (not a `name` field). Skip in that case.
2597    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
2650/// For a method qname like `mod.Class.m`, return `Some("mod.Class")` provided
2651/// `mod.Class` is a known symbol in this file. Otherwise `None`.
2652fn 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    // Drop the function/method name; look for the deepest ancestor that is a
2662    // known symbol AND lives below `module_prefix`.
2663    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/// Resolve the function-position node of a `call` to a fully-qualified name,
2725/// when possible. Precedence (LEGB-ish, for our purposes):
2726///
2727/// 1. Intra-module `simple_name -> qname` (local definitions shadow imports).
2728/// 2. Import map (`from foo import bar` / `import foo as f`).
2729/// 3. For attribute calls, try intra-module class resolution, then imports.
2730///
2731/// Cross-module resolutions are only emitted when the resolved qname is
2732/// present in `workspace.qnames`. This drops stdlib / third-party calls
2733/// whose symbols we didn't index.
2734#[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            // 1) intra-module first (shadows imports)
2749            if let Some(q) = by_simple.get(&name) {
2750                return Some(q.clone());
2751            }
2752            // 2) import map (e.g., `from logger import write_log`)
2753            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            // `<object>.<attribute>` — resolve `self.X`, `Class.X`, and
2762            // `<imported_module>.X`.
2763            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                    // Intra-module class attribute: `Foo.bar` -> `mod.Foo.bar`
2780                    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                    // Imported module or symbol: `logger.write_log` ->
2793                    // `<prefix>.write_log`, validated against workspace.
2794                    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                    // Chained attribute like `foo.bar.baz(...)`. Try to
2804                    // flatten into a dotted path and match against imports.
2805                    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
2821/// Flatten a nested `attribute` node back into its dotted text form.
2822/// Returns `None` if any inner node isn't an identifier/attribute chain.
2823fn 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        // FastAPI: APIRouter(prefix=) on the router the decorator hangs on.
2844        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        // Flask blueprint url_prefix.
2865        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        // Same-file mount: include_router prefix precedes the router's own prefix.
2872        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        // `prefix` must not be matched inside `url_prefix`, and a plain FastAPI()
2887        // app with no prefix yields nothing.
2888        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        // join handles missing/extra slashes and root paths.
2895        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        // ctor boundary: APIRouter not matched inside a longer identifier.
2900        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        // The real-world shape: nested include_router across files, where the
2921        // mounted router is an ALIASED import and the decorator uses a generic
2922        // `router`. Qname identity (module.var) bridges the alias.
2923        //   main:  app=FastAPI(); app.include_router(api_router, "/api")
2924        //   api:   api_router=APIRouter(); include_router(calculators_router, "/calculators")
2925        //          (calculators_router is `from calc import router as calculators_router`)
2926        //   calc:  router=APIRouter(); @router.get("/budget")  → /api/calculators/budget
2927        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        // `router` in two files → distinct qnames (a.router, b.router), each
2949        // resolving to its own prefix (no cross-contamination).
2950        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        // api_router mounted at BOTH /api/v1 and /api → route served at both.
2974        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        // src/ anchor — stable for PEP 517 src-layout projects
2999        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        // __init__.py at root of package src/ gets package-name prefix
3005        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        // no src segment — full relative path (fallback)
3014        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    // ----- Plan L t-002: comment + string-literal false-positive guards -----
3057
3058    #[test]
3059    fn comment_mentioning_open_does_not_infer_fs_effects() {
3060        // `open(` appears only in a comment; we must NOT infer fs.read.
3061        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        // `open(` only inside a string literal — no real call.
3073        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        // A docstring describing what `requests.get` would do — must
3084        // not infer IoNetOut.
3085        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        // The positive counterpart to the docstring test above. A live
3103        // `requests.get(url)` MUST infer IoNetOut. Regression guard for the
3104        // dead-code bug where `find_calls(scan, "requests.")` returned nothing
3105        // because the prefix didn't end in `(` — found by the cross-language
3106        // conformance matrix (effects column was blank for Python).
3107        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        // The masking must not suppress a real call site that sits
3126        // next to a comment containing the same pattern.
3127        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        // The downstream offset-based slicing relies on masked + body
3150        // being the same byte length AND splitting into the same lines.
3151        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        // Spot check: `open(` inside the literal AND the comment must
3160        // be erased from the masked view.
3161        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        // `request_open` is an identifier, not a string prefix —
3170        // masking must not eat the `r` thinking it's `r"…"`.
3171        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    /// Build a WorkspaceSymbols with just a set of qnames (kinds defaulted
3190    /// to Function). Tests don't currently exercise the kinds map.
3191    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        // `from logger import write_log` — `write_log(...)` should resolve
3241        // to `logger.write_log`.
3242        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        // `import logger` — `logger.write_log(...)` should resolve.
3265        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        // Workspace doesn't know about `stdlib_mod.thing` — no edge emitted.
3288        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        // `helper` defined locally should win over `from x import helper`.
3339        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        // Sanity: ensure the trait method exists and the default returns empty.
3369        // This pins the behavior so callers can rely on it.
3370        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    // -- Plan D t-004: relative-import resolver -----------------------------
3388
3389    #[test]
3390    fn relative_dot_resolves_to_current_package() {
3391        // crucible/agents/litellm_agent.py → file qname = crucible.agents.litellm_agent
3392        // `from . import base` → "crucible.agents.base"
3393        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        // `from ..util import x` from crucible/agents/litellm_agent.py
3406        // current package = crucible.agents; parent = crucible; "..util" → crucible.util
3407        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        // crucible/agents/__init__.py has module_prefix = crucible.agents.__init__
3414        // `from . import base` → drop 1 segment → crucible.agents; +"base" → crucible.agents.base
3415        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        // Two dots when we only have one segment to drop.
3422        assert!(super::resolve_relative_import("crucible", "..foo").is_none());
3423    }
3424
3425    // ---- Plan L t-006: unresolved-call reporter -----------------------
3426
3427    #[test]
3428    fn unresolved_calls_reports_third_party_call() {
3429        // A call to a name that isn't in the workspace and isn't on
3430        // the stdlib allowlist should surface.
3431        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        // print() / len() / range() must NOT show up as unresolved —
3460        // they're on the allowlist.
3461        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        // A call to an internal symbol that resolves must NOT appear
3490        // in the unresolved list.
3491        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    // ---- Plan L t-005: dynamic-dispatch scanner -----------------------
3533
3534    #[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        // A bare attribute read — not a dispatch — should NOT warn.
3547        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        // The scanner runs on masked source — a getattr in a comment
3584        // or string literal must NOT produce a warning.
3585        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    // ---- Plan L t-004: nested-import resolution -----------------------
3594
3595    #[test]
3596    fn extract_call_edges_resolves_function_body_import() {
3597        // `def f(): import requests; requests.get(...)` — the import
3598        // lives inside the function body, not at module scope. The
3599        // walker must descend into the body and pick it up.
3600        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        // Imports inside `if TYPE_CHECKING:` blocks should also be
3638        // collected — the walker doesn't prune on conditional nodes.
3639        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        // `try: import cPickle as pickle / except: import pickle` —
3676        // the try-block import lives inside a `try_statement`. Walker
3677        // must descend.
3678        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        // Either branch's resolution is acceptable — both are real
3712        // bindings in the same module. We just need at least one.
3713        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        // Regression guard: the recursive walker must not break the
3725        // already-working module-scope case.
3726        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        // Plan L t-003 acceptance gate: the double-dot case
3764        // (`from ..pkg import x`) was tested in isolation
3765        // (`relative_double_dot_goes_up_one_more`) but never end-to-end.
3766        // This locks in the full pipeline behavior so future refactors
3767        // can't silently regress on the two-dot case.
3768        use crate::PythonAdapter;
3769        use agentstatedeveloper_core::{
3770            LanguageAdapter, ParsedSymbol, SymbolKind, WorkspaceSymbols,
3771        };
3772
3773        let adapter = PythonAdapter::new();
3774        // crucible.agents.litellm_agent imports from crucible.util
3775        // (two dots up: agents → crucible, then .util).
3776        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        // End-to-end: a relative import + a call should produce a cross-module
3822        // CallEdge. This is the Crucible reproducer collapsed to a unit test.
3823        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        // A variable and an f-string can't form a usable contract.
3964        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        // The whole point: an outbound client contract equals the inbound server
3978        // contract for the same logical endpoint, so match_edges would link them.
3979        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        // helper isn't defined in this file → not resolved (cross-file is a follow-up).
4018        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        // @property / @app.get(...) must not register as a pub-sub listener.
4081        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}