ローカルで変更差分だけに Super-Linter を実行し、効率的に品質を上げる

## はじめに

Super-Linter は GitHub Actions で様々な静的解析ツール（Linter）をまとめて実行してくれるツールです

https://github.com/super-linter/super-linter

２０２３年９月時点で 49 種類の言語（JavaScriptやPython、SQL、Markdown など）、 67 種類の静的解析ツール（eslint、pylint、sql-lint、markdownlint など）に対応しています

大抵の言語はこれでまとめて静的解析してくれるため、 CI をかなり簡略的に書くことができます

未対応の言語（Swift、Elixir など）は個別に CI を書きます

以前、この Super-Linter をローカルで実行する方法について記事を書きました

https://qiita.com/RyoWakabayashi/items/80a20e06ea0722f7998f

GitHub Actions はパブリックリポジトリーなら無償で使えますが、プライベートリポジトリーでは有償なので出来るだけ実行回数を少なく抑えたいです

そのため、ローカルであらかじめ実行してエラーを排除してから、最終確認だけを GitHub Actions で行う、という手順を実装します

ただし、ローカルで実行すると必ず対象ディレクトリー配下の全てのファイルを対象にしてしまう、という問題点があります

https://github.com/super-linter/super-linter/blob/main/docs/run-linter-locally.md#run-the-container-locally

> NOTE: The flag:RUN_LOCAL will set: VALIDATE_ALL_CODEBASE to true. This means it will scan all the files in the directory you have mapped. If you want to only validate a subset of your codebase, map a folder with only the files you wish to have linted

もしリポジトリー内の全てのファイルを毎回静的解析していたら、恐ろしく時間がかかってしまいます

そこで、ローカルでの変更差分だけを対象とするようなスクリプトを実装します

実装したコードはこちら

https://github.com/RyoWakabayashi/ci-example

## 変更差分の取得

### 各ファイルの状態取得

ローカルでの変更差分をスクリプトで取得するため、 git コマンドを使います

`git status` コマンドは作業ディレクトリー内の状態を確認するコマンドです

例えば VSCode で以下のように表示される変更差分があるとします

![スクリーンショット 2023-09-03 14.10.14.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/d0e25322-8fbd-ed1f-9fc5-55ce0d8fda2d.png)

そのとき、 `git status`　の結果は以下のように表示されます

```bash
$ git status
On branch feature/no-renames
Your branch is up to date with 'origin/feature/no-renames'.

Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
        new file:   new.txt
        modified:   package.json
        deleted:    requirements.txt
        renamed:    webpack-sample/src/hello.js -> webpack-sample/src/hero.js

Changes not staged for commit:
  (use "git add/rm <file>..." to update what will be committed)
  (use "git restore <file>..." to discard changes in working directory)
        deleted:    LICENSE
        modified:   webpack-sample/src/index.js

Untracked files:
  (use "git add <file>..." to include in what will be committed)
        new/
```

この出力形式は色々な説明を含んでいて親切ではありますが、スクリプトで使うには冗長です

`--porcelain` (ポーセリン = 磁器) オプションを指定して、スッキリした形式にします

```bash
$ git status --porcelain
 D LICENSE
A  new.txt
M  package.json
D  requirements.txt
R  webpack-sample/src/hello.js -> webpack-sample/src/hero.js
 M webpack-sample/src/index.js
?? new/
```

ファイルの状態が左側の文字で表現されています

- A: 追加
- M: 変更
- D: 削除
- R: 名前変更
- ??: 未追跡（まだgit管理下に入っていない）

また、「M 」のように文字が左にある場合はステージされている状態、「 M」のように文字が右側にある場合はステージしていない状態です

これを上手く加工していけば対象差分のファイル一覧を取得できそうですね

### ファイル名一覧の取得

`awk` コマンドを使えばスペースで区切られた文字列から指定した位置の文字列を取得することができます

ファイル名はスペース区切りの２列に存在しているので、 `git status --porcelain` の結果を `awk '{print $2}'` にパイプしてあげます

