Repository: sverweij/dependency-cruiser (js)

Issue:
Issue: cruise API - breaking changes
Hello ! Hope you're doing well.

### Context

I had the chance to implement a little Vite Plugin running a circular deps check on fileq that are hot reloaded in my project. I decided to use your tool and it works as a charm in command-line. However, I encountered some problems with the API, since my goal is to use it programmatically.

I believe/suppose they are due to previous breaking changes of your project.

### Current Behavior

1. The utilitary function `extractDepcruiseConfig` that is supposed to extract `.dependency-cruiser.js` config file works, but the resulting object does not match the `ICruiseOptions` interface. For example, the `ruleSet` property isn't set, and rules (forbidden, allowed, etc) are extracted to `{ forbidden: [...] }` instead of `{ ruleSet: { forbidden: [...] } }`. The same goes for `doNotFollow`, probably other options.
2. I had to set `cruiseOptions.validate = true` in order to have a dependency check performed. Once done, it couldn't work (undefined error) unless I set `cruiseOptions.ruleSet.allowed = []`. This was not required using the command-line interface. 
3. I couldn't have my tsconfig.json be taken into account while performing the dependency checks, I figured out it was due to an interface problem with the cruise() function. See below
```
const cruiseResult = await cruise([path], cruiseOptions, {
	tsConfig: './tsconfig.json'
});
```
This code sample works, but does not match the interface nor your documentation found at https://github.com/sverweij/dependency-cruiser/blob/main/doc/api.md

### Expected Behavior

The `extractDepcruiseConfig` util should structure the resulting object accordingly to the `ICruiseOptions` interface.
I believe the option `validate = true` shouldn't be required to perform the cruise.
The `cruise` entrypoint should recognize the `tsconfig.json` file if specified in cruiseOptions or as an object provided as 4th parameter, as intended in the documentation.

### Code sample

My project is work related, but if necessary I guess I could provide more but for now here's how I use your tool :)

```
const path = relative(__dirname, file);
const depcruiseConfig: ICruiseOptions = await extractDepcruiseConfig('./.dependency-cruiser.js');
const cruiseOptions: ICruiseOptions = {
        ruleSet: {
	        forbidden: depcruiseConfig['forbidden'],
	        allowed: []
        },
        doNotFollow: {
	        path: ['node_modules']
        },
        enhancedResolveOptions: {
	        exportsFields: ['exports'],
	        conditionNames: ['import', 'require', 'node', 'default', 'types'],
	        mainFields: ['main', 'types', 'typings']
        },
        tsPreCompilationDeps: true,
        combinedDependencies: true,
        validate: true
};
try {
        const cruiseResult = await cruise([path], cruiseOptions, {
	        tsConfig: './tsconfig.json'
        });
        const output = cruiseResult.output as ICruiseResult;
        if (output.summary.violations.length > 0) {
	        printViolations(output.summary.violations);
        }
} catch (error) {
        console.error(error);
}
```

### Your Environment

- Version used: 16.3.1
- Node version: v21.6.0
- Operating System and version: Windows 11
- Link to your project: sorry, work related


Pull request:
fix(config-util): adds utility that extracts dependency-cruiser options from a dependency-cruiser config
## Description

- adds utility that extracts dependency-cruiser options from a dependency-cruiser config, and that returns an `ICruiseOptions` one can use as input parameter for the `cruise` function. This function also sets the `validate` attribute to `true` if there's a rule set in the config (and to `false` otherwise), so there's no need to do that anymore either.
- corrects the documented method signature of `extractDepcruiseConfig` to what it really returns (an `IConfiguration`)
- updates the docs/api.md documentation so all examples (1) run as intended (2) reflect current situation of the API.

## Motivation and Context

fixes #932 

`extractDepcruiseConfig` returns an `IConfiguration` object while the cruise function needs an `ICruiseOptions`. Changing the signature of `extractDepcruiseConfig` would constitute a breaking change - and `extractDepcruiseConfig` also has its own uses => we need an _additional_ utility function that translates a dependency-cruiser configuration file into an `ICruiseOptions` object. This PR adds that.

