pub mod calls;
pub mod expr;
pub mod mutation;
pub mod risk;
use std::collections::HashSet;
use crate::coverage;
use crate::functions::{FnBody, FnUnit};
use crate::imports::Imports;
use crate::source::SpanIndex;
use fxrank_core::confidence::function_confidence;
use fxrank_core::effect::{RiskFeature, RiskKind, Tier};
use fxrank_core::model::Hotspot;
use fxrank_core::score::{
BoundaryCoverage, apply_boundary_discount, max_class, own_score, weight_for_class,
};
use libcst_native::{
Assert, AssignTargetExpression, Call, CompoundStatement, Decorator, Element, Expression,
FormattedStringContent, Parameters, Raise, SmallStatement, Statement, Suite,
};
pub trait EffectSink {
fn on_call(&mut self, call: &Call);
fn on_assert(&mut self, assert: &Assert);
fn on_raise(&mut self, raise: &Raise);
fn on_assign_target(&mut self, target: &AssignTargetExpression, is_aug: bool);
fn on_attribute_read(&mut self, _attr: &Expression) {}
}
pub fn walk_own_body<'a>(unit: &FnUnit<'a>, sink: &mut dyn EffectSink) {
match &unit.body {
FnBody::Suite(suite) => walk_suite(suite, sink),
FnBody::Expr(expr) => walk_expr(expr, sink),
}
}
fn walk_nested_def_header(def: &libcst_native::FunctionDef, sink: &mut dyn EffectSink) {
for dec in &def.decorators {
walk_decorator(dec, sink);
}
walk_param_defaults(&def.params, sink);
}
fn walk_decorator(dec: &Decorator, sink: &mut dyn EffectSink) {
walk_expr(&dec.decorator, sink);
}
fn walk_param_defaults(params: &Parameters, sink: &mut dyn EffectSink) {
let all = params
.posonly_params
.iter()
.chain(¶ms.params)
.chain(¶ms.kwonly_params);
for p in all {
if let Some(default) = &p.default {
walk_expr(default, sink);
}
}
if let Some(libcst_native::StarArg::Param(p)) = ¶ms.star_arg
&& let Some(default) = &p.default
{
walk_expr(default, sink);
}
if let Some(p) = ¶ms.star_kwarg
&& let Some(default) = &p.default
{
walk_expr(default, sink);
}
}
fn walk_suite(suite: &Suite, sink: &mut dyn EffectSink) {
match suite {
Suite::IndentedBlock(b) => {
for stmt in &b.body {
walk_statement(stmt, sink);
}
}
Suite::SimpleStatementSuite(s) => {
for small in &s.body {
walk_small(small, sink);
}
}
}
}
fn walk_statement(stmt: &Statement, sink: &mut dyn EffectSink) {
match stmt {
Statement::Simple(line) => {
for small in &line.body {
walk_small(small, sink);
}
}
Statement::Compound(c) => walk_compound(c, sink),
}
}
fn walk_compound(compound: &CompoundStatement, sink: &mut dyn EffectSink) {
match compound {
CompoundStatement::FunctionDef(d) => walk_nested_def_header(d, sink),
CompoundStatement::ClassDef(_) => {}
CompoundStatement::If(i) => {
walk_expr(&i.test, sink);
walk_suite(&i.body, sink);
if let Some(orelse) = &i.orelse {
walk_or_else(orelse, sink);
}
}
CompoundStatement::For(f) => {
walk_expr(&f.iter, sink);
walk_suite(&f.body, sink);
if let Some(orelse) = &f.orelse {
walk_suite(&orelse.body, sink);
}
}
CompoundStatement::While(w) => {
walk_expr(&w.test, sink);
walk_suite(&w.body, sink);
if let Some(orelse) = &w.orelse {
walk_suite(&orelse.body, sink);
}
}
CompoundStatement::Try(t) => {
walk_suite(&t.body, sink);
for handler in &t.handlers {
walk_suite(&handler.body, sink);
}
if let Some(orelse) = &t.orelse {
walk_suite(&orelse.body, sink);
}
if let Some(finalbody) = &t.finalbody {
walk_suite(&finalbody.body, sink);
}
}
CompoundStatement::TryStar(t) => {
walk_suite(&t.body, sink);
for handler in &t.handlers {
walk_suite(&handler.body, sink);
}
if let Some(orelse) = &t.orelse {
walk_suite(&orelse.body, sink);
}
if let Some(finalbody) = &t.finalbody {
walk_suite(&finalbody.body, sink);
}
}
CompoundStatement::With(w) => {
for item in &w.items {
walk_expr(&item.item, sink);
}
walk_suite(&w.body, sink);
}
CompoundStatement::Match(m) => {
walk_expr(&m.subject, sink);
for case in &m.cases {
walk_suite(&case.body, sink);
}
}
}
}
fn walk_or_else(orelse: &libcst_native::OrElse, sink: &mut dyn EffectSink) {
match orelse {
libcst_native::OrElse::Elif(elif) => {
walk_expr(&elif.test, sink);
walk_suite(&elif.body, sink);
if let Some(inner) = &elif.orelse {
walk_or_else(inner, sink);
}
}
libcst_native::OrElse::Else(e) => {
walk_suite(&e.body, sink);
}
}
}
fn walk_small(small: &SmallStatement, sink: &mut dyn EffectSink) {
match small {
SmallStatement::Expr(e) => walk_expr(&e.value, sink),
SmallStatement::Return(r) => {
if let Some(v) = &r.value {
walk_expr(v, sink);
}
}
SmallStatement::Assign(a) => {
for target in &a.targets {
sink.on_assign_target(&target.target, false);
walk_assign_target_subexprs(&target.target, sink);
}
walk_expr(&a.value, sink);
}
SmallStatement::AnnAssign(a) => {
sink.on_assign_target(&a.target, false);
walk_assign_target_subexprs(&a.target, sink);
if let Some(v) = &a.value {
walk_expr(v, sink);
}
}
SmallStatement::AugAssign(a) => {
sink.on_assign_target(&a.target, true);
walk_assign_target_subexprs(&a.target, sink);
walk_expr(&a.value, sink);
}
SmallStatement::Assert(a) => {
sink.on_assert(a);
walk_expr(&a.test, sink);
if let Some(msg) = &a.msg {
walk_expr(msg, sink);
}
}
SmallStatement::Raise(r) => {
sink.on_raise(r);
if let Some(exc) = &r.exc {
walk_expr(exc, sink);
}
}
_ => {}
}
}
fn walk_assign_target_subexprs(target: &AssignTargetExpression, sink: &mut dyn EffectSink) {
match target {
AssignTargetExpression::Name(_) => {}
AssignTargetExpression::Attribute(a) => walk_expr(&a.value, sink),
AssignTargetExpression::Subscript(s) => {
walk_expr(&s.value, sink);
for element in &s.slice {
walk_base_slice(&element.slice, sink);
}
}
AssignTargetExpression::Tuple(t) => {
for el in &t.elements {
walk_target_element(el, sink);
}
}
AssignTargetExpression::List(l) => {
for el in &l.elements {
walk_target_element(el, sink);
}
}
AssignTargetExpression::StarredElement(s) => walk_target_value(&s.value, sink),
}
}
fn walk_target_element(el: &Element, sink: &mut dyn EffectSink) {
match el {
Element::Simple { value, .. } => walk_target_value(value, sink),
Element::Starred(s) => walk_target_value(&s.value, sink),
}
}
fn walk_target_value(expr: &Expression, sink: &mut dyn EffectSink) {
match expr {
Expression::Name(_) => {}
Expression::Attribute(a) => walk_expr(&a.value, sink),
Expression::Subscript(s) => {
walk_expr(&s.value, sink);
for element in &s.slice {
walk_base_slice(&element.slice, sink);
}
}
Expression::Tuple(t) => {
for el in &t.elements {
walk_target_element(el, sink);
}
}
Expression::List(l) => {
for el in &l.elements {
walk_target_element(el, sink);
}
}
Expression::StarredElement(s) => walk_target_value(&s.value, sink),
_ => {}
}
}
fn walk_expr(expr: &Expression, sink: &mut dyn EffectSink) {
match expr {
Expression::Call(c) => {
sink.on_call(c);
walk_expr(&c.func, sink);
for arg in &c.args {
walk_expr(&arg.value, sink);
}
}
Expression::Lambda(l) => walk_param_defaults(&l.params, sink),
Expression::Attribute(a) => {
sink.on_attribute_read(expr);
walk_expr(&a.value, sink);
}
Expression::Subscript(s) => {
walk_expr(&s.value, sink);
for element in &s.slice {
walk_base_slice(&element.slice, sink);
}
}
Expression::BinaryOperation(b) => {
walk_expr(&b.left, sink);
walk_expr(&b.right, sink);
}
Expression::BooleanOperation(b) => {
walk_expr(&b.left, sink);
walk_expr(&b.right, sink);
}
Expression::UnaryOperation(u) => walk_expr(&u.expression, sink),
Expression::Comparison(c) => {
walk_expr(&c.left, sink);
for comp in &c.comparisons {
walk_expr(&comp.comparator, sink);
}
}
Expression::IfExp(i) => {
walk_expr(&i.test, sink);
walk_expr(&i.body, sink);
walk_expr(&i.orelse, sink);
}
Expression::Tuple(t) => {
for el in &t.elements {
walk_element(el, sink);
}
}
Expression::List(l) => {
for el in &l.elements {
walk_element(el, sink);
}
}
Expression::Set(s) => {
for el in &s.elements {
walk_element(el, sink);
}
}
Expression::Dict(d) => {
for el in &d.elements {
match el {
libcst_native::DictElement::Simple { key, value, .. } => {
walk_expr(key, sink);
walk_expr(value, sink);
}
libcst_native::DictElement::Starred(s) => walk_expr(&s.value, sink),
}
}
}
Expression::ListComp(l) => {
walk_expr(&l.elt, sink);
walk_comp_for(&l.for_in, sink, true);
}
Expression::SetComp(s) => {
walk_expr(&s.elt, sink);
walk_comp_for(&s.for_in, sink, true);
}
Expression::DictComp(d) => {
walk_expr(&d.key, sink);
walk_expr(&d.value, sink);
walk_comp_for(&d.for_in, sink, true);
}
Expression::GeneratorExp(g) => {
walk_comp_for(&g.for_in, sink, false);
}
Expression::FormattedString(fs) => {
for part in &fs.parts {
if let FormattedStringContent::Expression(e) = part {
walk_expr(&e.expression, sink);
if let Some(spec_parts) = &e.format_spec {
for sp in spec_parts {
if let FormattedStringContent::Expression(se) = sp {
walk_expr(&se.expression, sink);
}
}
}
}
}
}
Expression::Yield(y) => {
if let Some(v) = &y.value {
match &**v {
libcst_native::YieldValue::Expression(e) => walk_expr(e, sink),
libcst_native::YieldValue::From(f) => walk_expr(&f.item, sink),
}
}
}
Expression::Await(a) => walk_expr(&a.expression, sink),
Expression::NamedExpr(n) => walk_expr(&n.value, sink),
Expression::StarredElement(s) => walk_expr(&s.value, sink),
_ => {}
}
}
fn walk_comp_for(comp: &libcst_native::CompFor, sink: &mut dyn EffectSink, eager: bool) {
walk_expr(&comp.iter, sink);
if eager {
for cond in &comp.ifs {
walk_expr(&cond.test, sink);
}
if let Some(inner) = &comp.inner_for_in {
walk_comp_for(inner, sink, true);
}
}
}
fn walk_element(el: &Element, sink: &mut dyn EffectSink) {
match el {
Element::Simple { value, .. } => walk_expr(value, sink),
Element::Starred(s) => walk_expr(&s.value, sink),
}
}
fn walk_base_slice(slice: &libcst_native::BaseSlice, sink: &mut dyn EffectSink) {
match slice {
libcst_native::BaseSlice::Index(i) => walk_expr(&i.value, sink),
libcst_native::BaseSlice::Slice(s) => {
if let Some(lower) = &s.lower {
walk_expr(lower, sink);
}
if let Some(upper) = &s.upper {
walk_expr(upper, sink);
}
if let Some(step) = &s.step {
walk_expr(step, sink);
}
}
}
}
fn count_awaits(unit: &FnUnit) -> usize {
fn count_in_body(body: &FnBody) -> usize {
match body {
FnBody::Suite(suite) => count_in_suite(suite),
FnBody::Expr(expr) => count_in_expr(expr),
}
}
fn count_in_suite(suite: &libcst_native::Suite) -> usize {
match suite {
libcst_native::Suite::IndentedBlock(b) => b.body.iter().map(count_in_stmt).sum(),
libcst_native::Suite::SimpleStatementSuite(s) => {
s.body.iter().map(count_in_small).sum()
}
}
}
fn count_in_stmt(stmt: &libcst_native::Statement) -> usize {
match stmt {
libcst_native::Statement::Simple(line) => line.body.iter().map(count_in_small).sum(),
libcst_native::Statement::Compound(c) => count_in_compound(c),
}
}
fn count_in_compound(c: &libcst_native::CompoundStatement) -> usize {
match c {
libcst_native::CompoundStatement::FunctionDef(d) => count_in_def_header(d),
libcst_native::CompoundStatement::ClassDef(_) => 0,
libcst_native::CompoundStatement::If(i) => {
count_in_expr(&i.test)
+ count_in_suite(&i.body)
+ i.orelse.as_ref().map_or(0, |o| count_in_orelse(o))
}
libcst_native::CompoundStatement::For(f) => {
count_in_expr(&f.iter)
+ count_in_suite(&f.body)
+ f.orelse.as_ref().map_or(0, |e| count_in_suite(&e.body))
}
libcst_native::CompoundStatement::While(w) => {
count_in_expr(&w.test)
+ count_in_suite(&w.body)
+ w.orelse.as_ref().map_or(0, |e| count_in_suite(&e.body))
}
libcst_native::CompoundStatement::Try(t) => {
count_in_suite(&t.body)
+ t.handlers
.iter()
.map(|h| count_in_suite(&h.body))
.sum::<usize>()
+ t.orelse.as_ref().map_or(0, |e| count_in_suite(&e.body))
+ t.finalbody.as_ref().map_or(0, |e| count_in_suite(&e.body))
}
libcst_native::CompoundStatement::TryStar(t) => {
count_in_suite(&t.body)
+ t.handlers
.iter()
.map(|h| count_in_suite(&h.body))
.sum::<usize>()
+ t.orelse.as_ref().map_or(0, |e| count_in_suite(&e.body))
+ t.finalbody.as_ref().map_or(0, |e| count_in_suite(&e.body))
}
libcst_native::CompoundStatement::With(w) => {
w.items
.iter()
.map(|item| count_in_expr(&item.item))
.sum::<usize>()
+ count_in_suite(&w.body)
}
libcst_native::CompoundStatement::Match(m) => {
count_in_expr(&m.subject)
+ m.cases
.iter()
.map(|case| count_in_suite(&case.body))
.sum::<usize>()
}
}
}
fn count_in_orelse(orelse: &libcst_native::OrElse) -> usize {
match orelse {
libcst_native::OrElse::Elif(elif) => {
count_in_expr(&elif.test)
+ count_in_suite(&elif.body)
+ elif.orelse.as_ref().map_or(0, |o| count_in_orelse(o))
}
libcst_native::OrElse::Else(e) => count_in_suite(&e.body),
}
}
fn count_in_small(small: &libcst_native::SmallStatement) -> usize {
match small {
libcst_native::SmallStatement::Expr(e) => count_in_expr(&e.value),
libcst_native::SmallStatement::Return(r) => r.value.as_ref().map_or(0, count_in_expr),
libcst_native::SmallStatement::Assign(a) => {
a.targets
.iter()
.map(|t| count_in_assign_target(&t.target))
.sum::<usize>()
+ count_in_expr(&a.value)
}
libcst_native::SmallStatement::AnnAssign(a) => {
count_in_assign_target(&a.target) + a.value.as_ref().map_or(0, count_in_expr)
}
libcst_native::SmallStatement::AugAssign(a) => {
count_in_assign_target(&a.target) + count_in_expr(&a.value)
}
libcst_native::SmallStatement::Assert(a) => {
count_in_expr(&a.test) + a.msg.as_ref().map_or(0, count_in_expr)
}
libcst_native::SmallStatement::Raise(r) => r.exc.as_ref().map_or(0, count_in_expr),
_ => 0,
}
}
fn count_in_expr(expr: &libcst_native::Expression) -> usize {
match expr {
libcst_native::Expression::Await(a) => {
1 + count_in_expr(&a.expression)
}
libcst_native::Expression::Lambda(l) => count_in_params_defaults(&l.params),
libcst_native::Expression::Call(c) => {
count_in_expr(&c.func)
+ c.args
.iter()
.map(|a| count_in_expr(&a.value))
.sum::<usize>()
}
libcst_native::Expression::Attribute(a) => count_in_expr(&a.value),
libcst_native::Expression::Subscript(s) => {
count_in_expr(&s.value)
+ s.slice
.iter()
.map(|e| count_in_base_slice(&e.slice))
.sum::<usize>()
}
libcst_native::Expression::BinaryOperation(b) => {
count_in_expr(&b.left) + count_in_expr(&b.right)
}
libcst_native::Expression::BooleanOperation(b) => {
count_in_expr(&b.left) + count_in_expr(&b.right)
}
libcst_native::Expression::UnaryOperation(u) => count_in_expr(&u.expression),
libcst_native::Expression::Comparison(c) => {
count_in_expr(&c.left)
+ c.comparisons
.iter()
.map(|comp| count_in_expr(&comp.comparator))
.sum::<usize>()
}
libcst_native::Expression::IfExp(i) => {
count_in_expr(&i.test) + count_in_expr(&i.body) + count_in_expr(&i.orelse)
}
libcst_native::Expression::Tuple(t) => t.elements.iter().map(count_in_element).sum(),
libcst_native::Expression::List(l) => l.elements.iter().map(count_in_element).sum(),
libcst_native::Expression::Set(s) => s.elements.iter().map(count_in_element).sum(),
libcst_native::Expression::Dict(d) => d
.elements
.iter()
.map(|el| match el {
libcst_native::DictElement::Simple { key, value, .. } => {
count_in_expr(key) + count_in_expr(value)
}
libcst_native::DictElement::Starred(s) => count_in_expr(&s.value),
})
.sum(),
libcst_native::Expression::ListComp(l) => {
count_in_expr(&l.elt) + count_in_comp_for(&l.for_in)
}
libcst_native::Expression::SetComp(s) => {
count_in_expr(&s.elt) + count_in_comp_for(&s.for_in)
}
libcst_native::Expression::DictComp(d) => {
count_in_expr(&d.key) + count_in_expr(&d.value) + count_in_comp_for(&d.for_in)
}
libcst_native::Expression::GeneratorExp(g) => count_in_expr(&g.for_in.iter),
libcst_native::Expression::FormattedString(fs) => fs
.parts
.iter()
.map(|p| {
if let libcst_native::FormattedStringContent::Expression(e) = p {
let in_expr = count_in_expr(&e.expression);
let in_spec = e
.format_spec
.as_deref()
.unwrap_or(&[])
.iter()
.map(|sp| {
if let libcst_native::FormattedStringContent::Expression(se) = sp {
count_in_expr(&se.expression)
} else {
0
}
})
.sum::<usize>();
in_expr + in_spec
} else {
0
}
})
.sum(),
libcst_native::Expression::Yield(y) => {
y.value.as_ref().map_or(0, |v| match v.as_ref() {
libcst_native::YieldValue::Expression(e) => count_in_expr(e),
libcst_native::YieldValue::From(f) => count_in_expr(&f.item),
})
}
libcst_native::Expression::NamedExpr(n) => count_in_expr(&n.value),
libcst_native::Expression::StarredElement(s) => count_in_expr(&s.value),
_ => 0,
}
}
fn count_in_def_header(def: &libcst_native::FunctionDef) -> usize {
def.decorators
.iter()
.map(|dec| count_in_expr(&dec.decorator))
.sum::<usize>()
+ count_in_params_defaults(&def.params)
}
fn count_in_params_defaults(params: &libcst_native::Parameters) -> usize {
let mut n = 0;
let all = params
.posonly_params
.iter()
.chain(¶ms.params)
.chain(¶ms.kwonly_params);
for p in all {
if let Some(default) = &p.default {
n += count_in_expr(default);
}
}
if let Some(libcst_native::StarArg::Param(p)) = ¶ms.star_arg
&& let Some(default) = &p.default
{
n += count_in_expr(default);
}
if let Some(p) = ¶ms.star_kwarg
&& let Some(default) = &p.default
{
n += count_in_expr(default);
}
n
}
fn count_in_comp_for(comp: &libcst_native::CompFor) -> usize {
count_in_expr(&comp.iter)
+ comp
.ifs
.iter()
.map(|c| count_in_expr(&c.test))
.sum::<usize>()
+ comp
.inner_for_in
.as_ref()
.map_or(0, |inner| count_in_comp_for(inner))
}
fn count_in_assign_target(target: &libcst_native::AssignTargetExpression) -> usize {
use libcst_native::AssignTargetExpression as T;
match target {
T::Name(_) => 0,
T::Attribute(a) => count_in_expr(&a.value),
T::Subscript(s) => {
count_in_expr(&s.value)
+ s.slice
.iter()
.map(|e| count_in_base_slice(&e.slice))
.sum::<usize>()
}
T::Tuple(t) => t.elements.iter().map(count_in_target_element).sum(),
T::List(l) => l.elements.iter().map(count_in_target_element).sum(),
T::StarredElement(s) => count_in_target_value(&s.value),
}
}
fn count_in_target_element(el: &libcst_native::Element) -> usize {
match el {
libcst_native::Element::Simple { value, .. } => count_in_target_value(value),
libcst_native::Element::Starred(s) => count_in_target_value(&s.value),
}
}
fn count_in_target_value(expr: &libcst_native::Expression) -> usize {
match expr {
libcst_native::Expression::Name(_) => 0,
libcst_native::Expression::Attribute(a) => count_in_expr(&a.value),
libcst_native::Expression::Subscript(s) => {
count_in_expr(&s.value)
+ s.slice
.iter()
.map(|e| count_in_base_slice(&e.slice))
.sum::<usize>()
}
libcst_native::Expression::Tuple(t) => {
t.elements.iter().map(count_in_target_element).sum()
}
libcst_native::Expression::List(l) => {
l.elements.iter().map(count_in_target_element).sum()
}
libcst_native::Expression::StarredElement(s) => count_in_target_value(&s.value),
_ => 0,
}
}
fn count_in_base_slice(slice: &libcst_native::BaseSlice) -> usize {
match slice {
libcst_native::BaseSlice::Index(i) => count_in_expr(&i.value),
libcst_native::BaseSlice::Slice(s) => {
s.lower.as_ref().map_or(0, count_in_expr)
+ s.upper.as_ref().map_or(0, count_in_expr)
+ s.step.as_ref().map_or(0, count_in_expr)
}
}
}
fn count_in_element(el: &libcst_native::Element) -> usize {
match el {
libcst_native::Element::Simple { value, .. } => count_in_expr(value),
libcst_native::Element::Starred(s) => count_in_expr(&s.value),
}
}
count_in_body(&unit.body)
}
pub fn analyze_unit(
unit: &FnUnit,
path: &str,
imports: &Imports,
module_bindings: &HashSet<String>,
span: &SpanIndex,
) -> Hotspot {
let mut effects = calls::detect(unit, imports, span);
let cov = coverage::of(unit, imports);
let discount_coverage = if cov.any_in_body {
BoundaryCoverage::None
} else {
cov.boundary
};
let mut_pairs = mutation::detect(unit, imports, module_bindings, span);
effects.extend(mut_pairs.into_iter().map(|(mut e, contained)| {
if contained && discount_coverage != BoundaryCoverage::None {
e.discounted_to = Some(apply_boundary_discount(e.class, discount_coverage, true));
e.discount = Some(
match discount_coverage {
BoundaryCoverage::Full => "contained, Full-typed boundary",
BoundaryCoverage::Partial => "contained, Partial-typed boundary",
BoundaryCoverage::None => unreachable!("guarded above"),
}
.to_string(),
);
e.sync_weight();
}
e
}));
let mut risks: Vec<RiskFeature> = Vec::new();
risks.extend(risk::detect(unit, imports, span, path));
if cov.any_in_signature || cov.any_in_body {
let class = RiskKind::TypeEscape.class();
risks.push(RiskFeature {
kind: RiskKind::TypeEscape,
class,
weight: weight_for_class(class),
path: path.into(),
line: unit.line,
evidence: "explicit Any (signature or body) — type-escape hatch".into(),
tier: Tier::Exact,
});
}
let await_count = count_awaits(unit);
let async_boundary = unit.is_async || await_count > 0;
let weights: Vec<u32> = effects.iter().map(|e| e.weight).collect();
let classes: Vec<u8> = effects.iter().map(|e| e.effective_class()).collect();
let mut confidences: Vec<f64> = effects.iter().map(|e| e.confidence).collect();
if await_count > 0 {
confidences.push(0.8);
}
if cov.unknown_decorator {
confidences.push(0.8);
}
let risk_class = risks.iter().map(|r| r.class).max().unwrap_or(0);
let risk_weight = if risks.is_empty() {
0
} else {
weight_for_class(risk_class)
};
Hotspot {
id: format!("{}:{}:{}:{}", path, unit.line, unit.col, unit.symbol),
symbol: unit.symbol.clone(),
path: path.into(),
line: unit.line,
max_class: max_class(&classes, risk_class),
own_score: own_score(&weights),
risk_weight,
confidence: function_confidence(&confidences),
async_boundary,
await_count,
effects,
risk_features: risks,
}
}
#[cfg(test)]
mod tests {
use super::*;
use fxrank_core::model::Hotspot;
fn scan_fixture_hotspots(name: &str) -> Vec<Hotspot> {
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 module_bindings = crate::imports::module_bindings(&module);
let span = SpanIndex::new(&src);
let anchors = crate::source::lambda_anchors(&src).expect("tokenize must succeed");
let (units, _) = crate::functions::collect(&module, &src, &span, &anchors);
units
.iter()
.map(|unit| {
analyze_unit(
unit,
&format!("tests/fixtures/{name}.py"),
&imports,
&module_bindings,
&span,
)
})
.collect()
}
#[test]
fn def_header_defaults_charge_to_enclosing_scope() {
let h = scan_fixture_hotspots("attribution");
let net = |sym: &str| {
h.iter()
.find(|x| x.symbol == sym)
.unwrap_or_else(|| panic!("symbol {sym} not found"))
.effects
.iter()
.any(|e| e.kind.wire() == "net.fs.db")
};
assert!(
net("outer"),
"open(p) default must be charged to enclosing outer"
);
assert!(
!net("inner"),
"open(p) must NOT be charged to nested inner (its default runs in outer)"
);
assert!(
!net("top_default"),
"a top-level def's own param default is module-time → uncounted on itself"
);
}
#[test]
fn subscript_index_expression_is_traversed() {
let h = scan_fixture_hotspots("attribution");
let si = h.iter().find(|x| x.symbol == "subscript_index").unwrap();
assert!(
si.effects.iter().any(|e| e.kind.wire() == "net.fs.db"),
"subscript index requests.get(u) must surface net.fs.db, got: {:?}",
si.effects.iter().map(|e| e.kind.wire()).collect::<Vec<_>>()
);
}
#[test]
fn assign_target_subexprs_are_traversed_without_double_counting() {
let h = scan_fixture_hotspots("attribution");
let s = h
.iter()
.find(|x| x.symbol == "assign_target_subscript_index")
.unwrap();
let net_count = s
.effects
.iter()
.filter(|e| e.kind.wire() == "net.fs.db")
.count();
assert_eq!(
net_count,
1,
"subscript-target index requests.get(u) must surface exactly one net.fs.db, got: {:?}",
s.effects.iter().map(|e| e.kind.wire()).collect::<Vec<_>>()
);
let param_mut_count = s
.effects
.iter()
.filter(|e| e.kind.wire() == "param.mutation")
.count();
assert_eq!(
param_mut_count,
1,
"the subscript target `xs` must emit exactly ONE param.mutation (no double-count), got: {:?}",
s.effects.iter().map(|e| e.kind.wire()).collect::<Vec<_>>()
);
let a = h
.iter()
.find(|x| x.symbol == "assign_target_attr_base")
.unwrap();
assert!(
a.effects.iter().any(|e| e.kind.wire() == "net.fs.db"),
"attribute-target base requests.get(u) must surface net.fs.db, got: {:?}",
a.effects.iter().map(|e| e.kind.wire()).collect::<Vec<_>>()
);
}
#[test]
fn subscript_index_await_counts() {
let src = "async def f(xs):\n return xs[await key()]\n";
let module = libcst_native::parse_module(src, None).unwrap();
let imports = Imports::build(&module);
let module_bindings = crate::imports::module_bindings(&module);
let span = SpanIndex::new(src);
let anchors = crate::source::lambda_anchors(src).unwrap();
let (units, _) = crate::functions::collect(&module, src, &span, &anchors);
let f = units.iter().find(|u| u.symbol == "f").unwrap();
let h = analyze_unit(f, "x.py", &imports, &module_bindings, &span);
assert!(
h.await_count >= 1,
"await in subscript index must count, got await_count={}",
h.await_count
);
}
#[test]
fn assign_target_subscript_index_await_counts() {
let src = "async def f(xs):\n xs[await key()] = 1\n";
let module = libcst_native::parse_module(src, None).unwrap();
let imports = Imports::build(&module);
let module_bindings = crate::imports::module_bindings(&module);
let span = SpanIndex::new(src);
let anchors = crate::source::lambda_anchors(src).unwrap();
let (units, _) = crate::functions::collect(&module, src, &span, &anchors);
let f = units.iter().find(|u| u.symbol == "f").unwrap();
let h = analyze_unit(f, "x.py", &imports, &module_bindings, &span);
assert!(
h.await_count >= 1,
"await in an assignment-target subscript index must count, got await_count={}",
h.await_count
);
assert!(
h.async_boundary,
"await in an assignment-target subscript index must set async_boundary"
);
}
#[test]
fn function_local_import_resolves_effect_and_risk() {
let h = scan_fixture_hotspots("local_import");
let f = h.iter().find(|x| x.symbol == "f").unwrap();
assert!(
f.effects.iter().any(|e| e.kind.wire() == "process.control"),
"function-local import must resolve subprocess.run → process.control, got: {:?}",
f.effects.iter().map(|e| e.kind.wire()).collect::<Vec<_>>()
);
assert!(
f.risk_features
.iter()
.any(|r| r.kind.wire() == "dynamic.code"),
"shell=True must emit dynamic.code once the local import resolves"
);
}
#[test]
fn analyze_unit_scores_world_effects() {
let h = scan_fixture_hotspots("calls");
let io = h.iter().find(|x| x.symbol == "io_boundary").unwrap();
assert_eq!(io.max_class, 7);
assert!(
io.own_score >= 21.0,
"expected own_score >= 21.0, got {}",
io.own_score
);
}
fn coverage_of_symbol(src: &str, symbol: &str) -> crate::coverage::Coverage {
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, _) = crate::functions::collect(&module, src, &span, &anchors);
let unit = units
.iter()
.find(|u| u.symbol == symbol)
.expect("unit not found");
crate::coverage::of(unit, &imports)
}
#[test]
fn boundary_discount_zeros_contained_local_when_typed() {
let h = scan_fixture_hotspots("coverage");
let ft = h.iter().find(|x| x.symbol == "fully_typed").unwrap();
assert_eq!(ft.own_score, 0.0); }
#[test]
fn any_emits_type_escape_and_blocks_discount() {
let h = scan_fixture_hotspots("coverage");
let has_type_escape = h
.iter()
.find(|x| x.symbol == "has_any")
.unwrap()
.risk_features
.iter()
.any(|r| r.kind.wire() == "type.escape");
assert!(has_type_escape); let ba = h.iter().find(|x| x.symbol == "body_any").unwrap();
assert!(
ba.risk_features
.iter()
.any(|r| r.kind.wire() == "type.escape")
); assert!(ba.own_score >= 1.0); }
#[test]
fn body_any_in_eager_containers_emits_escape_and_voids_discount() {
let h = scan_fixture_hotspots("coverage");
for sym in [
"body_any_in_list",
"body_any_in_fstring",
"body_any_in_comprehension",
] {
let f = h.iter().find(|x| x.symbol == sym).unwrap();
assert!(
f.risk_features
.iter()
.any(|r| r.kind.wire() == "type.escape"),
"{sym}: body Any in an eager container must emit type.escape"
);
assert!(
f.own_score >= 1.0,
"{sym}: body Any must void the discount (local.mutation stays class 1), \
got own_score={}",
f.own_score
);
}
}
#[test]
fn discounted_effect_sets_rationale_string() {
let h = scan_fixture_hotspots("coverage");
let ft = h.iter().find(|x| x.symbol == "fully_typed").unwrap();
let lm = ft
.effects
.iter()
.find(|e| e.kind.wire() == "local.mutation")
.expect("fully_typed must have a local.mutation effect");
assert_eq!(
lm.discount.as_deref(),
Some("contained, Full-typed boundary"),
"discounted effect must carry the Full-boundary rationale"
);
}
#[test]
fn coverage_tiers_and_decorator_confidence() {
let h = scan_fixture_hotspots("coverage");
let score = |s: &str| h.iter().find(|x| x.symbol == s).unwrap().own_score;
assert_eq!(score("untyped"), 1.0); assert_eq!(score("partial"), 0.0); let dec = h.iter().find(|x| x.symbol == "decorated").unwrap();
assert!(dec.confidence < 1.0); }
#[test]
fn coverage_excludes_self_and_degrades_untyped_star_args() {
use fxrank_core::score::BoundaryCoverage;
let src = "class C:\n def m(self, x: int) -> int:\n return x\ndef v(*args) -> int:\n return 0\n";
let cov_m = coverage_of_symbol(src, "m");
assert_eq!(cov_m.boundary, BoundaryCoverage::Full); let cov_v = coverage_of_symbol(src, "v");
assert_ne!(cov_v.boundary, BoundaryCoverage::Full); }
#[test]
fn count_awaits_genexp_if_and_nested_for_are_lazy_outermost_iterable_is_eager() {
let h = scan_fixture_hotspots("genexp_await");
let find = |sym: &str| {
h.iter()
.find(|x| x.symbol == sym)
.unwrap_or_else(|| panic!("symbol {sym} not found in hotspots"))
};
let lazy_if = find("genexp_await_in_if_condition");
assert_eq!(
lazy_if.await_count, 0,
"genexp `if` condition await must NOT count toward enclosing await_count; \
got await_count={} for genexp_await_in_if_condition",
lazy_if.await_count
);
let eager_listcomp = find("listcomp_await_in_if_condition");
assert!(
eager_listcomp.await_count >= 1,
"list-comp `if` condition await IS eager and MUST count toward await_count; \
got await_count={} for listcomp_await_in_if_condition",
eager_listcomp.await_count
);
assert!(
eager_listcomp.async_boundary,
"list-comp `if` condition await must set async_boundary; \
got async_boundary={} for listcomp_await_in_if_condition",
eager_listcomp.async_boundary
);
let lazy_nested = find("genexp_await_in_nested_for_iterable");
assert_eq!(
lazy_nested.await_count, 0,
"genexp nested-for iterable await must NOT count toward enclosing await_count; \
got await_count={} for genexp_await_in_nested_for_iterable",
lazy_nested.await_count
);
let eager_iterable = find("genexp_await_in_outermost_iterable");
assert!(
eager_iterable.await_count >= 1,
"genexp outermost-iterable await IS eager and MUST count; \
got await_count={} for genexp_await_in_outermost_iterable",
eager_iterable.await_count
);
}
#[test]
fn fstring_format_spec_walk_expr_charges_effects() {
let src = "import requests\ndef f(x, u):\n return f\"{x:{requests.get(u)}}\"\n";
let module = libcst_native::parse_module(src, None).unwrap();
let imports = Imports::build(&module);
let module_bindings = crate::imports::module_bindings(&module);
let span = SpanIndex::new(src);
let anchors = crate::source::lambda_anchors(src).expect("tokenize must succeed");
let (units, _) = crate::functions::collect(&module, src, &span, &anchors);
let unit = units.iter().find(|u| u.symbol == "f").unwrap();
let h = analyze_unit(unit, "x.py", &imports, &module_bindings, &span);
assert!(
h.effects.iter().any(|e| e.kind.wire() == "net.fs.db"),
"requests.get(u) inside f-string format_spec must emit net.fs.db; got: {:?}",
h.effects.iter().map(|e| e.kind.wire()).collect::<Vec<_>>()
);
}
#[test]
fn fstring_format_spec_await_counts() {
let src = "async def f(x):\n async def w(): ...\n return f\"{x:{await w()}}\"\n";
let module = libcst_native::parse_module(src, None).unwrap();
let imports = Imports::build(&module);
let module_bindings = crate::imports::module_bindings(&module);
let span = SpanIndex::new(src);
let anchors = crate::source::lambda_anchors(src).expect("tokenize must succeed");
let (units, _) = crate::functions::collect(&module, src, &span, &anchors);
let outer = units.iter().find(|u| u.symbol == "f").unwrap();
let h = analyze_unit(outer, "x.py", &imports, &module_bindings, &span);
assert!(
h.await_count >= 1,
"await inside f-string format_spec must count; got await_count={}",
h.await_count
);
assert!(
h.async_boundary,
"await inside f-string format_spec must set async_boundary"
);
}
#[test]
fn fstring_format_spec_body_any_emits_type_escape() {
let src = "from typing import Any, cast\ndef f(x: int, y: int) -> int:\n acc: list[int] = []\n _ = f\"{x:{cast(Any, y)}}\"\n return x\n";
let module = libcst_native::parse_module(src, None).unwrap();
let imports = Imports::build(&module);
let module_bindings = crate::imports::module_bindings(&module);
let span = SpanIndex::new(src);
let anchors = crate::source::lambda_anchors(src).expect("tokenize must succeed");
let (units, _) = crate::functions::collect(&module, src, &span, &anchors);
let unit = units.iter().find(|u| u.symbol == "f").unwrap();
let h = analyze_unit(unit, "x.py", &imports, &module_bindings, &span);
assert!(
h.risk_features
.iter()
.any(|r| r.kind.wire() == "type.escape"),
"cast(Any, …) inside f-string format_spec must emit type.escape; got: {:?}",
h.risk_features
.iter()
.map(|r| r.kind.wire())
.collect::<Vec<_>>()
);
}
}