Repository: sverweij/dependency-cruiser (js)

Issue:
Question: can i override a node's prefix via criteria ?
I would like to change the prefix or the href for specific nodes

### Summary

- I'm perfectly fine with how things work for code under version control and can add the URL to my gitlab instance in the configuration file under the `prefix` option BUT I would like to be able to link to node packages when the dependency type is npm

### Environment

<!--- Include as many details about the environment - when relevant for this issue.
  --->

- Version used: 14.1.1
- Node version: 18.17.0
- Operating System and version: alpine node
- Link to your project: <private>


Pull request description:
feat(report): links to npmjs/ nodejs.org for npm respectively node core modules
## Description

- for npm modules in the dot reporters link to npmjs.com
- for node modules link to nodejs.org

## Motivation and Context

Fixes #852 

## How Has This Been Tested?

- [x] green ci
- [x] additional automated integration tests

## Types of changes

- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] Documentation only change
- [ ] Refactor (non-breaking change which fixes an issue without changing functionality)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to change)

## Checklist

- [x] :book:

  - My change doesn't require a documentation update, or ...
  - it _does_ and I have updated it

- [x] :balance_scale:
  - The contribution will be subject to [The MIT license](https://github.com/sverweij/dependency-cruiser/blob/main/LICENSE), and I'm OK with that.
  - The contribution is my own original work.
  - I am ok with the stuff in [**CONTRIBUTING.md**](https://github.com/sverweij/dependency-cruiser/blob/main/.github/CONTRIBUTING.md).


Patch:
diff --git a/src/report/dot/module-utl.mjs b/src/report/dot/module-utl.mjs
index b14f44da..131b40d6 100644
--- a/src/report/dot/module-utl.mjs
+++ b/src/report/dot/module-utl.mjs
@@ -1,10 +1,8 @@
-import { basename, sep, dirname, join } from "node:path/posix";
+import { basename, sep, dirname } from "node:path/posix";
 import has from "lodash/has.js";
-import { formatPercentage } from "../utl/index.mjs";
+import { formatPercentage, getURLForModule } from "../utl/index.mjs";
 import theming from "./theming.mjs";
 
-const PROTOCOL_PREFIX_RE = /^[a-z]+:\/\//;
-
 function attributizeObject(pObject) {
   return (
     Object.keys(pObject)
@@ -25,7 +23,7 @@ function extractFirstTransgression(pModule) {
             ...pDependency,
             rule: pDependency.rules[0],
           }
-        : pDependency
+        : pDependency,
     ),
   };
 }
@@ -37,7 +35,7 @@ function applyTheme(pTheme) {
       .map((pDependency) => ({
         ...pDependency,
         themeAttrs: attributizeObject(
-          theming.determineAttributes(pDependency, pTheme.dependencies)
+          theming.determineAttributes(pDependency, pTheme.dependencies),
         ),
       }))
       .map((pDependency) => ({
@@ -45,7 +43,7 @@ function applyTheme(pTheme) {
         hasExtraAttributes: Boolean(pDependency.rule || pDependency.themeAttrs),
       })),
     themeAttrs: attributizeObject(
-      theming.determineAttributes(pModule, pTheme.modules)
+      theming.determineAttributes(pModule, pTheme.modules),
     ),
   });
 }
