Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
5 changes: 5 additions & 0 deletions .changeset/brave-scripts-dance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@biomejs/biome": patch
---

Added the rule [`useRequiredScripts`](https://biomejs.dev/linter/rules/use-required-scripts/), which enforces presence of configurable entries in the `scripts` section of `package.json` files.
5 changes: 5 additions & 0 deletions .changeset/green-humans-try.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@biomejs/biome": patch
---

Fixed [`noDuplicateDependencies`](https://biomejs.dev/linter/rules/no-duplicate-dependencies/) incorrectly triggering on files like `_package.json`.
77 changes: 49 additions & 28 deletions crates/biome_configuration/src/analyzer/linter/rules.rs

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions crates/biome_diagnostics_categories/src/categories.rs
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,7 @@ define_categories! {
"lint/nursery/useMaxParams": "https://biomejs.dev/linter/rules/use-max-params",
"lint/nursery/useQwikMethodUsage": "https://biomejs.dev/linter/rules/use-qwik-method-usage",
"lint/nursery/useQwikValidLexicalScope": "https://biomejs.dev/linter/rules/use-qwik-valid-lexical-scope",
"lint/nursery/useRequiredScripts": "https://biomejs.dev/linter/rules/use-required-scripts",
"lint/nursery/useRegexpExec": "https://biomejs.dev/linter/rules/use-regexp-exec",
"lint/nursery/useSortedClasses": "https://biomejs.dev/linter/rules/use-sorted-classes",
"lint/nursery/useSpread": "https://biomejs.dev/linter/rules/no-spread",
Expand Down
2 changes: 1 addition & 1 deletion crates/biome_json_analyze/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,12 @@ biome_rowan = { workspace = true }
biome_rule_options = { workspace = true }
biome_string_case = { workspace = true }
biome_suppression = { workspace = true }
camino = { workspace = true }
rustc-hash = { workspace = true }

[dev-dependencies]
biome_json_parser = { path = "../biome_json_parser" }
biome_test_utils = { path = "../biome_test_utils" }
camino = { workspace = true }
criterion = { package = "codspeed-criterion-compat", version = "=3.0.5" }
insta = { workspace = true, features = ["glob"] }
tests_macros = { path = "../tests_macros" }
Expand Down
3 changes: 2 additions & 1 deletion crates/biome_json_analyze/src/lint/nursery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@

use biome_analyze::declare_lint_group;
pub mod no_duplicate_dependencies;
declare_lint_group! { pub Nursery { name : "nursery" , rules : [self :: no_duplicate_dependencies :: NoDuplicateDependencies ,] } }
pub mod use_required_scripts;
declare_lint_group! { pub Nursery { name : "nursery" , rules : [self :: no_duplicate_dependencies :: NoDuplicateDependencies , self :: use_required_scripts :: UseRequiredScripts ,] } }
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ use biome_rowan::{AstNode, AstSeparatedList};
use biome_rule_options::no_duplicate_dependencies::NoDuplicateDependenciesOptions;
use rustc_hash::FxHashMap;

use crate::utils::is_package_json;

declare_lint_rule! {
/// Prevent the listing of duplicate dependencies.
/// The rule supports the following dependency groups: "bundledDependencies", "bundleDependencies", "dependencies", "devDependencies", "overrides", "optionalDependencies", and "peerDependencies".
Expand Down Expand Up @@ -89,8 +91,6 @@ declare_lint_rule! {
}
}

const PACKAGE_JSON: &str = "package.json";

// dependencies <-> devDependencies / optionalDependencies / peerDependencies
// peerDependencies <-> optionalDependencies
const UNIQUE_PROPERTY_KEYS: [(&str, &[&str]); 2] = [
Expand Down Expand Up @@ -133,7 +133,7 @@ impl Rule for NoDuplicateDependencies {
let value = query.value().ok()?;
let object_value = value.as_json_object_value()?;

if !path.ends_with(PACKAGE_JSON) {
if !is_package_json(path) {
return None;
}

Expand Down
155 changes: 155 additions & 0 deletions crates/biome_json_analyze/src/lint/nursery/use_required_scripts.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
use biome_analyze::{Ast, Rule, RuleDiagnostic, context::RuleContext, declare_lint_rule};
use biome_console::markup;
use biome_json_syntax::{JsonRoot, TextRange};
use biome_rowan::{AstNode, AstSeparatedList};
use biome_rule_options::use_required_scripts::UseRequiredScriptsOptions;

use crate::utils::is_package_json;

/// State containing the missing scripts and the range to highlight
pub struct UseRequiredScriptsState {
/// The list of missing script names
pub missing_scripts: Vec<String>,
/// The range to highlight in the diagnostic (scripts object or root object)
pub range: TextRange,
}

declare_lint_rule! {
/// Enforce the presence of required scripts in package.json.
///
/// This rule ensures that specified scripts are defined in the `scripts` section of a `package.json` file.
/// It's particularly useful in monorepo environments where consistency across workspaces is important.
///
/// Without required scripts configured, this rule doesn't do anything.
///
/// ## Examples
///
/// ### Invalid
///
/// ```json,options
/// {
/// "options": {
/// "requiredScripts": ["test", "build"]
/// }
/// }
/// ```
///
/// ```json,use_options
/// {
/// "scripts": {
/// "test": "vitest"
/// }
/// }
/// ```
///
/// ### Valid
///
/// ```json,use_options
/// {
/// "scripts": {
/// "test": "vitest",
/// "build": "tsc"
/// }
/// }
/// ```
///
/// ## Options
///
/// ### `requiredScripts`
///
/// An array of script names that must be present in the `scripts` section of `package.json`.
/// Default: `[]` (no scripts required)
///
pub UseRequiredScripts {
version: "next",
name: "useRequiredScripts",
language: "json",
recommended: false,
}
}

impl Rule for UseRequiredScripts {
type Query = Ast<JsonRoot>;
type State = UseRequiredScriptsState;
type Signals = Option<Self::State>;
type Options = UseRequiredScriptsOptions;

fn run(ctx: &RuleContext<Self>) -> Self::Signals {
let query = ctx.query();
let path = ctx.file_path();
let options = ctx.options();

if !is_package_json(path) {
return None;
}
if options.required_scripts.is_empty() {
return None;
}

let value = query.value().ok()?;
let object_value = value.as_json_object_value()?;

let scripts_member = object_value.find_member("scripts");

// If there's no scripts section, all required scripts are missing
// Point to the root object in this case
let Some(scripts_member) = scripts_member else {
return Some(UseRequiredScriptsState {
missing_scripts: options.required_scripts.clone(),
range: object_value.range(),
});
};

let scripts_value = scripts_member.value().ok()?;
let scripts_object = scripts_value.as_json_object_value()?;

let existing_scripts: Vec<String> = scripts_object
.json_member_list()
.iter()
.flatten()
.filter_map(|member| {
let name = member.name().ok()?;
let text = name.inner_string_text().ok()?;
Some(text.to_string())
})
.collect();

let missing_scripts: Vec<String> = options
.required_scripts
.iter()
.filter(|script| !existing_scripts.iter().any(|s| s == *script))
.cloned()
.collect();

if missing_scripts.is_empty() {
None
} else {
// Point to the scripts member when scripts exist but some are missing
Some(UseRequiredScriptsState {
missing_scripts,
range: scripts_member.range(),
})
}
}

fn diagnostic(_ctx: &RuleContext<Self>, state: &Self::State) -> Option<RuleDiagnostic> {
let missing_count = state.missing_scripts.len();
let missing_list = state.missing_scripts.join(", ");

let message = if missing_count == 1 {
markup! {
"The required script "<Emphasis>{missing_list}</Emphasis>" is missing from package.json."
}
} else {
markup! {
"The required scripts "<Emphasis>{missing_list}</Emphasis>" are missing from package.json."
}
};

Some(
RuleDiagnostic::new(rule_category!(), state.range, message).note(markup! {
"Consistent scripts across packages ensure that each can be run reliably from the root of our project. Add the missing script"{{if missing_count > 1 { "s" } else { "" }}}" to your package.json."
}),
)
}
}
9 changes: 9 additions & 0 deletions crates/biome_json_analyze/src/utils.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use biome_json_syntax::JsonMember;
use biome_rowan::AstNode;
use camino::Utf8Path;

/// Finds the first ancestor [JsonMember], and returns [true] if it's name matches the given input
pub(crate) fn matches_parent_object(node: &JsonMember, name: &str) -> bool {
Expand All @@ -11,3 +12,11 @@ pub(crate) fn matches_parent_object(node: &JsonMember, name: &str) -> bool {
.and_then(|member| member.inner_string_text().ok())
.is_some_and(|text| text.text() == name)
}

/// Returns `true` if the given path has a filename of `package.json`.
pub(crate) fn is_package_json(path: &Utf8Path) -> bool {
match path.file_name() {
Some(name) => name == "package.json",
None => false,
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"name": "test-package",
"scripts": {
"start": "node index.js"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
---
source: crates/biome_json_analyze/tests/spec_tests.rs
expression: package.json
---
# Input
```json
{
"name": "test-package",
"scripts": {
"start": "node index.js"
}
}

```

# Diagnostics
```
package.json:3:3 lint/nursery/useRequiredScripts ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

i The required scripts test, build are missing from package.json.

1 │ {
2 │ "name": "test-package",
> 3 │ "scripts": {
│ ^^^^^^^^^^^^
> 4 │ "start": "node index.js"
> 5 │ }
│ ^
6 │ }
7 │

i Consistent scripts across packages ensure that each can be run reliably from the root of our project. Add the missing scripts to your package.json.


```
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"linter": {
"rules": {
"nursery": {
"useRequiredScripts": {
"level": "error",
"options": {
"requiredScripts": ["test", "build"]
}
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"name": "test-package",
"scripts": {
"test": "vitest"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
---
source: crates/biome_json_analyze/tests/spec_tests.rs
expression: package.json
---
# Input
```json
{
"name": "test-package",
"scripts": {
"test": "vitest"
}
}

```

# Diagnostics
```
package.json:3:3 lint/nursery/useRequiredScripts ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

i The required scripts build, lint are missing from package.json.

1 │ {
2 │ "name": "test-package",
> 3 │ "scripts": {
│ ^^^^^^^^^^^^
> 4 │ "test": "vitest"
> 5 │ }
│ ^
6 │ }
7 │

i Consistent scripts across packages ensure that each can be run reliably from the root of our project. Add the missing scripts to your package.json.


```
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"linter": {
"rules": {
"nursery": {
"useRequiredScripts": {
"level": "error",
"options": {
"requiredScripts": ["test", "build", "lint"]
}
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"name": "test-package",
"scripts": {
"test": "vitest"
}
}
Loading