Repository: denoland/deno
Pull request: fix: make unmet peer dep error message less verbose

Before:

```
Warning The following peer dependency issues were found:
├─┬ eslint-config-next@15.4.5
│ └─┬ @typescript-eslint/eslint-plugin@8.38.0
│   └── peer typescript@>=4.8.4 <5.9.0: resolved to 5.9.2
├─┬ eslint-config-next@15.4.5
│ └─┬ @typescript-eslint/parser@8.38.0
│   └── peer typescript@>=4.8.4 <5.9.0: resolved to 5.9.2
├─┬ eslint-config-next@15.4.5
│ └─┬ @typescript-eslint/eslint-plugin@8.38.0
│   └─┬ @typescript-eslint/parser@8.38.0
│     └── peer typescript@>=4.8.4 <5.9.0: resolved to 5.9.2
├─┬ eslint-config-next@15.4.5
│ └─┬ @typescript-eslint/eslint-plugin@8.38.0
│   └─┬ @typescript-eslint/type-utils@8.38.0
│     └── peer typescript@>=4.8.4 <5.9.0: resolved to 5.9.2
├─┬ eslint-config-next@15.4.5
│ └─┬ @typescript-eslint/eslint-plugin@8.38.0
│   └─┬ @typescript-eslint/utils@8.38.0
│     └── peer typescript@>=4.8.4 <5.9.0: resolved to 5.9.2
├─┬ eslint-config-next@15.4.5
│ └─┬ @typescript-eslint/parser@8.38.0
│   └─┬ @typescript-eslint/typescript-estree@8.38.0
│     └── peer typescript@>=4.8.4 <5.9.0: resolved to 5.9.2
├─┬ eslint-config-next@15.4.5
│ └─┬ @typescript-eslint/eslint-plugin@8.38.0
│   └─┬ @typescript-eslint/parser@8.38.0
│     └─┬ @typescript-eslint/typescript-estree@8.38.0
│       └── peer typescript@>=4.8.4 <5.9.0: resolved to 5.9.2
├─┬ eslint-config-next@15.4.5
│ └─┬ @typescript-eslint/eslint-plugin@8.38.0
│   └─┬ @typescript-eslint/type-utils@8.38.0
│     └─┬ @typescript-eslint/typescript-estree@8.38.0
│       └── peer typescript@>=4.8.4 <5.9.0: resolved to 5.9.2
├─┬ eslint-config-next@15.4.5
│ └─┬ @typescript-eslint/eslint-plugin@8.38.0
│   └─┬ @typescript-eslint/type-utils@8.38.0
│     └─┬ @typescript-eslint/utils@8.38.0
│       └── peer typescript@>=4.8.4 <5.9.0: resolved to 5.9.2
├─┬ eslint-config-next@15.4.5
│ └─┬ @typescript-eslint/eslint-plugin@8.38.0
│   └─┬ @typescript-eslint/utils@8.38.0
│     └─┬ @typescript-eslint/typescript-estree@8.38.0
│       └── peer typescript@>=4.8.4 <5.9.0: resolved to 5.9.2
├─┬ eslint-config-next@15.4.5
│ └─┬ @typescript-eslint/parser@8.38.0
│   └─┬ @typescript-eslint/typescript-estree@8.38.0
│     └─┬ @typescript-eslint/project-service@8.38.0
│       └── peer typescript@>=4.8.4 <5.9.0: resolved to 5.9.2
├─┬ eslint-config-next@15.4.5
│ └─┬ @typescript-eslint/parser@8.38.0
│   └─┬ @typescript-eslint/typescript-estree@8.38.0
│     └─┬ @typescript-eslint/tsconfig-utils@8.38.0
│       └── peer typescript@>=4.8.4 <5.9.0: resolved to 5.9.2
├─┬ eslint-config-next@15.4.5
│ └─┬ @typescript-eslint/eslint-plugin@8.38.0
│   └─┬ @typescript-eslint/parser@8.38.0
│     └─┬ @typescript-eslint/typescript-estree@8.38.0
│       └─┬ @typescript-eslint/project-service@8.38.0
│         └── peer typescript@>=4.8.4 <5.9.0: resolved to 5.9.2
├─┬ eslint-config-next@15.4.5
│ └─┬ @typescript-eslint/eslint-plugin@8.38.0
│   └─┬ @typescript-eslint/parser@8.38.0
│     └─┬ @typescript-eslint/typescript-estree@8.38.0
│       └─┬ @typescript-eslint/tsconfig-utils@8.38.0
│         └── peer typescript@>=4.8.4 <5.9.0: resolved to 5.9.2
├─┬ eslint-config-next@15.4.5
│ └─┬ @typescript-eslint/eslint-plugin@8.38.0
│   └─┬ @typescript-eslint/type-utils@8.38.0
│     └─┬ @typescript-eslint/typescript-estree@8.38.0
│       └─┬ @typescript-eslint/project-service@8.38.0
│         └── peer typescript@>=4.8.4 <5.9.0: resolved to 5.9.2
├─┬ eslint-config-next@15.4.5
│ └─┬ @typescript-eslint/eslint-plugin@8.38.0
│   └─┬ @typescript-eslint/type-utils@8.38.0
│     └─┬ @typescript-eslint/typescript-estree@8.38.0
│       └─┬ @typescript-eslint/tsconfig-utils@8.38.0
│         └── peer typescript@>=4.8.4 <5.9.0: resolved to 5.9.2
├─┬ eslint-config-next@15.4.5
│ └─┬ @typescript-eslint/eslint-plugin@8.38.0
│   └─┬ @typescript-eslint/type-utils@8.38.0
│     └─┬ @typescript-eslint/utils@8.38.0
│       └─┬ @typescript-eslint/typescript-estree@8.38.0
│         └── peer typescript@>=4.8.4 <5.9.0: resolved to 5.9.2
├─┬ eslint-config-next@15.4.5
│ └─┬ @typescript-eslint/eslint-plugin@8.38.0
│   └─┬ @typescript-eslint/utils@8.38.0
│     └─┬ @typescript-eslint/typescript-estree@8.38.0
│       └─┬ @typescript-eslint/project-service@8.38.0
│         └── peer typescript@>=4.8.4 <5.9.0: resolved to 5.9.2
├─┬ eslint-config-next@15.4.5
│ └─┬ @typescript-eslint/eslint-plugin@8.38.0
│   └─┬ @typescript-eslint/utils@8.38.0
│     └─┬ @typescript-eslint/typescript-estree@8.38.0
│       └─┬ @typescript-eslint/tsconfig-utils@8.38.0
│         └── peer typescript@>=4.8.4 <5.9.0: resolved to 5.9.2
├─┬ eslint-config-next@15.4.5
│ └─┬ @typescript-eslint/parser@8.38.0
│   └─┬ @typescript-eslint/typescript-estree@8.38.0
│     └─┬ @typescript-eslint/project-service@8.38.0
│       └─┬ @typescript-eslint/tsconfig-utils@8.38.0
│         └── peer typescript@>=4.8.4 <5.9.0: resolved to 5.9.2
├─┬ eslint-config-next@15.4.5
│ └─┬ @typescript-eslint/eslint-plugin@8.38.0
│   └─┬ @typescript-eslint/parser@8.38.0
│     └─┬ @typescript-eslint/typescript-estree@8.38.0
│       └─┬ @typescript-eslint/project-service@8.38.0
│         └─┬ @typescript-eslint/tsconfig-utils@8.38.0
│           └── peer typescript@>=4.8.4 <5.9.0: resolved to 5.9.2
├─┬ eslint-config-next@15.4.5
│ └─┬ @typescript-eslint/eslint-plugin@8.38.0
│   └─┬ @typescript-eslint/type-utils@8.38.0
│     └─┬ @typescript-eslint/typescript-estree@8.38.0
│       └─┬ @typescript-eslint/project-service@8.38.0
│         └─┬ @typescript-eslint/tsconfig-utils@8.38.0
│           └── peer typescript@>=4.8.4 <5.9.0: resolved to 5.9.2
├─┬ eslint-config-next@15.4.5
│ └─┬ @typescript-eslint/eslint-plugin@8.38.0
│   └─┬ @typescript-eslint/type-utils@8.38.0
│     └─┬ @typescript-eslint/utils@8.38.0
│       └─┬ @typescript-eslint/typescript-estree@8.38.0
│         └─┬ @typescript-eslint/project-service@8.38.0
│           └── peer typescript@>=4.8.4 <5.9.0: resolved to 5.9.2
├─┬ eslint-config-next@15.4.5
│ └─┬ @typescript-eslint/eslint-plugin@8.38.0
│   └─┬ @typescript-eslint/type-utils@8.38.0
│     └─┬ @typescript-eslint/utils@8.38.0
│       └─┬ @typescript-eslint/typescript-estree@8.38.0
│         └─┬ @typescript-eslint/tsconfig-utils@8.38.0
│           └── peer typescript@>=4.8.4 <5.9.0: resolved to 5.9.2
├─┬ eslint-config-next@15.4.5
│ └─┬ @typescript-eslint/eslint-plugin@8.38.0
│   └─┬ @typescript-eslint/utils@8.38.0
│     └─┬ @typescript-eslint/typescript-estree@8.38.0
│       └─┬ @typescript-eslint/project-service@8.38.0
│         └─┬ @typescript-eslint/tsconfig-utils@8.38.0
│           └── peer typescript@>=4.8.4 <5.9.0: resolved to 5.9.2
└─┬ eslint-config-next@15.4.5
  └─┬ @typescript-eslint/eslint-plugin@8.38.0
    └─┬ @typescript-eslint/type-utils@8.38.0
      └─┬ @typescript-eslint/utils@8.38.0
        └─┬ @typescript-eslint/typescript-estree@8.38.0
          └─┬ @typescript-eslint/project-service@8.38.0
            └─┬ @typescript-eslint/tsconfig-utils@8.38.0
              └── peer typescript@>=4.8.4 <5.9.0: resolved to 5.9.2
```

