Skip to main content

fxrank_lang_python/detect/
calls.rs

1//! World-effect detection: classifies effectful Python calls (fs/net/db, process
2//! control, env read/write, concurrency, time, random, logging, stdin) plus bare
3//! `assert`/`raise` statements, emitting an [`Effect`] per signal.
4//!
5//! This is the libcst analog of `fxrank-lang-rust`/`fxrank-lang-ts`'s
6//! `detect/calls.rs`. It does **not** own traversal — the [`walk_own_body`] driver
7//! in `detect/mod.rs` decides which nodes are evaluated in the enclosing body and
8//! calls back through the [`EffectSink`] trait; this module classifies and pushes.
9//!
10//! # Resolution
11//! A call's callee is rendered to a dotted string (`os.getenv`, `requests.get`).
12//! The leading root name is resolved through [`Imports`] so an aliased import
13//! (`import numpy as np`) maps back to its module, and the `import a.b.c` root-key
14//! convention is honored (root `a` → `"a.b.c"`). Bare builtins (`open`, `input`,
15//! `print`) need no import. Method-name-only signals (`.commit()`, `.to_csv()`) are
16//! receiver-type-unknown → `Heuristic`.
17
18use fxrank_core::confidence::detection_confidence;
19use fxrank_core::effect::{Effect, EffectKind, Tier};
20use fxrank_core::score::weight_for_class;
21use libcst_native::{Assert, AssignTargetExpression, Call, Expression, Name, Raise, Subscript};
22
23use super::{
24    EffectSink,
25    expr::{leftmost_name, render_expr},
26    walk_own_body,
27};
28use crate::functions::FnUnit;
29use crate::imports::Imports;
30use crate::source::{SpanIndex, anchor_of_subslice};
31
32/// Detect world effects (IO, process, env, time, random, logging, panic) charged to
33/// `unit`'s own body, per the driver's attribution rules.
34pub fn detect(unit: &FnUnit, imports: &Imports, span: &SpanIndex) -> Vec<Effect> {
35    let mut sink = CallSink {
36        imports,
37        span,
38        effects: Vec::new(),
39    };
40    walk_own_body(unit, &mut sink);
41    sink.effects
42}
43
44struct CallSink<'a> {
45    imports: &'a Imports,
46    span: &'a SpanIndex<'a>,
47    effects: Vec<Effect>,
48}
49
50impl EffectSink for CallSink<'_> {
51    fn on_call(&mut self, call: &Call) {
52        // Anchor on the callee's leading `Name` &str (borrowed from source).
53        let Some(anchor) = leftmost_name(&call.func) else {
54            return;
55        };
56        let (line, col) = name_line_col(anchor, self.span);
57        let Some(rendered) = render_expr(&call.func) else {
58            return;
59        };
60
61        // `subprocess(..., shell=True)` emits its process.control effect here; the
62        // dynamic.code risk it ALSO emits is Task 10's job.
63        if let Some((kind, tier, evidence)) = self.classify_call(&rendered) {
64            self.push(kind, tier, line, col, evidence);
65        }
66    }
67
68    fn on_assert(&mut self, assert: &Assert) {
69        let (line, col) = match leftmost_name(&assert.test) {
70            Some(n) => name_line_col(n, self.span),
71            None => (0, 0),
72        };
73        self.push(
74            EffectKind::Panic,
75            Tier::Exact,
76            line,
77            col,
78            "assert — stripped under -O".to_string(),
79        );
80    }
81
82    fn on_raise(&mut self, raise: &Raise) {
83        let (line, col) = raise
84            .exc
85            .as_ref()
86            .and_then(leftmost_name)
87            .map(|n| name_line_col(n, self.span))
88            .unwrap_or((0, 0));
89        self.push(
90            EffectKind::Panic,
91            Tier::Exact,
92            line,
93            col,
94            "raise".to_string(),
95        );
96    }
97
98    fn on_assign_target(&mut self, target: &AssignTargetExpression, _is_aug: bool) {
99        // `os.environ[...] = …` → env.write (heuristic). The target is a Subscript
100        // on `os.environ`.
101        if let AssignTargetExpression::Subscript(sub) = target
102            && let Some(rendered) = render_subscript_base(sub)
103            && self.resolve_dotted(&rendered).as_deref() == Some("os.environ")
104        {
105            let (line, col) = leftmost_subscript_name(sub)
106                .map(|n| name_line_col(n, self.span))
107                .unwrap_or((0, 0));
108            self.push(
109                EffectKind::EnvWrite,
110                Tier::Heuristic,
111                line,
112                col,
113                "os.environ[...] = … — environment write".to_string(),
114            );
115        }
116    }
117
118    fn on_attribute_read(&mut self, attr: &Expression) {
119        // Detect `sys.argv` (and `sys.argv[N]` — whose value walk reaches here as the
120        // inner `sys.argv` Attribute) as an ambient-read.  Resolution: `sys` must map
121        // to the `sys` module through the import table; `argv` must be the attribute name.
122        let Expression::Attribute(a) = attr else {
123            return;
124        };
125        if a.attr.value != "argv" {
126            return;
127        }
128        let Some(rendered_base) = render_expr(&a.value) else {
129            return;
130        };
131        if self.resolve_dotted(&rendered_base).as_deref() != Some("sys") {
132            return;
133        }
134        let (line, col) = leftmost_name(attr)
135            .map(|n| name_line_col(n, self.span))
136            .unwrap_or((0, 0));
137        self.push(
138            EffectKind::AmbientRead,
139            Tier::Path,
140            line,
141            col,
142            "sys.argv".to_string(),
143        );
144    }
145}
146
147impl CallSink<'_> {
148    fn push(&mut self, kind: EffectKind, tier: Tier, line: usize, col: usize, evidence: String) {
149        let class = kind.base_class();
150        // Path-tier effects carry a shadow penalty when the file imports dynamic-import
151        // infrastructure (importlib/__import__), because a bare name might resolve to
152        // a module we cannot see statically — mirrors the TS frontend's glob/dynamic shadow.
153        let shadowed = matches!(tier, Tier::Path) && self.imports.has_dynamic();
154        let confidence = detection_confidence(tier, false, shadowed);
155        self.effects.push(Effect {
156            kind,
157            class,
158            discounted_to: None,
159            weight: weight_for_class(class),
160            line,
161            col,
162            tier,
163            hidden: false,
164            contained: false,
165            evidence,
166            discount: None,
167            subreason: None,
168            confidence,
169        });
170    }
171
172    /// Resolve a rendered dotted callee through the import table, honoring the
173    /// `import a.b.c` root-key convention: split off the root name, resolve it, and
174    /// re-attach the trailing path. `requests.get` with `import requests` → root
175    /// `requests` resolves to `"requests"` → `"requests.get"`. `r.get` with
176    /// `import requests as r` → `"requests.get"`.
177    fn resolve_dotted(&self, rendered: &str) -> Option<String> {
178        let (root, rest) = match rendered.split_once('.') {
179            Some((r, rest)) => (r, Some(rest)),
180            None => (rendered, None),
181        };
182        let base = self.imports.resolve(root)?;
183        Some(match rest {
184            Some(rest) => format!("{base}.{rest}"),
185            None => base.to_string(),
186        })
187    }
188
189    /// Classify a rendered callee into (kind, tier, evidence).
190    fn classify_call(&self, rendered: &str) -> Option<(EffectKind, Tier, String)> {
191        use EffectKind::*;
192
193        // ── Bare builtins (no import resolution; could be shadowed, accepted) ──
194        match rendered {
195            "input" => {
196                return Some((
197                    EnvRead,
198                    Tier::Exact,
199                    "input() — interactive stdin read".to_string(),
200                ));
201            }
202            "print" => return Some((Logging, Tier::Exact, "print()".to_string())),
203            "open" => {
204                return Some((
205                    NetFsDb,
206                    Tier::Exact,
207                    "open(…) — file read/write".to_string(),
208                ));
209            }
210            _ => {}
211        }
212
213        // ── Path-resolved through the import table ──
214        if let Some(full) = self.resolve_dotted(rendered)
215            && let Some((kind, tier)) = classify_resolved(&full)
216        {
217            return Some((kind, tier, format!("{full}(…)")));
218        }
219
220        // ── Method-name-only heuristics (receiver type unknown) ──
221        if let Some((_, method)) = rendered.rsplit_once('.')
222            && let Some(kind) = classify_method(method)
223        {
224            return Some((kind, Tier::Heuristic, format!("{rendered}(…)")));
225        }
226
227        None
228    }
229}
230
231/// Classify a fully-resolved dotted module path (`requests.get`, `subprocess.run`,
232/// `os.getenv`, `time.time`) into (kind, tier).
233fn classify_resolved(full: &str) -> Option<(EffectKind, Tier)> {
234    use EffectKind::*;
235
236    let root = full.split('.').next().unwrap_or(full);
237    let leaf = full.rsplit('.').next().unwrap_or(full);
238
239    // ── net.fs.db (class 7) ──
240    if matches!(root, "shutil" | "tempfile" | "csv" | "socket")
241        || matches!(
242            root,
243            "requests" | "httpx" | "urllib" | "aiohttp" | "sqlite3" | "sqlalchemy"
244        )
245    {
246        return Some((NetFsDb, Tier::Path));
247    }
248    if root == "pathlib"
249        && matches!(
250            leaf,
251            "read_text" | "write_text" | "read_bytes" | "write_bytes"
252        )
253    {
254        return Some((NetFsDb, Tier::Path));
255    }
256    if root == "json" && matches!(leaf, "load" | "dump") {
257        return Some((NetFsDb, Tier::Path));
258    }
259    if root == "pandas" && matches!(leaf, "read_csv" | "read_excel") {
260        return Some((NetFsDb, Tier::Path));
261    }
262    // `os` filesystem ops (a representative set; broadened by dogfooding).
263    if root == "os"
264        && matches!(
265            leaf,
266            "remove"
267                | "unlink"
268                | "rename"
269                | "replace"
270                | "mkdir"
271                | "makedirs"
272                | "rmdir"
273                | "removedirs"
274                | "listdir"
275                | "scandir"
276                | "stat"
277                | "open"
278                | "read"
279                | "write"
280                | "chmod"
281                | "chown"
282                | "walk"
283        )
284    {
285        return Some((NetFsDb, Tier::Path));
286    }
287
288    // ── process.control (class 6) ──
289    if root == "subprocess" {
290        return Some((ProcessControl, Tier::Path));
291    }
292    if full == "os.system" || full == "sys.exit" {
293        return Some((ProcessControl, Tier::Path));
294    }
295
296    // ── env.write (class 6) ──
297    // `dotenv.load_dotenv` is constrained to the `dotenv` package: only flag when the
298    // call resolves to `dotenv.load_dotenv` (root == "dotenv"), not an arbitrary
299    // `load_dotenv` imported from any user package.
300    if full == "os.putenv" || full == "dotenv.load_dotenv" {
301        return Some((EnvWrite, Tier::Path));
302    }
303
304    // ── concurrency (class 6) ──
305    if matches!(root, "threading" | "multiprocessing" | "asyncio") {
306        return Some((Concurrency, Tier::Heuristic));
307    }
308
309    // ── time.read (class 5) ──
310    if root == "time" {
311        return Some((TimeRead, Tier::Path));
312    }
313    if root == "datetime" && matches!(leaf, "now" | "today" | "utcnow") {
314        return Some((TimeRead, Tier::Heuristic));
315    }
316
317    // ── random (class 5) ──
318    if matches!(root, "random" | "secrets") {
319        return Some((Random, Tier::Path));
320    }
321
322    // ── env.read (class 4) ──
323    if full == "os.getenv" || full == "os.environ.get" {
324        return Some((EnvRead, Tier::Path));
325    }
326
327    // ── logging (class 2) ──
328    if root == "logging" {
329        return Some((Logging, Tier::Path));
330    }
331
332    None
333}
334
335/// Method-name-only DB/file-write heuristics (receiver type unknown → all
336/// `net.fs.db` class 7, `Heuristic`).
337fn classify_method(method: &str) -> Option<EffectKind> {
338    match method {
339        "commit" | "save" | "execute" | "to_sql" | "to_csv" | "create" => Some(EffectKind::NetFsDb),
340        _ => None,
341    }
342}
343
344// ─── callee rendering ─────────────────────────────────────────────────────────
345
346/// Render the base of a subscript target (`os.environ[...]` → `"os.environ"`).
347fn render_subscript_base(sub: &Subscript) -> Option<String> {
348    render_expr(&sub.value)
349}
350
351/// The leftmost `Name` of a subscript target's base.
352fn leftmost_subscript_name<'a>(sub: &'a Subscript<'a>) -> Option<&'a Name<'a>> {
353    leftmost_name(&sub.value)
354}
355
356/// 1-based `(line, col)` of a `Name`'s anchor (its `value` &str borrows the source buffer).
357fn name_line_col(name: &Name, span: &SpanIndex) -> (usize, usize) {
358    span.line_col(anchor_of_subslice(span.src(), name.value))
359}
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364    use crate::functions;
365    use fxrank_core::effect::EffectKind::{self, *};
366    use std::collections::HashMap;
367
368    /// Parse `tests/fixtures/<name>.py`, collect units, run `detect` per unit, and
369    /// return `symbol → Vec<(EffectKind, class)>`.
370    fn analyze_fixture(name: &str) -> HashMap<String, Vec<(EffectKind, u8)>> {
371        let src = std::fs::read_to_string(format!("tests/fixtures/{name}.py")).unwrap();
372        let module = libcst_native::parse_module(&src, None).unwrap();
373        let imports = Imports::build(&module);
374        let span = SpanIndex::new(&src);
375        let anchors = crate::source::lambda_anchors(&src).expect("tokenize must succeed");
376        let (units, _) = functions::collect(&module, &src, &span, &anchors);
377        let mut out: HashMap<String, Vec<(EffectKind, u8)>> = HashMap::new();
378        for unit in &units {
379            let effects = detect(unit, &imports, &span);
380            out.insert(
381                unit.symbol.clone(),
382                effects.iter().map(|e| (e.kind, e.class)).collect(),
383            );
384        }
385        out
386    }
387
388    #[test]
389    fn detects_world_effects() {
390        let by_fn = analyze_fixture("calls");
391
392        let io: Vec<_> = by_fn["io_boundary"].clone();
393        assert!(io.contains(&(NetFsDb, 7))); // open + requests.get
394        assert!(io.contains(&(Logging, 2))); // logging.info
395
396        let env = &by_fn["env_and_rng"];
397        assert!(env.contains(&(ProcessControl, 6))); // subprocess.run
398        assert!(env.contains(&(EnvRead, 4))); // os.getenv
399        assert!(env.contains(&(Random, 5)) && env.contains(&(TimeRead, 5)));
400
401        assert!(by_fn["reads_stdin"].contains(&(EnvRead, 4))); // input()
402        assert!(by_fn["db_write"].contains(&(NetFsDb, 7))); // session.commit() heuristic
403
404        // wrapper attribution: with-open and eager comprehension ARE charged...
405        assert!(by_fn["in_wrapper"].contains(&(NetFsDb, 7))); // with open(...)
406        assert!(by_fn["eager_comp"].contains(&(NetFsDb, 7))); // [requests.get(u) for ...]
407
408        // ...but a lazy genexp's element body is NOT charged (deferred execution)
409        assert!(
410            !by_fn
411                .get("lazy_gen")
412                .is_some_and(|e| e.contains(&(NetFsDb, 7)))
413        );
414
415        // sys.argv attribute read → AmbientRead class 2
416        assert!(by_fn["cli_args"].contains(&(AmbientRead, 2)));
417    }
418
419    /// FIX 1: `load_dotenv` is only flagged when it resolves to the `dotenv` package.
420    ///
421    /// Positive case: `from dotenv import load_dotenv; load_dotenv()` → env.write.
422    /// Negative case: `from myapp.config import load_dotenv; load_dotenv()` → NOT flagged.
423    #[test]
424    fn load_dotenv_constrained_to_dotenv_package() {
425        // Positive: imported from `dotenv` → must flag EnvWrite class 6.
426        let pos = analyze_fixture("load_dotenv_positive");
427        assert!(
428            pos["configure_env"].contains(&(EnvWrite, 6)),
429            "load_dotenv from dotenv package must flag env.write; got: {:?}",
430            pos.get("configure_env")
431        );
432
433        // Negative: imported from a user package → must NOT flag EnvWrite.
434        let neg = analyze_fixture("load_dotenv_negative");
435        assert!(
436            !neg["configure_env"].iter().any(|(k, _)| *k == EnvWrite),
437            "load_dotenv from a user package must NOT flag env.write; got: {:?}",
438            neg.get("configure_env")
439        );
440    }
441
442    /// Parse inline source and return effects for the first (or only) function unit.
443    fn effects_for_src(src: &str) -> Vec<Effect> {
444        let module = libcst_native::parse_module(src, None).unwrap();
445        let imports = Imports::build(&module);
446        let span = SpanIndex::new(src);
447        let anchors = crate::source::lambda_anchors(src).expect("tokenize must succeed");
448        let (units, _) = functions::collect(&module, src, &span, &anchors);
449        let unit = units.first().expect("at least one unit");
450        detect(unit, &imports, &span)
451    }
452
453    /// Copilot FIX 1: `open` is a bare builtin → must be `Tier::Exact`, not `Tier::Path`.
454    /// With a dynamic import present, its confidence must NOT be shadow-penalized
455    /// (shadow penalty applies only to Path-tier).
456    #[test]
457    fn open_bare_builtin_is_exact_tier_and_unshadowed() {
458        // Plain case: `open(p)` → NetFsDb, Tier::Exact.
459        let effects = effects_for_src("def f(p):\n    return open(p).read()\n");
460        let e = effects
461            .iter()
462            .find(|e| e.kind == NetFsDb)
463            .expect("open(p) must emit NetFsDb");
464        assert_eq!(
465            e.tier,
466            Tier::Exact,
467            "open() is a bare builtin and must be Tier::Exact, got {:?}",
468            e.tier
469        );
470
471        // With a dynamic import: shadow penalty applies only to Path-tier imports;
472        // Exact-tier builtins must not be penalized.
473        let src_dyn = "import importlib\ndef f(p):\n    return open(p).read()\n";
474        let effects_dyn = effects_for_src(src_dyn);
475        let e_dyn = effects_dyn
476            .iter()
477            .find(|e| e.kind == NetFsDb)
478            .expect("open(p) must emit NetFsDb even with dynamic imports present");
479        // Exact base = 1.0; shadow penalty is only applied when tier == Path.
480        // confidence must equal 1.0 (no penalty).
481        assert!(
482            (e_dyn.confidence - 1.0).abs() < f64::EPSILON,
483            "open() Exact-tier confidence must be 1.0 (no shadow penalty), got {}",
484            e_dyn.confidence
485        );
486    }
487
488    /// FIX 1 (Copilot): import-resolved env signals must be `Tier::Path`, not `Tier::Heuristic`.
489    /// `os.getenv` / `os.environ.get` (EnvRead) and `dotenv.load_dotenv` (EnvWrite) are
490    /// gated on `full == "..."` after import resolution — same mechanism as `requests.get`.
491    #[test]
492    fn env_signals_resolved_via_import_table_are_path_tier() {
493        // os.getenv → EnvRead, Tier::Path
494        let effects = effects_for_src("import os\ndef f():\n    return os.getenv(\"X\")\n");
495        let e = effects
496            .iter()
497            .find(|e| e.kind == EnvRead)
498            .expect("os.getenv must emit EnvRead");
499        assert_eq!(
500            e.tier,
501            Tier::Path,
502            "os.getenv is import-resolved; must be Tier::Path, got {:?}",
503            e.tier
504        );
505
506        // dotenv.load_dotenv → EnvWrite, Tier::Path
507        let effects2 =
508            effects_for_src("from dotenv import load_dotenv\ndef f():\n    load_dotenv()\n");
509        let e2 = effects2
510            .iter()
511            .find(|e| e.kind == EnvWrite)
512            .expect("dotenv.load_dotenv must emit EnvWrite");
513        assert_eq!(
514            e2.tier,
515            Tier::Path,
516            "dotenv.load_dotenv is import-resolved; must be Tier::Path, got {:?}",
517            e2.tier
518        );
519    }
520
521    /// Two same-kind effects on the **same line** at different columns must
522    /// produce distinct `col` values, not both zero. Verifies the col fix
523    /// prevents SiteKey collapse in the cross-file fold.
524    #[test]
525    fn two_same_kind_effects_same_line_have_distinct_col() {
526        // Both `open` calls are on line 2, separated by a semicolon (whitespace apart).
527        let src = "def f():\n    open('a'); open('b')\n";
528        let effects = effects_for_src(src);
529        let net: Vec<_> = effects.iter().filter(|e| e.kind == NetFsDb).collect();
530        assert_eq!(net.len(), 2, "expected two net.fs.db effects; got {net:?}");
531        assert_eq!(
532            net[0].line, net[1].line,
533            "both open() calls must be on line 2"
534        );
535        assert_ne!(
536            net[0].col, net[1].col,
537            "two open() calls on the same line must have distinct cols, got col={} and col={}",
538            net[0].col, net[1].col
539        );
540    }
541
542    /// Regression pin (spec 028 §2.3): `TimeRead` and `Random` call-effects are world
543    /// effects and must never be marked contained.  They are scored as escaping so that
544    /// cross-file propagation folds them into callers — a future containment discount
545    /// (e.g. a per-kind flag or a caller-site wrap) would silently wash the score unless
546    /// this pin catches it.
547    ///
548    /// The `analyze_fixture` helper returns `(EffectKind, class)` pairs and drops the
549    /// `contained` field; this test uses `effects_for_src` instead to inspect the raw
550    /// `Effect` struct directly.
551    #[test]
552    fn time_read_and_random_are_not_contained() {
553        let src = "import time\nimport random\ndef f():\n    t = time.time()\n    r = random.random()\n    return t + r\n";
554        let effects = effects_for_src(src);
555
556        let time_effect = effects
557            .iter()
558            .find(|e| e.kind == TimeRead)
559            .expect("time.time() must emit a TimeRead effect");
560        assert!(
561            !time_effect.contained,
562            "TimeRead must be escaping (contained=false); got contained=true — \
563             time is a world effect and must propagate to callers"
564        );
565        assert!(
566            time_effect.escapes(),
567            "TimeRead must satisfy escapes() — contained={} kind={:?}",
568            time_effect.contained,
569            time_effect.kind
570        );
571
572        let rng_effect = effects
573            .iter()
574            .find(|e| e.kind == Random)
575            .expect("random.random() must emit a Random effect");
576        assert!(
577            !rng_effect.contained,
578            "Random must be escaping (contained=false); got contained=true — \
579             random is a world effect and must propagate to callers"
580        );
581        assert!(
582            rng_effect.escapes(),
583            "Random must satisfy escapes() — contained={} kind={:?}",
584            rng_effect.contained,
585            rng_effect.kind
586        );
587    }
588}