algonote(en)

There's More Than One Way To Do It

Learning dependency-cruiser with Misskey

Visualizing and validating frontend dependencies

I want to visualize frontend dependencies

In many full-stack backend web frameworks, the directory structure is predetermined—controllers go here, models there, views over there. On the other hand, frontend libraries, are much freer, and directory layouts often differ from company to company.

dependency-cruiser is a library for visualizing and validating dependencies. It supports JavaScript, TypeScript, and CoffeeScript, and works with React, Vue, and Svelte.

Using Misskey’s (Vue) codebase, let’s see what we can do around frontend dependencies with dependency-cruiser.

Setup

Install Misskey:

$ git clone git@github.com:misskey-dev/misskey.git
$ cd misskey

# HEAD ebdb4431804cb23670054a0d37928ba92c84a0a4
$ nodenv install 20.10.0
$ pnpm i

Install dependency-cruiser:

$ cd packages/frontend/
$ pnpm add -D dependency-cruiser
$ pnpm exec dependency-cruise
Usage: dependency-cruise [options] [files-or-directories]

Validate and visualize dependencies.
Details: https://github.com/sverweij/dependency-cruiser

Options:
  --init [oneshot]               set up dependency-cruiser for use in your environment (<<< recommended!)

  -c, --config [file]            read rules and options from [file] (e.g. .dependency-cruiser.js) (default: true)
  -T, --output-type <type>       output type; e.g. err, err-html, dot, ddot, archi, flat, d2, mermaid, text or json (default:
                                 "err")
  -m, --metrics                  calculate stability metrics (default: false)
  -f, --output-to <file>         file to write output to; - for stdout (default: "-")
  -I, --include-only <regex>     only include modules matching the regex
  -F, --focus <regex>            only include modules matching the regex + their direct neighbours
  --focus-depth <number>         the depth to focus on - only applied when --focus is passed too. 1= direct neighbors,
                                 2=neighbours of neighbours etc. (default: 1)
  -R, --reaches <regex>          only include modules matching the regex + all modules that can reach it
  -H, --highlight <regex>        mark modules matching the regex as 'highlighted'
  -x, --exclude <regex>          exclude all modules matching the regex
  -X, --do-not-follow <regex>    include modules matching the regex, but don't follow their dependencies
  --ignore-known [file]          ignore known violations as saved in [file] (default:
                                 .dependency-cruiser-known-violations.json)
  -S, --collapse <regex>         collapse a to a folder depth by passing a single digit (e.g. 2). When passed a regex collapses
                                 to that pattern. E.g. "^packages/[^/]+/" would collapse to modules/ folders directly under
                                 your packages folder.
  -p, --progress [type]          show progress while dependency-cruiser is busy (choices: "cli-feedback", "performance-log",
                                 "ndjson", "none")
  -P, --prefix <prefix>          prefix to use for links in the dot and err-html reporters

  -C, --cache [cache-directory]  (experimental) use a cache to speed up execution. The directory defaults to
                                 node_modules/.cache/dependency-cruiser
  --cache-strategy <strategy>    (experimental) strategy to use for detecting changed files in the cache. (choices: "metadata",
                                 "content")
  -i, --info                     shows what languages and extensions dependency-cruiser supports
  -V, --version                  output the version number
  -h, --help                     display help for command

Other options:
  see https://github.com/sverweij/dependency-cruiser/blob/main/doc/cli.md

Use --init to generate a config file (.dependency-cruiser.cjs).

$ pnpm exec depcruise --init
✔ It looks like this is an ESM package. Is that correct? … yes
✔ Where do your source files live? … src
✔ Do your test files live in a separate folder? … yes
✔ Where do your test files live? … test
✔ Looks like you're using a 'tsconfig.json'. Use that? … yes
✔ Full path to your 'tsconfig.json › tsconfig.json
✔ Also regard TypeScript dependencies that exist only before compilation? … yes

  ✔ Successfully created '.dependency-cruiser.cjs'

By adjusting your tsconfig, it will also take path aliases into account when analyzing dependencies.

Analyzing code smells

Let’s analyze code smells. Specify --include-only to suppress external files output.

