12
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?

既存の負債はノイズにしない。PHPStan×GitHub Actionsで「今触ったコード」の認知複雑度だけを可視化する

12
Posted at

認知複雑度をPRで可視化するGitHub Actions

はじめに

コードレビュー中、「このメソッド、なんか読みにくいな」と感じたことはないでしょうか。
ネストが深く、条件分岐が絡み合い、頭の中で状態を追いかけるのがつらくなる——そういったコードは、認知複雑度/Cognitive Complexityが高い状態です。

認知複雑度は、SonarSource が提唱した指標です。よく知られる循環的複雑度/Cyclomatic Complexityがコードの実行パス数を測るのに対し、認知複雑度は「人間がコードを読んで理解するコスト」に着目しています。ネストの深さや分岐・ループの複雑さなど、読み手の認知負荷を上げる構造に重みを置いて加算します。

問題は、複雑度は一度に大きく上がるのではなく、少しずつ積み重なっていく点です。1 PR で少し複雑になり、次の PR でまた少し増え…気づいたときには手がつけられない状態になっています。

この記事では、PHP プロジェクトで PR のたびに認知複雑度を自動チェックし、結果を PR コメントに投稿する GitHub Actions の実装を紹介します。単純に「ファイル全体を計測して出す」のではなく、その PR で変更したメソッドだけに絞って出すのがポイントです。


完成イメージ

実装すると、PHP ファイルを変更した PR に対して、自動でこのようなコメントが投稿されます。

## 🧠 認知複雑度チェック

⚠️ 2件の閾値超過

| ファイル | 対象 | スコア | 閾値 |
|---------|------|-------|------|
| `app/Services/FooService.php` | `getDetail()` | 21 | 15 |
| `app/Services/BarService.php` | `buildQuery()` | 18 | 15 |

問題がなければこう表示されます。

## 🧠 認知複雑度チェック
✅ 変更ファイルに複雑度超過なし

PR を更新するたびにコメントが追記されるのではなく、既存コメントが上書きされるため、スレッドが汚れません。


仕組みの全体像

PR open / synchronize
  │
  ├─ 変更された PHP ファイルを検出(app/**/*.php のみ)
  │
  ├─ git diff | PHP スクリプト → 触った関数名を抽出
  │
  ├─ PHPStan で変更ファイルの複雑度を計測(JSON 出力)
  │
  └─ 抽出した関数名と照合 → PR コメントを投稿 or 更新

使用するツールは以下の 3 つです。

ツール 役割
tomasvotruba/cognitive-complexity PHPStan で認知複雑度を計測する拡張
PHP-Parser PHP コードを AST に変換し、メソッドの行範囲を取得
actions/github-script PR コメントの投稿・更新

ポイント①:「触った関数だけ」に絞る設計

なぜ必要か

変更したファイルに複雑なメソッドが 10 個あっても、今回の PR で触ったのが 1 つだけなら、残り 9 つの警告はノイズです。「既存の負債」まで一気に表示すると、レビュアーが本来見るべき変更に集中できなくなります。

そこで、diff に含まれる行が関数本体に掛かっているかどうかで絞り込みます。

extract-touched-functions.php の仕組み

このスクリプトは標準入力から unified diff を受け取り、変更されたメソッド名を出力します。

// PHP-Parser で AST を解析し、各メソッドの行範囲を収集する
class FunctionRangeCollector extends NodeVisitorAbstract
{
    public array $functions = [];

    public function enterNode(Node $node): ?int
    {
        if ($node instanceof ClassMethod || $node instanceof Function_) {
            $this->functions[] = [
                'name'      => $node->name->toString(),
                'startLine' => $node->getStartLine(),
                'endLine'   => $node->getEndLine(),
            ];
        }
        return null;
    }
}

unified diff からは「どのファイルの何行目が変わったか」を取り出します。

function parseChangedLines(string $diff): array
{
    $result = [];
    $currentFile = null;
    $newLineNum = 0;

    foreach (explode("\n", $diff) as $line) {
        if (strncmp($line, '+++ b/', 6) === 0) {
            $currentFile = substr($line, 6);
            continue;
        }
        if (strncmp($line, '@@ ', 3) === 0 && $currentFile !== null) {
            if (preg_match('/\+(\d+)/', $line, $m)) {
                $newLineNum = (int) $m[1];
            }
            continue;
        }
        // '+' で始まる行(追加行)の行番号を記録
        if ($line !== '' && $line[0] === '+' && substr($line, 0, 4) !== '+++ ') {
            $result[$currentFile][] = $newLineNum;
            $newLineNum++;
        } elseif ($line !== '' && $line[0] === '-') {
            $result[$currentFile][] = $newLineNum;
        } else {
            $newLineNum++;
        }
    }

    return $result;
}