## Example
```typescript
import {
  cruise,
  type ICruiseOptions,
  type IReporterOutput,
  type IResolveOptions,
} from "dependency-cruiser";
import extractDepcruiseOptions from "dependency-cruiser/config-utl/extract-depcruise-options";
import extractTSConfig from "dependency-cruiser/config-utl/extract-ts-config";
import extractWebpackResolveConfig from "dependency-cruiser/config-utl/extract-webpack-resolve-config";

try {
  const lArrayOfFilesAndDirectoriesToCruise = ["src"];

  const depcruiseOptions: ICruiseOptions = await extractDepcruiseOptions(
    "./.dependency-cruiser.json",
  );
  const lWebpackResolveConfig = (await extractWebpackResolveConfig(
    "./webpack.config.js",
  )) as IResolveOptions;
  const tsConfig = extractTSConfig("./tsconfig.json");

  const cruiseResult: IReporterOutput = await cruise(
    lArrayOfFilesAndDirectoriesToCruise,
    depcruiseOptions,
    lWebpackResolveConfig,
    {
      tsConfig,
    },
  );

  console.dir(cruiseResult.output, { depth: 10 });
} catch (pError) {
  console.error(pError);
}
```


## How Has This Been Tested?

- [x] green ci
- [x] additional integration tests

## Types of changes

- [x] Bug fix (non-breaking change which fixes an issue)
- [ ] Documentation only change
- [ ] 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).


Patch:
diff --git a/.dependency-cruiser.json b/.dependency-cruiser.json
index ef75d3b0..01620e89 100644
--- a/.dependency-cruiser.json
+++ b/.dependency-cruiser.json
@@ -229,7 +229,12 @@
       "from": { "path": "^bin/" },
       "to": {
         "path": "^src",
-        "pathNot": ["[.]schema[.]json$", "[.]d[.]ts$", "^src/report/"],
+        "pathNot": [
+          "[.]schema[.]json$",
+          "[.]d[.]ts$",
+          "^src/report/",
+          "^src/config-utl/extract-depcruise-options.mjs$"
+        ],
         "reachable": false
       }
     },
diff --git a/doc/api.md b/doc/api.md
index ba6e4ca3..a7f922bc 100644
--- a/doc/api.md
+++ b/doc/api.md
@@ -17,15 +17,17 @@ and between modules in folder. Here's an example that cruises all files in
 the `src` folder and prints the dependencies to stdout:
 
 ```typescript
-import { cruise, IReporterOutput } from "dependency-cruiser";
+import { cruise, type IReporterOutput } from "dependency-cruiser";
 
 const ARRAY_OF_FILES_AND_DIRS_TO_CRUISE: string[] = ["src"];
 try {
-    const cruiseResult: IReporterOutput = await cruise(ARRAY_OF_FILES_AND_DIRS_TO_CRUISE);
-} catch(error)
-
-
-console.dir(cruiseResult.output, { depth: 10 });
+  const cruiseResult: IReporterOutput = await cruise(
+    ARRAY_OF_FILES_AND_DIRS_TO_CRUISE,
+  );
+  console.dir(cruiseResult.output, { depth: 10 });
+} catch (pError) {
+  console.error(pError);
+}
 ```
 
 You might notice a few things when you do this
