use std::collections::HashSet;
use swc_ecma_ast::{
AssignExpr, AssignTarget, CallExpr, Callee, Expr, ExprStmt, IfStmt, ModuleItem, Stmt, TryStmt,
};
use swc_ecma_visit::{Visit, VisitWith};
use crate::core::ast::parser::ParsedModule;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
#[allow(dead_code)]
pub enum SafetyLevel {
Safe = 0,
MostlySafe = 1,
Risky = 2,
Unsafe = 3,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[allow(dead_code)]
pub enum RiskReason {
DynamicRequire(String),
ConditionalImport(String),
CircularDependency,
MutationHeavyExports,
MixedModuleSystems,
TopLevelSideEffect(String),
ReassignedExports,
NestedRequire(String),
ReassignedImport(String),
TryCatchRequire,
HoistedFunctionRequire,
}
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct SafetyReport {
pub level: SafetyLevel,
pub reasons: Vec<RiskReason>,
pub warnings: Vec<String>,
pub can_transform: bool,
}
pub(crate) struct SafetyAnalyzer {
#[allow(dead_code)]
visited_requires: HashSet<String>,
#[allow(dead_code)]
exported_names: HashSet<String>,
reassigned_exports: bool,
has_conditional_require: bool,
has_top_level_side_effects: bool,
has_dynamic_require: bool,
has_mixed_imports: bool,
require_depth: u32,
conditional_depth: u32,
}
impl SafetyAnalyzer {
#[allow(dead_code)]
pub fn analyze(parsed: &ParsedModule) -> SafetyReport {
let mut analyzer = SafetyAnalyzer {
visited_requires: HashSet::new(),
exported_names: HashSet::new(),
reassigned_exports: false,
has_conditional_require: false,
has_top_level_side_effects: false,
has_dynamic_require: false,
has_mixed_imports: false,
require_depth: 0,
conditional_depth: 0,
};
analyzer.visit_module(&parsed.module);
let mut reasons = Vec::new();
let mut warnings = Vec::new();
if analyzer.has_dynamic_require {
reasons.push(RiskReason::DynamicRequire(
"variable or expression".to_string(),
));
}
if analyzer.has_conditional_require {
reasons.push(RiskReason::ConditionalImport(
"if/unary expression".to_string(),
));
}
if analyzer.reassigned_exports {
reasons.push(RiskReason::ReassignedExports);
}
if analyzer.has_mixed_imports {
reasons.push(RiskReason::MixedModuleSystems);
}
if analyzer.has_top_level_side_effects {
warnings.push("top-level side effects detected".to_string());
}
let level = calculate_safety_level(&reasons);
let can_transform = level <= SafetyLevel::MostlySafe;
SafetyReport {
level,
reasons,
warnings,
can_transform,
}
}
}
impl Visit for SafetyAnalyzer {
fn visit_module(&mut self, module: &swc_ecma_ast::Module) {
for item in &module.body {
if let ModuleItem::Stmt(Stmt::Expr(ExprStmt { expr, .. })) = item
&& !is_import_export_expr(expr)
{
self.has_top_level_side_effects = true;
break;
}
}
module.visit_children_with(self);
}
fn visit_module_decl(&mut self, decl: &swc_ecma_ast::ModuleDecl) {
self.has_mixed_imports = true;
decl.visit_children_with(self);
}
fn visit_call_expr(&mut self, call: &CallExpr) {
if let Callee::Expr(expr) = &call.callee
&& is_require_ident(expr)
{
let arg_expr = call.args.first().map(|a| &*a.expr);
if let Some(Expr::Ident(ident)) = arg_expr {
self.visited_requires.insert(ident.sym.to_string());
}
if self.conditional_depth > 0 {
self.has_conditional_require = true;
}
if self.require_depth > 0 {
let reason = format!("depth {}", self.require_depth);
self.visited_requires.insert(reason);
}
if !is_static_require_arg(arg_expr) {
self.has_dynamic_require = true;
}
}
if let Callee::Expr(expr) = &call.callee
&& is_import_ident(expr)
{
self.has_mixed_imports = true;
}
call.visit_children_with(self);
}
fn visit_assign_expr(&mut self, assign: &AssignExpr) {
if let AssignTarget::Simple(simple) = &assign.left
&& let swc_ecma_ast::SimpleAssignTarget::Ident(ident) = simple
&& ident.id.sym == "exports"
{
self.reassigned_exports = true;
}
assign.visit_children_with(self);
}
fn visit_if_stmt(&mut self, stmt: &IfStmt) {
if contains_require_call(&stmt.test) {
self.has_conditional_require = true;
}
self.conditional_depth += 1;
stmt.visit_children_with(self);
self.conditional_depth -= 1;
}
fn visit_try_stmt(&mut self, stmt: &TryStmt) {
self.conditional_depth += 1;
stmt.visit_children_with(self);
self.conditional_depth -= 1;
}
}
#[allow(dead_code)]
fn calculate_safety_level(reasons: &[RiskReason]) -> SafetyLevel {
let mut score = 0i32;
for reason in reasons {
score += match reason {
RiskReason::DynamicRequire(_) => 3,
RiskReason::ConditionalImport(_) => 4,
RiskReason::CircularDependency => 4,
RiskReason::MutationHeavyExports => 2,
RiskReason::MixedModuleSystems => 2,
RiskReason::TopLevelSideEffect(_) => 1,
RiskReason::ReassignedExports => 5,
RiskReason::NestedRequire(_) => 2,
RiskReason::ReassignedImport(_) => 4,
RiskReason::TryCatchRequire => 2,
RiskReason::HoistedFunctionRequire => 1,
};
}
match score {
0 => SafetyLevel::Safe,
1..=3 => SafetyLevel::MostlySafe,
4..=6 => SafetyLevel::Risky,
_ => SafetyLevel::Unsafe,
}
}
#[allow(dead_code)]
fn is_require_ident(expr: &Expr) -> bool {
matches!(expr, Expr::Ident(ident) if ident.sym == "require")
}
#[allow(dead_code)]
fn is_import_ident(expr: &Expr) -> bool {
matches!(expr, Expr::Ident(ident) if ident.sym == "import")
}
#[allow(dead_code)]
fn is_static_require_arg(arg: Option<&Expr>) -> bool {
match arg {
Some(Expr::Lit(swc_ecma_ast::Lit::Str(_))) => true,
Some(Expr::Tpl(tpl)) => tpl.exprs.is_empty(),
_ => false,
}
}
#[allow(dead_code)]
fn is_import_export_expr(expr: &Expr) -> bool {
matches!(expr, Expr::Assign(_))
}
#[allow(dead_code)]
fn contains_require_call(expr: &Expr) -> bool {
match expr {
Expr::Call(call) => {
if let Callee::Expr(callee) = &call.callee {
is_require_ident(callee)
} else {
false
}
}
Expr::Unary(unary) => contains_require_call(&unary.arg),
_ => false,
}
}
impl SafetyReport {
#[allow(dead_code)]
pub fn summary(&self) -> String {
let level_str = match self.level {
SafetyLevel::Safe => "safe",
SafetyLevel::MostlySafe => "mostly safe",
SafetyLevel::Risky => "risky",
SafetyLevel::Unsafe => "unsafe",
};
if self.reasons.is_empty() {
format!("Safety: {} - no risks detected", level_str)
} else {
let reason_count = self.reasons.len();
format!("Safety: {} - {} risk(s) detected", level_str, reason_count)
}
}
#[allow(dead_code)]
pub fn should_transform(&self, allow_risky: bool, strict: bool) -> bool {
if strict {
self.level == SafetyLevel::Safe
} else if allow_risky {
self.level <= SafetyLevel::Risky
} else {
self.level <= SafetyLevel::MostlySafe
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::ast::parser::parse_source;
use std::path::Path;
fn analyze(source: &str) -> SafetyReport {
let parsed = parse_source(Path::new("fixture.js"), source).expect("source should parse");
SafetyAnalyzer::analyze(&parsed)
}
#[test]
fn test_safe_require() {
let report = analyze("const lib = require('lib'); module.exports = lib;");
assert_eq!(report.level, SafetyLevel::Safe);
assert!(report.can_transform);
assert!(report.reasons.is_empty());
}
#[test]
fn test_dynamic_require() {
let report = analyze("const lib = require(variable); module.exports = lib;");
assert!(report.level >= SafetyLevel::Safe);
}
#[test]
fn test_conditional_require() {
let report = analyze("if (condition) { require('a'); }");
assert!(report.level >= SafetyLevel::Risky);
}
#[test]
fn test_exports_reassignment() {
let report = analyze("exports = factory();");
assert!(report.level >= SafetyLevel::Risky);
assert!(
report
.reasons
.iter()
.any(|r| matches!(r, RiskReason::ReassignedExports))
);
}
#[test]
fn test_mixed_imports() {
let report = analyze("const x = require('bar'); const lib = require('lib');");
assert!(report.can_transform);
}
#[test]
fn test_side_effects() {
let report = analyze("console.log('hello'); const lib = require('lib');");
assert!(!report.warnings.is_empty());
assert!(report.warnings.iter().any(|w| w.contains("side effect")));
}
#[test]
fn test_should_transform_strict() {
let safe_report = analyze("const lib = require('lib'); module.exports = lib;");
let risky_report = analyze("const lib = require(variable);");
assert!(safe_report.should_transform(false, true));
assert!(!risky_report.should_transform(false, true));
}
#[test]
fn test_should_transform_allow_risky() {
let safe_report = analyze("const lib = require('lib'); module.exports = lib;");
let risky_report = analyze("const lib = require(variable);");
assert!(safe_report.should_transform(true, false));
assert!(risky_report.should_transform(true, false));
}
#[test]
fn test_multiple_risks() {
let report = analyze("const lib = require(condition ? 'a' : variable); exports = lib;");
assert!(report.level >= SafetyLevel::Risky);
}
#[test]
fn test_nested_require() {
let report = analyze(
"function getLib() { return require('lib'); } module.exports = { get: getLib };",
);
assert!(report.level <= SafetyLevel::Risky);
}
#[test]
fn test_try_catch_require() {
let report =
analyze("try { const lib = require('lib'); } catch(e) { } module.exports = lib;");
assert!(report.can_transform || !report.reasons.is_empty());
}
#[test]
fn test_export_member() {
let report = analyze("exports.foo = require('lib');");
assert!(report.can_transform);
}
#[test]
fn test_module_exports_object() {
let report = analyze("module.exports = { foo: require('foo'), bar: require('bar') };");
assert!(report.can_transform);
}
}