あとは「変更行がメソッドの startLine〜endLine に含まれるか」を突き合わせるだけです。

function findTouchedFunctions(string $filePath, array $changedLines): array
{
    if (!file_exists($filePath)) {
        return [];
    }

    $code = file_get_contents($filePath);
    $parser = (new ParserFactory())->createForHostVersion();
    $ast = $parser->parse($code);

    $visitor = new FunctionRangeCollector();
    $traverser = new NodeTraverser();
    $traverser->addVisitor($visitor);
    $traverser->traverse($ast);
    $touched = [];
    $changedSet = array_flip($changedLines);

    foreach ($visitor->functions as $func) {
        for ($i = $func['startLine']; $i <= $func['endLine']; $i++) {
            if (isset($changedSet[$i])) {
                $touched[] = $func['name'];
                break;
            }
        }
    }

    return array_unique($touched);
}

出力形式は filepath\tメソッド名(タブ区切り)で、後段の GitHub Actions スクリプトが読み込みます。


ポイント②:PHPStan と GitHub Actions の設定

PHPStan 設定(phpstan-ci-cognitive.neon

認知複雑度の計測には tomasvotruba/cognitive-complexity を PHPStan 拡張として使います。設定ファイルはシンプルです。

includes:
  - vendor/tomasvotruba/cognitive-complexity/config/extension.neon

parameters:
  cognitive_complexity:
    class: 80
    function: 15
  level: 0

閾値はクラス全体 80、関数単位 15 に設定しています。最初から厳しくしすぎると既存コードが大量に引っかかるため、最初は緩めに設定して段階的に下げていく運用が現実的です。

ワークフロー全体

name: 認知複雑度チェック

on:
  pull_request:
    types: [opened, synchronize]
    paths:
      - 'app/**/*.php'

concurrency:
  group: cognitive-${{ github.event.pull_request.number }}
  cancel-in-progress: true

permissions: {}

jobs:
  cognitive-complexity:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      pull-requests: write

    steps:
      - name: Checkout code
        uses: actions/checkout@v4
        with:
          ref: refs/pull/${{ github.event.pull_request.number }}/head
          fetch-depth: 0

      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.0'
          coverage: none

      - name: 変更 PHP ファイルを取得
        id: changed-php-files
        uses: tj-actions/changed-files@v47
        with:
          files: 'app/**/*.php'
          safe_output: true

      - name: Composer cache
        if: steps.changed-php-files.outputs.any_changed == 'true'
        uses: actions/cache@v4
        with:
          path: vendor
          key: composer-${{ hashFiles('composer.lock') }}
          restore-keys: composer-

      - name: Composer install
        if: steps.changed-php-files.outputs.any_changed == 'true'
        run: composer install --no-scripts --no-plugins --no-interaction --prefer-dist

      - name: 触った関数名を diff から抽出
        if: steps.changed-php-files.outputs.any_changed == 'true'
        run: |
          git fetch origin ${{ github.event.pull_request.base.ref }}
          git diff origin/${{ github.event.pull_request.base.ref }}...HEAD -- 'app/**/*.php' \
            | php .github/scripts/extract-touched-functions.php \
            > /tmp/touched-functions.txt

      - name: PHPStan 実行
        if: steps.changed-php-files.outputs.any_changed == 'true'
        env:
          ALL_CHANGED_FILES: ${{ steps.changed-php-files.outputs.all_changed_files }}
        run: |
          IFS=' ' read -ra FILES <<< "$ALL_CHANGED_FILES"
          vendor/bin/phpstan analyse \
            -c phpstan-ci-cognitive.neon \
            --error-format=json \
            --no-progress \
            --memory-limit=1G \
            "${FILES[@]}" \
            > /tmp/phpstan-result.json || true

      - name: PR コメント投稿
        if: steps.changed-php-files.outputs.any_changed == 'true'
        uses: actions/github-script@v7
        with:
          script: |
            # 詳細はポイント③で解説

設計・ステップのポイント

paths フィルタ
app/**/*.php のみをトリガーにしています。設定ファイルや Blade テンプレートの変更では走りません。

fetch-depth: 0
デフォルトはシャロークローン(fetch-depth: 1)なので、そのままでは git diff origin/<base>...HEAD がベースブランチのコミットを見つけられずエラーになります。フル履歴を取得するために 0 を指定しています。

if: any_changed == 'true'
changed-files で PHP ファイルの変更がなかった場合、Composer install 以降のステップをすべてスキップします。余計な処理を走らせないための最適化です。

concurrency
同じ PR に複数回 push があった場合、古いジョブをキャンセルして最新だけ実行します。

最小権限
トップレベルの permissions: {} ですべてを拒否し、ジョブレベルで contents: readpull-requests: write だけを付与しています。

|| true
PHPStan は違反があると exit code 1 を返しますが、|| true でワークフロー失敗扱いにせず、結果を JSON ファイルとして後続ステップに渡します。


ポイント③:結果の絞り込みと PR コメントの upsert

PR コメント投稿 ステップの actions/github-script では JavaScript で 2 つのことをしています。PHPStan の結果から「今回変更したメソッドだけ」を絞り込むことと、コメントを upsert することです。

違反メソッドの絞り込みロジック

PHPStan の JSON 出力と touched-functions.txt を突き合わせて、「変更したメソッドかつ閾値超過」だけを抽出します。

const funcMatch = text.match(/Cognitive complexity for "(.+?)" is (\d+)/);
if (funcMatch) {
  const simpleName = funcMatch[1].includes('::')
    ? funcMatch[1].split('::').pop().replace(/\(\)$/, '')
    : funcMatch[1];

  // touched-functions.txt に含まれるメソッドだけ表示
  if (touchedInFile && touchedInFile.has(simpleName)) {
    violations.push({ file: relPath, target: `\`${simpleName}()\``, score: parseInt(funcMatch[2]), threshold: 15 });
  }
}

PHPStan のメッセージは Cognitive complexity for "App\Services\FooService::getDetail" is 21 という形式なので、:: で分割してメソッド名だけを取り出して照合します。

クラス全体の複雑度超過(Class cognitive complexity is XX)は、変更関数フィルタなしで常に表示します。クラス全体に影響するスコアのため、どのメソッドを変更しても関係があるからです。

コメントの upsert 実装

createComment を毎回呼ぶだけだと、push のたびに同じ bot コメントが積み重なります。代わりに既存コメントを探して update/create を切り替えます。

const marker = '🧠 認知複雑度チェック';

const { data: comments } = await github.rest.issues.listComments({
  owner: context.repo.owner,
  repo: context.repo.repo,
  issue_number: context.payload.pull_request.number,
});
const existing = comments.find(
  c => c.user.login === 'github-actions[bot]' && c.body.includes(marker)
);

if (existing) {
  await github.rest.issues.updateComment({
    owner: context.repo.owner,
    repo: context.repo.repo,
    comment_id: existing.id,
    body,
  });
} else {
  await github.rest.issues.createComment({
    owner: context.repo.owner,
    repo: context.repo.repo,
    issue_number: context.payload.pull_request.number,
    body,
  });
}

検索条件は c.user.login === 'github-actions[bot]'c.body.includes(marker) の AND です。投稿者チェックを入れることで、人間が手動で同じ文字列を書いたコメントを誤って上書きしてしまうのを防いでいます。


まとめ

今回実装したワークフローの要点をまとめます。

工夫 効果
diff から触った関数だけ抽出 ノイズを減らし、今回の変更に集中できる
PHPStan + cognitive-complexity 拡張 既存の PHPStan 環境があればすぐ導入できる
コメントの upsert スレッドが汚れない
concurrency でキャンセル push を連続しても CI が積み上がらない

導入のハードル

tomasvotruba/cognitive-complexity は PHPStan の拡張なので、composer require --dev で追加するだけで使えます。既存の PHPStan 環境があれば、設定追加とワークフロー追加のみで導入できます。

閾値の決め方

いきなり厳しくすると既存コードが大量にヒットするため、最初は緩めに設定するのが現実的です。
function: 20 あたりから始め、チームが慣れてきたら 1510 と段階的に下げていくと無理なく品質を上げられます。

ローカルでの計測

CI だけでなく、開発中にローカルで計測できると便利です。PHPStan を直接実行するだけなので、npm script や Makefile のターゲットとして追加しておくと気軽に使えます。

vendor/bin/phpstan analyse -c phpstan-ci-cognitive.neon app/

認知複雑度は「今すぐ直せ」という指標ではなく、「この方向に気をつけよう」というシグナルです。PR で可視化することで、複雑度が少しずつ積み上がっていく状態に気づきやすくなり、長期的なコードの読みやすさにつながります。

12
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
12
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?