@@ -68,13 +66,14 @@ function makeInstabilityString(pModule, pShowMetrics = false) {
 
   if (pShowMetrics && has(pModule, "instability") && !pModule.consolidated) {
     lInstabilityString = ` <FONT color="#808080" point-size="8">${formatPercentage(
-      pModule.instability
+      pModule.instability,
     )}</FONT>`;
   }
   return lInstabilityString;
 }
 
 function folderify(pShowMetrics) {
+  /** @param {import("../../../types/cruise-result").IModule} pModule*/
   return (pModule) => {
     let lAdditions = {};
     let lDirectoryName = dirname(pModule.source);
@@ -86,7 +85,7 @@ function folderify(pShowMetrics) {
 
     lAdditions.label = `<${basename(pModule.source)}${makeInstabilityString(
       pModule,
-      pShowMetrics
+      pShowMetrics,
     )}>`;
     lAdditions.tooltip = basename(pModule.source);
 
@@ -98,37 +97,25 @@ function folderify(pShowMetrics) {
 }
 
 /**
- * Sort of smartly concatenate the given prefix and source:
- *
- * if it's an uri pattern (e.g. https://yadda, file://snorkel/bla)
- * simply concat.
- *
- * in all other cases path.posix.join the two
- *
- * @param {string} pPrefix - prefix
- * @param {string} pSource - filename
- * @return {string} prefix and filename concatenated
+ * @param {string} pPrefix
+ * @returns {URL?: string}
  */
-function smartURIConcat(pPrefix, pSource) {
-  if (pPrefix.match(PROTOCOL_PREFIX_RE)) {
-    return `${pPrefix}${pSource}`;
-  } else {
-    return join(pPrefix, pSource);
-  }
-}
-
 function addURL(pPrefix) {
-  return (pModule) => ({
-    ...pModule,
-    ...(pModule.coreModule || pModule.couldNotResolve
-      ? {}
-      : { URL: smartURIConcat(pPrefix, pModule.source) }),
-  });
+  return (pModule) => {
+    if (pModule.couldNotResolve) {
+      return pModule;
+    }
+
+    return {
+      ...pModule,
+      URL: getURLForModule(pModule, pPrefix),
+    };
+  };
 }
 
 function makeLabel(pModule, pShowMetrics) {
   return `<${dirname(pModule.source)}/<BR/><B>${basename(
-    pModule.source
+    pModule.source,
   )}</B>${makeInstabilityString(pModule, pShowMetrics)}>`;
 }
 
diff --git a/src/report/utl/index.mjs b/src/report/utl/index.mjs
index 6145f16b..ff4cc4b3 100644
--- a/src/report/utl/index.mjs
+++ b/src/report/utl/index.mjs
@@ -1,3 +1,7 @@
+import { join } from "node:path/posix";
+
+const PROTOCOL_PREFIX_RE = /^[a-z]+:\/\//;
+
 // eslint-disable-next-line no-undefined
 export const formatPercentage = new Intl.NumberFormat(undefined, {
   style: "percent",
@@ -6,9 +10,78 @@ export const formatPercentage = new Intl.NumberFormat(undefined, {
 export function formatViolation(
   pViolation,
   pViolationType2Formatter,
-  pDefaultFormatter
+  pDefaultFormatter,
 ) {
   return (pViolationType2Formatter[pViolation.type] || pDefaultFormatter)(
-    pViolation
+    pViolation,
   );
 }
+
+/**
+ * Sort of smartly concatenate the given prefix and source:
+ *
+ * if it's an uri pattern (e.g. https://yadda, file://snorkel/bla)
+ * simply concat.
+ *
+ * in all other cases path.posix.join the two
+ *
+ * @param {string} pPrefix - prefix
+ * @param {string} pSource - filename
+ * @return {string} prefix and filename concatenated
+ */
+function smartURIConcat(pPrefix, pSource) {
+  if (pPrefix.match(PROTOCOL_PREFIX_RE)) {
+    return `${pPrefix}${pSource}`;
+  } else {
+    return join(pPrefix, pSource);
+  }
+}
+
+/**
+ *
+ * @param {string} pSource
+ * @returns {string}
+ */
+function deriveExternalPackageName(pSource) {
+  const lRE =
+    /node_modules\/(?<packageName>[^@][^/]+)|(?<atPackageName>@[^/]+\/[^/]+)/;
+  const lMatch = pSource.match(lRE);
+  if (lMatch) {
+    return lMatch.groups.packageName || lMatch.groups.atPackageName;
+  }
+  return "";
+}
+
+/**
+ * @param {string} pSource
+ * @returns {string}
+ */
+function deriveCorePackageName(pSource) {
+  return pSource.split("/").shift();
+}
+
+/**
+ *
+ * @param {import("../../../types/cruise-result").IModule} pModule
+ * @param {string} pPrefix
+ * @returns {string}
+ */
+export function getURLForModule(pModule, pPrefix) {
+  // TODO: derive the URLs from configuration
+  if (pModule.dependencyTypes?.some((pType) => pType === "core")) {
+    return "https://nodejs.org/api/{{packageName}}.html".replace(
+      "{{packageName}}",
+      deriveCorePackageName(pModule.source),
+    );
+  } else if (
+    pModule.dependencyTypes?.some((pType) => pType.startsWith("npm"))
+  ) {
+    return "https://www.npmjs.com/package/{{packageName}}".replace(
+      "{{packageName}}",
+      deriveExternalPackageName(pModule.source),
+    );
+  } else if (pPrefix) {
+    return smartURIConcat(pPrefix, pModule.source);
+  }
+  return pModule.source;
+}


Test patch:
diff --git a/test/cli/__fixtures__/cjs.dir.filtered.dot b/test/cli/__fixtures__/cjs.dir.filtered.dot
index 034307eb..942bcb3a 100644
--- a/test/cli/__fixtures__/cjs.dir.filtered.dot
+++ b/test/cli/__fixtures__/cjs.dir.filtered.dot
@@ -3,9 +3,9 @@ strict digraph "dependency-cruiser output"{
     node [shape="box" style="rounded, filled" height="0.2" color="black" fillcolor="#ffffcc" fontcolor="black" fontname="Helvetica" fontsize="9"]
     edge [arrowhead="normal" arrowsize="0.6" penwidth="2.0" color="#00000033" fontname="Helvetica" fontsize="9"]
 
-    "fs" [label=<fs> tooltip="fs" color="grey" fontcolor="grey"]
-    "http" [label=<http> tooltip="http" color="grey" fontcolor="grey"]
-    "path" [label=<path> tooltip="path" color="grey" fontcolor="grey"]
+    "fs" [label=<fs> tooltip="fs" URL="https://nodejs.org/api/fs.html" color="grey" fontcolor="grey"]
+    "http" [label=<http> tooltip="http" URL="https://nodejs.org/api/http.html" color="grey" fontcolor="grey"]
+    "path" [label=<path> tooltip="path" URL="https://nodejs.org/api/path.html" color="grey" fontcolor="grey"]
     subgraph "cluster_test" {label="test" subgraph "cluster_test/cli" {label="cli" subgraph "cluster_test/cli/__fixtures__" {label="__fixtures__" subgraph "cluster_test/cli/__fixtures__/cjs" {label="cjs" "test/cli/__fixtures__/cjs/one_only_one.js" [label=<one_only_one.js> tooltip="one_only_one.js" URL="test/cli/__fixtures__/cjs/one_only_one.js" ] } } } }
     "test/cli/__fixtures__/cjs/one_only_one.js" -> "path" [style="dashed" penwidth="1.0"]
     subgraph "cluster_test" {label="test" subgraph "cluster_test/cli" {label="cli" subgraph "cluster_test/cli/__fixtures__" {label="__fixtures__" subgraph "cluster_test/cli/__fixtures__/cjs" {label="cjs" "test/cli/__fixtures__/cjs/one_only_two.js" [label=<one_only_two.js> tooltip="one_only_two.js" URL="test/cli/__fixtures__/cjs/one_only_two.js" ] } } } }
diff --git a/test/report/dot/folder-level/__fixtures__/dependency-cruiser-2019-01-14.dot b/test/report/dot/folder-level/__fixtures__/dependency-cruiser-2019-01-14.dot
index 80da1442..f9b1a793 100644
--- a/test/report/dot/folder-level/__fixtures__/dependency-cruiser-2019-01-14.dot
+++ b/test/report/dot/folder-level/__fixtures__/dependency-cruiser-2019-01-14.dot
@@ -3,30 +3,30 @@ strict digraph "dependency-cruiser output"{
     node [shape="box" style="rounded, filled" height="0.2" color="black" fillcolor="#ffffcc" fontcolor="black" fontname="Helvetica" fontsize="9"]
     edge [arrowhead="normal" arrowsize="0.6" penwidth="2.0" color="#00000033" fontname="Helvetica" fontsize="9"]
 
-    "." [label=<.> tooltip="." color="grey" fontcolor="grey" shape="box3d"]
+    "." [label=<.> tooltip="." URL="https://nodejs.org/api/..html" color="grey" fontcolor="grey" shape="box3d"]
     "bin" [label=<bin> tooltip="bin" URL="https://github.com/sverweij/dependency-cruiser/blob/develop/bin" shape="box3d"]
     "bin" -> "."
     "bin" -> "src/cli"
     "bin" -> "node_modules/commander" [penwidth="1.0"]
     "bin" -> "node_modules/semver" [penwidth="1.0"]
-    subgraph "cluster_node_modules" {label="node_modules" "node_modules" [width="0.05" shape="point" style="invis"] subgraph "cluster_node_modules/acorn-loose" {label="acorn-loose" "node_modules/acorn-loose" [width="0.05" shape="point" style="invis"] "node_modules/acorn-loose/dist" [label=<dist> tooltip="dist" URL="https://github.com/sverweij/dependency-cruiser/blob/develop/node_modules/acorn-loose/dist" shape="box3d" fillcolor="#c40b0a1a" fontcolor="#c40b0a"] } }
-    subgraph "cluster_node_modules" {label="node_modules" "node_modules" [width="0.05" shape="point" style="invis"] subgraph "cluster_node_modules/acorn-walk" {label="acorn-walk" "node_modules/acorn-walk" [width="0.05" shape="point" style="invis"] "node_modules/acorn-walk/dist" [label=<dist> tooltip="dist" URL="https://github.com/sverweij/dependency-cruiser/blob/develop/node_modules/acorn-walk/dist" shape="box3d" fillcolor="#c40b0a1a" fontcolor="#c40b0a"] } }
-    subgraph "cluster_node_modules" {label="node_modules" "node_modules" [width="0.05" shape="point" style="invis"] subgraph "cluster_node_modules/acorn" {label="acorn" "node_modules/acorn" [width="0.05" shape="point" style="invis"] "node_modules/acorn/dist" [label=<dist> tooltip="dist" URL="https://github.com/sverweij/dependency-cruiser/blob/develop/node_modules/acorn/dist" shape="box3d" fillcolor="#c40b0a1a" fontcolor="#c40b0a"] } }
-    subgraph "cluster_node_modules" {label="node_modules" "node_modules" [width="0.05" shape="point" style="invis"] subgraph "cluster_node_modules/ajv" {label="ajv" "node_modules/ajv" [width="0.05" shape="point" style="invis"] "node_modules/ajv/lib" [label=<lib> tooltip="lib" URL="https://github.com/sverweij/dependency-cruiser/blob/develop/node_modules/ajv/lib" shape="box3d" fillcolor="#c40b0a1a" fontcolor="#c40b0a"] } }
-    subgraph "cluster_node_modules" {label="node_modules" "node_modules" [width="0.05" shape="point" style="invis"] subgraph "cluster_node_modules/awesome-typescript-loader" {label="awesome-typescript-loader" "node_modules/awesome-typescript-loader" [width="0.05" shape="point" style="invis"] "node_modules/awesome-typescript-loader/dist" [label=<dist> tooltip="dist" URL="https://github.com/sverweij/dependency-cruiser/blob/develop/node_modules/awesome-typescript-loader/dist" shape="box3d" fillcolor="#c40b0a1a" fontcolor="#c40b0a"] } }
-    subgraph "cluster_node_modules" {label="node_modules" "node_modules" [width="0.05" shape="point" style="invis"] "node_modules/chalk" [label=<chalk> tooltip="chalk" URL="https://github.com/sverweij/dependency-cruiser/blob/develop/node_modules/chalk" shape="box3d" fillcolor="#c40b0a1a" fontcolor="#c40b0a"] }
-    subgraph "cluster_node_modules" {label="node_modules" "node_modules" [width="0.05" shape="point" style="invis"] "node_modules/commander" [label=<commander> tooltip="commander" URL="https://github.com/sverweij/dependency-cruiser/blob/develop/node_modules/commander" shape="box3d" fillcolor="#c40b0a1a" fontcolor="#c40b0a"] }
-    subgraph "cluster_node_modules" {label="node_modules" "node_modules" [width="0.05" shape="point" style="invis"] subgraph "cluster_node_modules/enhanced-resolve" {label="enhanced-resolve" "node_modules/enhanced-resolve" [width="0.05" shape="point" style="invis"] "node_modules/enhanced-resolve/lib" [label=<lib> tooltip="lib" URL="https://github.com/sverweij/dependency-cruiser/blob/develop/node_modules/enhanced-resolve/lib" shape="box3d" fillcolor="#c40b0a1a" fontcolor="#c40b0a"] } }
-    subgraph "cluster_node_modules" {label="node_modules" "node_modules" [width="0.05" shape="point" style="invis"] "node_modules/figures" [label=<figures> tooltip="figures" URL="https://github.com/sverweij/dependency-cruiser/blob/develop/node_modules/figures" shape="box3d" fillcolor="#c40b0a1a" fontcolor="#c40b0a"] }
-    subgraph "cluster_node_modules" {label="node_modules" "node_modules" [width="0.05" shape="point" style="invis"] "node_modules/glob" [label=<glob> tooltip="glob" URL="https://github.com/sverweij/dependency-cruiser/blob/develop/node_modules/glob" shape="box3d" fillcolor="#c40b0a1a" fontcolor="#c40b0a"] }
-    subgraph "cluster_node_modules" {label="node_modules" "node_modules" [width="0.05" shape="point" style="invis"] "node_modules/handlebars" [label=<handlebars> tooltip="handlebars" URL="https://github.com/sverweij/dependency-cruiser/blob/develop/node_modules/handlebars" shape="box3d" fillcolor="#c40b0a1a" fontcolor="#c40b0a"] }
-    subgraph "cluster_node_modules" {label="node_modules" "node_modules" [width="0.05" shape="point" style="invis"] subgraph "cluster_node_modules/inquirer" {label="inquirer" "node_modules/inquirer" [width="0.05" shape="point" style="invis"] "node_modules/inquirer/lib" [label=<lib> tooltip="lib" URL="https://github.com/sverweij/dependency-cruiser/blob/develop/node_modules/inquirer/lib" shape="box3d" fillcolor="#c40b0a1a" fontcolor="#c40b0a"] } }
-    subgraph "cluster_node_modules" {label="node_modules" "node_modules" [width="0.05" shape="point" style="invis"] "node_modules/lodash" [label=<lodash> tooltip="lodash" URL="https://github.com/sverweij/dependency-cruiser/blob/develop/node_modules/lodash" shape="box3d" fillcolor="#c40b0a1a" fontcolor="#c40b0a"] }
-    subgraph "cluster_node_modules" {label="node_modules" "node_modules" [width="0.05" shape="point" style="invis"] "node_modules/resolve" [label=<resolve> tooltip="resolve" URL="https://github.com/sverweij/dependency-cruiser/blob/develop/node_modules/resolve" shape="box3d" fillcolor="#c40b0a1a" fontcolor="#c40b0a"] }
-    subgraph "cluster_node_modules" {label="node_modules" "node_modules" [width="0.05" shape="point" style="invis"] "node_modules/safe-regex" [label=<safe-regex> tooltip="safe-regex" URL="https://github.com/sverweij/dependency-cruiser/blob/develop/node_modules/safe-regex" shape="box3d" fillcolor="#c40b0a1a" fontcolor="#c40b0a"] }
-    subgraph "cluster_node_modules" {label="node_modules" "node_modules" [width="0.05" shape="point" style="invis"] "node_modules/semver" [label=<semver> tooltip="semver" URL="https://github.com/sverweij/dependency-cruiser/blob/develop/node_modules/semver" shape="box3d" fillcolor="#c40b0a1a" fontcolor="#c40b0a"] }
-    subgraph "cluster_node_modules" {label="node_modules" "node_modules" [width="0.05" shape="point" style="invis"] subgraph "cluster_node_modules/semver-try-require" {label="semver-try-require" "node_modules/semver-try-require" [width="0.05" shape="point" style="invis"] "node_modules/semver-try-require/src" [label=<src> tooltip="src" URL="https://github.com/sverweij/dependency-cruiser/blob/develop/node_modules/semver-try-require/src" shape="box3d" fillcolor="#c40b0a1a" fontcolor="#c40b0a"] } }
-    subgraph "cluster_node_modules" {label="node_modules" "node_modules" [width="0.05" shape="point" style="invis"] "node_modules/strip-json-comments" [label=<strip-json-comments> tooltip="strip-json-comments" URL="https://github.com/sverweij/dependency-cruiser/blob/develop/node_modules/strip-json-comments" shape="box3d" fillcolor="#c40b0a1a" fontcolor="#c40b0a"] }
+    subgraph "cluster_node_modules" {label="node_modules" "node_modules" [width="0.05" shape="point" style="invis"] subgraph "cluster_node_modules/acorn-loose" {label="acorn-loose" "node_modules/acorn-loose" [width="0.05" shape="point" style="invis"] "node_modules/acorn-loose/dist" [label=<dist> tooltip="dist" URL="https://www.npmjs.com/package/acorn-loose" shape="box3d" fillcolor="#c40b0a1a" fontcolor="#c40b0a"] } }
+    subgraph "cluster_node_modules" {label="node_modules" "node_modules" [width="0.05" shape="point" style="invis"] subgraph "cluster_node_modules/acorn-walk" {label="acorn-walk" "node_modules/acorn-walk" [width="0.05" shape="point" style="invis"] "node_modules/acorn-walk/dist" [label=<dist> tooltip="dist" URL="https://www.npmjs.com/package/acorn-walk" shape="box3d" fillcolor="#c40b0a1a" fontcolor="#c40b0a"] } }
+    subgraph "cluster_node_modules" {label="node_modules" "node_modules" [width="0.05" shape="point" style="invis"] subgraph "cluster_node_modules/acorn" {label="acorn" "node_modules/acorn" [width="0.05" shape="point" style="invis"] "node_modules/acorn/dist" [label=<dist> tooltip="dist" URL="https://www.npmjs.com/package/acorn" shape="box3d" fillcolor="#c40b0a1a" fontcolor="#c40b0a"] } }
+    subgraph "cluster_node_modules" {label="node_modules" "node_modules" [width="0.05" shape="point" style="invis"] subgraph "cluster_node_modules/ajv" {label="ajv" "node_modules/ajv" [width="0.05" shape="point" style="invis"] "node_modules/ajv/lib" [label=<lib> tooltip="lib" URL="https://www.npmjs.com/package/ajv" shape="box3d" fillcolor="#c40b0a1a" fontcolor="#c40b0a"] } }
+    subgraph "cluster_node_modules" {label="node_modules" "node_modules" [width="0.05" shape="point" style="invis"] subgraph "cluster_node_modules/awesome-typescript-loader" {label="awesome-typescript-loader" "node_modules/awesome-typescript-loader" [width="0.05" shape="point" style="invis"] "node_modules/awesome-typescript-loader/dist" [label=<dist> tooltip="dist" URL="https://www.npmjs.com/package/awesome-typescript-loader" shape="box3d" fillcolor="#c40b0a1a" fontcolor="#c40b0a"] } }
+    subgraph "cluster_node_modules" {label="node_modules" "node_modules" [width="0.05" shape="point" style="invis"] "node_modules/chalk" [label=<chalk> tooltip="chalk" URL="https://www.npmjs.com/package/chalk" shape="box3d" fillcolor="#c40b0a1a" fontcolor="#c40b0a"] }
+    subgraph "cluster_node_modules" {label="node_modules" "node_modules" [width="0.05" shape="point" style="invis"] "node_modules/commander" [label=<commander> tooltip="commander" URL="https://www.npmjs.com/package/commander" shape="box3d" fillcolor="#c40b0a1a" fontcolor="#c40b0a"] }
+    subgraph "cluster_node_modules" {label="node_modules" "node_modules" [width="0.05" shape="point" style="invis"] subgraph "cluster_node_modules/enhanced-resolve" {label="enhanced-resolve" "node_modules/enhanced-resolve" [width="0.05" shape="point" style="invis"] "node_modules/enhanced-resolve/lib" [label=<lib> tooltip="lib" URL="https://www.npmjs.com/package/enhanced-resolve" shape="box3d" fillcolor="#c40b0a1a" fontcolor="#c40b0a"] } }
+    subgraph "cluster_node_modules" {label="node_modules" "node_modules" [width="0.05" shape="point" style="invis"] "node_modules/figures" [label=<figures> tooltip="figures" URL="https://www.npmjs.com/package/figures" shape="box3d" fillcolor="#c40b0a1a" fontcolor="#c40b0a"] }
+    subgraph "cluster_node_modules" {label="node_modules" "node_modules" [width="0.05" shape="point" style="invis"] "node_modules/glob" [label=<glob> tooltip="glob" URL="https://www.npmjs.com/package/glob" shape="box3d" fillcolor="#c40b0a1a" fontcolor="#c40b0a"] }
+    subgraph "cluster_node_modules" {label="node_modules" "node_modules" [width="0.05" shape="point" style="invis"] "node_modules/handlebars" [label=<handlebars> tooltip="handlebars" URL="https://www.npmjs.com/package/handlebars" shape="box3d" fillcolor="#c40b0a1a" fontcolor="#c40b0a"] }
+    subgraph "cluster_node_modules" {label="node_modules" "node_modules" [width="0.05" shape="point" style="invis"] subgraph "cluster_node_modules/inquirer" {label="inquirer" "node_modules/inquirer" [width="0.05" shape="point" style="invis"] "node_modules/inquirer/lib" [label=<lib> tooltip="lib" URL="https://www.npmjs.com/package/inquirer" shape="box3d" fillcolor="#c40b0a1a" fontcolor="#c40b0a"] } }
+    subgraph "cluster_node_modules" {label="node_modules" "node_modules" [width="0.05" shape="point" style="invis"] "node_modules/lodash" [label=<lodash> tooltip="lodash" URL="https://www.npmjs.com/package/lodash" shape="box3d" fillcolor="#c40b0a1a" fontcolor="#c40b0a"] }
+    subgraph "cluster_node_modules" {label="node_modules" "node_modules" [width="0.05" shape="point" style="invis"] "node_modules/resolve" [label=<resolve> tooltip="resolve" URL="https://www.npmjs.com/package/resolve" shape="box3d" fillcolor="#c40b0a1a" fontcolor="#c40b0a"] }
+    subgraph "cluster_node_modules" {label="node_modules" "node_modules" [width="0.05" shape="point" style="invis"] "node_modules/safe-regex" [label=<safe-regex> tooltip="safe-regex" URL="https://www.npmjs.com/package/safe-regex" shape="box3d" fillcolor="#c40b0a1a" fontcolor="#c40b0a"] }
+    subgraph "cluster_node_modules" {label="node_modules" "node_modules" [width="0.05" shape="point" style="invis"] "node_modules/semver" [label=<semver> tooltip="semver" URL="https://www.npmjs.com/package/semver" shape="box3d" fillcolor="#c40b0a1a" fontcolor="#c40b0a"] }
+    subgraph "cluster_node_modules" {label="node_modules" "node_modules" [width="0.05" shape="point" style="invis"] subgraph "cluster_node_modules/semver-try-require" {label="semver-try-require" "node_modules/semver-try-require" [width="0.05" shape="point" style="invis"] "node_modules/semver-try-require/src" [label=<src> tooltip="src" URL="https://www.npmjs.com/package/semver-try-require" shape="box3d" fillcolor="#c40b0a1a" fontcolor="#c40b0a"] } }
+    subgraph "cluster_node_modules" {label="node_modules" "node_modules" [width="0.05" shape="point" style="invis"] "node_modules/strip-json-comments" [label=<strip-json-comments> tooltip="strip-json-comments" URL="https://www.npmjs.com/package/strip-json-comments" shape="box3d" fillcolor="#c40b0a1a" fontcolor="#c40b0a"] }
     subgraph "cluster_src" {label="src" "src" [width="0.05" shape="point" style="invis"] "src/cli" [label=<cli> tooltip="cli" URL="https://github.com/sverweij/dependency-cruiser/blob/develop/src/cli" shape="box3d"] }
     "src/cli" -> "src/main"
     "src/cli" -> "src/cli/initConfig"
diff --git a/test/report/dot/module-level/__fixtures__/clusterless.dot b/test/report/dot/module-level/__fixtures__/clusterless.dot
index 7626b02e..3f7ced54 100644
--- a/test/report/dot/module-level/__fixtures__/clusterless.dot
+++ b/test/report/dot/module-level/__fixtures__/clusterless.dot
@@ -3,8 +3,8 @@ strict digraph "dependency-cruiser output"{
 
 
 
-    "fs" [label=<fs> tooltip="fs" ]
-    "http" [label=<http> tooltip="http" ]
+    "fs" [label=<fs> tooltip="fs" URL="https://nodejs.org/api/fs.html" ]
+    "http" [label=<http> tooltip="http" URL="https://nodejs.org/api/http.html" ]
     subgraph "cluster_node_modules" {label="node_modules" subgraph "cluster_node_modules/somemodule" {label="somemodule" subgraph "cluster_node_modules/somemodule/node_modules" {label="node_modules" subgraph "cluster_node_modules/somemodule/node_modules/someothermodule" {label="someothermodule" "node_modules/somemodule/node_modules/someothermodule/main.js" [label=<main.js> tooltip="main.js" URL="node_modules/somemodule/node_modules/someothermodule/main.js" ] } } } }
     subgraph "cluster_node_modules" {label="node_modules" subgraph "cluster_node_modules/somemodule" {label="somemodule" subgraph "cluster_node_modules/somemodule/src" {label="src" "node_modules/somemodule/src/moar-javascript.js" [label=<moar-javascript.js> tooltip="moar-javascript.js" URL="node_modules/somemodule/src/moar-javascript.js" ] } } }
     subgraph "cluster_node_modules" {label="node_modules" subgraph "cluster_node_modules/somemodule" {label="somemodule" subgraph "cluster_node_modules/somemodule/src" {label="src" "node_modules/somemodule/src/somemodule.js" [label=<somemodule.js> tooltip="somemodule.js" URL="node_modules/somemodule/src/somemodule.js" ] } } }
@@ -14,7 +14,7 @@ strict digraph "dependency-cruiser output"{
     "one_only_one.js" -> "path" [xlabel="unnamed" tooltip="unnamed" ]
     "one_only_two.js" [label=<one_only_two.js> tooltip="one_only_two.js" URL="one_only_two.js" ]
     "one_only_two.js" -> "path" [xlabel="unnamed" tooltip="unnamed" ]
-    "path" [label=<path> tooltip="path" ]
+    "path" [label=<path> tooltip="path" URL="https://nodejs.org/api/path.html" ]
     "root_one.js" [label=<root_one.js> tooltip="root_one.js" URL="root_one.js" ]
     "root_one.js" -> "one_only_one.js" [xlabel="unnamed" tooltip="unnamed" ]
     "root_one.js" -> "one_only_two.js" [xlabel="unnamed" tooltip="unnamed" ]
diff --git a/test/report/dot/module-level/__mocks__/clusterless.json b/test/report/dot/module-level/__mocks__/clusterless.json
index 7b3b308d..3d0b4cd0 100644
--- a/test/report/dot/module-level/__mocks__/clusterless.json
+++ b/test/report/dot/module-level/__mocks__/clusterless.json
@@ -69,7 +69,8 @@
       "valid": true,
       "followable": false,
       "coreModule": true,
-      "dependencies": []
+      "dependencies": [],
+      "dependencyTypes": ["core"]
     },
     {
       "source": "one_only_two.js",
@@ -185,7 +186,8 @@
       "valid": true,
       "followable": false,
       "coreModule": true,
-      "dependencies": []
+      "dependencies": [],
+      "dependencyTypes": ["core"]
     },
     {
       "source": "shared.js",
@@ -336,7 +338,8 @@
       "valid": true,
       "followable": false,
       "coreModule": true,
-      "dependencies": []
+      "dependencies": [],
+      "dependencyTypes": ["core"]
     },
     {
       "source": "two_only_one.js",
diff --git a/test/report/utl/index.spec.mjs b/test/report/utl/index.spec.mjs
new file mode 100644
index 00000000..5d35b851
--- /dev/null
+++ b/test/report/utl/index.spec.mjs
@@ -0,0 +1,57 @@
+import { deepEqual } from "node:assert/strict";
+import { getURLForModule } from "#report/utl/index.mjs";
+
+describe("[I] report/utl/index - getURLForModule", () => {
+  it("returns the correct URL for a core module", () => {
+    const lModule = {
+      source: "fs",
+      dependencyTypes: ["core"],
+    };
+    const lExpectedURL = "https://nodejs.org/api/fs.html";
+    deepEqual(getURLForModule(lModule), lExpectedURL);
+  });
+
+  it("returns the correct URL for an external module", () => {
+    const lModule = {
+      source: "node_modules/lodash/yadda/yadda.js",
+      dependencyTypes: ["npm"],
+    };
+    const lExpectedURL = "https://www.npmjs.com/package/lodash";
+    deepEqual(getURLForModule(lModule), lExpectedURL);
+  });
+
+  it("returns the correct URL for a scoped external module", () => {
+    const lModule = {
+      source: "node_modules/@org/beep/index.js",
+      dependencyTypes: ["npm"],
+    };
+    const lExpectedURL = "https://www.npmjs.com/package/@org/beep";
+    deepEqual(getURLForModule(lModule), lExpectedURL);
+  });
+
+  it("if it's not in node_modules it's not finding the package name, even though it might be an 'npm' dependencyType", () => {
+    const lModule = {
+      source: "claude_modules/beep/index.js",
+      dependencyTypes: ["npm"],
+    };
+    const lExpectedURL = "https://www.npmjs.com/package/";
+    deepEqual(getURLForModule(lModule), lExpectedURL);
+  });
+
+  it("returns the correct URL with a prefix", () => {
+    const lModule = {
+      source: "src/index.js",
+    };
+    const lPrefix = "https://github.com/username/repo/blob/main/";
+    const lExpectedURL =
+      "https://github.com/username/repo/blob/main/src/index.js";
+    deepEqual(getURLForModule(lModule, lPrefix), lExpectedURL);
+  });
+
+  it("returns the source when no URL can be derived", () => {
+    const lModule = {
+      source: "src/index.js",
+    };
+    deepEqual(getURLForModule(lModule), lModule.source);
+  });
+});