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: unsync-similar-dependencies rule #105

Merged
merged 9 commits into from
Jan 25, 2025
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
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,21 @@ sherif -i @next/*
sherif -i react -i next
```

### `unsync-similar-dependencies` ❌

Similar dependencies in a given `package.json` should use the same version. For example, if you use both `react` and `react-dom` dependencies in the same `package.json`, this rule will enforce that they use the same version.

<details>

<summary>List of detected similar dependencies</summary>

- `react`, `react-dom`
- `eslint-config-next`, `@next/eslint-plugin-next`, `@next/font` `@next/bundle-analyzer`, `@next/third-parties`, `@next/mdx`, `next`
- `eslint-config-turbo`, `eslint-plugin-turbo`, `@turbo/gen`, `turbo-ignore`, `turbo`
- `@tanstack/eslint-plugin-query`, `@tanstack/query-async-storage-persister`, `@tanstack/query-broadcast-client-experimental`, `@tanstack/query-core`, `@tanstack/query-devtools`, `@tanstack/query-persist-client-core`, `@tanstack/query-sync-storage-persister`, `@tanstack/react-query`, `@tanstack/react-query-devtools`, `@tanstack/react-query-persist-client`, `@tanstack/react-query-next-experimental`, `@tanstack/solid-query`, `@tanstack/solid-query-devtools`, `@tanstack/solid-query-persist-client`, `@tanstack/svelte-query`, `@tanstack/svelte-query-devtools`, `@tanstack/svelte-query-persist-client`, `@tanstack/vue-query`, `@tanstack/vue-query-devtools`, `@tanstack/angular-query-devtools-experimental`, `@tanstack/angular-query-experimental`

</details>

#### `non-existant-packages` ⚠️

All paths defined in the workspace (the root `package.json`' `workspaces` field or `pnpm-workspace.yaml`) should match at least one package.
Expand Down
8 changes: 8 additions & 0 deletions fixtures/unsync/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"name": "unsync",
"private": true,
"packageManager": "[email protected]",
"workspaces": [
"packages/*"
]
}
6 changes: 6 additions & 0 deletions fixtures/unsync/packages/abc/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"name": "abc",
"dependencies": {
"react": "2.0.0"
}
}
8 changes: 8 additions & 0 deletions fixtures/unsync/packages/def/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"name": "def",
"dependencies": {
"react": "1.0.0",
"turbo": "2.0.0",
"turbo-ignore": "3.0.0"
}
}
60 changes: 60 additions & 0 deletions src/collect.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
use crate::args::Args;
use crate::packages::root::RootPackage;
use crate::packages::semversion::SemVersion;
use crate::packages::{Package, PackagesList};
use crate::printer::print_error;
use crate::rules::multiple_dependency_versions::MultipleDependencyVersionsIssue;
use crate::rules::non_existant_packages::NonExistantPackagesIssue;
use crate::rules::packages_without_package_json::PackagesWithoutPackageJsonIssue;
use crate::rules::types_in_dependencies::TypesInDependenciesIssue;
use crate::rules::unsync_similar_dependencies::{
SimilarDependency, UnsyncSimilarDependenciesIssue,
};
use crate::rules::{BoxIssue, IssuesList, PackageType};
use anyhow::{anyhow, Result};
use indexmap::IndexMap;
Expand Down Expand Up @@ -237,6 +241,7 @@ pub fn collect_issues(args: &Args, packages_list: PackagesList) -> IssuesList<'_

let mut all_dependencies = IndexMap::new();
let mut joined_dependencies = IndexMap::new();
let mut similar_dependencies_by_package = IndexMap::new();

if let Some(dependencies) = root_package.get_dependencies() {
joined_dependencies.extend(dependencies);
Expand Down Expand Up @@ -303,6 +308,19 @@ pub fn collect_issues(args: &Args, packages_list: PackagesList) -> IssuesList<'_
}

for (name, versions) in all_dependencies {
if let Ok(similar_dependency) = SimilarDependency::try_from(name.as_str()) {
for (path, version) in versions.iter() {
similar_dependencies_by_package
.entry(path.clone())
.or_insert_with(
IndexMap::<SimilarDependency, IndexMap<SemVersion, String>>::new,
)
.entry(similar_dependency.clone())
.or_insert_with(IndexMap::new)
.insert(version.clone(), name.clone());
}
}

let mut filtered_versions = versions
.iter()
.filter(|(_, version)| {
Expand Down Expand Up @@ -342,6 +360,17 @@ pub fn collect_issues(args: &Args, packages_list: PackagesList) -> IssuesList<'_
}
}

for (path, similar_dependencies) in similar_dependencies_by_package {
for (similar_dependency, versions) in similar_dependencies {
if versions.len() > 1 {
issues.add_raw(
PackageType::Package(path.clone()),
UnsyncSimilarDependenciesIssue::new(similar_dependency, versions),
);
}
}
}

issues
}

Expand Down Expand Up @@ -778,4 +807,35 @@ mod test {
"unordered-dependencies"
);
}

#[test]
fn collect_unsync_similar_dependencies() {
let args = Args {
path: "fixtures/unsync".into(),
fix: false,
no_install: false,
ignore_rule: Vec::new(),
ignore_package: Vec::new(),
ignore_dependency: Vec::new(),
};

let packages_list = collect_packages(&args).unwrap();
assert_eq!(packages_list.root_package.get_name(), "unsync");
assert_eq!(packages_list.packages.len(), 2);

let issues = collect_issues(&args, packages_list);
assert_eq!(issues.total_len(), 2);

let issues = issues.into_iter().collect::<IndexMap<_, _>>();

assert_eq!(
issues
.get(&PackageType::Package(
"fixtures/unsync/packages/def".to_string()
))
.unwrap()[0]
.name(),
"unsync-similar-dependencies"
);
}
}
2 changes: 1 addition & 1 deletion src/packages/semversion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ use anyhow::{anyhow, Result};
use semver::{Prerelease, Version, VersionReq};
use std::{cmp::Ordering, fmt::Display};

#[derive(Debug, PartialEq, Eq, Clone)]
#[derive(Debug, PartialEq, Eq, Hash, Clone)]
pub enum SemVersion {
Exact(Version),
Range(VersionReq),
Expand Down
6 changes: 5 additions & 1 deletion src/rules/mod.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
use anyhow::{anyhow, Result};
use colored::Colorize;
use indexmap::IndexMap;
use std::{borrow::Cow, fmt::Display};
use std::{
borrow::Cow,
fmt::{Debug, Display},
};

pub mod empty_dependencies;
pub mod multiple_dependency_versions;
Expand All @@ -12,6 +15,7 @@ pub mod root_package_manager_field;
pub mod root_package_private_field;
pub mod types_in_dependencies;
pub mod unordered_dependencies;
pub mod unsync_similar_dependencies;

pub const ERROR: &str = "⨯";
pub const WARNING: &str = "⚠️";
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
source: src/rules/unsync_similar_dependencies.rs
expression: issue.message()
---
│ {
│ "dependencies": {
~ "react": "1.0.0",
~ "react-dom": "2.0.0"
│ }
│ }
178 changes: 178 additions & 0 deletions src/rules/unsync_similar_dependencies.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
use super::Issue;
use crate::packages::semversion::SemVersion;
use colored::Colorize;
use indexmap::IndexMap;
use std::{borrow::Cow, fmt::Display, hash::Hash};

#[derive(Debug, Hash, PartialEq, Eq, Clone)]
pub enum SimilarDependency {
React,
NextJS,
Turborepo,
TanstackQuery,
}

impl Display for SimilarDependency {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::React => write!(f, "React"),
Self::NextJS => write!(f, "Next.js"),
Self::Turborepo => write!(f, "Turborepo"),
Self::TanstackQuery => write!(f, "Tanstack Query"),
}
}
}

impl TryFrom<&str> for SimilarDependency {
type Error = anyhow::Error;

fn try_from(value: &str) -> Result<Self, Self::Error> {
match value {
"react" | "react-dom" => Ok(Self::React),
"eslint-config-next"
| "@next/eslint-plugin-next"
| "@next/font"
| "@next/bundle-analyzer"
| "@next/mdx"
| "next"
| "@next/third-parties" => Ok(Self::NextJS),
"eslint-config-turbo"
| "eslint-plugin-turbo"
| "@turbo/gen"
| "turbo-ignore"
| "turbo" => Ok(Self::Turborepo),
"@tanstack/eslint-plugin-query"
| "@tanstack/query-async-storage-persister"
| "@tanstack/query-broadcast-client-experimental"
| "@tanstack/query-core"
| "@tanstack/query-devtools"
| "@tanstack/query-persist-client-core"
| "@tanstack/query-sync-storage-persister"
| "@tanstack/react-query"
| "@tanstack/react-query-devtools"
| "@tanstack/react-query-persist-client"
| "@tanstack/react-query-next-experimental"
| "@tanstack/solid-query"
| "@tanstack/solid-query-devtools"
| "@tanstack/solid-query-persist-client"
| "@tanstack/svelte-query"
| "@tanstack/svelte-query-devtools"
| "@tanstack/svelte-query-persist-client"
| "@tanstack/vue-query"
| "@tanstack/vue-query-devtools"
| "@tanstack/angular-query-devtools-experimental"
| "@tanstack/angular-query-experimental" => Ok(Self::TanstackQuery),
_ => Err(anyhow::anyhow!("Unknown similar dependency")),
}
}
}

#[derive(Debug)]
pub struct UnsyncSimilarDependenciesIssue {
r#type: SimilarDependency,
versions: IndexMap<SemVersion, String>,
fixed: bool,
}

impl UnsyncSimilarDependenciesIssue {
pub fn new(r#type: SimilarDependency, versions: IndexMap<SemVersion, String>) -> Box<Self> {
Box::new(Self {
r#type,
versions,
fixed: false,
})
}
}

impl Issue for UnsyncSimilarDependenciesIssue {
fn name(&self) -> &str {
"unsync-similar-dependencies"
}

fn level(&self) -> super::IssueLevel {
match self.fixed {
true => super::IssueLevel::Fixed,
false => super::IssueLevel::Error,
}
}

fn message(&self) -> String {
let deps = self
.versions
.iter()
.map(|(version, dependency)| {
format!(
r#" {} "{}": "{}""#,
"~".yellow(),
dependency.white(),
version.to_string().yellow()
)
})
.collect::<Vec<String>>()
.join(",\n");

format!(
r#" │ {{
│ "{}": {{
{}
│ }}
│ }}"#,
"dependencies".white(),
deps,
)
.bright_black()
.to_string()
}

fn why(&self) -> Cow<'static, str> {
Cow::Owned(format!(
"Similar {} dependencies should use the same version.",
self.r#type
))
}

fn fix(&mut self, _package_type: &super::PackageType) -> anyhow::Result<()> {
Ok(())
}
}

#[cfg(test)]
mod tests {
use super::*;
use crate::rules::IssueLevel;

#[test]
fn test() {
let versions = vec![
(SemVersion::parse("1.0.0").unwrap(), "react".to_string()),
(SemVersion::parse("2.0.0").unwrap(), "react-dom".to_string()),
]
.into_iter()
.collect();

let issue = UnsyncSimilarDependenciesIssue::new(SimilarDependency::React, versions);

assert_eq!(issue.name(), "unsync-similar-dependencies");
assert_eq!(issue.level(), IssueLevel::Error);
assert_eq!(issue.versions.len(), 2);
assert_eq!(
issue.why(),
"Similar React dependencies should use the same version."
);
}

#[test]
fn basic() {
let versions = vec![
(SemVersion::parse("1.0.0").unwrap(), "react".to_string()),
(SemVersion::parse("2.0.0").unwrap(), "react-dom".to_string()),
]
.into_iter()
.collect();

let issue = UnsyncSimilarDependenciesIssue::new(SimilarDependency::React, versions);

colored::control::set_override(false);
insta::assert_snapshot!(issue.message());
}
}