Repository: sverweij/dependency-cruiser
Pull request #983 (2025-02-09 20:12:10)

feat(extract): improves detection of protocol-only builtins (node & bun)
## Description

- uses `modules.isBuiltin` instead of the `modules.builtinModules` array, so throwing in builtin modules wih protocol prefixes doesn't need any pre- or post processing anymore. We can do this since now all versions of node we support have the `modules.isBuiltin` method available.
- adds `bun:` to the list of protocols we recognize.
- leaves the protocol with the module name for builtins that are only available with the protocol in the name (e.g. node:test, node:sqlite, node:sea or bun:ffi).

- [x] To decide - previously if you had both `fs` and `node:fs` in your source code, both would map to `fs`, nicely de-duplicating them. 
	- [x] Should we bring that back somehow? Yes
	- [x] If we don't are we going to run into backwards compatibility issues (and how serious might these be)? We will, not sure how much
	- [x] Can we mitigate? Yes - implemented in this PR

## Motivation and Context

- Makes it easier to support new builtin modules that follow the new trend of being protocol-prefixed only.
- Now out of the box supports recognizing node:sqlite, node:sea, node:test/reporters as core modules.
- Adds support for recognizing modules prototype-prefixed with `bun:` (which is relevant if you use dependency-cruiser with bun).

## How Has This Been Tested?

- [x] green ci
- [x] updated and additional automated non-regression 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).


codeclimate[bot]: Code Climate has analyzed commit 70b8ce14 and detected **0 issues** on this pull request.

View more on [Code Climate](https://codeclimate.com/github/sverweij/dependency-cruiser/pull/983).

sonarqubecloud[bot]: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=sverweij_dependency-cruiser&pullRequest=983) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=sverweij_dependency-cruiser&pullRequest=983&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=sverweij_dependency-cruiser&pullRequest=983&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=sverweij_dependency-cruiser&pullRequest=983&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=sverweij_dependency-cruiser&pullRequest=983&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=sverweij_dependency-cruiser&pullRequest=983&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=sverweij_dependency-cruiser&pullRequest=983)


codeclimate[bot]: Code Climate has analyzed commit a13026ea and detected **0 issues** on this pull request.

View more on [Code Climate](https://codeclimate.com/github/sverweij/dependency-cruiser/pull/983).

sonarqubecloud[bot]: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=sverweij_dependency-cruiser&pullRequest=983) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=sverweij_dependency-cruiser&pullRequest=983&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=sverweij_dependency-cruiser&pullRequest=983&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=sverweij_dependency-cruiser&pullRequest=983&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=sverweij_dependency-cruiser&pullRequest=983&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=sverweij_dependency-cruiser&pullRequest=983&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=sverweij_dependency-cruiser&pullRequest=983)


sonarqubecloud[bot]: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=sverweij_dependency-cruiser&pullRequest=983) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=sverweij_dependency-cruiser&pullRequest=983&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=sverweij_dependency-cruiser&pullRequest=983&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=sverweij_dependency-cruiser&pullRequest=983&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=sverweij_dependency-cruiser&pullRequest=983&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=sverweij_dependency-cruiser&pullRequest=983&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=sverweij_dependency-cruiser&pullRequest=983)


codeclimate[bot]: Code Climate has analyzed commit 55247753 and detected **0 issues** on this pull request.

View more on [Code Climate](https://codeclimate.com/github/sverweij/dependency-cruiser/pull/983).

codeclimate[bot]: Code Climate has analyzed commit c94727f6 and detected **0 issues** on this pull request.

View more on [Code Climate](https://codeclimate.com/github/sverweij/dependency-cruiser/pull/983).

sonarqubecloud[bot]: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=sverweij_dependency-cruiser&pullRequest=983) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=sverweij_dependency-cruiser&pullRequest=983&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=sverweij_dependency-cruiser&pullRequest=983&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=sverweij_dependency-cruiser&pullRequest=983&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=sverweij_dependency-cruiser&pullRequest=983&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=sverweij_dependency-cruiser&pullRequest=983&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=sverweij_dependency-cruiser&pullRequest=983)



Patch:
diff --git a/src/extract/helpers.mjs b/src/extract/helpers.mjs
index bca2be48..ae055f66 100644
--- a/src/extract/helpers.mjs
+++ b/src/extract/helpers.mjs
@@ -28,6 +28,37 @@ export function detectPreCompilationNess(pTSDependencies, pJSDependencies) {
   );
 }
 
