Repository: sverweij/dependency-cruiser
Pull request #950 (2024-07-20 09:01:27)

refactor: replaces lodash
## Description

- [x] centralizes remaining uses of lodash in central utilities
- [x] in the central utilities replace the lodash array functions with native alternatives that work in our context
- [x] in the central utilities replace the lodash object functions with native alternatives that work in our context
- [x] removes lodash from the package manifest

## Motivation and Context

lodash is a great library, however 
- it's not really maintained anymore
- it has fairly large download size (1.4Mb [according to pkg-size](https://pkg-size.dev/lodash)) - while we only use a small subset of it

## How Has This Been Tested?

- [x] green ci


## 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 be85d85c and detected **0 issues** on this pull request.

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

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

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

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

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

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

**🚮 Removed packages:** [npm/@types/lodash@4.17.7](https://socket.dev/npm/package/@types/lodash/overview/4.17.7), [npm/lodash@4.17.21](https://socket.dev/npm/package/lodash/overview/4.17.21)


[**View full report↗︎**](https://socket.dev/dashboard/org/sverweij/diff/53c05b2f-fdde-45ef-88ed-5c2db43d7ce5/0efdb091-27c3-4387-9ee8-69f796c86689)
codeclimate[bot]: Code Climate has analyzed commit b4bf5577 and detected **0 issues** on this pull request.

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


Patch:
diff --git a/package-lock.json b/package-lock.json
index ae1397ba..69142769 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -21,7 +21,6 @@
         "interpret": "^3.1.1",
         "is-installed-globally": "1.0.0",
         "json5": "2.2.3",
-        "lodash": "4.17.21",
         "memoize": "10.0.0",
         "picocolors": "1.0.1",
         "picomatch": "4.0.2",
@@ -46,7 +45,6 @@
         "@babel/plugin-transform-modules-commonjs": "7.24.8",
         "@babel/preset-typescript": "7.24.7",
         "@swc/core": "1.7.0",
-        "@types/lodash": "4.17.7",
         "@types/node": "20.14.11",
         "@types/prompts": "2.4.9",
         "@typescript-eslint/eslint-plugin": "7.16.1",
@@ -1219,13 +1217,6 @@
       "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==",
       "dev": true
     },
-    "node_modules/@types/lodash": {
-      "version": "4.17.7",
-      "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.7.tgz",
-      "integrity": "sha512-8wTvZawATi/lsmNu10/j2hk1KEP0IvjubqPE3cu1Xz7xfXXt5oCq3SNUz4fMIP4XGF9Ky+Ue2tBA3hcS7LSBlA==",
-      "dev": true,
-      "license": "MIT"
-    },
     "node_modules/@types/node": {
       "version": "20.14.11",
       "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.11.tgz",
@@ -4756,10 +4747,6 @@
         "url": "https://github.com/sponsors/sindresorhus"
       }
     },
-    "node_modules/lodash": {
-      "version": "4.17.21",
-      "license": "MIT"
-    },
     "node_modules/lodash.merge": {
       "version": "4.6.2",
       "dev": true,
diff --git a/package.json b/package.json
index 20f8c43e..dc441a45 100644
--- a/package.json
+++ b/package.json
@@ -217,7 +217,6 @@
     "interpret": "^3.1.1",
     "is-installed-globally": "1.0.0",
     "json5": "2.2.3",
-    "lodash": "4.17.21",
     "memoize": "10.0.0",
     "picocolors": "1.0.1",
     "picomatch": "4.0.2",
@@ -234,7 +233,6 @@
     "@babel/plugin-transform-modules-commonjs": "7.24.8",
     "@babel/preset-typescript": "7.24.7",
     "@swc/core": "1.7.0",
-    "@types/lodash": "4.17.7",
     "@types/node": "20.14.11",
     "@types/prompts": "2.4.9",
     "@typescript-eslint/eslint-plugin": "7.16.1",
diff --git a/src/cli/index.mjs b/src/cli/index.mjs
index 0d01a661..8f642ee8 100644
--- a/src/cli/index.mjs
+++ b/src/cli/index.mjs
@@ -1,6 +1,5 @@
 import { join } from "node:path";
 import picomatch from "picomatch";
-import set from "lodash/set.js";
 import isInstalledGlobally from "is-installed-globally";
 import pc from "picocolors";
 
@@ -10,6 +9,7 @@ import { write } from "./utl/io.mjs";
 import setUpCliFeedbackListener from "./listeners/cli-feedback.mjs";
 import setUpPerformanceLogListener from "./listeners/performance-log/index.mjs";
 import setUpNDJSONListener from "./listeners/ndjson.mjs";
+import { set } from "#utl/object-util.mjs";
 import cruise from "#main/cruise.mjs";
 import { INFO, bus } from "#utl/bus.mjs";
 
diff --git a/src/cli/normalize-cli-options.mjs b/src/cli/normalize-cli-options.mjs
index 15163c18..63ff1121 100644
--- a/src/cli/normalize-cli-options.mjs
+++ b/src/cli/normalize-cli-options.mjs
@@ -1,6 +1,5 @@
 import { accessSync, R_OK } from "node:fs";
 import { isAbsolute } from "node:path";
-import set from "lodash/set.js";
 import {
   RULES_FILE_NAME_SEARCH_ARRAY,
   DEFAULT_BASELINE_FILE_NAME,
@@ -11,6 +10,7 @@ import {
   BABEL_CONFIG,
   OLD_DEFAULT_RULES_FILE_NAME,
 } from "./defaults.mjs";
+import { set } from "#utl/object-util.mjs";
 import loadConfig from "#config-utl/extract-depcruise-config/index.mjs";
 
 function getOptionValue(pDefault) {
diff --git a/src/config-utl/extract-depcruise-config/merge-configs.mjs b/src/config-utl/extract-depcruise-config/merge-configs.mjs
index 5e72cdbb..673e1590 100644
--- a/src/config-utl/extract-depcruise-config/merge-configs.mjs
+++ b/src/config-utl/extract-depcruise-config/merge-configs.mjs
@@ -1,6 +1,5 @@
 import { isDeepStrictEqual } from "node:util";
-import uniqBy from "lodash/uniqBy.js";
-import uniqWith from "lodash/uniqWith.js";
+import { uniqBy, uniqWith } from "#utl/array-util.mjs";
 
 function extendNamedRule(pExtendedRule, pForbiddenArrayBase) {
   return pForbiddenArrayBase
diff --git a/src/enrich/summarize/summarize-modules.mjs b/src/enrich/summarize/summarize-modules.mjs
index 25fa19b6..0f2ef18d 100644
--- a/src/enrich/summarize/summarize-modules.mjs
+++ b/src/enrich/summarize/summarize-modules.mjs
@@ -1,7 +1,7 @@
-import uniqWith from "lodash/uniqWith.js";
 import isSameViolation from "./is-same-violation.mjs";
 import { findRuleByName } from "#graph-utl/rule-set.mjs";
 import compare from "#graph-utl/compare.mjs";
+import { uniqWith } from "#utl/array-util.mjs";
 
 function cutNonTransgressions(pModule) {
   return {
diff --git a/src/extract/extract-dependencies.mjs b/src/extract/extract-dependencies.mjs
index c7b47fd1..3fda90f8 100644
--- a/src/extract/extract-dependencies.mjs
+++ b/src/extract/extract-dependencies.mjs
@@ -1,5 +1,4 @@
 import { join, extname, dirname } from "node:path";
-import uniqBy from "lodash/uniqBy.js";
 import { extract as acornExtract } from "./acorn/extract.mjs";
 import {
   extract as tscExtract,
@@ -14,7 +13,7 @@ import {
   detectPreCompilationNess,
   extractModuleAttributes,
 } from "./helpers.mjs";
-import { intersects } from "#utl/array-util.mjs";
+import { uniqBy, intersects } from "#utl/array-util.mjs";
 
 function extractWithTsc(pCruiseOptions, pFileName, pTranspileOptions) {
   let lDependencies = tscExtract(pCruiseOptions, pFileName, pTranspileOptions);
diff --git a/src/extract/resolve/external-module-helpers.mjs b/src/extract/resolve/external-module-helpers.mjs
index c01ead62..9f90b125 100644
--- a/src/extract/resolve/external-module-helpers.mjs
+++ b/src/extract/resolve/external-module-helpers.mjs
@@ -26,7 +26,7 @@ import { isScoped, isRelativeModuleName } from "./module-classifiers.mjs";
  *
  * At this time we don't take situations into account where the caller includes
  * a node module through a local path (which could make sense if you're on
- * non-commonJS and are still using node_modules) e.g. '../node_modules/lodash/fp'
+ * non-commonJS and are still using node_modules) e.g. '../node_modules/oldash/fp'
  *
  * @param  {string} pModule a module name
  * @return {string}         the module name root
@@ -58,8 +58,8 @@ export function getPackageRoot(pModule) {
  * returns the contents of the package.json of the given pModule as it would
  * resolve from pBaseDirectory
  *
- * e.g. to get the package.json of `lodash` that is required bya
- * `src/report/something.js` use `getPackageJSON('lodash', 'src/report/');`
+ * e.g. to get the package.json of `oldash` that is required bya
+ * `src/report/something.js` use `getPackageJSON('oldash', 'src/report/');`
  *
  * The pBaseDirectory parameter is necessary because dependency-cruiser/ this module
  * will have a different base dir, and will hence resolve either to the
diff --git a/src/graph-utl/consolidate-modules.mjs b/src/graph-utl/consolidate-modules.mjs
index a2d00c58..3b0a6d98 100644
--- a/src/graph-utl/consolidate-modules.mjs
+++ b/src/graph-utl/consolidate-modules.mjs
@@ -1,5 +1,5 @@
-import uniqBy from "lodash/uniqBy.js";
 import compare from "./compare.mjs";
+import { uniqBy } from "#utl/array-util.mjs";
 
 function mergeModule(pLeftModule, pRightModule) {
   return {
diff --git a/src/main/helpers.mjs b/src/main/helpers.mjs
index e34c6d95..b37dc663 100644
--- a/src/main/helpers.mjs
+++ b/src/main/helpers.mjs
@@ -1,6 +1,4 @@
-import get from "lodash/get.js";
-import has from "lodash/has.js";
-import set from "lodash/set.js";
+import { has, get, set } from "#utl/object-util.mjs";
 
 const RE_PROPERTIES = [
   "path",
@@ -30,7 +28,7 @@ export function normalizeREProperties(
   let lPropertyContainer = structuredClone(pPropertyContainer);
 
   for (const lProperty of pREProperties) {
-    // lProperty can be nested properties, so we use lodash.has and lodash.get
+    // lProperty can be nested properties, so we use _.has and _.get
     // instead of elvis operators
     if (has(lPropertyContainer, lProperty)) {
       set(
diff --git a/src/main/rule-set/assert-validity.mjs b/src/main/rule-set/assert-validity.mjs
index 7edc9aad..af17da0e 100644
--- a/src/main/rule-set/assert-validity.mjs
+++ b/src/main/rule-set/assert-validity.mjs
@@ -1,10 +1,9 @@
 import Ajv from "ajv";
 import safeRegex from "safe-regex";
-import has from "lodash/has.js";
-import get from "lodash/get.js";
 import { assertCruiseOptionsValid } from "../options/assert-validity.mjs";
 import { normalizeToREAsString } from "../helpers.mjs";
 import configurationSchema from "#configuration-schema";
+import { has, get } from "#utl/object-util.mjs";
 
 const ajv = new Ajv();
 // the default for this is 25 - as noted in the safe-regex source code already,
@@ -29,8 +28,8 @@ function assertSchemaCompliance(pSchema, pConfiguration) {
 }
 
 function hasPath(pObject, pSection, pCondition) {
-  // pCondition can be nested properties, so we use lodash.has instead
-  // of elvis operators
+  // pCondition can be nested properties, so we use a bespoke
+  // 'has' function instead of simple elvis operators
   return has(pObject, pSection) && has(pObject[pSection], pCondition);
 }
 
diff --git a/src/report/dot/index.mjs b/src/report/dot/index.mjs
index 330dd978..dbd4fd66 100644
--- a/src/report/dot/index.mjs
+++ b/src/report/dot/index.mjs
@@ -1,11 +1,11 @@
 /* eslint-disable prefer-template */
-import get from "lodash/get.js";
 import theming from "./theming.mjs";
 import moduleUtl from "./module-utl.mjs";
 import prepareFolderLevel from "./prepare-folder-level.mjs";
 import prepareCustomLevel from "./prepare-custom-level.mjs";
 import prepareFlatLevel from "./prepare-flat-level.mjs";
 import { applyFilters } from "#graph-utl/filter-bank.mjs";
+import { get } from "#utl/object-util.mjs";
 
 // not importing EOL from "node:os" so output is the same on windows and unices
 const EOL = "\n";
@@ -151,8 +151,8 @@ function pryReporterOptionsFromResults(pGranularity, pResults) {
   const lFallbackReporterOptions =
     pResults?.summary?.optionsUsed?.reporterOptions?.dot;
 
-  // using lodash.get here because the reporter options will contain nested
-  // properties, which it handles for us
+  // using a bespoke 'get' function here because the reporter options will
+  // contain nested properties, which it handles for us
   return get(
     pResults,
     GRANULARITY2REPORTER_OPTIONS.get(pGranularity),
diff --git a/src/report/dot/theming.mjs b/src/report/dot/theming.mjs
index 78ee6713..8649d531 100644
--- a/src/report/dot/theming.mjs
+++ b/src/report/dot/theming.mjs
@@ -1,6 +1,5 @@
-import get from "lodash/get.js";
-import has from "lodash/has.js";
 import DEFAULT_THEME from "./default-theme.mjs";
+import { has, get } from "#utl/object-util.mjs";
 
 function matchesRE(pValue, pRE) {
   const lMatchResult = pValue.match && pValue.match(pRE);
@@ -14,10 +13,13 @@ function matchesCriterion(pModuleKey, pCriterion) {
 
 function moduleOrDependencyMatchesCriteria(pSchemeEntry, pModule) {
   return Object.keys(pSchemeEntry.criteria).every((pKey) => {
-    // we use lodash.get here because in the criteria you can enter
-    // nested keys like "rules[0].severity" : "error", and lodash.get handles
-    // that for us
-    const lCriterion = get(pSchemeEntry.criteria, pKey);
+    // the keys can have paths in them like {"rules[0].severity": "info"}
+    // To get the criterion treat that key as a string and not as a path
+    // eslint-disable-next-line security/detect-object-injection
+    const lCriterion = pSchemeEntry.criteria[pKey];
+    // we use a bespoke 'get' here because in the criteria you can enter
+    // nested keys like "rules[0].severity" : "error", and that function
+    // handles those for us
     const lModuleKey = get(pModule, pKey);
 
     if (!(lModuleKey || has(pModule, pKey))) {
diff --git a/src/utl/array-util.mjs b/src/utl/array-util.mjs
index 350fee4e..e040e3ba 100644
--- a/src/utl/array-util.mjs
+++ b/src/utl/array-util.mjs
@@ -13,3 +13,27 @@ export function intersects(pLeftArray, pRightArray) {
 export function uniq(pArray) {
   return [...new Set(pArray)];
 }
+
+/**
+ * @param {any[]} pArray
+ * @param {function} pIteratee
+ * @returns {any[]}
+ */
+export function uniqBy(pArray, pIteratee) {
+  return pArray.filter(
+    (pElement, pIndex, pSelf) =>
+      pIndex === pSelf.findIndex((pY) => pIteratee(pElement) === pIteratee(pY)),
+  );
+}
+
+/**
+ * @param {any[]} pArray
+ * @param {function} pComparator
+ * @returns {any[]}
+ */
+export function uniqWith(pArray, pComparator) {
+  return pArray.filter(
+    (pElement, pIndex, pSelf) =>
+      pIndex === pSelf.findIndex((pY) => pComparator(pElement, pY)),
+  );
+}
diff --git a/src/utl/object-util.mjs b/src/utl/object-util.mjs
new file mode 100644
index 00000000..661c4dbd
--- /dev/null
+++ b/src/utl/object-util.mjs
@@ -0,0 +1,40 @@
+/* eslint-disable security/detect-object-injection */
+
+export function get(pObject, pPath, pDefault) {
+  if (!pObject || !pPath) {
+    return pDefault;
+  }
+  // Regex explained: https://regexr.com/58j0k
+  const lPathArray = pPath.match(/([^[.\]])+/g);
+
+  const lReturnValue = lPathArray.reduce((pPreviousObject, pKey) => {
+    return pPreviousObject && pPreviousObject[pKey];
+  }, pObject);
+
+  if (!lReturnValue) {
+    return pDefault;
+  }
+  return lReturnValue;
+}
+
+export function set(pObject, pPath, pValue) {
+  const lPathArray = pPath.match(/([^[.\]])+/g);
+
+  lPathArray.reduce((pPreviousObject, pKey, pIndex) => {
+    if (pIndex === lPathArray.length - 1) {
+      pPreviousObject[pKey] = pValue;
+    } else if (!pPreviousObject[pKey]) {
+      pPreviousObject[pKey] = {};
+    }
+    return pPreviousObject[pKey];
+  }, pObject);
+}
+
+/**
+ * @param {any} pObject
+ * @param {string} pPath
+ * @returns {boolean}
+ */
+export function has(pObject, pPath) {
+  return Boolean(get(pObject, pPath));
+}


Test patch:
diff --git a/test/utl/object-util.spec.mjs b/test/utl/object-util.spec.mjs
new file mode 100644
index 00000000..33b3fbd0
--- /dev/null
+++ b/test/utl/object-util.spec.mjs
@@ -0,0 +1,48 @@
+/* eslint-disable no-magic-numbers, no-undefined */
+import { equal } from "node:assert/strict";
+import { has, get, set } from "#utl/object-util.mjs";
+
+describe("[U] object-util", () => {
+  describe("[U] has", () => {
+    it("should return true if the object has the specified path", () => {
+      const lObject = { a: { b: { c: 123 } } };
+      equal(has(lObject, "a.b.c"), true);
+    });
+
+    it("should return false if the object does not have the specified path", () => {
+      const lObject = { a: { b: { c: 123 } } };
+      equal(has(lObject, "a.b.d"), false);
+    });
+  });
+
+  describe("[U] get", () => {
+    it("should return the value at the specified path", () => {
+      const lObject = { a: { b: { c: 123 } } };
+      equal(get(lObject, "a.b.c"), 123);
+    });
+
+    it("should return the default value if the path does not exist", () => {
+      const lObject = { a: { b: { c: 123 } } };
+      equal(get(lObject, "a.b.d", "default"), "default");
+    });
+
+    it("should return undefined if the path does not exist and no default value is provided", () => {
+      const lObject = { a: { b: { c: 123 } } };
+      equal(get(lObject, "a.b.d"), undefined);
+    });
+  });
+
+  describe("[U] set", () => {
+    it("should set the value at the specified path", () => {
+      const lObject = { a: { b: { c: 123 } } };
+      set(lObject, "a.b.c", 456);
+      equal(lObject.a.b.c, 456);
+    });
+
+    it("should create nested objects if the path does not exist", () => {
+      const lObject = {};
+      set(lObject, "a.b.c", 123);
+      equal(lObject.a.b.c, 123);
+    });
+  });
+});