From e770fd417618f6c78351077d010ce68ce1f4463d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Iwa=C5=84czuk?= Date: Mon, 29 Jun 2026 23:06:04 +0200 Subject: [PATCH] feat(lint): add node-builtin-specifier rule 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"), covering both static imports and dynamic import() calls, with an autofix that adds the prefix. The rule was previously implemented in deno_lint but maintained its own hardcoded copy of the built-in module list. It now lives here as an extended CLI lint rule that reuses the canonical list from ext/node via node_resolver's DenoIsBuiltInNodeModuleChecker, so there is a single source of truth for which modules are Node built-ins. --- cli/schemas/lint-rules.v1.json | 1 + cli/tools/lint/rules/mod.rs | 16 ++- .../lint/rules/node_builtin_specifier.rs | 124 ++++++++++++++++++ 3 files changed, 136 insertions(+), 5 deletions(-) create mode 100644 cli/tools/lint/rules/node_builtin_specifier.rs diff --git a/cli/schemas/lint-rules.v1.json b/cli/schemas/lint-rules.v1.json index 7e565ae0e90783..4f819f659f781a 100644 --- a/cli/schemas/lint-rules.v1.json +++ b/cli/schemas/lint-rules.v1.json @@ -117,6 +117,7 @@ "no-window", "no-window-prefix", "no-with", + "node-builtin-specifier", "prefer-as-const", "prefer-ascii", "prefer-const", diff --git a/cli/tools/lint/rules/mod.rs b/cli/tools/lint/rules/mod.rs index 735d1d3d540dca..e3e1c036f5d749 100644 --- a/cli/tools/lint/rules/mod.rs +++ b/cli/tools/lint/rules/mod.rs @@ -19,6 +19,7 @@ use crate::sys::CliSys; mod no_sloppy_imports; mod no_slow_types; +mod node_builtin_specifier; // used for publishing pub use no_slow_types::collect_no_slow_type_diagnostics; @@ -164,11 +165,16 @@ impl LintRuleProvider { pub fn all_rules(&self) -> Vec { let deno_lint_rules = deno_lint::rules::get_all_rules(); - let cli_lint_rules = vec![CliLintRule(CliLintRuleKind::Extended( - Box::new(no_sloppy_imports::NoSloppyImportsRule::new( - self.workspace_resolver.clone(), - )), - ))]; + let cli_lint_rules = vec![ + CliLintRule(CliLintRuleKind::Extended(Box::new( + no_sloppy_imports::NoSloppyImportsRule::new( + self.workspace_resolver.clone(), + ), + ))), + CliLintRule(CliLintRuleKind::Extended(Box::new( + node_builtin_specifier::NodeBuiltinSpecifierRule, + ))), + ]; let cli_graph_rules = vec![CliLintRule(CliLintRuleKind::Package( Box::new(no_slow_types::NoSlowTypesRule), ))]; diff --git a/cli/tools/lint/rules/node_builtin_specifier.rs b/cli/tools/lint/rules/node_builtin_specifier.rs new file mode 100644 index 00000000000000..2440c538e21151 --- /dev/null +++ b/cli/tools/lint/rules/node_builtin_specifier.rs @@ -0,0 +1,124 @@ +// Copyright 2018-2026 the Deno authors. MIT license. + +use std::borrow::Cow; + +use deno_ast::SourceRange; +use deno_ast::SourceRangedForSpanned; +use deno_ast::swc::ast; +use deno_ast::swc::ecma_visit::Visit; +use deno_ast::swc::ecma_visit::VisitWith; +use deno_ast::swc::ecma_visit::noop_visit_type; +use deno_lint::diagnostic::LintDiagnosticDetails; +use deno_lint::diagnostic::LintDiagnosticRange; +use deno_lint::diagnostic::LintDocsUrl; +use deno_lint::diagnostic::LintFix; +use deno_lint::diagnostic::LintFixChange; +use deno_lint::rules::LintRule; +use deno_lint::tags; +use node_resolver::DenoIsBuiltInNodeModuleChecker; +use node_resolver::IsBuiltInNodeModuleChecker; + +use super::ExtendedLintRule; + +#[derive(Debug)] +pub struct NodeBuiltinSpecifierRule; + +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"; +const DOCS_URL: &str = + "https://docs.deno.com/lint/rules/node-builtin-specifier"; + +impl ExtendedLintRule for NodeBuiltinSpecifierRule { + fn supports_incremental_cache(&self) -> bool { + // This rule only looks at the current file, so it's safe to cache. + true + } + + fn help_docs_url(&self) -> Cow<'static, str> { + Cow::Borrowed(DOCS_URL) + } + + fn into_base(self: Box) -> Box { + self + } +} + +impl LintRule for NodeBuiltinSpecifierRule { + fn lint_program_with_ast_view<'view>( + &self, + context: &mut deno_lint::context::Context<'view>, + _program: deno_lint::Program<'view>, + ) { + let mut collector = BareNodeBuiltinCollector::default(); + context.parsed_source().program().visit_with(&mut collector); + + for (range, specifier) in collector.violations { + let new_text = format!("\"node:{}\"", specifier); + context.add_diagnostic_details( + Some(LintDiagnosticRange { + range, + description: None, + text_info: context.text_info().clone(), + }), + LintDiagnosticDetails { + message: MESSAGE.to_string(), + code: CODE.to_string(), + hint: Some(HINT.to_string()), + fixes: vec![LintFix { + description: Cow::Borrowed(FIX_DESC), + changes: vec![LintFixChange { + new_text: Cow::Owned(new_text), + range, + }], + }], + custom_docs_url: LintDocsUrl::Default, + info: vec![], + }, + ); + } + } + + fn code(&self) -> &'static str { + CODE + } + + fn tags(&self) -> tags::Tags { + &[tags::RECOMMENDED] + } +} + +#[derive(Default)] +struct BareNodeBuiltinCollector { + violations: Vec<(SourceRange, String)>, +} + +impl BareNodeBuiltinCollector { + fn maybe_add(&mut self, src: &ast::Str) { + let Some(value) = src.value.as_str() else { + return; + }; + if DenoIsBuiltInNodeModuleChecker.is_builtin_node_module(value) { + self.violations.push((src.range(), value.to_string())); + } + } +} + +impl Visit for BareNodeBuiltinCollector { + noop_visit_type!(); + + fn visit_import_decl(&mut self, node: &ast::ImportDecl) { + self.maybe_add(&node.src); + } + + fn visit_call_expr(&mut self, node: &ast::CallExpr) { + if let ast::Callee::Import(_) = &node.callee + && let Some(arg) = node.args.first() + && let ast::Expr::Lit(ast::Lit::Str(src)) = &*arg.expr + { + self.maybe_add(src); + } + node.visit_children_with(self); + } +}