use swc_ecma_ast::{AssignExpr, AssignTarget, Callee, Expr, IdentName, MemberExpr, MemberProp};
use swc_ecma_visit::{Visit, VisitWith};
use crate::core::ast::parser::ParsedModule;
use crate::core::recipe::{FileAnalysis, FileClassification};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CommonJsPattern {
RequireCall,
DynamicRequire,
ModuleExports,
ExportsMember,
ExportsAssignment,
}
impl CommonJsPattern {
fn label(self) -> &'static str {
match self {
Self::RequireCall => "require(...)",
Self::DynamicRequire => "dynamic require",
Self::ModuleExports => "module.exports",
Self::ExportsMember => "exports.foo",
Self::ExportsAssignment => "exports = ...",
}
}
}
#[derive(Default)]
struct CommonJsVisitor {
patterns: Vec<CommonJsPattern>,
}
impl CommonJsVisitor {
fn push_pattern(&mut self, pattern: CommonJsPattern) {
if !self.patterns.contains(&pattern) {
self.patterns.push(pattern);
}
}
}
impl Visit for CommonJsVisitor {
fn visit_call_expr(&mut self, call: &swc_ecma_ast::CallExpr) {
if let Callee::Expr(expr) = &call.callee
&& is_ident(expr, "require")
{
if call.args.len() == 1
&& call.args[0].spread.is_none()
&& is_static_arg(&call.args[0].expr)
{
self.push_pattern(CommonJsPattern::RequireCall);
} else {
self.push_pattern(CommonJsPattern::DynamicRequire);
}
}
call.visit_children_with(self);
}
fn visit_assign_expr(&mut self, assign: &AssignExpr) {
match &assign.left {
AssignTarget::Simple(simple) => {
if is_simple_ident(simple, "exports") {
self.push_pattern(CommonJsPattern::ExportsAssignment);
}
}
AssignTarget::Pat(_) => {}
}
if is_module_exports_member(&assign.left) {
self.push_pattern(CommonJsPattern::ModuleExports);
}
if is_exports_member_target(&assign.left) {
self.push_pattern(CommonJsPattern::ExportsMember);
}
assign.visit_children_with(self);
}
}
pub fn analyze_parsed_module(parsed: &ParsedModule) -> Option<FileAnalysis> {
let mut visitor = CommonJsVisitor::default();
parsed.module.visit_with(&mut visitor);
if visitor.patterns.is_empty() {
return None;
}
let classification = if visitor.patterns.contains(&CommonJsPattern::DynamicRequire)
|| visitor
.patterns
.contains(&CommonJsPattern::ExportsAssignment)
{
FileClassification::Risky
} else {
FileClassification::Safe
};
Some(FileAnalysis {
path: parsed.path.clone(),
detected_patterns: visitor
.patterns
.iter()
.map(|pattern| pattern.label().to_owned())
.collect(),
confidence_score: confidence_score(&visitor.patterns),
classification,
is_transform_safe: classification == FileClassification::Safe,
tags: Default::default(),
})
}
fn confidence_score(patterns: &[CommonJsPattern]) -> u8 {
let mut score = 0u8;
for pattern in patterns {
score = score.saturating_add(match pattern {
CommonJsPattern::RequireCall => 25,
CommonJsPattern::DynamicRequire => 35,
CommonJsPattern::ModuleExports => 35,
CommonJsPattern::ExportsMember => 30,
CommonJsPattern::ExportsAssignment => 30,
});
}
score.min(100)
}
fn is_static_arg(expr: &Expr) -> bool {
match expr {
Expr::Lit(swc_ecma_ast::Lit::Str(_)) => true,
Expr::Tpl(template) => template.exprs.is_empty(),
_ => false,
}
}
fn is_ident(expr: &Expr, name: &str) -> bool {
matches!(expr, Expr::Ident(ident) if ident.sym == *name)
}
fn is_simple_ident(target: &swc_ecma_ast::SimpleAssignTarget, name: &str) -> bool {
matches!(target, swc_ecma_ast::SimpleAssignTarget::Ident(binding) if binding.id.sym == *name)
}
fn is_module_exports_member(target: &AssignTarget) -> bool {
match target {
AssignTarget::Simple(simple) => match simple {
swc_ecma_ast::SimpleAssignTarget::Member(member) => {
is_member_pair(member, "module", "exports")
}
_ => false,
},
AssignTarget::Pat(_) => false,
}
}
fn is_exports_member_target(target: &AssignTarget) -> bool {
match target {
AssignTarget::Simple(simple) => match simple {
swc_ecma_ast::SimpleAssignTarget::Member(member) => {
is_member_pair(member, "exports", "")
}
_ => false,
},
AssignTarget::Pat(_) => false,
}
}
fn is_member_pair(member: &MemberExpr, first: &str, second: &str) -> bool {
match (&*member.obj, &member.prop) {
(Expr::Ident(ident), MemberProp::Ident(IdentName { sym, .. })) if ident.sym == *first => {
second.is_empty() || sym == second
}
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::analyze_parsed_module;
use crate::core::ast::parser::parse_source;
use crate::core::recipe::FileClassification;
use std::path::Path;
fn analyze(source: &str) -> crate::core::recipe::FileAnalysis {
let parsed = parse_source(Path::new("fixture.js"), source).expect("source should parse");
analyze_parsed_module(&parsed).expect("analysis should detect commonjs")
}
#[test]
fn detects_static_require_as_safe() {
let analysis = analyze("const lib = require('lib');");
assert!(
analysis
.detected_patterns
.iter()
.any(|pattern| pattern == "require(...)")
);
assert_eq!(analysis.classification, FileClassification::Safe);
assert!(analysis.confidence_score > 0);
}
#[test]
fn detects_dynamic_require_as_risky() {
let analysis = analyze("const lib = require(name);");
assert!(
analysis
.detected_patterns
.iter()
.any(|pattern| pattern == "dynamic require")
);
assert_eq!(analysis.classification, FileClassification::Risky);
}
#[test]
fn detects_module_exports_and_exports_member() {
let analysis = analyze("module.exports = factory(); exports.value = 1;");
assert!(
analysis
.detected_patterns
.iter()
.any(|pattern| pattern == "module.exports")
);
assert!(
analysis
.detected_patterns
.iter()
.any(|pattern| pattern == "exports.foo")
);
assert_eq!(analysis.classification, FileClassification::Safe);
}
#[test]
fn detects_exports_assignment_as_risky() {
let analysis = analyze("exports = buildExports();");
assert!(
analysis
.detected_patterns
.iter()
.any(|pattern| pattern == "exports = ...")
);
assert_eq!(analysis.classification, FileClassification::Risky);
}
#[test]
fn skips_non_commonjs_files() {
let parsed = parse_source(Path::new("fixture.js"), "export const value = 1;")
.expect("source should parse");
assert!(analyze_parsed_module(&parsed).is_none());
}
}