9
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ESLint×GitHub Pagesで始める『コードベース健康診断』― 認知複雑度を定点観測して負債を可視化するダッシュボード構築(フロントエンド編)

9
Last updated at Posted at 2026-06-24

フロントエンドの認知的複雑度を ESLint + sonarjs で定点計測する

バックエンド(PHP)編はこちら → PHPStan×GitHub Pagesで始める『コードベース健康診断』― 認知複雑度を定点観測して負債を可視化するダッシュボード構築(バックエンド編)

バックエンドで PHPStan による認知的複雑度計測の仕組みを作ったので、フロントエンド(Vue / JS)にも同じ仕組みを導入しました。

やりたいこと

  • 関数単位でスコアが閾値を超えたら検知する
  • ファイル単位で全関数スコアの合計が閾値を超えたら検知する(小さい関数がたくさんある場合も拾う)
  • 計測結果を蓄積して推移をグラフで見る

全体の流れ

ESLint (threshold=1) → report.json → Python → HTML レポート + history.json
                                                       ↓
                                               Chart.js ダッシュボード

アプローチの選定

計測結果の可視化として最初に SonarScanner + SonarQube を検討しました。sonarjs は SonarQube のルールセットと同じ計算式を使っており、そのまま連携できます。

ただし SonarQube はサーバーが必要で、常時アクセスできる環境を別途用意しなければなりません。
今回はいつでも GitHub Pages で見られることを優先したため、ESLint でスコアを取得し Python でパースして静的 HTML を生成する構成を採用しました。

方法 メリット デメリット
SonarScanner + SonarQube 公式ダッシュボードが充実 サーバーが必要、常時公開に手間がかかる
ESLint + Python(今回) 静的 HTML で GitHub Pages に載せられる 解析スクリプトを自前で書く必要がある

なぜ threshold=1 にするのか

ESLint の sonarjs/cognitive-complexity は、設定した閾値を超えた関数しか報告しません。

たとえば threshold=15 にすると、スコアが 1〜15 の関数は出力に含まれません。これでは「小さい関数がたくさんあってファイル全体は複雑」というケースを検知できなくなってしまいます。

そこで threshold=1 にして全関数のスコアを取得し、Python 側で閾値フィルタをかけます。
これで1回の実行で関数・ファイル両方の計測が完結できます。


1. インストール

npm install --save-dev eslint-plugin-sonarjs

バージョンは ^4.1.0 以降を推奨。


2. 計測専用の ESLint 設定ファイル

既存の設定は変えずに、計測専用ファイルを別途作ります。

// .eslintrc-cognitive.js
module.exports = {
  extends: ['./.eslintrc.js'],
  plugins: ['sonarjs'],
  rules: {
    'sonarjs/cognitive-complexity': ['error', 1],
  },
};

ESLint v9+ で Flat Config(eslint.config.js)を使っている場合は、eslint.config.cognitive.js として同様の設定を用意してください。


3. 計測の実行

./node_modules/.bin/eslint \
  --config .eslintrc-cognitive.js \
  --format json \
  --ext .js,.vue \
  src/ \
  > report.json 2>/dev/null

ESLint v9+ では --ext が廃止されているため、対象ファイルはグロブパターンで直接指定してください。

出力は以下のような配列形式になります。

[
  {
    "filePath": "/app/src/components/Foo.vue",
    "messages": [
      {
        "ruleId": "sonarjs/cognitive-complexity",
        "message": "Refactor this function to reduce its Cognitive Complexity from 18 to the 1 allowed.",
        "line": 42
      }
    ]
  }
]

スコアはメッセージ文字列の from N から正規表現で取得します。


4. Python で集計する(parse_report_js.py)

import json, re, sys

RULE_ID = "sonarjs/cognitive-complexity"
FUNC_LIMIT = 15   # 関数単位の閾値(SonarQube デフォルト)
FILE_LIMIT = 80   # ファイル単位の閾値(関数 15 × 5〜6本分が目安)

def severity(score, limit):
    ratio = score / limit
    if ratio >= 3.0: return "critical"
    if ratio >= 2.0: return "high"
    return "medium"