+const PROTOCOL_ONLY_BUILTINS = new Set([
+  // module.builtinModules.filter(m=>m.startsWith('node:'))
+  "node:sea",
+  "node:sqlite",
+  "node:test",
+  "node:test/reporters",
+  // module.builtinModules.filter(m=>m.startsWith('bun:'))
+  "bun:ffi",
+  "bun:jsc",
+  "bun:sqlite",
+  "bun:test",
+  "bun:wrap",
+]);
+
+/**
+ * Given a module name returns the canonical module name.
+ * @param {string} pProtocol
+ * @param {string} pModuleName
+ * @returns {string}
+ */
+function getCanonicalModuleName(pModuleName, pProtocol) {
+  const lModuleWithProtocol = pProtocol + pModuleName;
+  const lIsJsIshProtocol = pProtocol === "node:" || pProtocol === "bun:";
+  const lIsProtocolOnlyModule = PROTOCOL_ONLY_BUILTINS.has(lModuleWithProtocol);
+
+  if (!lIsJsIshProtocol || lIsProtocolOnlyModule) {
+    return lModuleWithProtocol;
+  }
+  return pModuleName;
+}
+
 /**
  * Given a module string returns in an object
  * - the module name
@@ -42,15 +73,19 @@ export function detectPreCompilationNess(pTSDependencies, pJSDependencies) {
  * @param {string} pString
  * @returns {{module:string; protocol?:string; mimeType?:string}}
  */
+// eslint-disable-next-line complexity
 export function extractModuleAttributes(pString) {
   let lReturnValue = { module: pString };
   const lModuleAttributes = pString.match(
     // eslint-disable-next-line security/detect-unsafe-regex
-    /^(?<protocol>node:|file:|data:)(?:(?<mimeType>[^,]+),)?(?<module>.+)$/,
+    /^(?<protocol>node:|file:|data:|bun:)(?:(?<mimeType>[^,]+),)?(?<module>.+)$/,
   );
 
   if (lModuleAttributes?.groups) {
-    lReturnValue.module = lModuleAttributes?.groups.module;
+    lReturnValue.module = getCanonicalModuleName(
+      lModuleAttributes?.groups.module,
+      lModuleAttributes?.groups.protocol,
+    );
     if (lModuleAttributes?.groups?.protocol) {
       lReturnValue.protocol = lModuleAttributes.groups.protocol;
     }
@@ -77,6 +112,7 @@ export function stripQueryParameters(pFilenameString) {
   // deprecated, hence this funky RE replace. And accompanying unit test :-/
   return pFilenameString.replace(/\?.+$/, "");
 }
+
 /**
  * Returns true if the file name has a TypeScript compatible extension
  * @param {string} pFileName
diff --git a/src/extract/resolve/is-built-in.mjs b/src/extract/resolve/is-built-in.mjs
index b3af740c..fbf1615f 100644
--- a/src/extract/resolve/is-built-in.mjs
+++ b/src/extract/resolve/is-built-in.mjs
@@ -1,28 +1,30 @@
-import { builtinModules } from "node:module";
-
-function getBuiltIns(pResolveOptions) {
-  // builtinModules does not expose all builtin modules for #reasons -
-  // see https://github.com/nodejs/node/issues/42785. In stead we could use
-  // isBuiltin, but that is not available in node 16.14, the lowest version
-  // of node dependency-cruiser currently supports. So we add the missing
-  // modules here.
-  let lReturnValue = builtinModules.concat(["test", "node:test"]);
-
-  if (pResolveOptions?.builtInModules?.override) {
-    lReturnValue = pResolveOptions?.builtInModules?.override;
-  }
-  if (pResolveOptions?.builtInModules?.add) {
-    lReturnValue = lReturnValue.concat(pResolveOptions.builtInModules.add);
-  }
-  return lReturnValue;
-}
+import { isBuiltin as moduleIsBuiltin } from "node:module";
 
 /**
- *
  * @param {string} pModuleName - the unresolved module name
  * @param {*} pResolveOptions
  * @returns {boolean} - true if the module is a built-in module
  */
 export function isBuiltin(pModuleName, pResolveOptions) {
-  return getBuiltIns(pResolveOptions).includes(pModuleName);
+  if (pResolveOptions?.builtInModules?.override) {
+    return pResolveOptions.builtInModules.override.includes(pModuleName);
+  }
+
+  // bun, as it turns out, has some additional builtin modules. Even when running
+  // dependency-cruiser with bunx, bunx will use node by default. To cover that scenario
+  // there's three options
+  // - tell everyone to run bunx with the --bun option, so bun uses bun instead of node
+  //   and hope everyone actually does that and it doesn't lead to a bunch of questions
+  //   in the issues section. I don't expect that to happen anytime soon => other options
+  // - add the bun builtins here.
+  //   this sounds attractive, but some of the modules ('undici', 'ws') are
+  //   also npm packages. In nodejs context that will lead to a.o. false
+  //   classifications.
+  // - add the bun builtins in dependency-cruiser.js
+  //   Current approach. The --init command will try to detect whether it's
+  //   in a bun repo and add the bun builtins to the config.
+  return (
+    moduleIsBuiltin(pModuleName) ||
+    (pResolveOptions?.builtInModules?.add ?? []).includes(pModuleName)
+  );
 }


Test patch:
diff --git a/test/extract/helpers-extract-module-attributes.spec.mjs b/test/extract/helpers-extract-module-attributes.spec.mjs
index dfbc2ad2..90ce1969 100644
--- a/test/extract/helpers-extract-module-attributes.spec.mjs
+++ b/test/extract/helpers-extract-module-attributes.spec.mjs
@@ -15,7 +15,28 @@ describe("[U] extract/helpers - extractModuleAttributes", () => {
     });
   });
 