$ pnpm exec depcruise --include-only "^src" src
  warn no-orphans: src/scripts/get-user-name.ts
  warn no-orphans: src/scripts/collect-page-vars.ts
  warn no-orphans: src/components/global/MkError.stories.meta.ts
  warn no-circular: src/ui/deck/deck-store.ts → 
      src/pizzax.ts →
      src/account.ts →
      src/components/MkSigninDialog.vue →
      src/components/MkModalWindow.vue →
      src/components/MkModal.vue →
      src/os.ts →
      src/components/MkDriveSelectDialog.vue →
      src/components/MkDrive.vue →
      src/components/MkDrive.file.vue →
      src/router.ts →
      src/pages/settings/deck.vue →
      src/ui/deck/deck-store.ts
  warn no-circular: src/ui/_common_/common.ts → 
      src/instance.ts →
      src/os.ts →
      src/components/MkDriveSelectDialog.vue →
      src/components/MkDrive.vue →
      src/components/MkDrive.file.vue →
      src/router.ts →
      src/pages/settings/navbar.vue →
      src/navbar.ts →
      src/ui/_common_/common.ts
(omitted)

We got no-orphans and no-circular warnings.

no-orphans detects files likely not used by anything else. In this run, get-user-name.ts and collect-page-vars.ts look like candidates.

no-circular reports cycle references. For example, deck-store.ts uses pizzax.ts, which uses account.ts, and following the chain eventually deck.vue leads back to the original deck-store.ts.

Other configuration options

In dependency-cruiser, some settings live in the config file while others are CLI options. Analysis-oriented settings are specified in the config file.

The generated .dependency-cruiser.cjs includes comments that describe most options:

