use fxrank_core::confidence::detection_confidence;
use fxrank_core::effect::{Effect, EffectKind, Tier};
use fxrank_core::score::weight_for_class;
use libcst_native::{Assert, AssignTargetExpression, Call, Expression, Name, Raise, Subscript};
use super::{
EffectSink,
expr::{leftmost_name, render_expr},
walk_own_body,
};
use crate::functions::FnUnit;
use crate::imports::Imports;
use crate::source::{SpanIndex, anchor_of_subslice};
pub fn detect(unit: &FnUnit, imports: &Imports, span: &SpanIndex) -> Vec<Effect> {
let mut sink = CallSink {
imports,
span,
effects: Vec::new(),
};
walk_own_body(unit, &mut sink);
sink.effects
}
struct CallSink<'a> {
imports: &'a Imports,
span: &'a SpanIndex<'a>,
effects: Vec<Effect>,
}
impl EffectSink for CallSink<'_> {
fn on_call(&mut self, call: &Call) {
let Some(anchor) = leftmost_name(&call.func) else {
return;
};
let (line, col) = name_line_col(anchor, self.span);
let Some(rendered) = render_expr(&call.func) else {
return;
};
if let Some((kind, tier, evidence)) = self.classify_call(&rendered) {
self.push(kind, tier, line, col, evidence);
}
}
fn on_assert(&mut self, assert: &Assert) {
let (line, col) = match leftmost_name(&assert.test) {
Some(n) => name_line_col(n, self.span),
None => (0, 0),
};
self.push(
EffectKind::Panic,
Tier::Exact,
line,
col,
"assert — stripped under -O".to_string(),
);
}
fn on_raise(&mut self, raise: &Raise) {
let (line, col) = raise
.exc
.as_ref()
.and_then(leftmost_name)
.map(|n| name_line_col(n, self.span))
.unwrap_or((0, 0));
self.push(
EffectKind::Panic,
Tier::Exact,
line,
col,
"raise".to_string(),
);
}
fn on_assign_target(&mut self, target: &AssignTargetExpression, _is_aug: bool) {
if let AssignTargetExpression::Subscript(sub) = target
&& let Some(rendered) = render_subscript_base(sub)
&& self.resolve_dotted(&rendered).as_deref() == Some("os.environ")
{
let (line, col) = leftmost_subscript_name(sub)
.map(|n| name_line_col(n, self.span))
.unwrap_or((0, 0));
self.push(
EffectKind::EnvWrite,
Tier::Heuristic,
line,
col,
"os.environ[...] = … — environment write".to_string(),
);
}
}
fn on_attribute_read(&mut self, attr: &Expression) {
let Expression::Attribute(a) = attr else {
return;
};
if a.attr.value != "argv" {
return;
}
let Some(rendered_base) = render_expr(&a.value) else {
return;
};
if self.resolve_dotted(&rendered_base).as_deref() != Some("sys") {
return;
}
let (line, col) = leftmost_name(attr)
.map(|n| name_line_col(n, self.span))
.unwrap_or((0, 0));
self.push(
EffectKind::AmbientRead,
Tier::Path,
line,
col,
"sys.argv".to_string(),
);
}
}
impl CallSink<'_> {
fn push(&mut self, kind: EffectKind, tier: Tier, line: usize, col: usize, evidence: String) {
let class = kind.base_class();
let shadowed = matches!(tier, Tier::Path) && self.imports.has_dynamic();
let confidence = detection_confidence(tier, false, shadowed);
self.effects.push(Effect {
kind,
class,
discounted_to: None,
weight: weight_for_class(class),
line,
col,
tier,
hidden: false,
contained: false,
evidence,
discount: None,
subreason: None,
confidence,
});
}
fn resolve_dotted(&self, rendered: &str) -> Option<String> {
let (root, rest) = match rendered.split_once('.') {
Some((r, rest)) => (r, Some(rest)),
None => (rendered, None),
};
let base = self.imports.resolve(root)?;
Some(match rest {
Some(rest) => format!("{base}.{rest}"),
None => base.to_string(),
})
}
fn classify_call(&self, rendered: &str) -> Option<(EffectKind, Tier, String)> {
use EffectKind::*;
match rendered {
"input" => {
return Some((
EnvRead,
Tier::Exact,
"input() — interactive stdin read".to_string(),
));
}
"print" => return Some((Logging, Tier::Exact, "print()".to_string())),
"open" => {
return Some((
NetFsDb,
Tier::Exact,
"open(…) — file read/write".to_string(),
));
}
_ => {}
}
if let Some(full) = self.resolve_dotted(rendered)
&& let Some((kind, tier)) = classify_resolved(&full)
{
return Some((kind, tier, format!("{full}(…)")));
}
if let Some((_, method)) = rendered.rsplit_once('.')
&& let Some(kind) = classify_method(method)
{
return Some((kind, Tier::Heuristic, format!("{rendered}(…)")));
}
None
}
}
fn classify_resolved(full: &str) -> Option<(EffectKind, Tier)> {
use EffectKind::*;
let root = full.split('.').next().unwrap_or(full);
let leaf = full.rsplit('.').next().unwrap_or(full);
if matches!(root, "shutil" | "tempfile" | "csv" | "socket")
|| matches!(
root,
"requests" | "httpx" | "urllib" | "aiohttp" | "sqlite3" | "sqlalchemy"
)
{
return Some((NetFsDb, Tier::Path));
}
if root == "pathlib"
&& matches!(
leaf,
"read_text" | "write_text" | "read_bytes" | "write_bytes"
)
{
return Some((NetFsDb, Tier::Path));
}
if root == "json" && matches!(leaf, "load" | "dump") {
return Some((NetFsDb, Tier::Path));
}
if root == "pandas" && matches!(leaf, "read_csv" | "read_excel") {
return Some((NetFsDb, Tier::Path));
}
if root == "os"
&& matches!(
leaf,
"remove"
| "unlink"
| "rename"
| "replace"
| "mkdir"
| "makedirs"
| "rmdir"
| "removedirs"
| "listdir"
| "scandir"
| "stat"
| "open"
| "read"
| "write"
| "chmod"
| "chown"
| "walk"
)
{
return Some((NetFsDb, Tier::Path));
}
if root == "subprocess" {
return Some((ProcessControl, Tier::Path));
}
if full == "os.system" || full == "sys.exit" {
return Some((ProcessControl, Tier::Path));
}
if full == "os.putenv" || full == "dotenv.load_dotenv" {
return Some((EnvWrite, Tier::Path));
}
if matches!(root, "threading" | "multiprocessing" | "asyncio") {
return Some((Concurrency, Tier::Heuristic));
}
if root == "time" {
return Some((TimeRead, Tier::Path));
}
if root == "datetime" && matches!(leaf, "now" | "today" | "utcnow") {
return Some((TimeRead, Tier::Heuristic));
}
if matches!(root, "random" | "secrets") {
return Some((Random, Tier::Path));
}
if full == "os.getenv" || full == "os.environ.get" {
return Some((EnvRead, Tier::Path));
}
if root == "logging" {
return Some((Logging, Tier::Path));
}
None
}
fn classify_method(method: &str) -> Option<EffectKind> {
match method {
"commit" | "save" | "execute" | "to_sql" | "to_csv" | "create" => Some(EffectKind::NetFsDb),
_ => None,
}
}
fn render_subscript_base(sub: &Subscript) -> Option<String> {
render_expr(&sub.value)
}
fn leftmost_subscript_name<'a>(sub: &'a Subscript<'a>) -> Option<&'a Name<'a>> {
leftmost_name(&sub.value)
}
fn name_line_col(name: &Name, span: &SpanIndex) -> (usize, usize) {
span.line_col(anchor_of_subslice(span.src(), name.value))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::functions;
use fxrank_core::effect::EffectKind::{self, *};
use std::collections::HashMap;
fn analyze_fixture(name: &str) -> HashMap<String, Vec<(EffectKind, u8)>> {
let src = std::fs::read_to_string(format!("tests/fixtures/{name}.py")).unwrap();
let module = libcst_native::parse_module(&src, None).unwrap();
let imports = Imports::build(&module);
let span = SpanIndex::new(&src);
let anchors = crate::source::lambda_anchors(&src).expect("tokenize must succeed");
let (units, _) = functions::collect(&module, &src, &span, &anchors);
let mut out: HashMap<String, Vec<(EffectKind, u8)>> = HashMap::new();
for unit in &units {
let effects = detect(unit, &imports, &span);
out.insert(
unit.symbol.clone(),
effects.iter().map(|e| (e.kind, e.class)).collect(),
);
}
out
}
#[test]
fn detects_world_effects() {
let by_fn = analyze_fixture("calls");
let io: Vec<_> = by_fn["io_boundary"].clone();
assert!(io.contains(&(NetFsDb, 7))); assert!(io.contains(&(Logging, 2)));
let env = &by_fn["env_and_rng"];
assert!(env.contains(&(ProcessControl, 6))); assert!(env.contains(&(EnvRead, 4))); assert!(env.contains(&(Random, 5)) && env.contains(&(TimeRead, 5)));
assert!(by_fn["reads_stdin"].contains(&(EnvRead, 4))); assert!(by_fn["db_write"].contains(&(NetFsDb, 7)));
assert!(by_fn["in_wrapper"].contains(&(NetFsDb, 7))); assert!(by_fn["eager_comp"].contains(&(NetFsDb, 7)));
assert!(
!by_fn
.get("lazy_gen")
.is_some_and(|e| e.contains(&(NetFsDb, 7)))
);
assert!(by_fn["cli_args"].contains(&(AmbientRead, 2)));
}
#[test]
fn load_dotenv_constrained_to_dotenv_package() {
let pos = analyze_fixture("load_dotenv_positive");
assert!(
pos["configure_env"].contains(&(EnvWrite, 6)),
"load_dotenv from dotenv package must flag env.write; got: {:?}",
pos.get("configure_env")
);
let neg = analyze_fixture("load_dotenv_negative");
assert!(
!neg["configure_env"].iter().any(|(k, _)| *k == EnvWrite),
"load_dotenv from a user package must NOT flag env.write; got: {:?}",
neg.get("configure_env")
);
}
fn effects_for_src(src: &str) -> Vec<Effect> {
let module = libcst_native::parse_module(src, None).unwrap();
let imports = Imports::build(&module);
let span = SpanIndex::new(src);
let anchors = crate::source::lambda_anchors(src).expect("tokenize must succeed");
let (units, _) = functions::collect(&module, src, &span, &anchors);
let unit = units.first().expect("at least one unit");
detect(unit, &imports, &span)
}
#[test]
fn open_bare_builtin_is_exact_tier_and_unshadowed() {
let effects = effects_for_src("def f(p):\n return open(p).read()\n");
let e = effects
.iter()
.find(|e| e.kind == NetFsDb)
.expect("open(p) must emit NetFsDb");
assert_eq!(
e.tier,
Tier::Exact,
"open() is a bare builtin and must be Tier::Exact, got {:?}",
e.tier
);
let src_dyn = "import importlib\ndef f(p):\n return open(p).read()\n";
let effects_dyn = effects_for_src(src_dyn);
let e_dyn = effects_dyn
.iter()
.find(|e| e.kind == NetFsDb)
.expect("open(p) must emit NetFsDb even with dynamic imports present");
assert!(
(e_dyn.confidence - 1.0).abs() < f64::EPSILON,
"open() Exact-tier confidence must be 1.0 (no shadow penalty), got {}",
e_dyn.confidence
);
}
#[test]
fn env_signals_resolved_via_import_table_are_path_tier() {
let effects = effects_for_src("import os\ndef f():\n return os.getenv(\"X\")\n");
let e = effects
.iter()
.find(|e| e.kind == EnvRead)
.expect("os.getenv must emit EnvRead");
assert_eq!(
e.tier,
Tier::Path,
"os.getenv is import-resolved; must be Tier::Path, got {:?}",
e.tier
);
let effects2 =
effects_for_src("from dotenv import load_dotenv\ndef f():\n load_dotenv()\n");
let e2 = effects2
.iter()
.find(|e| e.kind == EnvWrite)
.expect("dotenv.load_dotenv must emit EnvWrite");
assert_eq!(
e2.tier,
Tier::Path,
"dotenv.load_dotenv is import-resolved; must be Tier::Path, got {:?}",
e2.tier
);
}
#[test]
fn two_same_kind_effects_same_line_have_distinct_col() {
let src = "def f():\n open('a'); open('b')\n";
let effects = effects_for_src(src);
let net: Vec<_> = effects.iter().filter(|e| e.kind == NetFsDb).collect();
assert_eq!(net.len(), 2, "expected two net.fs.db effects; got {net:?}");
assert_eq!(
net[0].line, net[1].line,
"both open() calls must be on line 2"
);
assert_ne!(
net[0].col, net[1].col,
"two open() calls on the same line must have distinct cols, got col={} and col={}",
net[0].col, net[1].col
);
}
#[test]
fn time_read_and_random_are_not_contained() {
let src = "import time\nimport random\ndef f():\n t = time.time()\n r = random.random()\n return t + r\n";
let effects = effects_for_src(src);
let time_effect = effects
.iter()
.find(|e| e.kind == TimeRead)
.expect("time.time() must emit a TimeRead effect");
assert!(
!time_effect.contained,
"TimeRead must be escaping (contained=false); got contained=true — \
time is a world effect and must propagate to callers"
);
assert!(
time_effect.escapes(),
"TimeRead must satisfy escapes() — contained={} kind={:?}",
time_effect.contained,
time_effect.kind
);
let rng_effect = effects
.iter()
.find(|e| e.kind == Random)
.expect("random.random() must emit a Random effect");
assert!(
!rng_effect.contained,
"Random must be escaping (contained=false); got contained=true — \
random is a world effect and must propagate to callers"
);
assert!(
rng_effect.escapes(),
"Random must satisfy escapes() — contained={} kind={:?}",
rng_effect.contained,
rng_effect.kind
);
}
}