@@ -52,11 +54,11 @@ const cruiseOptions: ICruiseOptions = {
 try {
   const cruiseResult: IReporterOutput = await cruise(
     ARRAY_OF_FILES_AND_DIRS_TO_CRUISE,
-    cruiseOptions
+    cruiseOptions,
   );
   console.dir(cruiseResult.output, { depth: 10 });
-} catch (error) {
-  console.error(error);
+} catch (pError) {
+  console.error(pError);
 }
 ```
 
@@ -86,51 +88,56 @@ try {
   const cruiseResult: IReporterOutput = await cruise(
     ARRAY_OF_FILES_AND_DIRS_TO_CRUISE,
     cruiseOptions,
-    webpackResolveOptions
+    webpackResolveOptions,
   );
   console.dir(cruiseResult.output, { depth: 10 });
-} catch (error) {
-  console.error(error);
+} catch (pError) {
+  console.error(pError);
 }
 ```
 
 ### Utility functions
 
 ```typescript
-import { cruise, ICruiseOptions, IReporterOutput } from "dependency-cruiser";
-import extractDepcruiseConfig from "dependency-cruiser/config-utl/extract-depcruise-config";
+import {
+  IResolveOptions,
+  cruise,
+  type ICruiseOptions,
+  type IReporterOutput,
+} from "dependency-cruiser";
+import extractDepcruiseOptions from "dependency-cruiser/config-utl/extract-depcruise-options";
 import extractTSConfig from "dependency-cruiser/config-utl/extract-ts-config";
 import extractWebpackResolveConfig from "dependency-cruiser/config-utl/extract-webpack-resolve-config";
-import extractBabelConfig from "dependency-cruiser/config-utl/extract-babel-config";
+// import extractBabelConfig from "dependency-cruiser/config-utl/extract-babel-config";
 
 try {
-  const ARRAY_OF_FILES_AND_DIRS_TO_CRUISE = ["src"];
+  const lArrayOfFilesAndDirectoriesToCruise = ["src"];
 
-  const depcruiseConfig: ICruiseOptions = await extractDepcruiseConfig(
-    "./.dependency-cruiser.js"
-  );
-  const webpackResolveConfig = await extractWebpackResolveConfig(
-    "./webpack.conf.js"
+  const depcruiseOptions: ICruiseOptions = await extractDepcruiseOptions(
+    "./.dependency-cruiser.json",
   );
+  const lWebpackResolveConfig = (await extractWebpackResolveConfig(
+    "./webpack.config.js",
+  )) as IResolveOptions;
   const tsConfig = extractTSConfig("./tsconfig.json");
   // const babelConfig = await extractBabelConfig("./babel.conf.json");
 
   const cruiseResult: IReporterOutput = await cruise(
-    ARRAY_OF_FILES_AND_DIRS_TO_CRUISE,
-    depcruiseConfig,
-    webpackResolveConfig,
+    lArrayOfFilesAndDirectoriesToCruise,
+    depcruiseOptions,
+    lWebpackResolveConfig,
     //   change since v13: in stead of passing the tsConfig directly, like so:
     // tsconfig
     //   you now pass it into an object that also supports other types of
     //   compiler options, like those for babel:
     {
-      tsConfig: tsConfig,
+      tsConfig,
       // babelConfig: babelConfig,
-    }
+    },
   );
 
   console.dir(cruiseResult.output, { depth: 10 });
-} catch (error) {
-  console.error(error);
+} catch (pError) {
+  console.error(pError);
 }
 ```
diff --git a/package.json b/package.json
index 8a170205..cef19d5d 100644
--- a/package.json
+++ b/package.json
@@ -95,6 +95,10 @@
       "types": "./types/config-utl/extract-depcruise-config.d.mts",
       "import": "./src/config-utl/extract-depcruise-config/index.mjs"
     },