```bash
$ git status --porcelain | awk '{print $2}'
LICENSE
new.txt
package.json
requirements.txt
webpack-sample/src/hello.js
webpack-sample/src/index.js
new/
```

ただし、これだと問題があります

- 名前変更したファイルは変更前のパスを取得している
- 削除したファイルも取得している

最終的には変更差分ファイルに対して Super-Linter を実行するため、名前変更の場合は変更後のパスが必要であり、削除したファイルは対象外にすべきです

`--no-ranames` オプションを付けることで、名前変更を削除+追加として取得することができます

```bash
$ git status --porcelain --no-renames
 D LICENSE
A  new.txt
M  package.json
D  requirements.txt
D  webpack-sample/src/hello.js
A  webpack-sample/src/hero.js
 M webpack-sample/src/index.js
?? new/
```

また、 grep で `D` が左側にない = 削除以外に絞り込めば良さそうです

正規表現 `^ *D` により、先頭(`^`)にスペースが0個以上あった後に D の文字がある = 削除したファイルを取得します

`--invert-match` を指定することで、それ以外のファイルを取得します

```bash
$ git status --porcelain --no-renames | grep --invert-match '^ *D'
A  new.txt
M  package.json
A  webpack-sample/src/hero.js
 M webpack-sample/src/index.js
?? new/
```

これを改めて `awk` にパイプします

```bash
$ git status --porcelain --no-renames | grep --invert-match '^ *D' |  awk '{print $2}'
new.txt
package.json
webpack-sample/src/hero.js
webpack-sample/src/index.js
new/
```

これで、追加もしくは変更されたファイルの一覧が取得できました

## 対象ファイルの一時ディレクトリーへのコピー

### 変更差分ファイルのコピー

以下のコードで変更差分のファイルを取得し、 `./tmp/lint` ディレクトリー内にコピーします

繰り返し実行するため、毎回空にしてからコピーします

```bash
readonly WORKDIR="./tmp/lint"

rm -rf "${WORKDIR}"

mkdir -p "${WORKDIR}"

target_files=$(
    git status --porcelain --no-renames \
        | grep --invert-match '^ *D' \
        | awk '{print $2}'
)

for file in ${target_files}; do
    mkdir -p "${WORKDIR}/$(dirname "${file}")"
    cp -R "${file}" "${WORKDIR}/${file}"
done
```

`rsync` コマンドだと「前回差分があったファイル」だが「今回差分がなかったファイル」が残ってしまいます
（ファイルスク数指定の場合、 `--delete` オプションが効かない）

また、ディレクトリー全体が未追跡の場合、 `${file}` に `new/` のようにディレクトリーが入ってきます

そのときにエラーにならないよう、 `cp` に `-R` オプション（ディレクトリー内を全てコピー）を指定しています

### shfmt を使用したい場合

