Rails環境にESlintとSaddlerでassets/javascriptsを自動でチェックさせる仕組みを作った話

f:id:Rakuma:20201218152414p:plain

こんにちは、ラクマのKeitaです。

Webアプリケーションでは実装初期にフロントエンド周りの設計やコーディングルールの取り決めをしなかったことによりレガシーとなってしまったJavaScript達に目を向けなければならない時があると思います。 今回はそんなレガシーなJavaScript達と向き合うために作った仕組みについて書いていきます。

www.wantedly.com

肥大化してしまったレガシーJavaScript達を1ファイルずつ確認して直していくのは流石に大変なので、まずはレガシー化が進行しないようにする仕組みを作る必要がありました。

やったこと

  1. git diff から追加 / 変更したファイル名と行数を取得する
  2. 1で取得したファイルに対してEslintを実行する
  3. 2の結果から1で取得した行数の指摘だけ残すようにXMLを書き換える
  4. 3のXMLを使ってPull Requestに指摘内容をコメントする

確認環境

  • Rails:5.2.4
  • node:14.2.0
  • ESlint:6.8.0
  • Saddler:1.0.0
  • CircleCI

GitHubとCircleCIを連動させているのでルートの.circleci/にスクリプトを実装していきます。

.circleci/bin/run-eslint.js

#!/usr/bin/env node

import * as commander from 'commander';
import * as child_process from 'child_process';
import * as gitDiff from '../src/javascripts/gitDiff.js'
import * as lint from '../src/javascripts/lint.js'

const command = commander.default;
const exec = child_process.exec;

command
  .version('0.1.0')
  .option('-b, --base <branch name>', 'Base branch', 'origin/master')
  .option('-t, --target <branch name>', 'Target branch')
  .parse(process.argv);

const args = {
  baseBranch: command.base,
  targetBranch: command.target
}

/**
 * スクリプトのメイン処理
 *
 * @return void
 */
const main = () => {
  exec(gitDiff.getCommand(args), (err, stdout, stderr) => {
    if (err) {
      console.log(err);
      process.exit();
    }
    lint.run(args, gitDiff.getAffectedLines(stdout));
  });
}

main();

mainのスクリプトはこんな感じです。

このmainスクリプトがルートとなって各処理を実行していきます。

1. git diff から追加 / 変更したファイル名と行数を取得する

.circleci/src/javascripts/gitDiff.js

/**
 * git diffコマンド文字列を返す
 *
 * @args       {{baseBranch: string, targetBranch: string}}  run-eslint.jsに渡された引数群
 * @isNameOnly {boolean} --name-onlyオプションを使うかどうかのフラグ
 * @return {string} git diff コマンド文字列
 */
export const getCommand = (args, isNameOnly = false) => {
  const optionNameOnly = isNameOnly ? '--name-only ': '';
  const toBranch = args.targetBranch ? `...${args.targetBranch}`: '';

  return `git --no-pager diff --diff-filter=AM ${optionNameOnly}${args.baseBranch}${toBranch}`;
}

/**
 * git diffの結果から追加 / 変更のあった行番号を配列にして返す
 *
 * @stdout {boolean} git diff の出力結果
 * @return {Array} 差分ファイル毎の変更行配列
 */
export const getAffectedLines = (stdout) => {
  let path;
  let line;
  let affectedLines = {};
  stdout.split("\n").forEach(el => {
    if (el.startsWith('---')) {
      return;
    } else if (el.startsWith('+++')) {
      path = el.replace('+++ b/', '');
    } else if (el.startsWith('@@')) {
      var start = el.indexOf("-");
      var end = el.indexOf(",");
      line = el.slice(start + 1, end);
    } else {
      switch (el[0]) {
        case '+':
          if (affectedLines[path]) {
            affectedLines[path].push(line);
          } else {
            affectedLines[path] = [line];
          }
        case ' ':
          line++;
          break;
        case '-':
        default:
          break;
      }
    }
  })
  return affectedLines;
}

getAffectedLinesgit diff ***の結果から追加 / 変更したファイル名と行数を取得します。

2. 1で取得したファイルに対してEslintを実行する

.circleci/src/javascripts/lint.js

import * as child_process from 'child_process';
import * as xmldom from 'xmldom';
import * as xmlSerializer from 'xmlserializer';
import * as gitDiff from './gitDiff.js'