/** @type {import('dependency-cruiser').IConfiguration} */
module.exports = {
  forbidden: [
    {
      name: 'no-circular',
      severity: 'warn',
      comment:
        'This dependency is part of a circular relationship. You might want to revise ' +
        'your solution (i.e. use dependency inversion, make sure the modules have a single responsibility) ',
      from: {},
      to: {
        circular: true
      }
    },
    {
      name: 'no-orphans',
      comment:
        "This is an orphan module - it's likely not used (anymore?). Either use it or " +
        "remove it. If it's logical this module is an orphan (i.e. it's a config file), " +
        "add an exception for it in your dependency-cruiser configuration. By default " +
        "this rule does not scrutinize dot-files (e.g. .eslintrc.js), TypeScript declaration " +
        "files (.d.ts), tsconfig.json and some of the babel and webpack configs.",
      severity: 'warn',
      from: {
        orphan: true,
        pathNot: [
          '(^|/)[.][^/]+[.](js|cjs|mjs|ts|json)$', // dot files
          '[.]d[.]ts$',                            // TypeScript declaration files
          '(^|/)tsconfig[.]json$',                 // TypeScript config
          '(^|/)(babel|webpack)[.]config[.](js|cjs|mjs|ts|json)$' // other configs
        ]
      },
      to: {},
    },
    {
      name: 'no-deprecated-core',
      comment:
        'A module depends on a node core module that has been deprecated. Find an alternative - these are ' +
        "bound to exist - node doesn't deprecate lightly.",
      severity: 'warn',
      from: {},
      to: {
        dependencyTypes: [
          'core'
        ],
        path: [
          '^(v8/tools/codemap)$',
          '^(v8/tools/consarray)$',
          '^(v8/tools/csvparser)$',
          '^(v8/tools/logreader)$',
          '^(v8/tools/profile_view)$',
          '^(v8/tools/profile)$',
          '^(v8/tools/SourceMap)$',
          '^(v8/tools/splaytree)$',
          '^(v8/tools/tickprocessor-driver)$',
          '^(v8/tools/tickprocessor)$',
          '^(node-inspect/lib/_inspect)$',
          '^(node-inspect/lib/internal/inspect_client)$',
          '^(node-inspect/lib/internal/inspect_repl)$',
          '^(async_hooks)$',
          '^(punycode)$',
          '^(domain)$',
          '^(constants)$',
          '^(sys)$',
          '^(_linklist)$',
          '^(_stream_wrap)$'
        ],
      }
    },
    {
      name: 'not-to-deprecated',
      comment:
        'This module uses a (version of an) npm module that has been deprecated. Either upgrade to a later ' +
        'version of that module, or find an alternative. Deprecated modules are a security risk.',
      severity: 'warn',
      from: {},
      to: {
        dependencyTypes: [
          'deprecated'
        ]
      }
    },
    {
      name: 'no-non-package-json',
      severity: 'error',
      comment:
        "This module depends on an npm package that isn't in the 'dependencies' section of your package.json. " +
        "That's problematic as the package either (1) won't be available on live (2 - worse) will be " +
        "available on live with an non-guaranteed version. Fix it by adding the package to the dependencies " +
        "in your package.json.",
      from: {},
      to: {
        dependencyTypes: [
          'npm-no-pkg',
          'npm-unknown'
        ]
      }
    },
    {
      name: 'not-to-unresolvable',
      comment:
        "This module depends on a module that cannot be found ('resolved to disk'). If it's an npm " +
        'module: add it to your package.json. In all other cases you likely already know what to do.',
      severity: 'error',
      from: {},
      to: {
        couldNotResolve: true
      }
    },
    {
      name: 'no-duplicate-dep-types',
      comment:
        "Likely this module depends on an external ('npm') package that occurs more than once " +
        "in your package.json i.e. bot as a devDependencies and in dependencies. This will cause " +
        "maintenance problems later on.",
      severity: 'warn',
      from: {},
      to: {
        moreThanOneDependencyType: true,
        // as it's pretty common to have a type import be a type only import
        // _and_ (e.g.) a devDependency - don't consider type-only dependency
        // types for this rule
        dependencyTypesNot: ["type-only"]
      }
    },

    /* rules you might want to tweak for your specific situation: */
    {
      name: 'not-to-test',
      comment:
        "This module depends on code within a folder that should only contain tests. As tests don't " +
        "implement functionality this is odd. Either you're writing a test outside the test folder " +
        "or there's something in the test folder that isn't a test.",
      severity: 'error',
      from: {
        pathNot: '^(test)'
      },
      to: {
        path: '^(test)'
      }
    },
    {
      name: 'not-to-spec',
      comment:
        'This module depends on a spec (test) file. The sole responsibility of a spec file is to test code. ' +
        "If there's something in a spec that's of use to other modules, it doesn't have that single " +
        'responsibility anymore. Factor it out into (e.g.) a separate utility/ helper or a mock.',
      severity: 'error',
      from: {},
      to: {
        path: '[.](spec|test)[.](js|mjs|cjs|ts|ls|coffee|litcoffee|coffee[.]md)$'
      }
    },
    {
      name: 'not-to-dev-dep',
      severity: 'error',
      comment:
        "This module depends on an npm package from the 'devDependencies' section of your " +
        'package.json. It looks like something that ships to production, though. To prevent problems ' +
        "with npm packages that aren't there on production declare it (only!) in the 'dependencies'" +
        'section of your package.json. If this module is development only - add it to the ' +
        'from.pathNot re of the not-to-dev-dep rule in the dependency-cruiser configuration',
      from: {
        path: '^(src)',
        pathNot: '[.](spec|test)[.](js|mjs|cjs|ts|ls|coffee|litcoffee|coffee[.]md)$'
      },
      to: {
        dependencyTypes: [
          'npm-dev',
        ],
        // type only dependencies are not a problem as they don't end up in the
        // production code or are ignored by the runtime.
        dependencyTypesNot: [
          'type-only'
        ],
        pathNot: [
          'node_modules/@types/'
        ]
      }
    },
    {
      name: 'optional-deps-used',
      severity: 'info',
      comment:
        "This module depends on an npm package that is declared as an optional dependency " +
        "in your package.json. As this makes sense in limited situations only, it's flagged here. " +
        "If you're using an optional dependency here by design - add an exception to your" +
        "dependency-cruiser configuration.",
      from: {},
      to: {
        dependencyTypes: [
          'npm-optional'
        ]
      }
    },
    {
      name: 'peer-deps-used',
      comment:
        "This module depends on an npm package that is declared as a peer dependency " +
        "in your package.json. This makes sense if your package is e.g. a plugin, but in " +
        "other cases - maybe not so much. If the use of a peer dependency is intentional " +
        "add an exception to your dependency-cruiser configuration.",
      severity: 'warn',
      from: {},
      to: {
        dependencyTypes: [
          'npm-peer'
        ]
      }
    }
  ],
 // omitted
}
  • no-circular: checks for circular dependencies
  • no-orphans: checks for orphaned files
  • no-deprecated-core: flags usage of deprecated Node core modules
  • not-to-deprecated: flags usage of deprecated npm modules
  • no-non-package-json: flags npm packages not listed under dependencies
  • not-to-unresolvable: flags modules that can’t be resolved
  • no-duplicate-dep-types: detects duplicates in package.json
  • not-to-test: prevents depending on code in test-only folders
  • not-to-spec: prevents depending on spec files
  • not-to-dev-dep: prevents depending on devDependencies from production code
  • optional-deps-used: flags use of optional dependencies
  • peer-deps-used: flags use of peer dependencies