シェルスクリプトの形式チェックツール [shfmt](https://github.com/mvdan/sh) を Super-Linter 上で実行したい場合、 `./tmp/lint` ディレクトリー配下に `.editorconfig` ファイルが必要です
（`.editorconfig` ファイルが shfmt の設定になっているため）

`.editorconfig` ファイルだけは無条件で毎回コピーしましょう

```bash
cp .editorconfig "${WORKDIR}/.editorconfig"
```

## Super-Linter の実行

Super-Linter はローカルの Docker コンテナで実行します

毎回長いオプション指定をしたくないので、 `docker-compose.super-linter.yml` (ファイル名は何でも良い)に以下の内容を書いておきます

```bash
version: '3.2'
services:
  linter:
    image: "github/super-linter:slim-v5"
    environment:
      - RUN_LOCAL=true
      - USE_FIND_ALGORITHM=true
      - LINTER_RULES_PATH=../rules
    volumes:
      - ./tmp/lint:/tmp/lint
      - ./:/tmp/rules
```

### イメージの指定

SUper-Linter のイメージには `github/super-linter:slim-v5` を指定し、最新の軽量版を使います

軽量版では動かない Linter を使いたい場合は `slim-` を消してください

### 環境変数の指定

環境変数は最低限のものを指定しています

`RUN_LOCAL=true` でローカル実行を指定し、 `USE_FIND_ALGORITHM=true` でファイル探索を可能にします

静的解析の設定をカスタマイズしている場合、 `LINTER_RULES_PATH=../rules` を指定してください

また、特定の Linter を動かしたくない場合などは適宜環境変数を追加してください

### ボリュームの指定

ローカルの `./tmp/lint` をコンテナ上の `/tmp/lint` （静的解析の実行対象ディレクトリー）にマウントしています

これにより、 `./tmp/lint` 配下のファイルを対象として Super-Linter が実行されます

環境変数 `LINTER_RULES_PATH=../rules`　と併せてボリュームに `./:/tmp/rules` を指定することで、プロジェクトのルートディレクトリー配下にある `.eslintrc.yml` などのカスタム設定が使用されます

### M1 Mac 、 M2 Mac （Apple シリコン）を使用している場合の注意点

Super-Linter のイメージが amd64 アーキテクチャーにしか対応しておらず、 arm64 アーキテクチャーでは一部動作に問題があります

Super-Linter は GitHub Actions の Ubuntu 上で動かすことが基本的な利用方法なので、ここは仕方ないですが、、、

早くマルチアーキテクチャに対応して欲しいです

#### hadolint のエラー

M1 Mac 、 M2 Mac　を使用している場合、 Super-Linter 内の [hadolint](https://github.com/hadolint/hadolint) （Dockerfile に対する静的解析）が Segmentation fault を起こして必ずエラーになってしまいます

この場合、 `docker-compose.super-linter.yml` に以下の環境変数を追加し、 hadolint は別の手段で実行しましょう

```yml
...
    environment:
      ...
      - VALIDATE_DOCKERFILE_HADOLINT=false
```

#### actionlint のエラー

Super-Linter 内の [actionlint](https://github.com/rhysd/actionlint) （GitHub Actions の定義ファイルに対する静的解析）がエラーになる場合があります

GitHub Actions を定義している YAML ファイル内に `run:` が複数存在する場合です

この場合、以下のようなエラーが発生します

```
`/usr/bin/shellcheck --norc -f json -x --shell bash -e SC1091,SC2194,SC2050,SC2154,SC2157 -` did not run successfully while checking script at line:28,col:9: /usr/bin/shellcheck was terminated. stderr: ""
```

この場合、 `docker-compose.super-linter.yml` に以下の環境変数を追加し、  actionlint は別の手段で実行しましょう

```yml
...
    environment:
      ...
      - VALIDATE_GITHUB_ACTIONS=false
```

### スクリプトからのコンテナ起動

スクリプトからは以下のようにしてコンテナを起動できます

```bash
docker compose \
    --file docker-compose.super-linter.yml \
    up \
    --build \
    --exit-code-from linter
```

`--exit-code-from linter` で linter サービスの終了コードを docker コマンドの終了コードにします

これにより、静的解析でエラーを検知した場合、スクリプトをエラー終了させることができます

## スクリプト全体

スクリプト全体を　`super-linter.sh` として、以下のように実装します

```bash
#!/bin/bash
# -- super-linter.sh -----------------------------------------------------------
#
# Super Linter による静的コード解析を実行する
#
# Copyright (c) 2023 Ryo Wakabayashi
#
# ------------------------------------------------------------------------------

set -euo pipefail

readonly ME=${0##*/}
readonly WORKDIR="./tmp/lint"

display_usage() {

    cat << EOE

    Super Linter による静的コード解析を実行するスクリプト

    構文: ./${ME}

EOE

    exit

}

check_sanity() {

    [[ $(command -v docker) ]] \
        || whoopsie "Please install Docker first."

}

copy_target_files() {

    rm -rf "${WORKDIR}"

    mkdir -p "${WORKDIR}"

    target_files=$(
        git status --porcelain --no-renames \
            | grep --invert-match '^ *D' \
            | awk '{print $2}'
    )

    for file in ${target_files}; do
        mkdir -p "${WORKDIR}/$(dirname "${file}")"
        cp -R "${file}" "${WORKDIR}/${file}"
    done

    cp .editorconfig "${WORKDIR}/.editorconfig"

}

lint() {

    docker compose \
        --file docker-compose.super-linter.yml \
        up \
        --build \
        --exit-code-from linter \
        || whoopsie "Lint failed."

}

main() {

    while getopts h opt; do
        case $opt in
            h)
                display_usage
            ;;
            :)
                whoopsie "Option missing!"
            ;;
            \?)
                whoopsie "Invalid option!"
            ;;
        esac
    done

    check_sanity

    copy_target_files

    lint

}

whoopsie() {

    local message=$1

    echo "${message} Aborting..."
    exit 192

}

main "$@"

exit 0
```

実行すると、以下のような結果が表示されます

```bash
./super-linter.sh
[+] Running 1/0
 ✔ Container ci-example-linter-1  Created                                                                                                                      0.0s 
Attaching to ci-example-linter-1
ci-example-linter-1  | --------------------------------------------------------------------------------
ci-example-linter-1  | 
ci-example-linter-1  |                               /@@#///////@@/(@//@%/(@.@(       @@
ci-example-linter-1  |                           @@//////////////////////////////#*  @@@
ci-example-linter-1  |                         @////@//(///////////@@@@@///@//@/@**//@@(
ci-example-linter-1  |                       @///////@///////////////@@@@    (           @,
ci-example-linter-1  |                      @/(&/@////////////////////                     @
ci-example-linter-1  |                     @////////////////////////@@                      @
ci-example-linter-1  |                   @%////////(//////////%/////&@            @@       *,@           ______________
ci-example-linter-1  |              @@@@@/@/#/////(&//////////////////                       .@         /              \
ci-example-linter-1  |         *@@@@@.    .%///(//@//////////////////&.   .@@,                 @%      / Don't mind me  \
ci-example-linter-1  |       @@%           .&@&&/@.@//&/////(//////////    @@@@@@@@@         .. &@    / I'm just looking \
ci-example-linter-1  |     @@%               @@@@@   @&/////////////////#   @/       V  @@/ ,@@@ @   <  for some trash... |
ci-example-linter-1  | @@@%                   @@@@        .%@@@@//////#@ @   @@         @     .,.     \__________________/
ci-example-linter-1  |                                           @@@/@(  (@@@@% @/\      %
ci-example-linter-1  |                                            @@@@(    .     .@@/\   #
ci-example-linter-1  |                                              @                  %@%
ci-example-linter-1  | 
ci-example-linter-1  | --------------------------------------------------------------------------------
ci-example-linter-1  | 
ci-example-linter-1  | 2023-09-03 05:57:00 [INFO]   ---------------------------------------------
ci-example-linter-1  | 2023-09-03 05:57:00 [INFO]   --- GitHub Actions Multi Language Linter ----
ci-example-linter-1  | 2023-09-03 05:57:00 [INFO]    - Image Creation Date:[2023-04-10T23:42:21Z]
ci-example-linter-1  | 2023-09-03 05:57:00 [INFO]    - Image Revision:[a9fdf79c4a29f2752e1faddc6dafe73a5734fd5c]
ci-example-linter-1  | 2023-09-03 05:57:00 [INFO]    - Image Version:[a9fdf79c4a29f2752e1faddc6dafe73a5734fd5c]
ci-example-linter-1  | 2023-09-03 05:57:00 [INFO]   ---------------------------------------------
ci-example-linter-1  | 2023-09-03 05:57:00 [INFO]   ---------------------------------------------
ci-example-linter-1  | 2023-09-03 05:57:00 [INFO]   The Super-Linter source code can be found at:
ci-example-linter-1  | 2023-09-03 05:57:00 [INFO]    - https://github.com/github/super-linter
ci-example-linter-1  | 2023-09-03 05:57:00 [INFO]   ---------------------------------------------
...
ci-example-linter-1  | 2023-09-03 05:57:35 [INFO]   Linting [JAVASCRIPT_ES] files...
ci-example-linter-1  | 2023-09-03 05:57:35 [INFO]   ----------------------------------------------
ci-example-linter-1  | 2023-09-03 05:57:35 [INFO]   ----------------------------------------------
ci-example-linter-1  | 2023-09-03 05:57:35 [INFO]   ---------------------------
ci-example-linter-1  | 2023-09-03 05:57:35 [INFO]   File:[/tmp/lint/webpack-sample/src/hero.js]
ci-example-linter-1  | 2023-09-03 05:57:39 [INFO]    - File:[hero.js] was linted with [eslint] successfully
ci-example-linter-1  | 2023-09-03 05:57:39 [INFO]   ---------------------------
ci-example-linter-1  | 2023-09-03 05:57:39 [INFO]   File:[/tmp/lint/webpack-sample/src/index.js]
ci-example-linter-1  | 2023-09-03 05:57:43 [INFO]    - File:[index.js] was linted with [eslint] successfully
ci-example-linter-1  | 2023-09-03 05:57:43 [INFO]   
...
ci-example-linter-1  | 2023-09-03 05:58:13 [INFO]   The script has completed
ci-example-linter-1  | 2023-09-03 05:58:13 [INFO]   ----------------------------------------------
ci-example-linter-1  | 2023-09-03 05:58:13 [INFO]   ----------------------------------------------
ci-example-linter-1  | 2023-09-03 05:58:13 [NOTICE]   All file(s) linted successfully with no errors detected
ci-example-linter-1  | 2023-09-03 05:58:13 [INFO]   ----------------------------------------------
ci-example-linter-1 exited with code 0
Aborting on container exit...
[+] Stopping 1/0
 ✔ Container ci-example-linter-1  Stopped                                                                                                                      0.0s
```

エラーがある場合は以下のようになります

```bash
./super-linter.sh
[+] Running 1/0
 ✔ Container ci-example-linter-1  Created                                                                                                                      0.0s 
Attaching to ci-example-linter-1
ci-example-linter-1  | --------------------------------------------------------------------------------
...
ci-example-linter-1  | 2023-09-03 06:13:30 [INFO]   File:[/tmp/lint/super-linter.sh]
ci-example-linter-1  | 2023-09-03 06:13:30 [ERROR]   Found errors in [editorconfig-checker] linter!
ci-example-linter-1  | 2023-09-03 06:13:30 [ERROR]   Error code: 1. Command output:
ci-example-linter-1  | ------
ci-example-linter-1  | super-linter.sh:
ci-example-linter-1  |  53: Wrong amount of left-padding spaces(want multiple of 4)
ci-example-linter-1  | 
ci-example-linter-1  | 1 errors found
ci-example-linter-1  | ------
...
ci-example-linter-1  | 2023-09-03 06:14:14 [INFO]   ----------------------------------------------
ci-example-linter-1  | 2023-09-03 06:14:14 [INFO]   Linting [SHELL_SHFMT] files...
ci-example-linter-1  | 2023-09-03 06:14:14 [INFO]   ----------------------------------------------
ci-example-linter-1  | 2023-09-03 06:14:14 [INFO]   ----------------------------------------------
ci-example-linter-1  | 2023-09-03 06:14:14 [INFO]   ---------------------------
ci-example-linter-1  | 2023-09-03 06:14:14 [INFO]   File:[/tmp/lint/super-linter.sh]
ci-example-linter-1  | 2023-09-03 06:14:14 [ERROR]   Found errors in [shfmt] linter!
ci-example-linter-1  | 2023-09-03 06:14:14 [ERROR]   Error code: 1. Command output:
ci-example-linter-1  | ------
ci-example-linter-1  | --- /tmp/lint/super-linter.sh.orig
ci-example-linter-1  | +++ /tmp/lint/super-linter.sh
ci-example-linter-1  | @@ -50,7 +50,7 @@
ci-example-linter-1  |          cp -r "${file}" "${WORKDIR}/${file}"
ci-example-linter-1  |      done
ci-example-linter-1  |  
ci-example-linter-1  | -  cp .editorconfig "${WORKDIR}/.editorconfig"
ci-example-linter-1  | +    cp .editorconfig "${WORKDIR}/.editorconfig"
ci-example-linter-1  |  
ci-example-linter-1  |  }
ci-example-linter-1  |  
ci-example-linter-1  | ------
...
ci-example-linter-1  | 2023-09-03 06:14:17 [INFO]   ----------------------------------------------
ci-example-linter-1  | 2023-09-03 06:14:17 [INFO]   ----------------------------------------------
ci-example-linter-1  | 2023-09-03 06:14:17 [INFO]   The script has completed
ci-example-linter-1  | 2023-09-03 06:14:17 [INFO]   ----------------------------------------------
ci-example-linter-1  | 2023-09-03 06:14:17 [INFO]   ----------------------------------------------
ci-example-linter-1  | 2023-09-03 06:14:17 [ERROR]   ERRORS FOUND in EDITORCONFIG:[1]
ci-example-linter-1  | 2023-09-03 06:14:17 [ERROR]   ERRORS FOUND in SHELL_SHFMT:[1]
ci-example-linter-1  | 2023-09-03 06:14:17 [FATAL]   Exiting with errors found!
ci-example-linter-1 exited with code 1
Aborting on container exit...
[+] Stopping 1/0
 ✔ Container ci-example-linter-1  Stopped                                                                                                                      0.0s 
Lint failed. Aborting...
```

きちんとエラーを検出できています

## pre-commit からの実行

シェルスクリプトにしたことで、 pre-commit からも実行できます

https://pre-commit.com/

pre-commit は名前の通り、コミット前にチェックを自動実行して、エラーがあればコミットさせないツールです

### pre-commit のインストール

以下のように pre-commit は Python モジュールとしてインストールできます

```bash
pip install pre-commit
```

[asdf](https://asdf-vm.com/) を使っている場合はインストール後に `asdf reshim python` を実行します

### pre-commit の設定

ローカルのシェルスクリプトを実行する場合、以下のような設定を .pre-commit-config.yaml に記載します

```yml
---
repos:
  - repo: local
    hooks:
      - id: super-linter
        name: 'super-linter'
        entry: ./super-linter.sh
        language: system
        pass_filenames: false
```

### pre-commit の手動実行

　手動で実行する場合は以下のコマンドを実行します

```bash
pre-commit run --all-files
```

問題なければ以下のような実行結果になります

![スクリーンショット 2023-09-03 19.38.52.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/d81add65-70e4-a42a-f7f9-5b87a38efa08.png)

エラーが発生した場合は以下のように Super-Linter の実行結果を表示します

![スクリーンショット 2023-09-03 19.41.48.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/87922add-a430-9301-5ac4-6646ffa0c4e2.png)
...
![スクリーンショット 2023-09-03 19.41.59.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/fde407a4-0c1e-71ac-7f65-7b0a377735e6.png)

### pre-commit の自動実行

pre-commit をコミット実行時に自動実行する場合、以下のコマンドを実行しておきます

```bash
pre-commit install
```

### hadolint の実行

M1 Mac や M2 Mac で Super-Linter から hadolint が実行できない場合、 pre-commit から実行できます

.pre-commit-config.yaml に以下の設定を追加します

```yml
...
  - repo: https://github.com/hadolint/hadolint
    rev: v2.12.0
    hooks:
      - id: hadolint-docker
```

hadolint-docker としている場合、 Docker コンテナ上で hadolint が実行されます

hadolint のイメージはマルチアーキテクチャになっているため、 M1 Mac 、 M2 Mac でも問題なく動作します

### actionlint の実行

M1 Mac や M2 Mac で Super-Linter から actionlint が実行できない場合、 pre-commit から実行できます

.pre-commit-config.yaml に以下の設定を追加します

```yml
...
  - repo: https://github.com/rhysd/actionlint
    rev: v1.6.25
    hooks:
      - id: actionlint-docker
```

actionlint-docker としている場合、 Docker コンテナ上で actionlint が実行されます

actionlint のイメージはマルチアーキテクチャになっているため、 M1 Mac 、 M2 Mac でも問題なく動作します

## まとめ

シェルスクリプトで色々な工夫をすることで、変更差分だけに Super-Linter を実行することができました

arm64 アーキテクチャの場合などまだ課題はありますが、おおよそのケースで使えそうです