Repository: sverweij/dependency-cruiser
Pull request #939 (2024-06-28 19:22:42)

refactor: pulls semver-try-require functionality in & removes the dependency
## Description

- pulls in the try-import and try-require functions from semver-try-require
- removes the semver-try-require dependency

## Motivation and Context

semver-try-require is only used by dependency-cruiser. Although it _might_ have been used by other packages it isn't. Maintaining semver-try-require as a separate package introduces overhead that isn't warranted => should simplify releasing dependency-cruiser.

As a separate action semver-try-require will be deprecated.

## How Has This Been Tested?

- [x] green ci
- [x] additional unit tests


## Types of changes

- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] Documentation only change
- [x] Refactor (non-breaking change which fixes an issue without changing functionality)
- [ ] 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 e457a1f9 and detected **0 issues** on this pull request.

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

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

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

socket-security[bot]: <!-- overview-comment -->
**Removed dependencies detected.** Learn more about [Socket for GitHub ↗︎](https://socket.dev?utm_medium=gh)

**🚮 Removed packages:** [npm/semver-try-require@7.0.0](https://socket.dev/npm/package/semver-try-require/overview/7.0.0)


[**View full report↗︎**](https://socket.dev/dashboard/org/sverweij/diff/dce8144a-0f27-447d-bf77-0b265c3143af/f4a2aefa-cb98-495b-a50a-4edbfef45405)

Patch:
diff --git a/.dependency-cruiser-known-violations.json b/.dependency-cruiser-known-violations.json
index 792dad8e..c893c35b 100644
--- a/.dependency-cruiser-known-violations.json
+++ b/.dependency-cruiser-known-violations.json
@@ -1,4 +1,49 @@
 [
+  {
+    "type": "dependency",
+    "from": "test/utl/try-import.spec.mjs",
+    "to": "test/utl/node_modules/beta/index.js",
+    "rule": {
+      "severity": "error",
+      "name": "no-non-package-json"
+    }
+  },
+  {
+    "type": "dependency",
+    "from": "test/utl/try-import.spec.mjs",
+    "to": "test/utl/node_modules/no-default-export/main.mjs",
+    "rule": {
+      "severity": "error",
+      "name": "no-non-package-json"
+    }
+  },
+  {
+    "type": "dependency",
+    "from": "test/utl/try-import.spec.mjs",
+    "to": "test/utl/node_modules/rc/index.js",
+    "rule": {
+      "severity": "error",
+      "name": "no-non-package-json"
+    }
+  },
+  {
+    "type": "dependency",
+    "from": "test/utl/try-require.spec.cjs",
+    "to": "test/utl/node_modules/beta/index.js",
+    "rule": {
+      "severity": "error",
+      "name": "no-non-package-json"
+    }
+  },
+  {
+    "type": "dependency",
+    "from": "test/utl/try-require.spec.cjs",
+    "to": "test/utl/node_modules/rc/index.js",
+    "rule": {
+      "severity": "error",
+      "name": "no-non-package-json"
+    }
+  },
   {
     "type": "module",
     "from": "src/graph-utl/consolidate-to-folder.mjs",
@@ -46,8 +91,8 @@
   },
   {
     "type": "module",
-    "from": "src/utl/array-util.mjs",
-    "to": "src/utl/array-util.mjs",
+    "from": "src/utl/extract-root-module-name.cjs",
+    "to": "src/utl/extract-root-module-name.cjs",
     "rule": {
       "severity": "info",
       "name": "utl-module-not-shared-enough"
@@ -62,6 +107,15 @@
       "name": "utl-module-not-shared-enough"
     }
   },
+  {
+    "type": "module",
+    "from": "src/utl/try-require.cjs",
+    "to": "src/utl/try-require.cjs",
+    "rule": {
+      "severity": "info",
+      "name": "utl-module-not-shared-enough"
+    }
+  },
   {
     "type": "module",
     "from": "src/utl/wrap-and-indent.mjs",
diff --git a/.dependency-cruiser.json b/.dependency-cruiser.json
index 01620e89..cbaf6747 100644
--- a/.dependency-cruiser.json
+++ b/.dependency-cruiser.json
@@ -36,7 +36,14 @@
     {
       "name": "not-to-unresolvable",
       "comment": "This module tried to depend on something that can't be resolved to disk. Revise yer code",
-      "from": {},
+      "from": {
+        "pathNot": [
+          // those import 'local node_modules' which, on e.g. yarn berry are not
+          // resolvable. Still ok, though, as for the (test) purpose & under e.g.
+          // npm they still work 100% ok
+          "^test/utl/(?:try-require[.]spec[.]cjs|try-import[.]spec[.]mjs)$"
+        ]
+      },
       "to": {
         "couldNotResolve": true,
         "exoticallyRequired": false
diff --git a/package-lock.json b/package-lock.json
index e8686b2b..01e03f33 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -31,7 +31,6 @@
         "rechoir": "^0.8.0",
         "safe-regex": "2.1.1",
         "semver": "^7.6.2",
-        "semver-try-require": "7.0.0",
         "teamcity-service-messages": "0.1.14",
         "tsconfig-paths-webpack-plugin": "4.1.0",
         "watskeburt": "4.0.2",
@@ -5957,17 +5956,6 @@
         "node": ">=10"
       }
     },
-    "node_modules/semver-try-require": {
-      "version": "7.0.0",
-      "resolved": "https://registry.npmjs.org/semver-try-require/-/semver-try-require-7.0.0.tgz",
-      "integrity": "sha512-LI7GzDuAZmNKOY0/LY4nB3ifh6kYMvBimFTHVpA6wNEl3gw59QrLbTAnJb7vQzPd1qXPz+BtKJZaYORXWMerrA==",
-      "dependencies": {
-        "semver": "^7.6.0"
-      },
-      "engines": {
-        "node": "^18.17||>=20"
-      }
-    },
     "node_modules/serialize-javascript": {
       "version": "6.0.0",
       "dev": true,
diff --git a/package.json b/package.json
index 80af7426..63e8f97a 100644
--- a/package.json
+++ b/package.json
@@ -59,6 +59,7 @@
     "electrovir (https://github.com/electrovir)",
     "fusheng (https://github.com/lin-hun)"
   ],
+  "type": "commonjs",
   "license": "MIT",
   "repository": {
     "type": "git",
@@ -224,7 +225,6 @@
     "rechoir": "^0.8.0",
     "safe-regex": "2.1.1",
     "semver": "^7.6.2",
-    "semver-try-require": "7.0.0",
     "teamcity-service-messages": "0.1.14",
     "tsconfig-paths-webpack-plugin": "4.1.0",
     "watskeburt": "4.0.2",
diff --git a/src/config-utl/extract-babel-config.mjs b/src/config-utl/extract-babel-config.mjs
index f8cef5b0..59d8b8c2 100644
--- a/src/config-utl/extract-babel-config.mjs
+++ b/src/config-utl/extract-babel-config.mjs
@@ -2,8 +2,8 @@ import { readFile } from "node:fs/promises";
 
 import { extname } from "node:path";
 import json5 from "json5";
-import tryImport from "semver-try-require";
 import makeAbsolute from "./make-absolute.mjs";
+import tryImport from "#utl/try-import.mjs";
 import meta from "#meta.cjs";
 
 async function getJSConfig(pBabelConfigFileName) {
diff --git a/src/config-utl/extract-ts-config.mjs b/src/config-utl/extract-ts-config.mjs
index 501e0e59..d135067b 100644
--- a/src/config-utl/extract-ts-config.mjs
+++ b/src/config-utl/extract-ts-config.mjs
@@ -1,5 +1,5 @@
 import { dirname, resolve } from "node:path";
-import tryImport from "semver-try-require";
+import tryImport from "#utl/try-import.mjs";
 import meta from "#meta.cjs";
 
 const typescript = await tryImport(
diff --git a/src/extract/swc/dependency-visitor.mjs b/src/extract/swc/dependency-visitor.mjs
index b0f36ba5..eb402107 100644
--- a/src/extract/swc/dependency-visitor.mjs
+++ b/src/extract/swc/dependency-visitor.mjs
@@ -1,6 +1,6 @@
 /* eslint-disable no-inline-comments */
 /* eslint-disable max-classes-per-file */
-import tryImport from "semver-try-require";
+import tryImport from "#utl/try-import.mjs";
 import meta from "#meta.cjs";
 
 /** @type {import('@swc/core/Visitor')} */
diff --git a/src/extract/swc/parse.mjs b/src/extract/swc/parse.mjs
index 2ee464c8..8c68cb07 100644
--- a/src/extract/swc/parse.mjs
+++ b/src/extract/swc/parse.mjs
@@ -1,5 +1,5 @@
-import tryImport from "semver-try-require";
 import memoize, { memoizeClear } from "memoize";
+import tryImport from "#utl/try-import.mjs";
 import meta from "#meta.cjs";
 
 /** @type {import('@swc/core')} */
diff --git a/src/extract/transpile/babel-wrap.mjs b/src/extract/transpile/babel-wrap.mjs
index 59955eb4..207568ee 100644
--- a/src/extract/transpile/babel-wrap.mjs
+++ b/src/extract/transpile/babel-wrap.mjs
@@ -1,4 +1,4 @@
-import tryImport from "semver-try-require";
+import tryImport from "#utl/try-import.mjs";
 import meta from "#meta.cjs";
 
 const babel = await tryImport("@babel/core", meta.supportedTranspilers.babel);
diff --git a/src/extract/transpile/coffeescript-wrap.mjs b/src/extract/transpile/coffeescript-wrap.mjs
index 4e96387c..5890674e 100644
--- a/src/extract/transpile/coffeescript-wrap.mjs
+++ b/src/extract/transpile/coffeescript-wrap.mjs
@@ -1,4 +1,4 @@
-import tryImport from "semver-try-require";
+import tryImport from "#utl/try-import.mjs";
 import meta from "#meta.cjs";
 
 /*
diff --git a/src/extract/transpile/livescript-wrap.mjs b/src/extract/transpile/livescript-wrap.mjs
index 4567c245..af5e74ad 100644
--- a/src/extract/transpile/livescript-wrap.mjs
+++ b/src/extract/transpile/livescript-wrap.mjs
@@ -1,4 +1,4 @@
-import tryImport from "semver-try-require";
+import tryImport from "#utl/try-import.mjs";
 import meta from "#meta.cjs";
 
 const livescript = await tryImport(
diff --git a/src/extract/transpile/svelte-wrap.mjs b/src/extract/transpile/svelte-wrap.mjs
index 741ce674..fb48b130 100644
--- a/src/extract/transpile/svelte-wrap.mjs
+++ b/src/extract/transpile/svelte-wrap.mjs
@@ -1,5 +1,5 @@
-import tryImport from "semver-try-require";
 import preProcess from "./svelte-preprocess.mjs";
+import tryImport from "#utl/try-import.mjs";
 import meta from "#meta.cjs";
 
 const { compile } = await tryImport(
diff --git a/src/extract/transpile/typescript-wrap.mjs b/src/extract/transpile/typescript-wrap.mjs
index cd87afbb..016b03b0 100644
--- a/src/extract/transpile/typescript-wrap.mjs
+++ b/src/extract/transpile/typescript-wrap.mjs
@@ -1,4 +1,4 @@
-import tryImport from "semver-try-require";
+import tryImport from "#utl/try-import.mjs";
 import meta from "#meta.cjs";
 
 const typescript = await tryImport(
diff --git a/src/extract/transpile/vue-template-wrap.cjs b/src/extract/transpile/vue-template-wrap.cjs
index 2c053007..e8b502e9 100644
--- a/src/extract/transpile/vue-template-wrap.cjs
+++ b/src/extract/transpile/vue-template-wrap.cjs
@@ -1,6 +1,6 @@
 const { EOL } = require("node:os");
 const isEmpty = require("lodash/isEmpty");
-const tryRequire = require("semver-try-require");
+const tryRequire = require("#utl/try-require.cjs");
 const meta = require("#meta.cjs");
 
 /*
diff --git a/src/extract/tsc/extract-typescript-deps.mjs b/src/extract/tsc/extract-typescript-deps.mjs
index 351485b5..64035288 100644
--- a/src/extract/tsc/extract-typescript-deps.mjs
+++ b/src/extract/tsc/extract-typescript-deps.mjs
@@ -1,6 +1,6 @@
 /* eslint-disable max-lines */
 /* eslint-disable no-inline-comments */
-import tryImport from "semver-try-require";
+import tryImport from "#utl/try-import.mjs";
 import meta from "#meta.cjs";
 
 /** @type {import("typescript")} */
diff --git a/src/extract/tsc/parse.mjs b/src/extract/tsc/parse.mjs
index 7552fa39..1a031f52 100644
--- a/src/extract/tsc/parse.mjs
+++ b/src/extract/tsc/parse.mjs
@@ -1,7 +1,7 @@
 import { readFileSync } from "node:fs";
-import tryImport from "semver-try-require";
 import memoize, { memoizeClear } from "memoize";
 import transpile from "../transpile/index.mjs";
+import tryImport from "#utl/try-import.mjs";
 import meta from "#meta.cjs";
 import getExtension from "#utl/get-extension.mjs";
 
diff --git a/src/utl/extract-root-module-name.cjs b/src/utl/extract-root-module-name.cjs
new file mode 100644
index 00000000..7c178283
--- /dev/null
+++ b/src/utl/extract-root-module-name.cjs
@@ -0,0 +1,23 @@
+const LOCAL_MODULE_RE = /^[.]{1,2}($|\/.*)/g;
+const ABSOLUTE_MODULE_RE = /^\/.*/g;
+
+const PACKAGE_RE = "[^/]+";
+const SCOPED_PACKAGE_RE = "@[^/]+(/[^/]+)";
+const ROOT_MODULE_RE = new RegExp(`^(${SCOPED_PACKAGE_RE}|${PACKAGE_RE})`, "g");
+
+/**
+ * returns the module name that likely contains the package.json
+ *
+ * @param {string} pModuleName module name string as you'd require it
+ * @returns {string|undefined} the module name that likely contains the package.json
+ */
+module.exports = function extractRootModuleName(pModuleName) {
+  if (
+    pModuleName.match(LOCAL_MODULE_RE) ||
+    pModuleName.match(ABSOLUTE_MODULE_RE)
+  ) {
+    return pModuleName;
+  } else {
+    return (pModuleName.match(ROOT_MODULE_RE) || []).shift();
+  }
+};
diff --git a/src/utl/try-import.mjs b/src/utl/try-import.mjs
new file mode 100644
index 00000000..dc9f1a22
--- /dev/null
+++ b/src/utl/try-import.mjs
@@ -0,0 +1,67 @@
+import { join } from "node:path/posix";
+import { createRequire } from "node:module";
+import { coerce, satisfies } from "semver";
+import extractRootModuleName from "./extract-root-module-name.cjs";
+
+const require = createRequire(import.meta.url);
+
+/**
+ * @throws {Error}
+ * @param {string} pModuleName
+ * @returns {string}
+ */
+function getVersion(pModuleName) {
+  // // The 'proper' way to do this would be with a dynamic import with an
+  // // import assertion. Because it's 'experimental' since node 16 and prints
+  // // an ugly warning on stderr since node 19 we'll be using the require
+  // // hack below in stead.
+  // const lManifest = await import(
+  //   // @ts-expect-error TS2345 extractRootModuleName can return either a string or
+  //   // undefined. If undefined this function will throw. Which is _fine_, even
+  //   // _expected_ in the context it's currently used
+  //   path.join(extractRootModuleName(pModuleName), "package.json"),
+  //   { assert: { type: "json" } }
+  // );
+  // // changes the return type to Promise<string>
+  // return lManifest.default.version;
+  // eslint-disable-next-line import/no-dynamic-require, security/detect-non-literal-require
+  const lManifest = require(
+    join(
+      // @ts-expect-error TS2345 extractRootModuleName can return either a string or
+      // undefined. If undefined this function will throw. Which is _fine_, even
+      // _expected_ in the context it's currently used
+      extractRootModuleName(pModuleName),
+      "package.json",
+    ),
+  );
+  return lManifest.version;
+}
+
+/**
+ * Tries to import a module and optionally checks its version.
+ *
+ * @param {string} pModuleName - The name of the module to import.
+ * @param {string} [pSemanticVersion] - An semantic version to check against.
+ * @returns {Promise<NodeModule | false>} - The imported module or false if the import fails or the version does not satisfy the provided semantic version.
+ */
+
+// eslint-disable-next-line complexity
+export default async function tryImport(pModuleName, pSemanticVersion) {
+  try {
+    if (pSemanticVersion) {
+      const lVersion = getVersion(pModuleName);
+      const lCoerced = coerce(lVersion);
+      if (
+        lVersion &&
+        lCoerced &&
+        !satisfies(lCoerced.version, pSemanticVersion)
+      ) {
+        return false;
+      }
+    }
+    const lModule = await import(pModuleName);
+    return lModule.default ? lModule.default : lModule;
+  } catch (pError) {
+    return false;
+  }
+}
diff --git a/src/utl/try-require.cjs b/src/utl/try-require.cjs
new file mode 100644
index 00000000..d08dae61
--- /dev/null
+++ b/src/utl/try-require.cjs
@@ -0,0 +1,51 @@
+const { join } = require("node:path");
+const satisfies = require("semver/functions/satisfies");
+const coerce = require("semver/functions/coerce");
+const extractRootModuleName = require("./extract-root-module-name.cjs");
+
+/**
+ * @throws {Error}
+ * @param pModuleName the name of the module to get the version for
+ * @return the version of the module identified by pModuleName
+ */
+function getVersion(pModuleName) {
+  // @ts-expect-error TS2345 extractRootModuleName can return either a string or
+  // undefined. If undefined this function will throw. Which is _fine_, even
+  // _expected_ in the context it's currently used
+  // eslint-disable-next-line import/no-dynamic-require, node/global-require, security/detect-non-literal-require
+  return require(join(extractRootModuleName(pModuleName), "package.json"))
+    .version;
+}
+
+/**
+ * returns the (resolved) module identified by pModuleName:
+ * - if it is available, and
+ * - it satisfies the semantic version range specified by pSemVer
+ *
+ * returns false in all other cases
+ *
+ * @param {string} pModuleName      the name of the module to resolve
+ * @param {string} [pSemanticVersion] (optional) a semantic version (range)
+ * @return {NodeModule | false }the (resolved) module identified by pModuleName or false
+ */
+function tryRequire(pModuleName, pSemanticVersion) {
+  try {
+    if (pSemanticVersion) {
+      const lVersion = getVersion(pModuleName);
+      const lCoerced = coerce(lVersion);
+      if (
+        lVersion &&
+        lCoerced &&
+        !satisfies(lCoerced.version, pSemanticVersion)
+      ) {
+        return false;
+      }
+    }
+    // eslint-disable-next-line import/no-dynamic-require, node/global-require, security/detect-non-literal-require
+    return require(pModuleName);
+  } catch (pError) {
+    return false;
+  }
+}
+
+module.exports = tryRequire;


Test patch:
diff --git a/test/extract/transpile/vue3-template-wrap.spec.mjs b/test/extract/transpile/vue3-template-wrap.spec.mjs
index d616c18c..8f70095f 100644
--- a/test/extract/transpile/vue3-template-wrap.spec.mjs
+++ b/test/extract/transpile/vue3-template-wrap.spec.mjs
@@ -16,7 +16,7 @@ const __dirname = fileURLToPath(new URL(".", import.meta.url));
 const wrap = proxyquire.load("#extract/transpile/vue-template-wrap.cjs", {
   // Force the tryRequire on "vue-template-compiler" to fail
   // so that we ensure we are using Vue 3 for this test
-  "semver-try-require": (pModuleName) =>
+  "#utl/try-require.cjs": (pModuleName) =>
     pModuleName === "vue-template-compiler"
       ? false
       : require("@vue/compiler-sfc"),
diff --git a/test/utl/extract-root-module-name.spec.cjs b/test/utl/extract-root-module-name.spec.cjs
new file mode 100644
index 00000000..22aab429
--- /dev/null
+++ b/test/utl/extract-root-module-name.spec.cjs
@@ -0,0 +1,42 @@
+const { equal } = require("node:assert/strict");
+const extractRootModuleName = require("#utl/extract-root-module-name.cjs");
+
+describe("[U] extract-root-module-name", () => {
+  it("returns the local module name when passed that (same folder)", () => {
+    equal(extractRootModuleName("./hoi-pippeloi"), "./hoi-pippeloi");
+    equal(extractRootModuleName("./hoi/pippeloi"), "./hoi/pippeloi");
+  });
+  it("returns the local module name when passed that (one ore more folders up)", () => {
+    equal(extractRootModuleName("../hoi-pippeloi"), "../hoi-pippeloi");
+    equal(extractRootModuleName("../hoi/pippeloi"), "../hoi/pippeloi");
+    equal(extractRootModuleName("../../hoi/pippeloi"), "../../hoi/pippeloi");
+  });
+  it("returns . when passed current folder", () => {
+    equal(extractRootModuleName("."), ".");
+    equal(extractRootModuleName("./"), "./");
+  });
+  it("returns .. when passed one folder up", () => {
+    equal(extractRootModuleName(".."), "..");
+    equal(extractRootModuleName("../"), "../");
+  });
+  it("returns the module name when passed an absolute module name", () => {
+    equal(extractRootModuleName("/"), "/");
+    equal(extractRootModuleName("/Users/root/hello"), "/Users/root/hello");
+  });
+  it("returns undefined when passed an empty string", () => {
+    // eslint-disable-next-line no-undefined
+    equal(extractRootModuleName(""), undefined);
+  });
+  it("returns the module name when there's no special shizzle", () => {
+    equal(extractRootModuleName("yodash"), "yodash");
+  });
+  it("returns the root module when passed a submodule", () => {
+    equal(extractRootModuleName("yodash/ship-ahoi"), "yodash");
+  });
+  it("returns the scope + module when passed scoped module", () => {
+    equal(extractRootModuleName("@yodash/yodash"), "@yodash/yodash");
+  });
+  it("returns the scope + module when passed submodule of a scoped module", () => {
+    equal(extractRootModuleName("@yodash/yodash/alaaf"), "@yodash/yodash");
+  });
+});
diff --git a/test/utl/node_modules/beta/index.js b/test/utl/node_modules/beta/index.js
new file mode 100644
index 00000000..f053ebf7
--- /dev/null
+++ b/test/utl/node_modules/beta/index.js
@@ -0,0 +1,1 @@
+module.exports = {};
diff --git a/test/utl/node_modules/beta/package.json b/test/utl/node_modules/beta/package.json
new file mode 100644
index 00000000..dcab738d
--- /dev/null
+++ b/test/utl/node_modules/beta/package.json
@@ -0,0 +1,6 @@
+{
+  "name": "beta-fixture",
+  "description": "test if release candidates are correctly detected as well",
+  "version": "3.0.0-beta-0",
+  "main": "./index.js"
+}
diff --git a/test/utl/node_modules/no-default-export/main.mjs b/test/utl/node_modules/no-default-export/main.mjs
new file mode 100644
index 00000000..51089623
--- /dev/null
+++ b/test/utl/node_modules/no-default-export/main.mjs
@@ -0,0 +1,6 @@
+function compile() {
+
+}
+const something = "something else";
+
+export {compile, something}
\ No newline at end of file
diff --git a/test/utl/node_modules/no-default-export/package.json b/test/utl/node_modules/no-default-export/package.json
new file mode 100644
index 00000000..91cb5a0b
--- /dev/null
+++ b/test/utl/node_modules/no-default-export/package.json
@@ -0,0 +1,11 @@
+{
+  "name": "no-default-export",
+  "description": "test whether packages without a default export get detected as well",
+  "version": "3.0.0",
+  "exports": {
+    ".": {
+        "import": "./main.mjs"
+    }
+  }
+  
+}
diff --git a/test/utl/node_modules/rc/index.js b/test/utl/node_modules/rc/index.js
new file mode 100644
index 00000000..f053ebf7
--- /dev/null
+++ b/test/utl/node_modules/rc/index.js
@@ -0,0 +1,1 @@
+module.exports = {};
diff --git a/test/utl/node_modules/rc/package.json b/test/utl/node_modules/rc/package.json
new file mode 100644
index 00000000..625fea9d
--- /dev/null
+++ b/test/utl/node_modules/rc/package.json
@@ -0,0 +1,6 @@
+{
+  "name": "rc-fixture",
+  "description": "test if release candidates are correctly detected as well",
+  "version": "3.0.0-rc",
+  "main": "./index.js"
+}
diff --git a/test/utl/try-import.spec.mjs b/test/utl/try-import.spec.mjs
new file mode 100644
index 00000000..ff11acce
--- /dev/null
+++ b/test/utl/try-import.spec.mjs
@@ -0,0 +1,85 @@
+import { equal, deepEqual } from "node:assert/strict";
+import { join } from "node:path";
+import { unlinkSync } from "node:fs";
+import semver from "semver";
+import symlinkDir from "symlink-dir";
+import * as noDefaultExportMock from "no-default-export";
+
+// eslint-disable-next-line node/no-extraneous-import
+import betaMock from "beta";
+// eslint-disable-next-line node/no-extraneous-import
+import rcMock from "rc";
+import tryImport from "#utl/try-import.mjs";
+
+describe("[U] utl/tryImport", () => {
+  before(() => {
+    try {
+      symlinkDir.sync(
+        join("test", "utl", "node_modules", "no-default-export"),
+        join("node_modules", "no-default-export"),
+      );
+      symlinkDir.sync(
+        join("test", "utl", "node_modules", "beta"),
+        join("node_modules", "beta"),
+      );
+      symlinkDir.sync(
+        join("test", "utl", "node_modules", "rc"),
+        join("node_modules", "rc"),
+      );
+    } catch (pError) {
+      // eslint-disable-next-line no-console
+      console.error(
+        `Symlinking in before trigger failed in test '[U] utl/tryImport': ${pError}`,
+      );
+    }
+  });
+
+  after(() => {
+    try {
+      unlinkSync("node_modules/no-default-export");
+      unlinkSync("node_modules/beta");
+      unlinkSync("node_modules/rc");
+    } catch (pError) {
+      // eslint-disable-next-line no-console
+      console.error(
+        `Unlinking in after trigger failed in test '[U] utl/tryImport': ${pError}`,
+      );
+    }
+  });
+
+  it("returns false for unresolvable modules", async () => {
+    equal(await tryImport("thispackage-is-not-there", ">=0.0.0"), false);
+  });
+
+  it("returns the module if it is resolvable", async () => {
+    deepEqual(await tryImport("semver", ">=0.0.0"), semver);
+  });
+
+  it("returns the module if it is resolvable and doesn't have a default export", async () => {
+    equal(await tryImport("no-default-export"), noDefaultExportMock);
+  });
+
+  it("returns the module if it is resolvable and satisfies specified semver", async () => {
+    deepEqual(await tryImport("semver", ">=5.0.0 <8.0.0"), semver);
+  });
+
+  it("returns the module if it is resolvable and satisfies specified semver (with rc postfix)", async () => {
+    deepEqual(await tryImport("rc", ">=2.0.0 <4.0.0"), rcMock);
+  });
+
+  it("returns false if it is resolvable but does not satisfy specified semver (with rc postfix)", async () => {
+    equal(await tryImport("rc", ">=2.0.0 <3.0.0"), false);
+  });
+
+  it("returns the module if it is resolvable and satisfies specified semver (with beta postfix)", async () => {
+    deepEqual(await tryImport("beta", ">=2.0.0 <4.0.0"), betaMock);
+  });
+
+  it("returns false if it is resolvable but does not satisfy specified semver (with beta postfix)", async () => {
+    equal(await tryImport("beta", ">=2.0.0 <3.0.0"), false);
+  });
+
+  it("returns false if it is resolvable but doesn't satisfy the specified semver", async () => {
+    equal(await tryImport("semver", "<5.0.0"), false);
+  });
+});
diff --git a/test/utl/try-require.spec.cjs b/test/utl/try-require.spec.cjs
new file mode 100644
index 00000000..efd88f5a
--- /dev/null
+++ b/test/utl/try-require.spec.cjs
@@ -0,0 +1,78 @@
+const { equal, deepEqual } = require("node:assert/strict");
+const { join } = require("node:path");
+const { unlinkSync } = require("node:fs");
+const symlinkDir = require("symlink-dir");
+const semver = require("semver");
+// eslint-disable-next-line node/no-extraneous-require
+const rcMock = require("rc");
+// eslint-disable-next-line node/no-extraneous-require
+const betaMock = require("beta");
+const tryRequire = require("#utl/try-require.cjs");
+
+describe("[U] utl/tryRequire", () => {
+  before(() => {
+    try {
+      symlinkDir.sync(
+        join("test", "utl", "node_modules", "no-default-export"),
+        join("node_modules", "no-default-export"),
+      );
+      symlinkDir.sync(
+        join("test", "utl", "node_modules", "beta"),
+        join("node_modules", "beta"),
+      );
+      symlinkDir.sync(
+        join("test", "utl", "node_modules", "rc"),
+        join("node_modules", "rc"),
+      );
+    } catch (pError) {
+      // eslint-disable-next-line no-console
+      console.error(
+        `Symlinking in before trigger failed in test '[U] utl/tryRequire': ${pError}`,
+      );
+    }
+  });
+
+  after(() => {
+    try {
+      unlinkSync("node_modules/no-default-export");
+      unlinkSync("node_modules/beta");
+      unlinkSync("node_modules/rc");
+    } catch (pError) {
+      // eslint-disable-next-line no-console
+      console.error(
+        `Unlinking in after trigger failed in test '[U] utl/tryRequire': ${pError}`,
+      );
+    }
+  });
+  it("returns false for unresolvable modules", () => {
+    equal(tryRequire("thispackage-is-not-there"), false);
+  });
+
+  it("returns the module if it is resolvable", () => {
+    deepEqual(tryRequire("semver"), semver);
+  });
+
+  it("returns the module if it is resolvable and satisfies specified semver", () => {
+    deepEqual(tryRequire("semver", ">=5.0.0 <8.0.0"), semver);
+  });
+
+  it("returns the module if it is resolvable and satisfies specified semver (with rc postfix)", () => {
+    deepEqual(tryRequire("rc", ">=2.0.0 <4.0.0"), rcMock);
+  });
+
+  it("returns false if it is resolvable but does not satisfy specified semver (with rc postfix)", () => {
+    equal(tryRequire("rc", ">=2.0.0 <3.0.0"), false);
+  });
+
+  it("returns the module if it is resolvable and satisfies specified semver (with beta postfix)", () => {
+    deepEqual(tryRequire("beta", ">=2.0.0 <4.0.0"), betaMock);
+  });
+
+  it("returns false if it is resolvable but does not satisfy specified semver (with beta postfix)", () => {
+    equal(tryRequire("beta", ">=2.0.0 <3.0.0"), false);
+  });
+
+  it("returns false if it is resolvable but doesn't satisfy the specified semver", () => {
+    equal(tryRequire("semver", "<5.0.0"), false);
+  });
+});