Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(noUndeclaredDependencies): add availability restriction based on dependency type #4376

Merged
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
use crate::{globals::is_node_builtin_module, services::manifest::Manifest};
use biome_analyze::{context::RuleContext, declare_lint_rule, Rule, RuleDiagnostic};
use std::path::Path;

use biome_analyze::{context::RuleContext, declare_lint_rule, Rule, RuleDiagnostic, RuleSource};
use biome_console::markup;
use biome_deserialize::{Deserializable, DeserializableType};
use biome_deserialize_macros::Deserializable;
use biome_js_syntax::{AnyJsImportClause, AnyJsImportLike};
use biome_rowan::AstNode;

use crate::utils::restricted_glob::{CandidatePath, RestrictedGlob};
use crate::{globals::is_node_builtin_module, services::manifest::Manifest};

declare_lint_rule! {
/// Disallow the use of dependencies that aren't specified in the `package.json`.
///
Expand Down Expand Up @@ -34,32 +40,192 @@ declare_lint_rule! {
/// ```js,ignore
/// import assert from "node:assert";
/// ```
///
/// ## Options
///
/// **Since v2.0.0**
/// **Since v2.0.0**
///
/// This rule supports the following options:
/// - `devDependencies`: If set to `false`, then the rule will show an error when `devDependencies` are imported. Defaults to `true`.
/// - `peerDependencies`: If set to `false`, then the rule will show an error when `peerDependencies` are imported. Defaults to `true`.
/// - `optionalDependencies`: If set to `false`, then the rule will show an error when `optionalDependencies` are imported. Defaults to `true`.
///
/// You can set the options like this:
/// ```json
/// {
/// "options": {
/// "devDependencies": false,
/// "peerDependencies": false,
/// "optionalDependencies": false
/// }
/// }
/// ```
///
/// You can also use an array of globs instead of literal booleans.
/// When using an array of globs, the setting will be set to `true` (no errors reported)
/// if the name of the file being linted (i.e. not the imported file/module) matches a single glob
/// in the array, and `false` otherwise.
///
/// In the following example, only test files can use dependencies in `devDependencies` section.
/// `dependencies`, `peerDependencies`, and `optionalDependencies` are always available.
///
/// ```json
/// {
/// "options": {
/// "devDependencies": ["tests/*.test.js", "tests/*.spec.js"]
/// }
/// }
/// ```
pub NoUndeclaredDependencies {
version: "1.6.0",
name: "noUndeclaredDependencies",
language: "js",
sources: &[
RuleSource::EslintImport("no-extraneous-dependencies"),
],
recommended: false,
}
}

#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
#[serde(untagged)]
enum DependencyAvailability {
/// Dependencies are always available or unavailable.
Bool(bool),

/// Dependencies are available in files that matches any of the globs.
Patterns(Box<[RestrictedGlob]>),
}

impl Default for DependencyAvailability {
fn default() -> Self {
Self::Bool(true)
}
}

impl Deserializable for DependencyAvailability {
fn deserialize(
value: &impl biome_deserialize::DeserializableValue,
name: &str,
diagnostics: &mut Vec<biome_deserialize::DeserializationDiagnostic>,
) -> Option<Self> {
Some(if value.visitable_type()? == DeserializableType::Bool {
Self::Bool(bool::deserialize(value, name, diagnostics)?)
} else {
Self::Patterns(Deserializable::deserialize(value, name, diagnostics)?)
})
}
}

#[cfg(feature = "schemars")]
impl schemars::JsonSchema for DependencyAvailability {
fn schema_name() -> String {
"DependencyAvailability".to_owned()
}

fn json_schema(_gen: &mut schemars::gen::SchemaGenerator) -> schemars::schema::Schema {
use schemars::schema::*;

Schema::Object(SchemaObject {
subschemas: Some(Box::new(SubschemaValidation {
one_of: Some(vec![
Schema::Object(SchemaObject {
instance_type: Some(InstanceType::Boolean.into()),
metadata: Some(Box::new(Metadata {
description: Some("This type of dependency will be always available or unavailable.".to_owned()),
..Default::default()
})),
..Default::default()
}),
Schema::Object(SchemaObject {
instance_type: Some(InstanceType::Array.into()),
array: Some(Box::new(ArrayValidation {
items: Some(SingleOrVec::Single(Box::new(Schema::Object(SchemaObject {
instance_type: Some(InstanceType::String.into()),
..Default::default()
})))),
min_items: Some(1),
..Default::default()
})),
metadata: Some(Box::new(Metadata {
description: Some("This type of dependency will be available only if the linted file matches any of the globs.".to_owned()),
..Default::default()
})),
..Default::default()
})
]),
..Default::default()
})),
..Default::default()
})
}
}

impl DependencyAvailability {
fn is_available(&self, path: &Path) -> bool {
match self {
Self::Bool(b) => *b,
Self::Patterns(globs) => CandidatePath::new(&path).matches_with_exceptions(globs),
}
}
}

/// Rule's options
#[derive(
Clone, Debug, Default, Deserializable, Eq, PartialEq, serde::Deserialize, serde::Serialize,
)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct NoUndeclaredDependenciesOptions {
/// If set to `false`, then the rule will show an error when `devDependencies` are imported. Defaults to `true`.
#[serde(default)]
dev_dependencies: DependencyAvailability,

/// If set to `false`, then the rule will show an error when `peerDependencies` are imported. Defaults to `true`.
#[serde(default)]
peer_dependencies: DependencyAvailability,

/// If set to `false`, then the rule will show an error when `optionalDependencies` are imported. Defaults to `true`.
#[serde(default)]
optional_dependencies: DependencyAvailability,
}