-  it("leaves things alone the protocol is unknown", () => {
+  it("extracts the protocol if there is one (bun)", () => {
+    deepEqual(extractModuleAttributes("bun:fs"), {
+      module: "fs",
+      protocol: "bun:",
+    });
+  });
+
+  it("extracts the protocol if there is one and leaves protocol in the name if no protocol-less variant exists (node)", () => {
+    deepEqual(extractModuleAttributes("node:sea"), {
+      module: "node:sea",
+      protocol: "node:",
+    });
+  });
+
+  it("extracts the protocol if there is one and leaves protocol in the name if no protocol-less variant exists (bun)", () => {
+    deepEqual(extractModuleAttributes("bun:ffi"), {
+      module: "bun:ffi",
+      protocol: "bun:",
+    });
+  });
+
+  it("leaves things alone when the protocol is unknown", () => {
     deepEqual(extractModuleAttributes("nod:fs"), {
       module: "nod:fs",
     });
@@ -29,7 +50,7 @@ describe("[U] extract/helpers - extractModuleAttributes", () => {
 
   it("extracts both protocol and mimeType when they're in the URI", () => {
     deepEqual(extractModuleAttributes("data:application/json,gegevens.json"), {
-      module: "gegevens.json",
+      module: "data:gegevens.json",
       protocol: "data:",
       mimeType: "application/json",
     });
@@ -37,7 +58,7 @@ describe("[U] extract/helpers - extractModuleAttributes", () => {
 
   it("handles emtpy mimeTypes gracefulley", () => {
     deepEqual(extractModuleAttributes("data:,gegevens.json"), {
-      module: ",gegevens.json",
+      module: "data:,gegevens.json",
       protocol: "data:",
     });
   });
@@ -50,7 +71,7 @@ describe("[U] extract/helpers - extractModuleAttributes", () => {
 
   it("when protocol separator is mistyped, returns it as part of the module name", () => {
     deepEqual(extractModuleAttributes("data:application/json;gegevens.json"), {
-      module: "application/json;gegevens.json",
+      module: "data:application/json;gegevens.json",
       protocol: "data:",
     });
   });
diff --git a/test/extract/resolve/index.general.spec.mjs b/test/extract/resolve/index.general.spec.mjs
index 60985727..1688074d 100644
--- a/test/extract/resolve/index.general.spec.mjs
+++ b/test/extract/resolve/index.general.spec.mjs
@@ -74,11 +74,13 @@ describe("[I] extract/resolve/index - general", () => {
     );
   });
 
-  it("resolves the 'test'  core module as core module", () => {
+  it("resolves the 'node:test'  core module as core module", () => {
+    // 'test' is not a core module. node:test is and it's not an alias or something for 'test'
+    // issue is the same in bun with bun:test
     deepEqual(
       resolve(
         {
-          module: "test",
+          module: "node:test",
           moduleSystem: "es6",
         },
         join(__dirname, "__mocks__"),
@@ -90,7 +92,7 @@ describe("[I] extract/resolve/index - general", () => {
         couldNotResolve: false,
         dependencyTypes: ["core"],
         followable: false,
-        resolved: "test",
+        resolved: "node:test",
       },
     );
   });
diff --git a/test/extract/resolve/is-built-in.spec.mjs b/test/extract/resolve/is-built-in.spec.mjs
index 2408fe86..7cfd6a2f 100644
--- a/test/extract/resolve/is-built-in.spec.mjs
+++ b/test/extract/resolve/is-built-in.spec.mjs
@@ -33,6 +33,21 @@ describe("[U] extract/resolve/is-built-in - isBuiltIn", () => {
     equal(lResult, true);
   });
 
+  it("should override default built-ins with custom built-ins", () => {
+    const lModuleName = "overridden";
+    const lResolveOptions = {
+      builtInModules: {
+        override: [lModuleName],
+      },
+    };
+
+    const lResultOverridden = isBuiltin(lModuleName, lResolveOptions);
+    const lResultForFs = isBuiltin("fs", lResolveOptions);
+
+    equal(lResultOverridden, true);
+    equal(lResultForFs, false);
+  });
+
   it("should override default built-ins with custom built-ins if both are provided", () => {
     const lModuleName = "overridden";
     const lResolveOptions = {