After:

```
Warning The following peer dependency issues were found:
└─┬ eslint-config-next@15.4.5
  ├─┬ @typescript-eslint/eslint-plugin@8.38.0
  │ ├─┬ @typescript-eslint/type-utils@8.38.0
  │ │ └── peer typescript@>=4.8.4 <5.9.0: resolved to 5.9.2
  │ ├─┬ @typescript-eslint/utils@8.38.0
  │ │ └── peer typescript@>=4.8.4 <5.9.0: resolved to 5.9.2
  │ └── peer typescript@>=4.8.4 <5.9.0: resolved to 5.9.2
  └─┬ @typescript-eslint/parser@8.38.0
    ├─┬ @typescript-eslint/typescript-estree@8.38.0
    │ ├─┬ @typescript-eslint/project-service@8.38.0
    │ │ └── peer typescript@>=4.8.4 <5.9.0: resolved to 5.9.2
    │ ├─┬ @typescript-eslint/tsconfig-utils@8.38.0
    │ │ └── peer typescript@>=4.8.4 <5.9.0: resolved to 5.9.2
    │ └── peer typescript@>=4.8.4 <5.9.0: resolved to 5.9.2
    └── peer typescript@>=4.8.4 <5.9.0: resolved to 5.9.2
```

Changed files:
filename: libs/npm_installer/resolution.rs
patch: @@ -1,5 +1,8 @@
 // Copyright 2018-2025 the Deno authors. MIT license.
 