pub struct RuleState {
package_name: String,
is_dev_dependency_available: bool,
is_peer_dependency_available: bool,
is_optional_dependency_available: bool,
}

impl Rule for NoUndeclaredDependencies {
type Query = Manifest<AnyJsImportLike>;
type State = ();
type State = RuleState;
type Signals = Option<Self::State>;
type Options = ();
type Options = NoUndeclaredDependenciesOptions;

fn run(ctx: &RuleContext<Self>) -> Self::Signals {
let node = ctx.query();
if node.is_in_ts_module_declaration() {
return None;
}

let path = ctx.file_path();
let is_dev_dependency_available = ctx.options().dev_dependencies.is_available(path);
let is_peer_dependency_available = ctx.options().peer_dependencies.is_available(path);
let is_optional_dependency_available =
ctx.options().optional_dependencies.is_available(path);

let is_available = |package_name| {
ctx.is_dependency(package_name)
|| (is_dev_dependency_available && ctx.is_dev_dependency(package_name))
|| (is_peer_dependency_available && ctx.is_peer_dependency(package_name))
|| (is_optional_dependency_available && ctx.is_optional_dependency(package_name))
};

let token_text = node.inner_string_text()?;
let package_name = parse_package_name(token_text.text())?;
if ctx.is_dependency(package_name)
|| ctx.is_dev_dependency(package_name)
|| ctx.is_peer_dependency(package_name)
|| ctx.is_optional_dependency(package_name)
if is_available(package_name)
// Self package imports
// TODO: we should also check that an `.` exports exists.
// See https://nodejs.org/api/packages.html#self-referencing-a-package-using-its-name
Expand All @@ -70,42 +236,67 @@ impl Rule for NoUndeclaredDependencies {
|| package_name == "bun"
{
return None;
} else if !package_name.starts_with('@') {
}

if !package_name.starts_with('@') {
// Handle DefinitelyTyped imports https://github.com/DefinitelyTyped/DefinitelyTyped
// e.g. `lodash` can import typ[es from `@types/lodash`.
// e.g. `lodash` can import types from `@types/lodash`.
if let Some(import_clause) = node.parent::<AnyJsImportClause>() {
if import_clause.type_token().is_some() {
let package_name = format!("@types/{package_name}");
if ctx.is_dependency(&package_name)
|| ctx.is_dev_dependency(&package_name)
|| ctx.is_peer_dependency(&package_name)
|| ctx.is_optional_dependency(&package_name)
{
if is_available(&package_name) {
return None;
}
}
}
}

Some(())
Some(RuleState {
package_name: package_name.to_string(),
is_dev_dependency_available,
is_peer_dependency_available,
is_optional_dependency_available,
})
}

fn diagnostic(ctx: &RuleContext<Self>, _state: &Self::State) -> Option<RuleDiagnostic> {
Some(
RuleDiagnostic::new(
rule_category!(),
ctx.query().range(),
markup! {
"The current dependency isn't specified in your package.json."
},
fn diagnostic(ctx: &RuleContext<Self>, state: &Self::State) -> Option<RuleDiagnostic> {
let RuleState {
package_name,
is_dev_dependency_available,
is_peer_dependency_available,
is_optional_dependency_available,
} = state;

let diag = RuleDiagnostic::new(
rule_category!(),
ctx.query().range(),
markup! {
"The current dependency isn't specified in your package.json."
},
);

let available_in = if ctx.is_dev_dependency(package_name) && !is_dev_dependency_available {
Some("devDependencies")
} else if ctx.is_peer_dependency(package_name) && !is_peer_dependency_available {
Some("peerDependencies")
} else if ctx.is_optional_dependency(package_name) && !is_optional_dependency_available {
Some("optionalDependencies")
} else {
None
};

if let Some(section) = available_in {
Some(diag.note(markup! {
<Emphasis>{package_name}</Emphasis>" is part of your "<Emphasis>{section}</Emphasis>", but it's not intended to be used in this file."
}).note(markup! {
"You may want to consider moving it to the "<Emphasis>"dependencies"</Emphasis>" section."
}))
} else {
Some(
diag.note(markup! { "This could lead to errors." })
.note(markup! { "Add the dependency in your manifest." }),
)
.note(markup! {
"This could lead to errors."
})
.note(markup! {
"Add the dependency in your manifest."
}),
)
}
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import "notInstalled";
import("notInstalled");
require("notInstalled")

import "@testing-library/react";
import("@testing-library/react");
require("@testing-library/react");
Loading
Loading