From f39cb751b107a49de48c901dd092fa5bf5ee8385 Mon Sep 17 00:00:00 2001 From: Marvin Hagemeister Date: Mon, 29 Jun 2026 10:43:57 +0200 Subject: [PATCH] feat: add `node-builtin-specifier` rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new lint rule that warns when a Node.js built-in module is imported with a bare specifier (e.g. "fs") instead of the required "node:" prefix (e.g. "node:fs"). It covers both static imports and dynamic `import()` calls and provides an autofix that adds the "node:" prefix. The rule is tagged `recommended` and emits diagnostics at warning severity rather than error, so existing code keeps working while nudging users toward the correct specifier. Co-authored-by: Bartek IwaƄczuk --- src/rules.rs | 2 + src/rules/node_builtin_specifier.rs | 236 ++++++++++++++++++++++++++++ 2 files changed, 238 insertions(+) create mode 100644 src/rules/node_builtin_specifier.rs diff --git a/src/rules.rs b/src/rules.rs index b5f9fcfc..ff86954a 100644 --- a/src/rules.rs +++ b/src/rules.rs @@ -115,6 +115,7 @@ pub mod no_var; pub mod no_window; pub mod no_window_prefix; pub mod no_with; +pub mod node_builtin_specifier; pub mod prefer_as_const; pub mod prefer_ascii; pub mod prefer_const; @@ -362,6 +363,7 @@ fn get_all_rules_raw() -> Vec> { Box::new(no_window::NoWindow), Box::new(no_window_prefix::NoWindowPrefix), Box::new(no_with::NoWith), + Box::new(node_builtin_specifier::NodeBuiltinsSpecifier), Box::new(prefer_as_const::PreferAsConst), Box::new(prefer_ascii::PreferAscii), Box::new(prefer_const::PreferConst), diff --git a/src/rules/node_builtin_specifier.rs b/src/rules/node_builtin_specifier.rs new file mode 100644 index 00000000..a70a481b --- /dev/null +++ b/src/rules/node_builtin_specifier.rs @@ -0,0 +1,236 @@ +// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. +use super::Context; +use super::LintRule; +use crate::diagnostic::LintDiagnosticSeverity; +use crate::diagnostic::LintFix; +use crate::diagnostic::LintFixChange; +use crate::handler::Handler; +use crate::handler::Traverse; +use crate::tags; +use crate::tags::Tags; +use crate::Program; + +use deno_ast::view as ast_view; +use deno_ast::SourceRange; +use deno_ast::SourceRanged; + +#[derive(Debug)] +pub struct NodeBuiltinsSpecifier; + +const CODE: &str = "node-builtin-specifier"; +const MESSAGE: &str = "built-in Node modules need the \"node:\" specifier"; +const HINT: &str = "Add \"node:\" prefix in front of the import specifier"; +const FIX_DESC: &str = "Add \"node:\" prefix"; + +impl LintRule for NodeBuiltinsSpecifier { + fn tags(&self) -> Tags { + &[tags::RECOMMENDED] + } + + fn code(&self) -> &'static str { + CODE + } + + fn lint_program_with_ast_view( + &self, + context: &mut Context, + program: Program<'_>, + ) { + NodeBuiltinsSpecifierGlobalHandler.traverse(program, context); + } +} + +struct NodeBuiltinsSpecifierGlobalHandler; + +impl NodeBuiltinsSpecifierGlobalHandler { + fn add_diagnostic(&self, ctx: &mut Context, src: &str, range: SourceRange) { + let specifier = format!(r#""node:{}""#, src); + + let diagnostic_range = ctx.create_diagnostic_range(range); + let details = ctx.create_diagnostic_details( + CODE, + MESSAGE, + Some(HINT.to_string()), + vec![LintFix { + description: FIX_DESC.into(), + changes: vec![LintFixChange { + new_text: specifier.into(), + range, + }], + }], + ); + // This rule defaults to a warning rather than an error so that existing + // code importing Node built-ins without the `node:` prefix keeps working. + ctx.add_diagnostic_details_with_severity( + Some(diagnostic_range), + details, + LintDiagnosticSeverity::Warning, + ); + } +} + +impl Handler for NodeBuiltinsSpecifierGlobalHandler { + fn import_decl(&mut self, decl: &ast_view::ImportDecl, ctx: &mut Context) { + let src = decl.src.value().to_string_lossy(); + if is_bare_node_builtin(&src) { + self.add_diagnostic(ctx, &src, decl.src.range()); + } + } + + fn call_expr(&mut self, expr: &ast_view::CallExpr, ctx: &mut Context) { + if let ast_view::Callee::Import(_) = expr.callee { + if let Some(src_expr) = expr.args.first() { + if let ast_view::Expr::Lit(ast_view::Lit::Str(str_value)) = + src_expr.expr + { + let src = str_value.value().to_string_lossy(); + if is_bare_node_builtin(&src) { + self.add_diagnostic(ctx, &src, str_value.range()); + } + } + } + } + } +} + +// Should match https://nodejs.org/api/module.html#modulebuiltinmodules +fn is_bare_node_builtin(src: &str) -> bool { + matches!( + src, + "assert" + | "assert/strict" + | "async_hooks" + | "buffer" + | "child_process" + | "cluster" + | "console" + | "constants" + | "crypto" + | "dgram" + | "diagnostics_channel" + | "dns" + | "dns/promises" + | "domain" + | "events" + | "fs" + | "fs/promises" + | "http" + | "http2" + | "https" + | "inspector" + | "inspector/promises" + | "module" + | "net" + | "os" + | "path" + | "path/posix" + | "path/win32" + | "perf_hooks" + | "process" + | "punycode" + | "querystring" + | "readline" + | "readline/promises" + | "repl" + | "stream" + | "stream/consumers" + | "stream/promises" + | "stream/web" + | "string_decoder" + | "sys" + | "timers" + | "timers/promises" + | "tls" + | "trace_events" + | "tty" + | "url" + | "util" + | "util/types" + | "v8" + | "vm" + | "wasi" + | "worker_threads" + | "zlib" + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn node_specifier_valid() { + assert_lint_ok! { + NodeBuiltinsSpecifier, + r#"import "node:path";"#, + r#"import "node:fs";"#, + r#"import "node:fs/promises";"#, + + r#"import * as fs from "node:fs";"#, + r#"import * as fsPromises from "node:fs/promises";"#, + r#"import fsPromises from "node:fs/promises";"#, + + r#"await import("node:fs");"#, + r#"await import("node:fs/promises");"#, + }; + } + + #[test] + fn node_specifier_invalid() { + assert_lint_err! { + NodeBuiltinsSpecifier, + MESSAGE, + HINT, + r#"import "path";"#: [ + { + col: 7, + fix: (FIX_DESC, r#"import "node:path";"#), + } + ], + r#"import "fs";"#: [ + { + col: 7, + fix: (FIX_DESC, r#"import "node:fs";"#), + } + ], + r#"import "fs/promises";"#: [ + { + col: 7, + fix: (FIX_DESC, r#"import "node:fs/promises";"#), + } + ], + + r#"import * as fs from "fs";"#: [ + { + col: 20, + fix: (FIX_DESC, r#"import * as fs from "node:fs";"#), + } + ], + r#"import * as fsPromises from "fs/promises";"#: [ + { + col: 28, + fix: (FIX_DESC, r#"import * as fsPromises from "node:fs/promises";"#), + } + ], + r#"import fsPromises from "fs/promises";"#: [ + { + col: 23, + fix: (FIX_DESC, r#"import fsPromises from "node:fs/promises";"#), + } + ], + + r#"await import("fs");"#: [ + { + col: 13, + fix: (FIX_DESC, r#"await import("node:fs");"#), + } + ], + r#"await import("fs/promises");"#: [ + { + col: 13, + fix: (FIX_DESC, r#"await import("node:fs/promises");"#), + } + ] + }; + } +}