From 5b78153e29407664bcfaa26b358c3b38f4ea8cb0 Mon Sep 17 00:00:00 2001 From: Tu Shaokun <2801884530@qq.com> Date: Thu, 18 Dec 2025 17:40:32 +0800 Subject: [PATCH 1/4] feat(noUndeclaredEnvVars): add bracket notation and Bun.env support - Support bracket notation: process.env["VAR"], import.meta.env["VAR"] - Support Bun runtime: Bun.env.VAR, Bun.env["VAR"] - Only check string literal keys; dynamic keys are still skipped --- .changeset/bracket-bun-env-support.md | 5 + .../lint/nursery/no_undeclared_env_vars.rs | 127 +++++++++++++----- .../nursery/noUndeclaredEnvVars/invalid.js | 8 ++ .../noUndeclaredEnvVars/invalid.js.snap | 66 +++++++++ .../nursery/noUndeclaredEnvVars/valid.js | 17 +++ .../nursery/noUndeclaredEnvVars/valid.js.snap | 17 +++ .../noUndeclaredEnvVars/validDynamicAccess.js | 16 +-- .../validDynamicAccess.js.snap | 16 +-- 8 files changed, 215 insertions(+), 57 deletions(-) create mode 100644 .changeset/bracket-bun-env-support.md diff --git a/.changeset/bracket-bun-env-support.md b/.changeset/bracket-bun-env-support.md new file mode 100644 index 000000000000..9aeae7d6de59 --- /dev/null +++ b/.changeset/bracket-bun-env-support.md @@ -0,0 +1,5 @@ +--- +"@biomejs/biome": patch +--- + +Extended [`noUndeclaredEnvVars`](https://biomejs.dev/linter/rules/no-undeclared-env-vars/) to support bracket notation (`process.env["VAR"]`, `import.meta.env["VAR"]`) and Bun runtime (`Bun.env.VAR`, `Bun.env["VAR"]`). Fixes [#8494](https://github.com/biomejs/biome/issues/8494). diff --git a/crates/biome_js_analyze/src/lint/nursery/no_undeclared_env_vars.rs b/crates/biome_js_analyze/src/lint/nursery/no_undeclared_env_vars.rs index dcdea309f73c..ee34424b736d 100644 --- a/crates/biome_js_analyze/src/lint/nursery/no_undeclared_env_vars.rs +++ b/crates/biome_js_analyze/src/lint/nursery/no_undeclared_env_vars.rs @@ -3,8 +3,11 @@ use biome_analyze::{ }; use biome_console::markup; use biome_diagnostics::Severity; -use biome_js_syntax::{AnyJsExpression, JsStaticMemberExpression, global_identifier}; -use biome_rowan::{AstNode, TokenText}; +use biome_js_syntax::{ + AnyJsExpression, AnyJsMemberExpression, JsComputedMemberExpression, JsStaticMemberExpression, + global_identifier, inner_string_text, +}; +use biome_rowan::AstNode; use biome_rule_options::no_undeclared_env_vars::NoUndeclaredEnvVarsOptions; use crate::services::turborepo::Turborepo; @@ -17,8 +20,12 @@ declare_lint_rule! { /// Using undeclared environment variables can lead to incorrect cache hits /// and unpredictable build behavior. /// - /// This rule checks for `process.env.VAR_NAME` and `import.meta.env.VAR_NAME` - /// accesses and validates them against: + /// This rule checks for environment variable accesses in the following patterns: + /// - `process.env.VAR_NAME` and `process.env["VAR_NAME"]` + /// - `import.meta.env.VAR_NAME` and `import.meta.env["VAR_NAME"]` + /// - `Bun.env.VAR_NAME` and `Bun.env["VAR_NAME"]` + /// + /// It validates them against: /// 1. Environment variables declared in `turbo.json(c)` (`globalEnv`, `globalPassThroughEnv`, task-level `env`, and task-level `passThroughEnv`) /// 2. Environment variables specified in the rule's `allowedEnvVars` option /// 3. Default allowed variables (common system vars and framework-specific patterns) @@ -88,11 +95,11 @@ declare_lint_rule! { /// State that holds the environment variable name being accessed pub struct EnvVarAccess { /// The name of the environment variable - env_var_name: TokenText, + env_var_name: String, } impl Rule for NoUndeclaredEnvVars { - type Query = Turborepo; + type Query = Turborepo; type State = EnvVarAccess; type Signals = Option; type Options = NoUndeclaredEnvVarsOptions; @@ -103,55 +110,46 @@ impl Rule for NoUndeclaredEnvVars { return None; } - let static_member_expr = ctx.query(); + let member_expr = ctx.query(); let options = ctx.options(); let model = ctx.model(); - // Check if this is either process.env.SOMETHING or import.meta.env.SOMETHING - let object = static_member_expr.object().ok()?; - let parent_member = object.as_js_static_member_expression()?; - let env_member = parent_member.member().ok()?; - - // Must be accessing ".env" - if env_member.as_js_name()?.to_trimmed_text().text() != "env" { - return None; - } - - let parent_object = parent_member.object().ok()?; + // Get the env var name and the parent object based on the expression type + let (env_var_name, parent_object) = match member_expr { + AnyJsMemberExpression::JsStaticMemberExpression(static_expr) => { + extract_from_static_member(static_expr)? + } + AnyJsMemberExpression::JsComputedMemberExpression(computed_expr) => { + extract_from_computed_member(computed_expr)? + } + }; - let is_process_env = is_global_process_object(&parent_object, model); + // Check if the parent object is a valid env access (process.env, import.meta.env, or Bun.env) + let is_process_or_bun_env = is_global_env_object(&parent_object, model); let is_import_meta_env = is_import_meta_object(&parent_object); - if !is_process_env && !is_import_meta_env { + if !is_process_or_bun_env && !is_import_meta_env { return None; } - // Get the env var name being accessed (e.g., NODE_ENV from process.env.NODE_ENV) - let member = static_member_expr.member().ok()?; - let env_var_name = member.as_js_name()?.value_token().ok()?; - let env_var_text = env_var_name.token_text_trimmed(); - let env_var = env_var_text.text(); - // Check if this env var is allowed by default patterns or user options - if is_env_var_allowed_by_defaults(env_var) - || is_env_var_allowed_by_options(env_var, options) + if is_env_var_allowed_by_defaults(&env_var_name) + || is_env_var_allowed_by_options(&env_var_name, options) { return None; } // Check if declared in turbo.json - if ctx.is_env_var_declared(env_var) { + if ctx.is_env_var_declared(&env_var_name) { return None; } - Some(EnvVarAccess { - env_var_name: env_var_text, - }) + Some(EnvVarAccess { env_var_name }) } fn diagnostic(ctx: &RuleContext, state: &Self::State) -> Option { let node = ctx.query(); - let env_var = state.env_var_name.text(); + let env_var = &state.env_var_name; Some(RuleDiagnostic::new( rule_category!(), @@ -163,8 +161,62 @@ impl Rule for NoUndeclaredEnvVars { } } -/// Checks if the object is the global `process` identifier (not shadowed by a local binding) -fn is_global_process_object( +/// Extracts the env var name and parent object from a static member expression (e.g., `process.env.MY_VAR`) +fn extract_from_static_member( + static_expr: &JsStaticMemberExpression, +) -> Option<(String, AnyJsExpression)> { + // Check if this is process.env.SOMETHING, import.meta.env.SOMETHING, or Bun.env.SOMETHING + let object = static_expr.object().ok()?; + let parent_member = object.as_js_static_member_expression()?; + let env_member = parent_member.member().ok()?; + + // Must be accessing ".env" + if env_member.as_js_name()?.to_trimmed_text().text() != "env" { + return None; + } + + let parent_object = parent_member.object().ok()?; + + // Get the env var name being accessed (e.g., NODE_ENV from process.env.NODE_ENV) + let member = static_expr.member().ok()?; + let env_var_name = member.as_js_name()?.value_token().ok()?; + let env_var_text = env_var_name.token_text_trimmed(); + + Some((env_var_text.to_string(), parent_object)) +} + +/// Extracts the env var name and parent object from a computed member expression (e.g., `process.env["MY_VAR"]`) +/// Only handles string literal keys; dynamic keys like `process.env[key]` are skipped +fn extract_from_computed_member( + computed_expr: &JsComputedMemberExpression, +) -> Option<(String, AnyJsExpression)> { + // Check if this is process.env["SOMETHING"], import.meta.env["SOMETHING"], or Bun.env["SOMETHING"] + let object = computed_expr.object().ok()?; + let parent_member = object.as_js_static_member_expression()?; + let env_member = parent_member.member().ok()?; + + // Must be accessing ".env" + if env_member.as_js_name()?.to_trimmed_text().text() != "env" { + return None; + } + + let parent_object = parent_member.object().ok()?; + + // Get the env var name from the computed member + // Only process string literals, skip dynamic accesses like process.env[key] + let member = computed_expr.member().ok()?; + let member = member.omit_parentheses(); + + // Check if it's a string literal + let string_literal = member.as_any_js_literal_expression()?.as_js_string_literal_expression()?; + let string_token = string_literal.value_token().ok()?; + let env_var_name = inner_string_text(&string_token); + + Some((env_var_name.to_string(), parent_object)) +} + +/// Checks if the object is a global env object (`process` or `Bun`, not shadowed by a local binding) +fn is_global_env_object( expr: &AnyJsExpression, model: &biome_js_semantic::SemanticModel, ) -> bool { @@ -172,8 +224,9 @@ fn is_global_process_object( return false; }; - // Check that it's named "process" and not bound to a local variable - name.text() == "process" && model.binding(&reference).is_none() + // Check that it's named "process" or "Bun" and not bound to a local variable + let name_text = name.text(); + (name_text == "process" || name_text == "Bun") && model.binding(&reference).is_none() } /// Checks if the object is `import.meta` diff --git a/crates/biome_js_analyze/tests/specs/nursery/noUndeclaredEnvVars/invalid.js b/crates/biome_js_analyze/tests/specs/nursery/noUndeclaredEnvVars/invalid.js index 96c9c4592eea..914b7c4ce69d 100644 --- a/crates/biome_js_analyze/tests/specs/nursery/noUndeclaredEnvVars/invalid.js +++ b/crates/biome_js_analyze/tests/specs/nursery/noUndeclaredEnvVars/invalid.js @@ -7,3 +7,11 @@ const acmeSecret = process.env.ACME_SECRET; // Also invalid with import.meta.env const importMetaVar = import.meta.env.CUSTOM_META_VAR; + +// Bracket notation with string literals should also be checked +const bracketVar = process.env["BRACKET_VAR"]; +const bracketMeta = import.meta.env["BRACKET_META_VAR"]; + +// Bun.env should also be checked +const bunVar = Bun.env.BUN_CUSTOM_VAR; +const bunBracketVar = Bun.env["BUN_BRACKET_VAR"]; diff --git a/crates/biome_js_analyze/tests/specs/nursery/noUndeclaredEnvVars/invalid.js.snap b/crates/biome_js_analyze/tests/specs/nursery/noUndeclaredEnvVars/invalid.js.snap index 91dc2130b956..a257b5bdfe82 100644 --- a/crates/biome_js_analyze/tests/specs/nursery/noUndeclaredEnvVars/invalid.js.snap +++ b/crates/biome_js_analyze/tests/specs/nursery/noUndeclaredEnvVars/invalid.js.snap @@ -14,6 +14,14 @@ const acmeSecret = process.env.ACME_SECRET; // Also invalid with import.meta.env const importMetaVar = import.meta.env.CUSTOM_META_VAR; +// Bracket notation with string literals should also be checked +const bracketVar = process.env["BRACKET_VAR"]; +const bracketMeta = import.meta.env["BRACKET_META_VAR"]; + +// Bun.env should also be checked +const bunVar = Bun.env.BUN_CUSTOM_VAR; +const bunBracketVar = Bun.env["BUN_BRACKET_VAR"]; + ``` # Diagnostics @@ -70,6 +78,64 @@ invalid.js:9:23 lint/nursery/noUndeclaredEnvVars ━━━━━━━━━━ > 9 │ const importMetaVar = import.meta.env.CUSTOM_META_VAR; │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 10 │ + 11 │ // Bracket notation with string literals should also be checked + + +``` + +``` +invalid.js:12:20 lint/nursery/noUndeclaredEnvVars ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! The environment variable BRACKET_VAR is not listed as a dependency in turbo.json. Add this environment variable a task's 'env'/'passThroughEnv', or to 'globalEnv', 'globalPassThroughEnv', in your turbo.json(c) configuration to ensure correct caching behavior in Turborepo. + + 11 │ // Bracket notation with string literals should also be checked + > 12 │ const bracketVar = process.env["BRACKET_VAR"]; + │ ^^^^^^^^^^^^^^^^^^^^^^^^^^ + 13 │ const bracketMeta = import.meta.env["BRACKET_META_VAR"]; + 14 │ + + +``` + +``` +invalid.js:13:21 lint/nursery/noUndeclaredEnvVars ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! The environment variable BRACKET_META_VAR is not listed as a dependency in turbo.json. Add this environment variable a task's 'env'/'passThroughEnv', or to 'globalEnv', 'globalPassThroughEnv', in your turbo.json(c) configuration to ensure correct caching behavior in Turborepo. + + 11 │ // Bracket notation with string literals should also be checked + 12 │ const bracketVar = process.env["BRACKET_VAR"]; + > 13 │ const bracketMeta = import.meta.env["BRACKET_META_VAR"]; + │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + 14 │ + 15 │ // Bun.env should also be checked + + +``` + +``` +invalid.js:16:16 lint/nursery/noUndeclaredEnvVars ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! The environment variable BUN_CUSTOM_VAR is not listed as a dependency in turbo.json. Add this environment variable a task's 'env'/'passThroughEnv', or to 'globalEnv', 'globalPassThroughEnv', in your turbo.json(c) configuration to ensure correct caching behavior in Turborepo. + + 15 │ // Bun.env should also be checked + > 16 │ const bunVar = Bun.env.BUN_CUSTOM_VAR; + │ ^^^^^^^^^^^^^^^^^^^^^^ + 17 │ const bunBracketVar = Bun.env["BUN_BRACKET_VAR"]; + 18 │ + + +``` + +``` +invalid.js:17:23 lint/nursery/noUndeclaredEnvVars ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! The environment variable BUN_BRACKET_VAR is not listed as a dependency in turbo.json. Add this environment variable a task's 'env'/'passThroughEnv', or to 'globalEnv', 'globalPassThroughEnv', in your turbo.json(c) configuration to ensure correct caching behavior in Turborepo. + + 15 │ // Bun.env should also be checked + 16 │ const bunVar = Bun.env.BUN_CUSTOM_VAR; + > 17 │ const bunBracketVar = Bun.env["BUN_BRACKET_VAR"]; + │ ^^^^^^^^^^^^^^^^^^^^^^^^^^ + 18 │ ``` diff --git a/crates/biome_js_analyze/tests/specs/nursery/noUndeclaredEnvVars/valid.js b/crates/biome_js_analyze/tests/specs/nursery/noUndeclaredEnvVars/valid.js index ff4e01f29a46..b258f171bd0f 100644 --- a/crates/biome_js_analyze/tests/specs/nursery/noUndeclaredEnvVars/valid.js +++ b/crates/biome_js_analyze/tests/specs/nursery/noUndeclaredEnvVars/valid.js @@ -22,8 +22,25 @@ const expo = process.env.EXPO_PUBLIC_KEY; const viteEnv = import.meta.env.VITE_BASE_URL; const nodeEnvMeta = import.meta.env.NODE_ENV; +// Bracket notation with allowed variables +const nodeEnvBracket = process.env["NODE_ENV"]; +const ciBracket = process.env["CI"]; +const viteBracket = import.meta.env["VITE_API_KEY"]; + +// Bun.env with allowed patterns +const bunNodeEnv = Bun.env.NODE_ENV; +const bunCi = Bun.env.CI; +const bunVite = Bun.env.VITE_APP_KEY; +const bunNodeEnvBracket = Bun.env["NODE_ENV"]; + // Local process variable (not global) - should not be flagged function test() { const process = { env: { LOCAL_VAR: 'test' } }; return process.env.LOCAL_VAR; } + +// Local Bun variable (not global) - should not be flagged +function testBun() { + const Bun = { env: { LOCAL_VAR: 'test' } }; + return Bun.env.LOCAL_VAR; +} diff --git a/crates/biome_js_analyze/tests/specs/nursery/noUndeclaredEnvVars/valid.js.snap b/crates/biome_js_analyze/tests/specs/nursery/noUndeclaredEnvVars/valid.js.snap index 213833c958ad..66e4ecea90db 100644 --- a/crates/biome_js_analyze/tests/specs/nursery/noUndeclaredEnvVars/valid.js.snap +++ b/crates/biome_js_analyze/tests/specs/nursery/noUndeclaredEnvVars/valid.js.snap @@ -28,10 +28,27 @@ const expo = process.env.EXPO_PUBLIC_KEY; const viteEnv = import.meta.env.VITE_BASE_URL; const nodeEnvMeta = import.meta.env.NODE_ENV; +// Bracket notation with allowed variables +const nodeEnvBracket = process.env["NODE_ENV"]; +const ciBracket = process.env["CI"]; +const viteBracket = import.meta.env["VITE_API_KEY"]; + +// Bun.env with allowed patterns +const bunNodeEnv = Bun.env.NODE_ENV; +const bunCi = Bun.env.CI; +const bunVite = Bun.env.VITE_APP_KEY; +const bunNodeEnvBracket = Bun.env["NODE_ENV"]; + // Local process variable (not global) - should not be flagged function test() { const process = { env: { LOCAL_VAR: 'test' } }; return process.env.LOCAL_VAR; } +// Local Bun variable (not global) - should not be flagged +function testBun() { + const Bun = { env: { LOCAL_VAR: 'test' } }; + return Bun.env.LOCAL_VAR; +} + ``` diff --git a/crates/biome_js_analyze/tests/specs/nursery/noUndeclaredEnvVars/validDynamicAccess.js b/crates/biome_js_analyze/tests/specs/nursery/noUndeclaredEnvVars/validDynamicAccess.js index b8dab516e39f..3a0cb7962466 100644 --- a/crates/biome_js_analyze/tests/specs/nursery/noUndeclaredEnvVars/validDynamicAccess.js +++ b/crates/biome_js_analyze/tests/specs/nursery/noUndeclaredEnvVars/validDynamicAccess.js @@ -1,13 +1,10 @@ -/* should not generate diagnostics - dynamic property access is not checked */ +/* should not generate diagnostics - truly dynamic property access is not checked */ -// Dynamic property access cannot be statically analyzed, so these are skipped +// Dynamic property access with variables cannot be statically analyzed, so these are skipped const key = "MY_VAR"; const dynamicVar = process.env[key]; -// Computed property with string literal -const computedVar = process.env["ANOTHER_VAR"]; - -// Dynamic with template literal +// Dynamic with template literal containing interpolation const prefix = "ACME"; const templateVar = process.env[`${prefix}_TOKEN`]; @@ -19,9 +16,8 @@ function getEnvVar(name) { return process.env[name]; } -// Also with import.meta.env +// Also with import.meta.env - dynamic access const dynamicMeta = import.meta.env[key]; -const computedMeta = import.meta.env["CUSTOM_VAR"]; -// These are valid because the rule only checks static member expressions -// (process.env.VAR_NAME), not computed/dynamic access (process.env[key]) +// These are valid because the rule cannot statically determine the key +// Note: String literal bracket access like process.env["VAR"] IS now checked diff --git a/crates/biome_js_analyze/tests/specs/nursery/noUndeclaredEnvVars/validDynamicAccess.js.snap b/crates/biome_js_analyze/tests/specs/nursery/noUndeclaredEnvVars/validDynamicAccess.js.snap index 8793e42af937..6c188edb3503 100644 --- a/crates/biome_js_analyze/tests/specs/nursery/noUndeclaredEnvVars/validDynamicAccess.js.snap +++ b/crates/biome_js_analyze/tests/specs/nursery/noUndeclaredEnvVars/validDynamicAccess.js.snap @@ -4,16 +4,13 @@ expression: validDynamicAccess.js --- # Input ```js -/* should not generate diagnostics - dynamic property access is not checked */ +/* should not generate diagnostics - truly dynamic property access is not checked */ -// Dynamic property access cannot be statically analyzed, so these are skipped +// Dynamic property access with variables cannot be statically analyzed, so these are skipped const key = "MY_VAR"; const dynamicVar = process.env[key]; -// Computed property with string literal -const computedVar = process.env["ANOTHER_VAR"]; - -// Dynamic with template literal +// Dynamic with template literal containing interpolation const prefix = "ACME"; const templateVar = process.env[`${prefix}_TOKEN`]; @@ -25,11 +22,10 @@ function getEnvVar(name) { return process.env[name]; } -// Also with import.meta.env +// Also with import.meta.env - dynamic access const dynamicMeta = import.meta.env[key]; -const computedMeta = import.meta.env["CUSTOM_VAR"]; -// These are valid because the rule only checks static member expressions -// (process.env.VAR_NAME), not computed/dynamic access (process.env[key]) +// These are valid because the rule cannot statically determine the key +// Note: String literal bracket access like process.env["VAR"] IS now checked ``` From bcc886aa417ac8b2e44f1e4c2954941535a66fc4 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 18 Dec 2025 09:46:30 +0000 Subject: [PATCH 2/4] [autofix.ci] apply automated fixes --- .../src/lint/nursery/no_undeclared_env_vars.rs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/crates/biome_js_analyze/src/lint/nursery/no_undeclared_env_vars.rs b/crates/biome_js_analyze/src/lint/nursery/no_undeclared_env_vars.rs index ee34424b736d..91381768b9ed 100644 --- a/crates/biome_js_analyze/src/lint/nursery/no_undeclared_env_vars.rs +++ b/crates/biome_js_analyze/src/lint/nursery/no_undeclared_env_vars.rs @@ -208,7 +208,9 @@ fn extract_from_computed_member( let member = member.omit_parentheses(); // Check if it's a string literal - let string_literal = member.as_any_js_literal_expression()?.as_js_string_literal_expression()?; + let string_literal = member + .as_any_js_literal_expression()? + .as_js_string_literal_expression()?; let string_token = string_literal.value_token().ok()?; let env_var_name = inner_string_text(&string_token); @@ -216,10 +218,7 @@ fn extract_from_computed_member( } /// Checks if the object is a global env object (`process` or `Bun`, not shadowed by a local binding) -fn is_global_env_object( - expr: &AnyJsExpression, - model: &biome_js_semantic::SemanticModel, -) -> bool { +fn is_global_env_object(expr: &AnyJsExpression, model: &biome_js_semantic::SemanticModel) -> bool { let Some((reference, name)) = global_identifier(expr) else { return false; }; From 1bb0c1bd367e68867caca95cc8e0f245cb8c2251 Mon Sep 17 00:00:00 2001 From: Tu Shaokun <2801884530@qq.com> Date: Fri, 19 Dec 2025 22:32:55 +0800 Subject: [PATCH 3/4] refactor(noUndeclaredEnvVars): address reviewer feedback - Update changeset to use past tense per guidelines - Avoid String allocations by using TokenText directly - Add Deno.env.get("VAR") support - Add comprehensive test cases for Deno runtime --- .changeset/bracket-bun-env-support.md | 2 +- .../lint/nursery/no_undeclared_env_vars.rs | 112 ++++++++++++++---- .../nursery/noUndeclaredEnvVars/invalid.js | 4 + .../noUndeclaredEnvVars/invalid.js.snap | 33 ++++++ .../nursery/noUndeclaredEnvVars/valid.js | 11 ++ .../nursery/noUndeclaredEnvVars/valid.js.snap | 11 ++ .../noUndeclaredEnvVars/validDynamicAccess.js | 7 ++ .../validDynamicAccess.js.snap | 7 ++ 8 files changed, 160 insertions(+), 27 deletions(-) diff --git a/.changeset/bracket-bun-env-support.md b/.changeset/bracket-bun-env-support.md index 9aeae7d6de59..aed43863493d 100644 --- a/.changeset/bracket-bun-env-support.md +++ b/.changeset/bracket-bun-env-support.md @@ -2,4 +2,4 @@ "@biomejs/biome": patch --- -Extended [`noUndeclaredEnvVars`](https://biomejs.dev/linter/rules/no-undeclared-env-vars/) to support bracket notation (`process.env["VAR"]`, `import.meta.env["VAR"]`) and Bun runtime (`Bun.env.VAR`, `Bun.env["VAR"]`). Fixes [#8494](https://github.com/biomejs/biome/issues/8494). +Fixed [#8494](https://github.com/biomejs/biome/issues/8494). Extended [`noUndeclaredEnvVars`](https://biomejs.dev/linter/rules/no-undeclared-env-vars/) to support bracket notation (`process.env["VAR"]`, `import.meta.env["VAR"]`), Bun runtime (`Bun.env.VAR`, `Bun.env["VAR"]`), and Deno runtime (`Deno.env.get("VAR")`). diff --git a/crates/biome_js_analyze/src/lint/nursery/no_undeclared_env_vars.rs b/crates/biome_js_analyze/src/lint/nursery/no_undeclared_env_vars.rs index 91381768b9ed..a1a430508594 100644 --- a/crates/biome_js_analyze/src/lint/nursery/no_undeclared_env_vars.rs +++ b/crates/biome_js_analyze/src/lint/nursery/no_undeclared_env_vars.rs @@ -4,10 +4,10 @@ use biome_analyze::{ use biome_console::markup; use biome_diagnostics::Severity; use biome_js_syntax::{ - AnyJsExpression, AnyJsMemberExpression, JsComputedMemberExpression, JsStaticMemberExpression, - global_identifier, inner_string_text, + AnyJsExpression, AnyJsMemberExpression, JsCallExpression, JsComputedMemberExpression, + JsStaticMemberExpression, global_identifier, inner_string_text, }; -use biome_rowan::AstNode; +use biome_rowan::{AstNode, AstSeparatedList, TokenText}; use biome_rule_options::no_undeclared_env_vars::NoUndeclaredEnvVarsOptions; use crate::services::turborepo::Turborepo; @@ -24,6 +24,7 @@ declare_lint_rule! { /// - `process.env.VAR_NAME` and `process.env["VAR_NAME"]` /// - `import.meta.env.VAR_NAME` and `import.meta.env["VAR_NAME"]` /// - `Bun.env.VAR_NAME` and `Bun.env["VAR_NAME"]` + /// - `Deno.env.get("VAR_NAME")` /// /// It validates them against: /// 1. Environment variables declared in `turbo.json(c)` (`globalEnv`, `globalPassThroughEnv`, task-level `env`, and task-level `passThroughEnv`) @@ -95,7 +96,7 @@ declare_lint_rule! { /// State that holds the environment variable name being accessed pub struct EnvVarAccess { /// The name of the environment variable - env_var_name: String, + env_var_name: TokenText, } impl Rule for NoUndeclaredEnvVars { @@ -114,6 +115,16 @@ impl Rule for NoUndeclaredEnvVars { let options = ctx.options(); let model = ctx.model(); + // Check if this is a Deno.env.get() call + if let Some(deno_result) = match_deno_env_get(member_expr, model) { + return match deno_result { + Some(env_var_name) => check_env_var(ctx, env_var_name, options), + // Matched Deno.env.get(...) but couldn't statically resolve the key (e.g. Deno.env.get(key)) + // so skip it. + None => None, + }; + } + // Get the env var name and the parent object based on the expression type let (env_var_name, parent_object) = match member_expr { AnyJsMemberExpression::JsStaticMemberExpression(static_expr) => { @@ -125,31 +136,16 @@ impl Rule for NoUndeclaredEnvVars { }; // Check if the parent object is a valid env access (process.env, import.meta.env, or Bun.env) - let is_process_or_bun_env = is_global_env_object(&parent_object, model); - let is_import_meta_env = is_import_meta_object(&parent_object); - - if !is_process_or_bun_env && !is_import_meta_env { - return None; - } - - // Check if this env var is allowed by default patterns or user options - if is_env_var_allowed_by_defaults(&env_var_name) - || is_env_var_allowed_by_options(&env_var_name, options) - { + if !is_global_env_object(&parent_object, model) && !is_import_meta_object(&parent_object) { return None; } - // Check if declared in turbo.json - if ctx.is_env_var_declared(&env_var_name) { - return None; - } - - Some(EnvVarAccess { env_var_name }) + check_env_var(ctx, env_var_name, options) } fn diagnostic(ctx: &RuleContext, state: &Self::State) -> Option { let node = ctx.query(); - let env_var = &state.env_var_name; + let env_var = state.env_var_name.text(); Some(RuleDiagnostic::new( rule_category!(), @@ -164,7 +160,7 @@ impl Rule for NoUndeclaredEnvVars { /// Extracts the env var name and parent object from a static member expression (e.g., `process.env.MY_VAR`) fn extract_from_static_member( static_expr: &JsStaticMemberExpression, -) -> Option<(String, AnyJsExpression)> { +) -> Option<(TokenText, AnyJsExpression)> { // Check if this is process.env.SOMETHING, import.meta.env.SOMETHING, or Bun.env.SOMETHING let object = static_expr.object().ok()?; let parent_member = object.as_js_static_member_expression()?; @@ -182,14 +178,14 @@ fn extract_from_static_member( let env_var_name = member.as_js_name()?.value_token().ok()?; let env_var_text = env_var_name.token_text_trimmed(); - Some((env_var_text.to_string(), parent_object)) + Some((env_var_text, parent_object)) } /// Extracts the env var name and parent object from a computed member expression (e.g., `process.env["MY_VAR"]`) /// Only handles string literal keys; dynamic keys like `process.env[key]` are skipped fn extract_from_computed_member( computed_expr: &JsComputedMemberExpression, -) -> Option<(String, AnyJsExpression)> { +) -> Option<(TokenText, AnyJsExpression)> { // Check if this is process.env["SOMETHING"], import.meta.env["SOMETHING"], or Bun.env["SOMETHING"] let object = computed_expr.object().ok()?; let parent_member = object.as_js_static_member_expression()?; @@ -214,7 +210,7 @@ fn extract_from_computed_member( let string_token = string_literal.value_token().ok()?; let env_var_name = inner_string_text(&string_token); - Some((env_var_name.to_string(), parent_object)) + Some((env_var_name, parent_object)) } /// Checks if the object is a global env object (`process` or `Bun`, not shadowed by a local binding) @@ -228,6 +224,70 @@ fn is_global_env_object(expr: &AnyJsExpression, model: &biome_js_semantic::Seman (name_text == "process" || name_text == "Bun") && model.binding(&reference).is_none() } +fn match_deno_env_get( + member_expr: &AnyJsMemberExpression, + model: &biome_js_semantic::SemanticModel, +) -> Option> { + let AnyJsMemberExpression::JsStaticMemberExpression(static_expr) = member_expr else { + return None; + }; + + let get_member = static_expr.member().ok()?; + if get_member.as_js_name()?.to_trimmed_text().text() != "get" { + return None; + } + + let object = static_expr.object().ok()?.omit_parentheses(); + let env_member = object.as_js_static_member_expression()?; + if env_member.member().ok()?.as_js_name()?.to_trimmed_text().text() != "env" { + return None; + } + + let deno_object = env_member.object().ok()?.omit_parentheses(); + let Some((reference, name)) = global_identifier(&deno_object) else { + return Some(None); + }; + if name.text() != "Deno" || model.binding(&reference).is_some() { + return Some(None); + } + + let Some(parent) = member_expr.syntax().parent() else { + return Some(None); + }; + let Some(call) = JsCallExpression::cast(parent) else { + return Some(None); + }; + if call.is_optional() { + return Some(None); + } + + let first_arg = call.arguments().ok()?.args().first()?.ok()?; + let first_arg = first_arg.as_any_js_expression()?.clone().omit_parentheses(); + let string_literal = first_arg + .as_any_js_literal_expression()? + .as_js_string_literal_expression()?; + let string_token = string_literal.value_token().ok()?; + + Some(Some(inner_string_text(&string_token))) +} + +fn check_env_var( + ctx: &RuleContext, + env_var_name: TokenText, + options: &NoUndeclaredEnvVarsOptions, +) -> Option { + let env_var = env_var_name.text(); + if is_env_var_allowed_by_defaults(env_var) || is_env_var_allowed_by_options(env_var, options) { + return None; + } + + if ctx.is_env_var_declared(env_var) { + return None; + } + + Some(EnvVarAccess { env_var_name }) +} + /// Checks if the object is `import.meta` fn is_import_meta_object(expr: &AnyJsExpression) -> bool { expr.as_js_import_meta_expression().is_some() diff --git a/crates/biome_js_analyze/tests/specs/nursery/noUndeclaredEnvVars/invalid.js b/crates/biome_js_analyze/tests/specs/nursery/noUndeclaredEnvVars/invalid.js index 914b7c4ce69d..882acd01a63d 100644 --- a/crates/biome_js_analyze/tests/specs/nursery/noUndeclaredEnvVars/invalid.js +++ b/crates/biome_js_analyze/tests/specs/nursery/noUndeclaredEnvVars/invalid.js @@ -15,3 +15,7 @@ const bracketMeta = import.meta.env["BRACKET_META_VAR"]; // Bun.env should also be checked const bunVar = Bun.env.BUN_CUSTOM_VAR; const bunBracketVar = Bun.env["BUN_BRACKET_VAR"]; + +// Deno.env.get should also be checked +const denoVar = Deno.env.get("DENO_CUSTOM_VAR"); +const denoVar2 = Deno.env.get("ANOTHER_DENO_VAR"); diff --git a/crates/biome_js_analyze/tests/specs/nursery/noUndeclaredEnvVars/invalid.js.snap b/crates/biome_js_analyze/tests/specs/nursery/noUndeclaredEnvVars/invalid.js.snap index a257b5bdfe82..d3a2c2c45270 100644 --- a/crates/biome_js_analyze/tests/specs/nursery/noUndeclaredEnvVars/invalid.js.snap +++ b/crates/biome_js_analyze/tests/specs/nursery/noUndeclaredEnvVars/invalid.js.snap @@ -22,6 +22,10 @@ const bracketMeta = import.meta.env["BRACKET_META_VAR"]; const bunVar = Bun.env.BUN_CUSTOM_VAR; const bunBracketVar = Bun.env["BUN_BRACKET_VAR"]; +// Deno.env.get should also be checked +const denoVar = Deno.env.get("DENO_CUSTOM_VAR"); +const denoVar2 = Deno.env.get("ANOTHER_DENO_VAR"); + ``` # Diagnostics @@ -136,6 +140,35 @@ invalid.js:17:23 lint/nursery/noUndeclaredEnvVars ━━━━━━━━━━ > 17 │ const bunBracketVar = Bun.env["BUN_BRACKET_VAR"]; │ ^^^^^^^^^^^^^^^^^^^^^^^^^^ 18 │ + 19 │ // Deno.env.get should also be checked + + +``` + +``` +invalid.js:20:17 lint/nursery/noUndeclaredEnvVars ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! The environment variable DENO_CUSTOM_VAR is not listed as a dependency in turbo.json. Add this environment variable a task's 'env'/'passThroughEnv', or to 'globalEnv', 'globalPassThroughEnv', in your turbo.json(c) configuration to ensure correct caching behavior in Turborepo. + + 19 │ // Deno.env.get should also be checked + > 20 │ const denoVar = Deno.env.get("DENO_CUSTOM_VAR"); + │ ^^^^^^^^^^^^ + 21 │ const denoVar2 = Deno.env.get("ANOTHER_DENO_VAR"); + 22 │ + + +``` + +``` +invalid.js:21:18 lint/nursery/noUndeclaredEnvVars ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! The environment variable ANOTHER_DENO_VAR is not listed as a dependency in turbo.json. Add this environment variable a task's 'env'/'passThroughEnv', or to 'globalEnv', 'globalPassThroughEnv', in your turbo.json(c) configuration to ensure correct caching behavior in Turborepo. + + 19 │ // Deno.env.get should also be checked + 20 │ const denoVar = Deno.env.get("DENO_CUSTOM_VAR"); + > 21 │ const denoVar2 = Deno.env.get("ANOTHER_DENO_VAR"); + │ ^^^^^^^^^^^^ + 22 │ ``` diff --git a/crates/biome_js_analyze/tests/specs/nursery/noUndeclaredEnvVars/valid.js b/crates/biome_js_analyze/tests/specs/nursery/noUndeclaredEnvVars/valid.js index b258f171bd0f..0f37f34e8a27 100644 --- a/crates/biome_js_analyze/tests/specs/nursery/noUndeclaredEnvVars/valid.js +++ b/crates/biome_js_analyze/tests/specs/nursery/noUndeclaredEnvVars/valid.js @@ -44,3 +44,14 @@ function testBun() { const Bun = { env: { LOCAL_VAR: 'test' } }; return Bun.env.LOCAL_VAR; } + +// Deno.env.get with allowed patterns +const denoNodeEnv = Deno.env.get("NODE_ENV"); +const denoCi = Deno.env.get("CI"); +const denoVite = Deno.env.get("VITE_APP_KEY"); + +// Local Deno variable (not global) - should not be flagged +function testDeno() { + const Deno = { env: { get: () => 'test' } }; + return Deno.env.get("LOCAL_VAR"); +} diff --git a/crates/biome_js_analyze/tests/specs/nursery/noUndeclaredEnvVars/valid.js.snap b/crates/biome_js_analyze/tests/specs/nursery/noUndeclaredEnvVars/valid.js.snap index 66e4ecea90db..5d0124e34918 100644 --- a/crates/biome_js_analyze/tests/specs/nursery/noUndeclaredEnvVars/valid.js.snap +++ b/crates/biome_js_analyze/tests/specs/nursery/noUndeclaredEnvVars/valid.js.snap @@ -51,4 +51,15 @@ function testBun() { return Bun.env.LOCAL_VAR; } +// Deno.env.get with allowed patterns +const denoNodeEnv = Deno.env.get("NODE_ENV"); +const denoCi = Deno.env.get("CI"); +const denoVite = Deno.env.get("VITE_APP_KEY"); + +// Local Deno variable (not global) - should not be flagged +function testDeno() { + const Deno = { env: { get: () => 'test' } }; + return Deno.env.get("LOCAL_VAR"); +} + ``` diff --git a/crates/biome_js_analyze/tests/specs/nursery/noUndeclaredEnvVars/validDynamicAccess.js b/crates/biome_js_analyze/tests/specs/nursery/noUndeclaredEnvVars/validDynamicAccess.js index 3a0cb7962466..9d18b9c34f52 100644 --- a/crates/biome_js_analyze/tests/specs/nursery/noUndeclaredEnvVars/validDynamicAccess.js +++ b/crates/biome_js_analyze/tests/specs/nursery/noUndeclaredEnvVars/validDynamicAccess.js @@ -19,5 +19,12 @@ function getEnvVar(name) { // Also with import.meta.env - dynamic access const dynamicMeta = import.meta.env[key]; +// Also with Bun.env - dynamic access +const dynamicBun = Bun.env[key]; + +// Also with Deno.env.get - dynamic access +const dynamicDeno = Deno.env.get(key); + // These are valid because the rule cannot statically determine the key // Note: String literal bracket access like process.env["VAR"] IS now checked +// Note: String literal in Deno.env.get("VAR") IS now checked diff --git a/crates/biome_js_analyze/tests/specs/nursery/noUndeclaredEnvVars/validDynamicAccess.js.snap b/crates/biome_js_analyze/tests/specs/nursery/noUndeclaredEnvVars/validDynamicAccess.js.snap index 6c188edb3503..8d8eea170517 100644 --- a/crates/biome_js_analyze/tests/specs/nursery/noUndeclaredEnvVars/validDynamicAccess.js.snap +++ b/crates/biome_js_analyze/tests/specs/nursery/noUndeclaredEnvVars/validDynamicAccess.js.snap @@ -25,7 +25,14 @@ function getEnvVar(name) { // Also with import.meta.env - dynamic access const dynamicMeta = import.meta.env[key]; +// Also with Bun.env - dynamic access +const dynamicBun = Bun.env[key]; + +// Also with Deno.env.get - dynamic access +const dynamicDeno = Deno.env.get(key); + // These are valid because the rule cannot statically determine the key // Note: String literal bracket access like process.env["VAR"] IS now checked +// Note: String literal in Deno.env.get("VAR") IS now checked ``` From 4b0b8a51790654bd85517dad4a562aa48725e128 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Fri, 19 Dec 2025 14:39:00 +0000 Subject: [PATCH 4/4] [autofix.ci] apply automated fixes --- .../src/lint/nursery/no_undeclared_env_vars.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/crates/biome_js_analyze/src/lint/nursery/no_undeclared_env_vars.rs b/crates/biome_js_analyze/src/lint/nursery/no_undeclared_env_vars.rs index a1a430508594..bf895871867e 100644 --- a/crates/biome_js_analyze/src/lint/nursery/no_undeclared_env_vars.rs +++ b/crates/biome_js_analyze/src/lint/nursery/no_undeclared_env_vars.rs @@ -239,7 +239,14 @@ fn match_deno_env_get( let object = static_expr.object().ok()?.omit_parentheses(); let env_member = object.as_js_static_member_expression()?; - if env_member.member().ok()?.as_js_name()?.to_trimmed_text().text() != "env" { + if env_member + .member() + .ok()? + .as_js_name()? + .to_trimmed_text() + .text() + != "env" + { return None; }