Visualizing dependencies

If you have GraphViz dot installed, you can render dependency images (or generate interactive HTML).

There are several styles to choose from. Since Misskey’s codebase has a fair number of files, a style that groups nodes somewhat tends to work better.

pnpm exec depcruise --include-only "^src" --output-type ddot src | dot -T jpg > dependency-graph.jpg

$ pnpm exec depcruise --include-only "^src" --output-type archi src | dot -T jpg > archi-graph.jpg

Emitting code quality metrics

Instability is one metric for code quality.

  • Afferent couplings (Ca): how many external modules depend on the module
  • Efferent couplings (Ce): how many dependencies the module uses

Instability = Ce / (Ce + Ca). Lower is better.

With dependency-cruiser you can output this via --output-type metrics.

$ pnpm exec depcruise --include-only "^src" --output-type metrics src |grep -v impl
name                                                                 N     Ca     Ce  I (%)
--------------------------------------------------------------- ------ ------ ------ ------
src/_dev_boot_.ts                                                    1      0      1   100%
src/directives/follow-append.ts                                      1      0      1   100%
src/scripts/gen-search-query.ts                                      1      0      1   100%
src/scripts/theme-editor.ts                                          1      0      1   100%
src/widgets                                                         69      2    144    99%
src/boot/main-boot.ts                                                1      1     26    96%
src/ui/deck.vue                                                      1      1     26    96%
src/boot                                                             3      2     46    96%
src/pages/admin-user.vue                                             1      1     22    96%
src/components/index.ts                                              1      1     20    95%
src/pages/channel.vue                                                1      1     20    95%
src/pages/settings/general.vue                                       1      1     20    95%
src/ui/universal.vue                                                 1      1     19    95%
src/pages/instance-info.vue                                          1      1     18    95%
src/pages/settings/profile.vue                                       1      1     18    95%
src/pages/about.vue                                                  1      1     16    94%
src/pages/page.vue                                                   1      1     16    94%
src/pages/user                                                      32      6     96    94%
(omitted)
src/widgets/WidgetDigitalClock.vue                                   1      1      0     0%
src/widgets/WidgetFederation.vue                                     1      1      0     0%
src/widgets/WidgetInstanceCloud.vue                                  1      1      0     0%
src/widgets/WidgetInstanceInfo.vue                                   1      1      0     0%
src/widgets/WidgetJobQueue.vue                                       1      1      0     0%
src/widgets/WidgetMemo.vue                                           1      1      0     0%
src/widgets/WidgetNotifications.vue                                  1      1      0     0%
src/widgets/WidgetOnlineUsers.vue                                    1      1      0     0%
src/widgets/WidgetPhotos.vue                                         1      1      0     0%
src/widgets/WidgetPostForm.vue                                       1      1      0     0%
src/widgets/WidgetProfile.vue                                        1      1      0     0%
src/widgets/WidgetRss.vue                                            1      1      0     0%
src/widgets/WidgetRssTicker.vue                                      1      1      0     0%
src/widgets/WidgetSlideshow.vue                                      1      1      0     0%
src/widgets/WidgetTimeline.vue                                       1      1      0     0%
src/widgets/WidgetTrends.vue                                         1      1      0     0%
src/widgets/WidgetUnixClock.vue                                      1      1      0     0%
src/widgets/WidgetUserList.vue                                       1      1      0     0%
src/workers                                                          2      2      0     0%
src/workers/draw-blurhash.ts                                         1      1      0     0%
src/workers/test-webgl2.ts                                           1      1      0     0%

Roughly speaking, consumer-side modules (like pages) trend higher, while provider-side modules (like widgets) trend lower.

Outputting a dependency matrix

You can output a dependency matrix by generating HTML.

If you can see which components depend on which other components, and which components are used by which pages, you can likely prevent unexpected regressions.

$ pnpm exec depcruise --include-only "^src/components.*vue|^src/pages.*vue" --output-type html src > dependencies.html

You can see MkButton is used in many places.

There are different schools of thought, but since MkButton and MkAnnouncementDialog are at different granularities, moving MkButton into an atoms directory instead of components might make the codebase easier to navigate.

Closing

That’s it.

As the docs describe, you can also validate architectural adherence—e.g., checking that features are isolated from other features (features-not-to-features). It’s quite a versatile tool.