JavaScriptプロジェクトの循環的複雑度を算出して定量化する

6 min読了の目安(約3600字TECH技術記事

皆様は循環的複雑度の高いコードを書いていますか?保守していますか?

プロジェクトのコードを見て、やたらややこしいなーと感じたときに、それを定量化するための方法を紹介します。

インストール

escomplex という循環的複雑度を算出するプログラムを使います。ただこのプログラムでは TypeScript や JSX などには対応していません。
そこで、Babel でいったんいい感じにトランスパイルしてから escomplex にかけます[1]

$ yarn add -D @babel/core @babel/plugin-proposal-class-properties \
@babel/preset-env @babel/preset-react @babel/preset-typescript \
@types/babel__core @types/node \
escomplex esprima

Babel関連のインストールと、escomplex および、それが必要とする esprima をインストールしています。

循環的複雑度を取得する

const { analyse } = require('escomplex')
import { transformFileSync } from '@babel/core'

const getAnalysed = (filename: string) => {
  const { code } = transformFileSync(filename, {
    presets: [
      '@babel/preset-typescript',
      '@babel/preset-react',
      '@babel/preset-env',
    ],
    plugins: ['@babel/plugin-proposal-class-properties'],
  })!

  const { cyclomatic } = analyse(code).aggregate
  return { filename, cyclomatic }
}

1行目で require を使ってるのは、escomplex に型定義がないため苦肉の策です。

getAnalysed 関数は引数にファイル名 filename を取ります。このファイル名から @babel/coretransformFileSyncescomplex が受け付ける形へトランスパイルしています。プロジェクトによっては必要によって他のプリセットやプラグインが必要になるかもしれません。

トランスパイル済みの codeescomplexanalyse(code).aggregate.cyclomatic で循環的複雑度を取得しています。

ここまでである1ファイルの循環的複雑度を取得できるようになりました。

ファイルを総なめする

import fs from 'fs'
import path from 'path'

const analysed: Array<{ filename: string; cyclomatic: number }> = []

const getExt = (filename: string) => {
  return filename.split('.').slice(-1)[0]
}

const isTarget = (filename: string) => {
  return ['js', 'ts', 'tsx'].includes(getExt(filename))
}

const traverse = (name: string) => {
  fs.readdirSync(name, { withFileTypes: true }).forEach((entry) => {
    const filename = path.join(name, entry.name)
    if (entry.isDirectory()) {
      traverse(filename)
    } else if (isTarget(filename)) {
      analysed.push(getAnalysed(filename))
    }
  })
}

traverse(path.resolve(process.argv[2]))

Node.js の FS API を使って、ファイル名を再帰的に取得し、.js.ts.tsx を見つけたらさっきの getAnalysed を呼び出して、全対象ファイルの循環的複雑度を取得します。

結果を表示する

あとは結果を表示します。

console.log('all files:', analysed.length, 'files')
const all = analysed.length
const under30 = analysed.filter((v) => v.cyclomatic <= 30).length
const under50 = analysed.filter((v) => v.cyclomatic > 30 && v.cyclomatic <= 50)
  .length
const under75 = analysed.filter((v) => v.cyclomatic > 50 && v.cyclomatic <= 75)
  .length
const over75 = analysed.filter((v) => v.cyclomatic > 75).length

console.log(
  '1-30 (bug rate 25%)',
  under30,
  'files',
  `${Math.floor((under30 / all) * 10000) / 100}%`,
)
console.log(
  '31-50 (bug rate 40-75%)',
  under50,
  'files',
  `${Math.floor((under50 / all) * 10000) / 100}%`,
)
console.log(
  '51-75 (bug rate 75%-99%)',
  under75,
  'files',
  `${Math.floor((under75 / all) * 10000) / 100}%`,
)
console.log(
  '76- (oops)',
  over75,
  'files',
  `${Math.floor((over75 / all) * 10000) / 100}%`,
)

console.log(
  analysed
    .sort((a, b) => b.cyclomatic - a.cyclomatic)
    .map((x) => `${path.relative(process.cwd(), x.filename)}: ${x.cyclomatic}`)
    .slice(0, 10),
)

すみません、すごく雑なコードです。

一応、循環的複雑度とバグの発生度合いの対応表を元に算出していますが、この結果は必ずしも厳密ではありません。
とはいえ、それなりに傾向としては合っているはずなので、もし、アホみたいにややこしいコードを抱えたプロジェクトをやっているなら、是非とも循環的複雑度の算出をしてみてください。

最後の console.log で、循環的複雑度の上位10ファイルを表示しています。.slice(0, 10) をいじればその数を調整することもできます。

これにより要修正候補のファイルが見つかるかもしれません。

※実際のところ色々な理由により、この方法では厳密な値を取ることはできません。あくまで傾向として参考にしてください。
真面目に算出したい場合、escomplexを使うよりは作り直した方が良いでしょう。

脚注
  1. Babel でトランスパイルしてしまってるため、厳密な循環的複雑度は算出できません。数値の厳密性は犠牲になりますが、おおよその複雑性の目安にはなるはずです……。たぶん……。 ↩︎