Repository: sverweij/dependency-cruiser
Pull request #907 (2024-02-03 14:08:36)

feat(report): adds a reporter that combines a dot reporter with executing dot and running the wrap-stream-in-html command
## Description

- adds an (_experimental_ hence the x- prefix: _x-dot-webpage_) reporter that takes the output from the dot reporter, pulls it through the `graphviz dot` command installed on your system and wraps its output in the same html depcruise-wrap-stream-in-html would.
- small refactoring so code depcruise-wrap-stream-in-html and this reporter share is indeed shared and not duplicated.

## Motivation and Context

A reporter like this is more convenient than having to pipe dependency-cruiser's dot output through graphviz and through the html wrapper. 

It also has drawbacks, which up till now held us back from implementing such a reporter:
- running `dot` means spawning a child process, which has security implications. This PR addresses some of these, but cannot address all of them (i.e. what if there's a malicious version of the dot on the system this is run on? Not something that can be practically addressed, and maybe not something to worry about from our point  as when a system is compromised like that there's other things wrong anyway). However, dependency-cruiser already spawns child processes anyway (to get the git diff it bases its cache-dirty algorithm on) in the same safe way. 
- the `dot` command might not be available or be available but do something else altogether. Both situations have been handled in this PR, but it will still be a likely cause for support requests.
- if `dot` takes a long time to finish or crashes (which it sometimes does) this might get reported as dependency-cruiser issue more likely then when it is clearly run as an external program. 

This is why we'll keep it as an experimental and only sparsely documented feature until we've solved the above issues or we're convinced their impact is more limited than we believed.

## How Has This Been Tested?