def parse(report_path):
    data = json.load(open(report_path))
    func_scores = []
    file_totals = {}

    for file_entry in data:
        path = file_entry.get("filePath", "")
        short_path = re.sub(r".*/src/", "src/", path)
        for msg in file_entry.get("messages", []):
            if msg.get("ruleId") != RULE_ID:
                continue
            m = re.search(r"Cognitive Complexity from (\d+)", msg.get("message", ""))
            if not m:
                continue
            score = int(m.group(1))
            # 全関数のスコアをファイルごとに合計
            file_totals[short_path] = file_totals.get(short_path, 0) + score
            # 関数単位の閾値超えだけ抽出
            if score > FUNC_LIMIT:
                func_scores.append((score, short_path))

    file_violations = [(t, p) for p, t in file_totals.items() if t > FILE_LIMIT]

    print(json.dumps({
        "function_violations": len(func_scores),
        "function_critical": sum(1 for s, _ in func_scores if severity(s, FUNC_LIMIT) == "critical"),
        "function_high":     sum(1 for s, _ in func_scores if severity(s, FUNC_LIMIT) == "high"),
        "function_medium":   sum(1 for s, _ in func_scores if severity(s, FUNC_LIMIT) == "medium"),
        "file_violations":   len(file_violations),
        "file_critical": sum(1 for t, _ in file_violations if severity(t, FILE_LIMIT) == "critical"),
        "file_high":     sum(1 for t, _ in file_violations if severity(t, FILE_LIMIT) == "high"),
        "file_medium":   sum(1 for t, _ in file_violations if severity(t, FILE_LIMIT) == "medium"),
    }))

if __name__ == "__main__":
    parse(sys.argv[1])

severity は「スコア ÷ 閾値」の比率で分類。

区分 条件
Critical score ≥ 3× 閾値
High score ≥ 2× 閾値
Medium score > 閾値

5. 関数名を行番号から取得する

上記の集計(JSON出力)とは別に、詳細なHTMLレポートを画面に書き出す際、ESLintの出力には関数名が含まれないため不便です。
そこで、以下のように違反行から最大10行遡って、正規表現でソースコードから関数名を取り出すロジックを組み込んでいます。

_P0 = r'(\w+)\s*:\s*(?:async\s*)?(?:function|\()'        # foo: function / foo: (
_P1 = r'(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s*)?\('  # const foo = (
_P2 = r'(?:async\s+)?function\s+(\w+)\s*\('               # function foo(
_P3 = r'(?:^|[\s,{])(?:async\s+)?(\w+)\s*\('              # foo(
FUNC_RE = re.compile(_P0 + '|' + _P1 + '|' + _P2 + '|' + _P3)
JS_KEYWORDS = {
    'if', 'for', 'while', 'switch', 'catch', 'return', 'typeof',
    'new', 'delete', 'void', 'throw', 'await', 'import', 'export',
    'super', 'function',
}

def extract_func_name(file_path, line_no):
    try:
        lines = open(file_path, encoding="utf-8", errors="ignore").readlines()
    except OSError:
        return None
    for i in range(line_no - 1, max(-1, line_no - 11), -1):
        line = lines[i] if 0 <= i < len(lines) else ""
        # 関数定義らしい行に絞る
        if not ('{' in line or '=>' in line or 'function' in line):
            continue
        m = FUNC_RE.search(line)
        if not m:
            continue
        name = next((g for g in m.groups() if g), None)
        if name and name not in JS_KEYWORDS:
            return name
    return None

対応しているパターンは以下の通り。

パターン
Options API メソッド mounted: function () {
アロー関数 const fetchData = async (id) => {
通常の関数宣言 function validateForm(input) {
メソッド shorthand handleClick(event) {

取得できなかった場合は line:行番号 にフォールバック。

※VueのSFC(.vue)などで稀に関数名がうまく取れないエッジケースもありますが、その場合は line:行番号 にフォールバックされるため、ダッシュボードとしての実用上はこれで十分に機能しています。


6. 推移の蓄積

計測結果を history.json に追記していく。形式はバックエンド編と共通にしておくと、ダッシュボードの実装を流用できます。

{
  "schema_version": 1,
  "entries": [
    {
      "date": "2026-06-23",
      "repo": "frontend",
      "function_violations": 6,
      "function_critical": 1,
      "function_high": 0,
      "function_medium": 5,
      "file_violations": 1,
      "file_critical": 0,
      "file_high": 1,
      "file_medium": 0
    }
  ]
}

ダッシュボードは Chart.js を CDN から読み込み、history.jswindow.historyData = {...})を <script> タグで読み込む形にすることでサーバーなしで動く。グラフの実装はバックエンド編と同じ構成なので省略します。


まとめ

  • sonarjs/cognitive-complexity は関数単位でしか計測できないが、threshold=1 にして全スコアを Python 側で集計すればファイル単位の計測も1回で実現できます
  • 関数名は ESLint の出力には含まれませんが、行番号から数行遡る正規表現で実用的に取得できます
  • history.json のスキーマをバックエンドと揃えておくと、ダッシュボードを共通化しやすくなります
9
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?