vueファイルの複雑度を計測したい


循環的複雑度

ソースコードを計測すると循環的複雑度という項目があります。
これは分岐が多いほど増加していく項目で、分岐が多い→通過するパスが多い→テストがしづらい→バグが出やすくなると考えられています。

ESlintなどの静的解析ツールでも複雑度を計測していて一定の閾値を超えると警告されたりするので、割と馴染みのある計測内容です。

今回はvue.jsを使用している環境でvueファイルを含んだjavascriptの複雑度を計測する方法を考えてみます。

対象のファイル
https://github.com/mima3/vuejs_metrics/tree/master/__tests__/test_src/

ES6-Plato

javascriptでコードメトリックスを取得する場合、ES6-Platoをよく使用していました。
https://www.npmjs.com/package/es6-plato

以下のような感じでグラフィカルに出力しているので重宝しておりました。

ただ残念ながら、jsファイルは解析してくれますが、vueファイルは解析してくれません。

typhonjs-escomplex

typhonjs-escomplexはes6-plato内で使用されているメトリックス集計のライブラリです。

templateタグとかscriptタグが混在しているファイルだとエラーになるので以下のようにscritpタグの部分だけ抽出して計測するのが下記のコードになります。

const fs = require('fs');

const doc = fs.readFileSync('test_src/vue/test1.vue', 'utf-8');
const escomplex = require('typhonjs-escomplex');

const srcs = doc.match(/(?<=<script>)[\s\S]*?(?=<\/script>)/g);
srcs.map((src)=>{
 console.log(escomplex.analyzeModule(src)); 

});

入力ファイル


<template>
  <div class="example">
    <span class="title">{{ text }}</span>
  </div>
</template>

<script>
  export default {
    name: 'Example',
    data() {
      return {
        text: 'example'
      }
    },
    methods: {
      // complex 3
      test1 () {
        if (this.text) {
        } else if (this.text === 'dog') {
        } else {
        }
      },
      // complex 2
      test2 () {
        try {
        } catch (ex) {
        }
      },
      // complex 2
      test3 () {
        for (var i = 0; i < 10 ; ++i) {
        }
      },
      // complex 2
      test4 () {
        for (var x in [1,2,3]) {
        }
      },
      // complex 2
      test5 () {
        for (var x of [1,2,3]) {
        }
      },
      // complex 2
      test6 () {
        while (true) {
        }
      },
      // complex 2
      test7 () {
        do {
        } while (i < 5);
      },
      // complex 5
      test8 () {
        switch (this.text) {
        case 'A':
          break;
        case 'B':
          break;
        case 'C':
          break;
        default:
        }
      },
      // complex 2
      test8 () {
        var x = this.text === 'dog'?'':'';
      },
      // complex 4
      test9 () {
        if (this.text !== null || this.text !== '' || this.text === 'inu') {
        }
      }
    }
  }
</script>

<!-- scoped CSS -->
<style scoped>
  .title {
    color: #ffbb00;
  }
</style>

結果

ModuleReport {
  aggregate: AggregateReport {
    aggregate: undefined,
    cyclomatic: 13,
    cyclomaticDensity: 35.135,
    halstead: HalsteadData {
      bugs: 0.198,
      difficulty: 23.333,
      effort: 13833.067,
      length: 104,
      time: 768.504,
      vocabulary: 52,
      volume: 592.846,
      operands: [Object],
      operators: [Object]
    },
    paramCount: 0,
    sloc: { logical: 37, physical: 86 }
  },
  settings: {
    commonjs: false,
    dependencyResolver: undefined,
    esmImportExport: { halstead: false, lloc: false },
    forin: false,
    logicalor: true,
    switchcase: true,
    templateExpression: { halstead: true, lloc: true },
    trycatch: false,
    newmi: false
  },
  classes: [],
  dependencies: [],
  errors: [],
  filePath: undefined,
  lineEnd: 86,
  lineStart: 1,
  maintainability: 77.329,
  methods: [],
  aggregateAverage: MethodAverage {
    cyclomatic: 13,
    cyclomaticDensity: 35.135,
    halstead: HalsteadAverage {
      bugs: 0.198,
      difficulty: 23.333,
      effort: 13833.067,
      length: 104,
      time: 768.504,
      vocabulary: 52,
      volume: 592.846,
      operands: [Object],
      operators: [Object]
    },
    paramCount: 0,
    sloc: { logical: 37, physical: 86 }
  },
  methodAverage: MethodAverage {
    cyclomatic: 0,
    cyclomaticDensity: 0,
    halstead: HalsteadAverage {
      bugs: 0,
      difficulty: 0,
      effort: 0,
      length: 0,
      time: 0,
      vocabulary: 0,
      volume: 0,
      operands: [Object],
      operators: [Object]
    },
    paramCount: 0,
    sloc: { logical: 0, physical: 0 }
  },
  srcPath: undefined,
  srcPathAlias: undefined
}

結果をみてみるとわかりますが、全体としての集計は出ているようですが、methodsのtest1()の値をみるということができません。

Acornを使って自力で計算する。

Acornを使用することでjavascriptの構文解析ができます。
https://github.com/acornjs/acorn

以下はacornjsの使用例になります

const fs = require('fs');
const acorn = require("acorn");
const walk = require("acorn-walk")

const doc = fs.readFileSync('test_src/vue/test1.vue', 'utf-8');
const srcs = doc.match(/(?<=<script>)[\s\S]*?(?=<\/script>)/g);


srcs.map((src)=>{
  const ast = acorn.parse(src, { sourceType: "module", ecmaVersion:2020});
  walk.fullAncestor(ast, (node, paths)=> {
    console.log(node.type, node.start, node.end);
    // 祖先ノード(現在のノードを含む)の配列
    paths.map((path)=>{
      console.log('  ', path.type, path.start, path.end);
    });
  });
});

acornを利用して自力で計算することができます。

ノードのタイプが次の場合、関数になります。

  • ArrowFunctionExpression
  • FunctionDeclaration
  • FunctionExpression

こうして抽出した関数を祖先とする以下のノードが存在したら、その関数の複雑度は増加します。

  • IfStatement
  • SwitchStatement
  • ForStatement
  • ForOfStatement
  • ForInStatement
  • DoWhileStatement
  • WhileStatement
  • CatchClause ... try-catchのcatch区
  • LogicalExpression ... a==1 || b==2とかの条件文
  • ConditionalExpression ... x = a?true:false とかの式

実際のコードは以下になります。
https://github.com/mima3/vuejs_metrics/blob/master/vuejs_metrics.js

まとめ

今回はvueファイルでも複雑度を計測する方法を検討してみました。
acornを使用することでjavascriptを解析して対応することもできます。
なお、ESLintはテストコード中にacornを使用しているようですが、実際の解析は自前でやっているようです。