const exec = child_process.exec;
const DOMParser = xmldom.default.DOMParser;
const XMLSerializer = xmlSerializer.default;

const cwd = process.cwd();

/**
 * lintの結果から変更行配列に存在しない行を取り除いた結果を出力する
 *
 * @args          {{baseBranch: string, targetBranch: string}}  run-eslint.jsに渡された引数群
 * @affectedLines {Array} gitDiff.getAffectedLinesから取得した変更行配列
 * @return void
 */
export const run = (args, affectedLines) => {
  exec(getLintCommand(args), (err, stdout, stderr) => {
    let doc = new DOMParser().parseFromString(stdout);
    if (!stdout) return;
    const fileNodes = doc.documentElement.getElementsByTagName('file');
    Array.prototype.forEach.call(fileNodes, fileNode => {
      filterToLintResult(fileNode, affectedLines);
      if (!fileNode.childNodes.length) {
        doc.documentElement.removeChild(fileNode);
      }
    });
    console.log(XMLSerializer.serializeToString(doc.documentElement));
  });
}

/**
 * lintの結果から変更行配列に存在しない行を取り除く
 *
 * @fileNode      {Object} ESlintの結果から生成したXMLDOMDocument
 * @affectedLines {Array} gitDiff.getAffectedLinesから取得した変更行配列
 * @return void
 */
const filterToLintResult = (fileNode, affectedLines) => {
  let childCount = fileNode.childNodes.length;
  const fileName = fileNode.getAttribute('name').replace(cwd + '/', '');
  for (let i= 1; i <= childCount; ++i) {
    var childNode = fileNode.childNodes[i - 1];
    var lineNumber = Number(childNode.getAttribute('line'));
    if (!affectedLines[fileName].includes(lineNumber)) {
      fileNode.removeChild(childNode);
      i = 0;
      childCount = fileNode.childNodes.length;
    }
  }
}

/**
 * git diff コマンドにlintコマンドを付与したコマンド文字列を取得する
 *
 * @args {{baseBranch: string, targetBranch: string}}  run-eslint.jsに渡された引数群
 * @return {string}
 */
const getLintCommand = (args) => {
  return gitDiff.getCommand(args, true) + '| grep .js | grep -v .json | xargs ./node_modules/eslint/bin/eslint.js -f checkstyle';
}

filterToLintResultで変更行以外の結果をXMLから削除します。

eslintに適用するルールは通常通りルートに.eslintrc.jsonを置いてやれば読み込んでくれます。

.eslintrc.json

{
    "env": {
        "browser": true,
        "es6": false,
        "jquery": true
    },
    "extends": "eslint:recommended",
    "globals": {
        "Atomics": "readonly",
        "SharedArrayBuffer": "readonly"
    },
    "parserOptions": {
        "ecmaVersion": 2018
    },
    "plugins": [
        "jquery",
        "es5"
    ],
    "rules": {
        "indent": "error",
        "linebreak-style": [
            "error",
            "unix"
        ],
        "es5/no-arrow-functions": "error"
    }
}

ラクマではjQueryのコードも多く残ってるのでjQueryプラグインを使ってます。

これをしないと$("#selector")みたいな記述がうまく読み込めないのです。

あとはIEでES6の書き方の一部が動かない物があるのでES5準拠でチェックするようにしています。

3のXMLを使ってPull Requestに指摘内容をコメントする

.circleci/bin/run-eslint.sh

#!/bin/bash
set -v
./.circleci/bin/run-eslint.js | saddler report --require saddler/reporter/github --reporter Saddler::Reporter::Github::PullRequestReviewComment
exit 0

あとはmainのスクリプトの実行結果をSaddlerに渡してあげればEslintの指摘をPull Requestにコメントしてくれます。

上記まででcircleCIで実行されるチェックスクリプトが用意できたので試し元々設置してあったsampleファイルに追記してcommitしてみます。 app/javascripts/test.js

const testFnc = () => {
  console.log('test');
};

// ここから追記
const testFnc2 = () => {
  console.log('test2');
};

するとcircleCIでビルドされてこのように追加した行のみGitHubコメントで指摘してくれます。 f:id:Rakuma:20201217162334p:plain