+use std::cell::RefCell;
+use std::collections::BTreeMap;
+use std::rc::Rc;
 use std::sync::Arc;
 
 use capacity_builder::StringBuilder;
@@ -14,6 +17,7 @@ use deno_npm::resolution::AddPkgReqsOptions;
 use deno_npm::resolution::DefaultTarballUrlProvider;
 use deno_npm::resolution::NpmResolutionError;
 use deno_npm::resolution::NpmResolutionSnapshot;
+use deno_npm::resolution::UnmetPeerDepDiagnostic;
 use deno_npm_cache::NpmCacheHttpClient;
 use deno_npm_cache::NpmCacheSys;
 use deno_npm_cache::RegistryInfoProvider;
@@ -172,32 +176,8 @@ impl<TNpmCacheHttpClient: NpmCacheHttpClient, TSys: NpmResolutionInstallerSys>
     if !result.unmet_peer_diagnostics.is_empty()
       && log::log_enabled!(log::Level::Warn)
     {
-      let root_node = DisplayTreeNode {
-        text: format!(
-          "{} The following peer dependency issues were found:",
-          colors::yellow("Warning")
-        ),
-        children: result
-          .unmet_peer_diagnostics
-          .iter()
-          .map(|diagnostic| {
-            let mut node = DisplayTreeNode {
-              text: format!(
-                "peer {}: resolved to {}",
-                diagnostic.dependency, diagnostic.resolved
-              ),
-              children: Vec::new(),
-            };
-            for ancestor in &diagnostic.ancestors {
-              node = DisplayTreeNode {
-                text: ancestor.to_string(),
-                children: vec![node],
-              };
-            }
-            node
-          })
-          .collect(),
-      };
+      let root_node =
+        peer_dep_diagnostics_to_display_tree(&result.unmet_peer_diagnostics);
       let mut text = String::new();
       _ = root_node.print(&mut text);
       log::warn!("{}", text);
@@ -302,3 +282,104 @@ impl<TNpmCacheHttpClient: NpmCacheHttpClient, TSys: NpmResolutionInstallerSys>
     }
   }
 }
