Engineering note · ESLint

Vue3 ESLint 로컬 커스텀 룰 만들기

2024년 05월 20일이현수
태그eslintvue3typescriptastflat-config

Vue3 프로젝트 내부에 ESLint 로컬 플러그인을 구성하고, AST Visitor로 defineEmits의 update 이벤트를 검사하는 커스텀 룰과 RuleTester 테스트 작성법.

ESLint를 활용해서 코딩 컨벤션을 정하는데, 이미 정의된 룰 외의 우리만의 규칙이 필요했다. Vue 3.4 이상에서 기존 update:modelValue 기반의 defineEmits 구문 대신 신규 API인 defineModel() 사용을 권장하고 싶었다. 그래서 커스텀 룰을 작성해서 적용했다.

이전에는 eslint-plugin-local-rules나 CommonJS 파일 로더를 억지로 우회 연결해야 했으나, 이젠 프로젝트 내부의 커스텀 룰 객체를 로컬 플러그인으로 바로 등록할 수 있다.

플러그인 모듈 등록

text
.
├── eslint.config.mjs
└── eslint/
    ├── local-plugin.mjs
    └── rules/
        ├── prefer-define-model.mjs
        └── prefer-define-model.test.mjs

위 구성으로 테스트까지 작성한다.

js
// eslint/local-plugin.mjs
import preferDefineModel from './rules/prefer-define-model.mjs'

export default {
  rules: {
    'prefer-define-model': preferDefineModel,
  },
}

모듈 파일을 만들고 로컬 플러그인으로 등록해준다.

js
// eslint.config.mjs
import tsParser from '@typescript-eslint/parser'
import vueParser from 'vue-eslint-parser'
import localPlugin from './eslint/local-plugin.mjs'

export default [
  {
    files: ['**/*.vue'],
    languageOptions: {
      parser: vueParser,
      parserOptions: {
        parser: tsParser,
        ecmaVersion: 'latest',
        sourceType: 'module',
        extraFileExtensions: ['.vue'],
      },
    },
    plugins: {
      local: localPlugin, // 로컬 플러그인 등록
    },
    rules: {
      'local/prefer-define-model': 'warn',
    },
  },
]

pluginslocalPlugin 객체를 직접 주입한다. 커스텀 룰 ID는 local/prefer-define-model이 된다.

Vue SFC 파일을 파싱하려면 vue-eslint-parser를 사용하고, <script setup lang="ts"> 내부 코드는 @typescript-eslint/parser가 파싱하도록 한다.

AST Visitor 구현

ESLint AST 노드 구조 다이어그램

ESLint는 파서가 생성한 AST의 노드를 순회하며 원하는 패턴을 찾는다. defineEmits 함수 호출의 인자에서 update:로 시작하는 이벤트 문자열이 존재하는지 검사했다.

js
// eslint/rules/prefer-define-model.mjs
export default {
  meta: {
    type: 'suggestion',
    docs: {
      description: 'update:이벤트를 defineModel로 바꿀 수 있는지 검사한다.',
    },
    schema: [],
    messages: {
      preferDefineModel:
        '{{ eventName }} 이벤트는 defineModel()로 단순화할 수 있는지 확인하세요.',
    },
  },

  create(context) {
    return {
      // defineEmits 함수 호출 노드를 타겟팅
      'CallExpression[callee.name="defineEmits"]'(node) {
        const eventList = node.arguments[0]

        if (!eventList || eventList.type !== 'ArrayExpression') return

        for (const eventNode of eventList.elements) {
          if (
            !eventNode ||
            eventNode.type !== 'Literal' ||
            typeof eventNode.value !== 'string' ||
            !eventNode.value.startsWith('update:')
          ) {
            continue
          }

          // 문제 지점 AST 노드 레포트
          context.report({
            node: eventNode,
            messageId: 'preferDefineModel',
            data: {
              eventName: eventNode.value,
            },
          })
        }
      },
    }
  },
}

context.report()에 위반이 일어난 실제 AST 노드를 넘겨주므로, IDE에서 update:modelValue 위치에 노란색 밑줄 경고가 나온다. 파서를 다루는 것이 약간 예전 LLVM pass 다룰 때와 비슷한 느낌이다.

RuleTester 단위 테스트

커스텀 룰이 의도대로 동작하는지 시나리오 별로 코드를 작성해 테스트를 돌려볼 수 있다. 직관적으로 어떤 코드를 검사할 때, 룰이 걸리는지 안걸리는지 파악한다.

js
// eslint/rules/prefer-define-model.test.mjs
import { RuleTester } from 'eslint'
import tsParser from '@typescript-eslint/parser'
import vueParser from 'vue-eslint-parser'
import rule from './prefer-define-model.mjs'

const ruleTester = new RuleTester({
  languageOptions: {
    parser: vueParser,
    parserOptions: {
      parser: tsParser,
      ecmaVersion: 'latest',
      sourceType: 'module',
    },
  },
})

ruleTester.run('prefer-define-model', rule, {
  valid: [
    {
      filename: 'Component.vue',
      code: `<script setup lang="ts">
const emit = defineEmits(['click', 'close'])
</script>`,
    },
    {
      filename: 'Component.vue',
      code: `<script setup lang="ts">
const model = defineModel<string>()
</script>`,
    },
  ],
  invalid: [
    {
      filename: 'Component.vue',
      code: `<script setup lang="ts">
const emit = defineEmits(['update:modelValue'])
</script>`,
      errors: [
        {
          messageId: 'preferDefineModel',
          data: { eventName: 'update:modelValue' },
        },
      ],
    },
  ],
})

filenameComponent.vue처럼 지정해 주어야 vue-eslint-parser가 올바르게 Vue SFC 구문을 테스트 파싱한다.