+    "./config-utl/extract-depcruise-options": {
+      "types": "./types/config-utl/extract-depcruise-options.d.mts",
+      "import": "./src/config-utl/extract-depcruise-options.mjs"
+    },
     "./config-utl/extract-ts-config": {
       "types": "./types/config-utl/extract-ts-config.d.mts",
       "import": "./src/config-utl/extract-ts-config.mjs"
diff --git a/src/config-utl/extract-depcruise-config/index.mjs b/src/config-utl/extract-depcruise-config/index.mjs
index 1c2822c4..830b0a9e 100644
--- a/src/config-utl/extract-depcruise-config/index.mjs
+++ b/src/config-utl/extract-depcruise-config/index.mjs
@@ -47,7 +47,7 @@ async function processExtends(pReturnValue, pAlreadyVisited, pBaseDirectory) {
  * @param {string} pConfigFileName
  * @param {Set?} pAlreadyVisited
  * @param {string?} pBaseDirectory
- * @return {import('../../../types/options.mjs').ICruiseOptions} dependency-cruiser options
+ * @return {import('../../../types/configuration.mjs').IConfiguration} dependency-cruiser options
  * @throws {Error} when the config is not valid (/ does not exist/ isn't readable)
  */
 export default async function extractDepcruiseConfig(
diff --git a/src/config-utl/extract-depcruise-config/read-config.mjs b/src/config-utl/extract-depcruise-config/read-config.mjs
index 47a6afee..3d4e2b60 100644
--- a/src/config-utl/extract-depcruise-config/read-config.mjs
+++ b/src/config-utl/extract-depcruise-config/read-config.mjs
@@ -2,6 +2,11 @@ import { readFile } from "node:fs/promises";
 import { extname } from "node:path";
 import json5 from "json5";
 
+/**
+ *
+ * @param {string} pAbsolutePathToConfigFile
+ * @returns {Promise<import('../../../types/configuration.mjs').IConfiguration>}
+ */
 export default async function readConfig(pAbsolutePathToConfigFile) {
   if (
     [".js", ".cjs", ".mjs", ""].includes(extname(pAbsolutePathToConfigFile))
diff --git a/src/config-utl/extract-depcruise-options.mjs b/src/config-utl/extract-depcruise-options.mjs
new file mode 100644
index 00000000..f764a08c
--- /dev/null
+++ b/src/config-utl/extract-depcruise-options.mjs
@@ -0,0 +1,28 @@
+import extractDepcruiseConfig from "./extract-depcruise-config/index.mjs";
+
+/**
+ *
+ * @param {import('../../../types/configuration.mjs').IConfiguration}} pConfiguration
+ * @returns {import('../../../types/configuration.mjs').ICruiseOptions}
+ */
+function configuration2options(pConfiguration) {
+  /* c8 ignore next 1 */
+  const lConfiguration = structuredClone(pConfiguration || {});
+  const lReturnValue = structuredClone(lConfiguration?.options ?? {});
+
+  delete lConfiguration.options;
+  lReturnValue.ruleSet = structuredClone(lConfiguration);
+  lReturnValue.validate = Object.keys(lReturnValue.ruleSet).length > 0;
+
+  return lReturnValue;
+}
+
+/**
+ *
+ * @param {string} pConfigFileName
+ * @returns {Promise<import('../../../types/configuration.mjs').ICruiseOptions>}
+ */
+export default async function extractDepcruiseOptions(pConfigFileName) {
+  const lReturnValue = await extractDepcruiseConfig(pConfigFileName);
+  return configuration2options(lReturnValue);
+}
diff --git a/types/config-utl/extract-depcruise-config.d.mts b/types/config-utl/extract-depcruise-config.d.mts
index d75e9d23..1fb906f6 100644
--- a/types/config-utl/extract-depcruise-config.d.mts
+++ b/types/config-utl/extract-depcruise-config.d.mts
@@ -1,25 +1,27 @@
-import type { ICruiseOptions } from "../options.mjs";
+import type { IConfiguration } from "../configuration.mjs";
 
 /**
  * Reads the file with name `pConfigFileName` returns the parsed cruise
- * options.
+ * configuration. If you're  looking for the function to read a
+ * dependency-cruiser configuration file and put the result into the `cruise`
+ * function use `extractDepcruiseOptions` instead.s
  *
  * You can safely ignore the optional parameters. This should work (given
  * `.dependency-cruiser.js` exists and contains a valid dependency-cruiser
  * config)
  *
  * ```javascript
- * const depcruiseConfig = extractDepcruiseConfig("./.dependency-cruiser.js")
+ * const depcruiseConfig = await extractDepcruiseConfig("./.dependency-cruiser.js")
  * ```
  *
  * @param pConfigFileName
  * @param pAlreadyVisited
  * @param pBaseDirectory
- * @return dependency-cruiser options
+ * @return dependency-cruiser configuration
  * @throws when the config is not valid (/ does not exist/ isn't readable)
  */
 export default function extractDepcruiseConfig(
   pConfigFileName: string,
   pAlreadyVisited?: Set<string>,
-  pBaseDirectory?: string
-): Promise<ICruiseOptions>;
+  pBaseDirectory?: string,
+): Promise<IConfiguration>;
diff --git a/types/config-utl/extract-depcruise-options.d.mts b/types/config-utl/extract-depcruise-options.d.mts
new file mode 100644
index 00000000..8bf36435
--- /dev/null
+++ b/types/config-utl/extract-depcruise-options.d.mts
@@ -0,0 +1,18 @@
+import type { ICruiseOptions } from "../options.mjs";
+
+/**
+ * Reads the file with name `pConfigFileName` returns the parsed cruise
+ * options you can use as
+ *
+ * ```javascript
+ * const depcruiseOptions = await extractDepcruiseOptions("./.dependency-cruiser.js")
+ * const cruiseResult = await cruise(["./src"], depcruiseOptions);
+ * ```
+ *
+ * @param pConfigFileName
+ * @return dependency-cruiser options
+ * @throws when the config is not valid (/ does not exist/ isn't readable)
+ */
+export default function extractDepcruiseOptions(
+  pConfigFileName: string,
+): Promise<ICruiseOptions>;
diff --git a/types/dependency-cruiser.d.mts b/types/dependency-cruiser.d.mts
index 440b8891..deb1ed52 100644
--- a/types/dependency-cruiser.d.mts
+++ b/types/dependency-cruiser.d.mts
@@ -90,7 +90,7 @@ export interface ITranspileOptions {
 export function cruise(
   pFileAndDirectoryArray: string[],
   pCruiseOptions?: ICruiseOptions,
-  pResolveOptions?: IResolveOptions,
+  pResolveOptions?: Partial<IResolveOptions>,
   pTranspileOptions?: ITranspileOptions,
 ): Promise<IReporterOutput>;
 


Test patch:
diff --git a/test/config-utl/__mocks__/depcruiseconfig/empty.mjs b/test/config-utl/__mocks__/depcruiseconfig/empty.mjs
new file mode 100644
index 00000000..ff8b4c56
--- /dev/null
+++ b/test/config-utl/__mocks__/depcruiseconfig/empty.mjs
@@ -0,0 +1,1 @@
+export default {};
diff --git a/test/config-utl/__mocks__/depcruiseconfig/rules.sub-not-allowed-error-with-options.json b/test/config-utl/__mocks__/depcruiseconfig/rules.sub-not-allowed-error-with-options.json
new file mode 100644
index 00000000..cc1ffb09
--- /dev/null
+++ b/test/config-utl/__mocks__/depcruiseconfig/rules.sub-not-allowed-error-with-options.json
@@ -0,0 +1,30 @@
+{
+  "allowed": [
+    {
+      "from": {},
+      "to": {}
+    }
+  ],
+  "forbidden": [
+    {
+      "name": "sub-not-allowed",
+      "severity": "error",
+      "from": {},
+      "to": {
+        "path": "sub"
+      }
+    }
+  ],
+  "options": {
+    "includeOnly": ["src"],
+    "reporterOptions": {
+      "text": {
+        "highlightFocused": true
+      }
+    },
+    "cache": {
+      "strategy": "metadata",
+      "compress": true
+    }
+  }
+}
diff --git a/test/config-utl/__mocks__/depcruiseconfig/rules.sub-not-allowed-error.json b/test/config-utl/__mocks__/depcruiseconfig/rules.sub-not-allowed-error.json
new file mode 100644
index 00000000..2d84f466
--- /dev/null
+++ b/test/config-utl/__mocks__/depcruiseconfig/rules.sub-not-allowed-error.json
@@ -0,0 +1,21 @@
+{
+    "allowed": [
+        {
+            "from": {
+            },
+            "to": {
+            }
+        }
+    ],
+    "forbidden": [
+        {
+            "name": "sub-not-allowed",
+            "severity": "error",
+            "from": {
+            },
+            "to": {
+                "path": "sub"
+            }
+        }
+    ]
+}
diff --git a/test/config-utl/extract-depcruise-options.spec.mjs b/test/config-utl/extract-depcruise-options.spec.mjs
new file mode 100644
index 00000000..b5a61f6d
--- /dev/null
+++ b/test/config-utl/extract-depcruise-options.spec.mjs
@@ -0,0 +1,71 @@
+import { deepEqual } from "node:assert/strict";
+import { join } from "node:path";
+import { fileURLToPath } from "node:url";
+import loadOptions from "#config-utl/extract-depcruise-options.mjs";
+
+const __dirname = fileURLToPath(new URL(".", import.meta.url));
+const mockDirectory = join(__dirname, "__mocks__", "depcruiseconfig");
+
+describe("[I] config-utl/extract-depcruise-options", () => {
+  it("correctly converts an empty configuration to options", async () => {
+    const lFoundOptions = await loadOptions(join(mockDirectory, "empty.mjs"));
+    deepEqual(lFoundOptions, {
+      ruleSet: {},
+      validate: false,
+    });
+  });
+
+  it("correctly converts a configuration with a rule set to options", async () => {
+    const lFoundOptions = await loadOptions(
+      join(mockDirectory, "rules.sub-not-allowed-error.json"),
+    );
+    deepEqual(lFoundOptions, {
+      ruleSet: {
+        allowed: [{ from: {}, to: {} }],
+        forbidden: [
+          {
+            name: "sub-not-allowed",
+            severity: "error",
+            from: {},
+            to: {
+              path: "sub",
+            },
+          },
+        ],
+      },
+      validate: true,
+    });
+  });
+
+  it("correctly converts a configuration with a rule set, & puts options in the right spot", async () => {
+    const lFoundOptions = await loadOptions(
+      join(mockDirectory, "rules.sub-not-allowed-error-with-options.json"),
+    );
+    deepEqual(lFoundOptions, {
+      ruleSet: {
+        allowed: [{ from: {}, to: {} }],
+        forbidden: [
+          {
+            name: "sub-not-allowed",
+            severity: "error",
+            from: {},
+            to: {
+              path: "sub",
+            },
+          },
+        ],
+      },
+      validate: true,
+      includeOnly: ["src"],
+      reporterOptions: {
+        text: {
+          highlightFocused: true,
+        },
+      },
+      cache: {
+        strategy: "metadata",
+        compress: true,
+      },
+    });
+  });
+});