フロントエンドの認知的複雑度を 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.js(window.historyData = {...})を <script> タグで読み込む形にすることでサーバーなしで動く。グラフの実装はバックエンド編と同じ構成なので省略します。
まとめ
-
sonarjs/cognitive-complexityは関数単位でしか計測できないが、threshold=1 にして全スコアを Python 側で集計すればファイル単位の計測も1回で実現できます - 関数名は ESLint の出力には含まれませんが、行番号から数行遡る正規表現で実用的に取得できます
- history.json のスキーマをバックエンドと揃えておくと、ダッシュボードを共通化しやすくなります