+
+fn peer_dep_diagnostics_to_display_tree(
+  diagnostics: &[UnmetPeerDepDiagnostic],
+) -> DisplayTreeNode {
+  struct MergedNode {
+    text: Rc<String>,
+    children: RefCell<Vec<Rc<MergedNode>>>,
+  }
+
+  // combine the nodes into a unified tree
+  let mut nodes: BTreeMap<Rc<String>, Rc<MergedNode>> = BTreeMap::new();
+  let mut top_level_nodes = Vec::new();
+
+  for diagnostic in diagnostics {
+    let text = Rc::new(format!(
+      "peer {}: resolved to {}",
+      diagnostic.dependency, diagnostic.resolved
+    ));
+    let mut node = Rc::new(MergedNode {
+      text: text.clone(),
+      children: Default::default(),
+    });
+    let mut found_ancestor = false;
+    for ancestor in &diagnostic.ancestors {
+      let nv_string = Rc::new(ancestor.to_string());
+      if let Some(current_node) = nodes.get(&nv_string) {
+        {
+          let mut children = current_node.children.borrow_mut();
+          if let Err(insert_index) =
+            children.binary_search_by(|n| n.text.cmp(&node.text))
+          {
+            children.insert(insert_index, node);
+          }
+        }
+        node = current_node.clone();
+        found_ancestor = true;
+        break;
+      } else {
+        let current_node = Rc::new(MergedNode {
+          text: nv_string.clone(),
+          children: RefCell::new(vec![node]),
+        });
+        nodes.insert(nv_string.clone(), current_node.clone());
+        node = current_node;
+      }
+    }
+    if !found_ancestor {
+      top_level_nodes.push(node);
+    }
+  }
+
+  // now output it
+  let mut root_node = DisplayTreeNode {
+    text: format!(
+      "{} The following peer dependency issues were found:",
+      colors::yellow("Warning")
+    ),
+    children: Vec::new(),
+  };
+
+  fn convert_node(node: &Rc<MergedNode>) -> DisplayTreeNode {
+    DisplayTreeNode {
+      text: node.text.to_string(),
+      children: node.children.borrow().iter().map(convert_node).collect(),
+    }
+  }
+
+  for top_level_node in top_level_nodes {
+    root_node.children.push(convert_node(&top_level_node));
+  }
+
+  root_node
+}
+
+#[cfg(test)]
+mod test {
+  use deno_semver::Version;
+  use deno_semver::package::PackageNv;
+
+  use super::*;
+
+  #[test]
+  fn same_ancestor_peer_dep_message() {
+    let peer_deps = Vec::from([
+      UnmetPeerDepDiagnostic {
+        ancestors: vec![PackageNv::from_str("a@1.0.0").unwrap()],
+        dependency: PackageReq::from_str("b@*").unwrap(),
+        resolved: Version::parse_standard("1.0.0").unwrap(),
+      },
+      UnmetPeerDepDiagnostic {
+        // same ancestor as above
+        ancestors: vec![PackageNv::from_str("a@1.0.0").unwrap()],
+        dependency: PackageReq::from_str("c@*").unwrap(),
+        resolved: Version::parse_standard("1.0.0").unwrap(),
+      },
+    ]);
+    let display_tree = peer_dep_diagnostics_to_display_tree(&peer_deps);
+    assert_eq!(display_tree.children.len(), 1);
+    assert_eq!(display_tree.children[0].children.len(), 2);
+  }
+}