1use 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
32pub 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 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 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 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 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 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 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 fn classify_call(&self, rendered: &str) -> Option<(EffectKind, Tier, String)> {
191 use EffectKind::*;
192
193 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 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 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
231fn 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 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 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 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 if full == "os.putenv" || full == "dotenv.load_dotenv" {
301 return Some((EnvWrite, Tier::Path));
302 }
303
304 if matches!(root, "threading" | "multiprocessing" | "asyncio") {
306 return Some((Concurrency, Tier::Heuristic));
307 }
308
309 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 if matches!(root, "random" | "secrets") {
319 return Some((Random, Tier::Path));
320 }
321
322 if full == "os.getenv" || full == "os.environ.get" {
324 return Some((EnvRead, Tier::Path));
325 }
326
327 if root == "logging" {
329 return Some((Logging, Tier::Path));
330 }
331
332 None
333}
334
335fn 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
344fn render_subscript_base(sub: &Subscript) -> Option<String> {
348 render_expr(&sub.value)
349}
350
351fn leftmost_subscript_name<'a>(sub: &'a Subscript<'a>) -> Option<&'a Name<'a>> {
353 leftmost_name(&sub.value)
354}
355
356fn 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 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))); assert!(io.contains(&(Logging, 2))); let env = &by_fn["env_and_rng"];
397 assert!(env.contains(&(ProcessControl, 6))); assert!(env.contains(&(EnvRead, 4))); assert!(env.contains(&(Random, 5)) && env.contains(&(TimeRead, 5)));
400
401 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!(
410 !by_fn
411 .get("lazy_gen")
412 .is_some_and(|e| e.contains(&(NetFsDb, 7)))
413 );
414
415 assert!(by_fn["cli_args"].contains(&(AmbientRead, 2)));
417 }
418
419 #[test]
424 fn load_dotenv_constrained_to_dotenv_package() {
425 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 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 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 #[test]
457 fn open_bare_builtin_is_exact_tier_and_unshadowed() {
458 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 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 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 #[test]
492 fn env_signals_resolved_via_import_table_are_path_tier() {
493 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 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 #[test]
525 fn two_same_kind_effects_same_line_have_distinct_col() {
526 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 #[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}