- [x] green ci
- [x] additional automated non-regression 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)
- [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 f767d1b1 and detected **1 issue** on this pull request.

Here's the issue category breakdown:

| Category | Count |
|:---:|:---:|
| Complexity | 1 |


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

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

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

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

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


Patch:
diff --git a/.codeclimate.yml b/.codeclimate.yml
index 983ff942..241ed51e 100644
--- a/.codeclimate.yml
+++ b/.codeclimate.yml
@@ -17,5 +17,6 @@ exclude_patterns:
   - "test/"
   - "types/"
   - "tools/"
+  - "src/report/dot-webpage/svg-in-html-snippets/script.js"
   - "**/node_modules/"
   - "src/**/*.schema.mjs"
diff --git a/.dependency-cruiser.json b/.dependency-cruiser.json
index 0b2232e8..609839a5 100644
--- a/.dependency-cruiser.json
+++ b/.dependency-cruiser.json
@@ -39,6 +39,7 @@
       "to": {
         "pathNot": [
           "^src/(main|utl|config-utl)/",
+          "^src/report/dot-webpage/wrap-in-html[.]mjs$",
           "$1",
           "^src/meta[.]js$",
           "^src/extract/transpile/meta[.]mjs$"
@@ -95,7 +96,10 @@
       "severity": "error",
       "from": {
         "pathNot": [
-          "^src/(main/resolve-options/normalize[.]mjs|extract/parse|extract/resolve|extract/gather-initial-sources[.]mjs|config-utl|cli|cache|utl/find-all-files[.]mjs)",
+          "^src/main/resolve-options/normalize[.]mjs",
+          "^src/extract/(parse|resolve|gather-initial-sources[.]mjs)",
+          "^src/(config-utl|cli|cache|utl/find-all-files[.]mjs)",
+          "^src/report/dot-webpage/wrap-in-html[.]mjs",
           "^test",
           "^tools"
         ]
@@ -150,7 +154,8 @@
         "dependencyTypesNot": [
           "type-only",
           "type-import",
-          "triple-slash-type-reference"
+          "triple-slash-type-reference",
+          "npm"
         ],
         "pathNot": ["node_modules/@types/"]
       }
@@ -307,7 +312,7 @@
       "mocks",
       "fixtures",
       "test/integration",
-      "src/cli/tools/svg-in-html-snippets/script.js"
+      "src/report/dot-webpage/svg-in-html-snippets/script.js"
     ],
 
     /* list of module systems to cruise */
diff --git a/.eslintrc.js b/.eslintrc.js
index c8f03a8d..3d967b10 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -61,7 +61,7 @@ module.exports = {
     "coverage",
     "tmp",
     "src/**/*.schema.mjs",
-    "src/cli/tools/svg-in-html-snippets/script.js",
+    "src/report/dot-webpage/svg-in-html-snippets/script.js",
     "test/integration/**",
     "test/*/__fixtures__/**",
     "test/*/*/__fixtures__/**",
diff --git a/configs/.dependency-cruiser-show-metrics-config.mjs b/configs/.dependency-cruiser-show-metrics-config.mjs
index ab974d6f..d3a4ad60 100644
--- a/configs/.dependency-cruiser-show-metrics-config.mjs
+++ b/configs/.dependency-cruiser-show-metrics-config.mjs
@@ -145,6 +145,14 @@ export default {
               criteria: { "rules[0].severity": "info" },
               attributes: { fontcolor: "blue", color: "blue" },
             },
+            {
+              criteria: { dependencyTypes: ["export"] },
+              attributes: {
+                fontcolor: "purple",
+                color: "pink",
+                xlabel: "this is an ex port",
+              },
+            },
             {
               criteria: { valid: false },
               attributes: { fontcolor: "red", color: "red" },
diff --git a/src/cli/tools/wrap-stream-in-html.mjs b/src/cli/tools/wrap-stream-in-html.mjs
index ce613e21..4c9d523c 100755
--- a/src/cli/tools/wrap-stream-in-html.mjs
+++ b/src/cli/tools/wrap-stream-in-html.mjs
@@ -1,53 +1,20 @@
 import { readFile } from "node:fs/promises";
 import { fileURLToPath } from "node:url";
+import { getHeader, getFooter } from "#report/dot-webpage/wrap-in-html.mjs";
 
 const STYLESHEET_FILE = fileURLToPath(
-  new URL("svg-in-html-snippets/style.css", import.meta.url),
+  new URL(
+    "../../report/dot-webpage/svg-in-html-snippets/style.css",
+    import.meta.url,
+  ),
 );
 const SCRIPT_FILE = fileURLToPath(
-  new URL("svg-in-html-snippets/script.js", import.meta.url),
+  new URL(
+    "../../report/dot-webpage/svg-in-html-snippets/script.js",
+    import.meta.url,
+  ),
 );
 
-/**
- * @param {string} pStylesheet
- * @returns {string}
- */
-function getHeader(pStylesheet) {
-  return `<!doctype html>
-<html lang="en" dir="ltr">
-  <head>
-    <meta charset="utf-8" />
-    <title>dependency graph</title>
-    <style>
-      ${pStylesheet}
-    </style>
-  </head>
-  <body>
-    <button id="button_help">?</button>
-    <div id="hints" class="hint" style="display: none">
-      <button id="close-hints">x</button>
-      <span id="hint-text"></span>
-      <ul>
-        <li><b>Hover</b> - highlight</li>
-        <li><b>Right-click</b> - pin highlight</li>
-        <li><b>ESC</b> - clear</li>
-      </ul>
-    </div>
-`;
-}
-
-/**
- * @param {string} pScript
- * @returns {string}
- */
-function getFooter(pScript) {
-  return `    <script>
-      ${pScript}
-    </script>
-  </body>
-</html>`;
-}
-
 /**
  * Slaps the stuff in the passed stream in between the contents
  * of the header and the footer and returns it as a string.
diff --git a/src/report/dot-webpage/dot-module.mjs b/src/report/dot-webpage/dot-module.mjs
new file mode 100644
index 00000000..117cbcc1
--- /dev/null
+++ b/src/report/dot-webpage/dot-module.mjs
@@ -0,0 +1,77 @@
+// @ts-check
+import { spawnSync } from "node:child_process";
+import dotModuleReporter from "../dot/dot-module.mjs";
+import { wrappapiccha } from "./wrap-in-html.mjs";
+
+const CONSTANTS = {
+  exec: "dot",
+  format: "svg",
+};
+
+/**
+ * @param {string} pDot Dot program
+ * @param {IDotPictureReporterOptions} pOptions
+ * @returns {string} the dot program converted to svg, wrapped in html
+ */
+function convert(pDot, pOptions) {
+  /* c8 ignore next */
+  const lSpawnFunction = pOptions.spawnFunction || spawnSync;
+  const { stdout, status, error } = lSpawnFunction(
+    CONSTANTS.exec,
+    [`-T${CONSTANTS.format}`],
+    {
+      input: pDot,
+    },
+  );
+  if (status === 0) {
+    return stdout.toString("binary");
+  } else if (error) {
+    throw error;
+  } else {
+    throw new Error(
+      `Unexpected error occurred executing GraphViz' dot. Exit code ${status}`,
+    );
+  }
+}
+
+/**
+ * @param {IDotPictureReporterOptions} pOptions
+ * @returns {boolean}
+ */
+function isAvailable(pOptions) {
+  /* c8 ignore next */
+  const lSpawnFunction = pOptions.spawnFunction || spawnSync;
+  const { status, stderr } = lSpawnFunction(CONSTANTS.exec, ["-V"]);
+  return (
+    status === 0 && stderr.toString("utf8").startsWith("dot - graphviz version")
+  );
+}
+
+/**
+ * @typedef {import("../../../types/cruise-result.mjs").ICruiseResult} ICruiseResult
+ * @typedef {import("../../../types/reporter-options.mjs").IDotReporterOptions} IDotReporterOptions
+ * @typedef {import("../../../types/dependency-cruiser.mjs").IReporterOutput} IReporterOutput
+ * @typedef {IDotReporterOptions & { spawnFunction?: typeof spawnSync }} IDotPictureReporterOptions
+ */
+
+/**
+ * Returns the results of a cruise as an svg picture, using the dot reporter
+ * and a system call to the GraphViz dot executable.
+ *
+ * @param {ICruiseResult} pResults
+ * @param {IDotPictureReporterOptions} pPicchaReporterOptions
+ * @returns {IReporterOutput}
+ */
+export default function piccha(pResults, pPicchaReporterOptions) {
+  const { output } = dotModuleReporter(pResults, pPicchaReporterOptions);
+
+  if (!isAvailable(pPicchaReporterOptions)) {
+    throw new Error(
+      "GraphViz dot, which is required for the 'x-dot-webpage' reporter doesn't seem to be available on this system. See the GraphViz download page for instruction on how to get it on your system: https://www.graphviz.org/download/",
+    );
+  }
+  return {
+    output: wrappapiccha(convert(output, pPicchaReporterOptions)),
+    exitCode: 0,
+  };
+}
diff --git a/src/cli/tools/svg-in-html-snippets/script.js b/src/report/dot-webpage/svg-in-html-snippets/script.js
similarity index 100%
rename from src/cli/tools/svg-in-html-snippets/script.js
rename to src/report/dot-webpage/svg-in-html-snippets/script.js
diff --git a/src/cli/tools/svg-in-html-snippets/style.css b/src/report/dot-webpage/svg-in-html-snippets/style.css
similarity index 100%
rename from src/cli/tools/svg-in-html-snippets/style.css
rename to src/report/dot-webpage/svg-in-html-snippets/style.css
diff --git a/src/report/dot-webpage/wrap-in-html.mjs b/src/report/dot-webpage/wrap-in-html.mjs
new file mode 100755
index 00000000..f0ce8b0f
--- /dev/null
+++ b/src/report/dot-webpage/wrap-in-html.mjs
@@ -0,0 +1,61 @@
+import { readFileSync } from "node:fs";
+import { fileURLToPath } from "node:url";
+
+const STYLESHEET_FILE = fileURLToPath(
+  new URL("svg-in-html-snippets/style.css", import.meta.url),
+);
+const SCRIPT_FILE = fileURLToPath(
+  new URL("svg-in-html-snippets/script.js", import.meta.url),
+);
+
+/**
+ * @param {string} pStylesheet
+ * @returns {string}
+ */
+export function getHeader(pStylesheet) {
+  return `<!doctype html>
+<html lang="en" dir="ltr">
+  <head>
+    <meta charset="utf-8" />
+    <title>dependency graph</title>
+    <style>
+      ${pStylesheet}
+    </style>
+  </head>
+  <body>
+    <button id="button_help">?</button>
+    <div id="hints" class="hint" style="display: none">
+      <button id="close-hints">x</button>
+      <span id="hint-text"></span>
+      <ul>
+        <li><b>Hover</b> - highlight</li>
+        <li><b>Right-click</b> - pin highlight</li>
+        <li><b>ESC</b> - clear</li>
+      </ul>
+    </div>
+`;
+}
+
+/**
+ * @param {string} pScript
+ * @returns {string}
+ */
+export function getFooter(pScript) {
+  return `    <script>
+      ${pScript}
+    </script>
+  </body>
+</html>`;
+}
+
+/**
+ *
+ * @param {string} pSVG
+ * @returns {string}
+ */
+export function wrappapiccha(pSVG) {
+  const lStylesheet = readFileSync(STYLESHEET_FILE, "utf8");
+  const lScript = readFileSync(SCRIPT_FILE, "utf8");
+
+  return getHeader(lStylesheet) + pSVG + getFooter(lScript);
+}
diff --git a/src/report/index.mjs b/src/report/index.mjs
index 4a7318ec..b4973fa6 100644
--- a/src/report/index.mjs
+++ b/src/report/index.mjs
@@ -23,6 +23,7 @@ const TYPE2MODULE = new Map([
   ["null", "./null.mjs"],
   ["teamcity", "./teamcity.mjs"],
   ["text", "./text.mjs"],
+  ["x-dot-webpage", "./dot-webpage/dot-module.mjs"],
 ]);
 
 /**


Test patch:
diff --git a/test/report/dot-webpage/dot-module.spec.mjs b/test/report/dot-webpage/dot-module.spec.mjs
new file mode 100644
index 00000000..47c97559
--- /dev/null
+++ b/test/report/dot-webpage/dot-module.spec.mjs
@@ -0,0 +1,135 @@
+import { throws, ok, equal, match } from "node:assert/strict";
+import { spawnSync } from "node:child_process";
+import dotModule from "#report/dot-webpage/dot-module.mjs";
+
+const MINIMAL_CRUISE_RESULT = {
+  modules: [],
+  summary: {
+    error: 0,
+    info: 0,
+    warn: 0,
+    ignore: 0,
+    totalCruised: 0,
+    violations: [],
+    optionsUsed: {},
+  },
+};
+
+describe("[I] dot-picture", () => {
+  it("should throw an error when dot is not available", () => {
+    const lDotPictureReporterOptions = {
+      spawnFunction: () => ({
+        stdout: "foo bar baz",
+        stderr: "error: command not found: dot",
+        status: 1,
+      }),
+    };
+
+    throws(
+      () => {
+        dotModule(MINIMAL_CRUISE_RESULT, lDotPictureReporterOptions);
+      },
+      Error,
+      "GraphViz dot, which is required for the 'x-dot-webpage' reporter doesn't seem to be available on this system. See the GraphViz download page for instruction on how to get it on your system: https://www.graphviz.org/download/",
+    );
+  });
+
+  it("throws an error when dot is available but isn't graphviz dot", () => {
+    const lDotPictureReporterOptions = {
+      spawnFunction: () => ({
+        stderr: "dot - sneaky template engine version 0.0.1",
+        status: 0,
+      }),
+    };
+
+    throws(
+      () => {
+        dotModule(MINIMAL_CRUISE_RESULT, lDotPictureReporterOptions);
+      },
+      Error,
+      "GraphViz dot, which is required for the 'x-dot-webpage' reporter doesn't seem to be available on this system. See the GraphViz download page for instruction on how to get it on your system: https://www.graphviz.org/download/",
+    );
+  });
+
+  it("throws an error when dot throws an error", () => {
+    const lDotPictureReporterOptions = {
+      spawnFunction: (pString, pCommandsArray) => {
+        if (pCommandsArray[0] === "-V") {
+          return {
+            stderr: "dot - graphviz version 1.2.3.4",
+            status: 0,
+          };
+        } else {
+          return {
+            stdout: "foo bar",
+            stderr: "baz quux corge grault garply waldo fred plugh xyzzy thud",
+            status: 1,
+            error: new Error("some error, doesn't really matter which"),
+          };
+        }
+      },
+    };
+
+    throws(
+      () => {
+        dotModule(MINIMAL_CRUISE_RESULT, lDotPictureReporterOptions);
+      },
+      Error,
+      "GraphViz dot, which is required for the 'x-dot-webpage' reporter doesn't seem to be available on this system. See the GraphViz download page for instruction on how to get it on your system: https://www.graphviz.org/download/",
+    );
+  });
+
+  it("throws an error when dot doesn't throw an error, but nonetheless fails", () => {
+    const lDotPictureReporterOptions = {
+      spawnFunction: (pString, pCommandsArray) => {
+        if (pCommandsArray[0] === "-V") {
+          return {
+            stderr: "dot - graphviz version 1.2.3.4",
+            status: 0,
+          };
+        } else {
+          return {
+            stdout: "foo bar",
+            stderr: "baz quux corge grault garply waldo fred plugh xyzzy thud",
+            status: 1,
+          };
+        }
+      },
+    };
+
+    throws(
+      () => {
+        dotModule(MINIMAL_CRUISE_RESULT, lDotPictureReporterOptions);
+      },
+      Error,
+      "GraphViz dot, which is required for the 'x-dot-webpage' reporter doesn't seem to be available on this system. See the GraphViz download page for instruction on how to get it on your system: https://www.graphviz.org/download/",
+    );
+  });
+
+  it("should return an object with output and exitCode when dot is available", () => {
+    const lDotPictureReporterOptions = {
+      spawnFunction: (pString, pCommandsArray) => {
+        if (pCommandsArray[0] === "-V") {
+          return {
+            stderr: "dot - graphviz version 1.2.3.4",
+            status: 0,
+          };
+        } else {
+          return {
+            stdout: "<svg></svg>",
+            status: 0,
+          };
+        }
+      },
+    };
+
+    const result = dotModule(MINIMAL_CRUISE_RESULT, lDotPictureReporterOptions);
+
+    ok(result);
+    ok(result.output);
+    match(result.output, /<html/);
+    match(result.output, /<[/]html/);
+    match(result.output, /<svg/);
+    equal(result.exitCode, 0);
+  });
+});