Skip to main content

code_baseline/rules/
banned_import.rs

1use crate::config::{RuleConfig, Severity};
2use crate::rules::{Rule, RuleBuildError, ScanContext, Violation};
3use regex::Regex;
4
5/// Scans source files for import/require statements referencing banned packages.
6///
7/// Detects patterns like:
8/// - `import ... from 'pkg'`
9/// - `import 'pkg'`
10/// - `require('pkg')`
11/// - Subpath imports like `import ... from 'lodash/debounce'`
12///
13/// Uses word-boundary matching to avoid false positives (e.g., `moment` won't
14/// match `momentum`).
15#[derive(Debug)]
16pub struct BannedImportRule {
17    id: String,
18    severity: Severity,
19    message: String,
20    suggest: Option<String>,
21    glob: Option<String>,
22    #[allow(dead_code)]
23    packages: Vec<String>,
24    import_re: Regex,
25}
26
27impl BannedImportRule {
28    pub fn new(config: &RuleConfig) -> Result<Self, RuleBuildError> {
29        if config.packages.is_empty() {
30            return Err(RuleBuildError::MissingField(
31                config.id.clone(),
32                "packages",
33            ));
34        }
35
36        // Build a regex that matches import/require of any banned package.
37        // Escaped package names joined with | to form alternatives.
38        let escaped: Vec<String> = config
39            .packages
40            .iter()
41            .map(|p| regex::escape(p))
42            .collect();
43        let pkg_group = escaped.join("|");
44
45        // Match:
46        //   import ... from ['"]pkg['"]      (named/default import)
47        //   import ['"]pkg['"]               (side-effect import)
48        //   require\(['"]pkg['"]\)           (CommonJS require)
49        //   export ... from ['"]pkg['"]      (re-exports)
50        // Also match subpath imports: pkg/subpath
51        let pattern = format!(
52            r#"(?:import\s+.*?\s+from\s+|import\s+|export\s+.*?\s+from\s+|require\s*\(\s*)['"]({})(?:/[^'"]*)?['"]"#,
53            pkg_group
54        );
55
56        let import_re = Regex::new(&pattern)
57            .map_err(|e| RuleBuildError::InvalidRegex(config.id.clone(), e))?;
58
59        let default_glob = "**/*.{ts,tsx,js,jsx,mjs,cjs}".to_string();
60
61        Ok(Self {
62            id: config.id.clone(),
63            severity: config.severity,
64            message: config.message.clone(),
65            suggest: config.suggest.clone(),
66            glob: config.glob.clone().or(Some(default_glob)),
67            packages: config.packages.clone(),
68            import_re,
69        })
70    }
71}
72
73impl Rule for BannedImportRule {
74    fn id(&self) -> &str {
75        &self.id
76    }
77
78    fn severity(&self) -> Severity {
79        self.severity
80    }
81
82    fn file_glob(&self) -> Option<&str> {
83        self.glob.as_deref()
84    }
85
86    fn check_file(&self, ctx: &ScanContext) -> Vec<Violation> {
87        let mut violations = Vec::new();
88
89        for (line_idx, line) in ctx.content.lines().enumerate() {
90            for cap in self.import_re.captures_iter(line) {
91                let matched_pkg = cap.get(1).unwrap().as_str();
92                let full_match = cap.get(0).unwrap();
93
94                violations.push(Violation {
95                    rule_id: self.id.clone(),
96                    severity: self.severity,
97                    file: ctx.file_path.to_path_buf(),
98                    line: Some(line_idx + 1),
99                    column: Some(full_match.start() + 1),
100                    message: format!("{}: '{}'", self.message, matched_pkg),
101                    suggest: self.suggest.clone(),
102                    source_line: Some(line.to_string()),
103                    fix: None,
104                });
105            }
106        }
107
108        violations
109    }
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115    use std::path::Path;
116
117    fn make_rule(packages: Vec<&str>) -> BannedImportRule {
118        let config = RuleConfig {
119            id: "test-banned-import".into(),
120            severity: Severity::Error,
121            message: "banned import".into(),
122            suggest: Some("use an alternative".into()),
123            packages: packages.into_iter().map(|s| s.to_string()).collect(),
124            ..Default::default()
125        };
126        BannedImportRule::new(&config).unwrap()
127    }
128
129    fn check(rule: &BannedImportRule, content: &str) -> Vec<Violation> {
130        let ctx = ScanContext {
131            file_path: Path::new("test.ts"),
132            content,
133        };
134        rule.check_file(&ctx)
135    }
136
137    #[test]
138    fn detects_named_import() {
139        let rule = make_rule(vec!["moment"]);
140        let violations = check(&rule, r#"import moment from 'moment';"#);
141        assert_eq!(violations.len(), 1);
142        assert!(violations[0].message.contains("moment"));
143    }
144
145    #[test]
146    fn detects_destructured_import() {
147        let rule = make_rule(vec!["moment"]);
148        let violations = check(&rule, r#"import { format } from "moment";"#);
149        assert_eq!(violations.len(), 1);
150    }
151
152    #[test]
153    fn detects_side_effect_import() {
154        let rule = make_rule(vec!["styled-components"]);
155        let violations = check(&rule, r#"import 'styled-components';"#);
156        assert_eq!(violations.len(), 1);
157    }
158
159    #[test]
160    fn detects_require() {
161        let rule = make_rule(vec!["moment"]);
162        let violations = check(&rule, r#"const moment = require('moment');"#);
163        assert_eq!(violations.len(), 1);
164    }
165
166    #[test]
167    fn detects_subpath_import() {
168        let rule = make_rule(vec!["lodash"]);
169        let violations = check(&rule, r#"import debounce from 'lodash/debounce';"#);
170        assert_eq!(violations.len(), 1);
171        assert!(violations[0].message.contains("lodash"));
172    }
173
174    #[test]
175    fn no_false_positive_on_similar_names() {
176        let rule = make_rule(vec!["moment"]);
177        let violations = check(&rule, r#"import momentum from 'momentum';"#);
178        assert!(violations.is_empty(), "should not match 'momentum' when banning 'moment'");
179    }
180
181    #[test]
182    fn detects_scoped_package() {
183        let rule = make_rule(vec!["@emotion/styled"]);
184        let violations = check(&rule, r#"import styled from '@emotion/styled';"#);
185        assert_eq!(violations.len(), 1);
186    }
187
188    #[test]
189    fn detects_export_from() {
190        let rule = make_rule(vec!["moment"]);
191        let violations = check(&rule, r#"export { default } from 'moment';"#);
192        assert_eq!(violations.len(), 1);
193    }
194
195    #[test]
196    fn no_match_on_safe_imports() {
197        let rule = make_rule(vec!["moment"]);
198        let violations = check(&rule, r#"import { format } from 'date-fns';"#);
199        assert!(violations.is_empty());
200    }
201
202    #[test]
203    fn multiple_banned_packages() {
204        let rule = make_rule(vec!["styled-components", "@emotion/styled", "@emotion/css"]);
205        let content = r#"import styled from 'styled-components';
206import { css } from '@emotion/css';
207import { jsx } from '@emotion/react';"#;
208        let violations = check(&rule, content);
209        assert_eq!(violations.len(), 2);
210    }
211
212    #[test]
213    fn missing_packages_error() {
214        let config = RuleConfig {
215            id: "test".into(),
216            severity: Severity::Error,
217            message: "test".into(),
218            ..Default::default()
219        };
220        let err = BannedImportRule::new(&config).unwrap_err();
221        assert!(matches!(err, RuleBuildError::MissingField(_, "packages")));
222    }
223
224    #[test]
225    fn default_glob_set() {
226        let rule = make_rule(vec!["moment"]);
227        assert_eq!(rule.file_glob(), Some("**/*.{ts,tsx,js,jsx,mjs,cjs}"));
228    }
229}