Repository: vuejs/eslint-plugin-vue
Pull request #2367 (2024-01-12 17:45:01)

Add new `vue/no-restricted-v-on` rule
This is an attempt at solving https://github.com/vuejs/eslint-plugin-vue/issues/2243

Keen for feedback 🫡

thesheppard: Have addressed PR comments, it's ready for another look 💪🏾
thesheppard: I've added the links to the related docs 🙏🏾

Patch:
diff --git a/docs/rules/index.md b/docs/rules/index.md
index 6e4c2fe6..1367f797 100644
--- a/docs/rules/index.md
+++ b/docs/rules/index.md
@@ -244,6 +244,7 @@ For example:
 | [vue/no-restricted-props](./no-restricted-props.md) | disallow specific props | :bulb: | :hammer: |
 | [vue/no-restricted-static-attribute](./no-restricted-static-attribute.md) | disallow specific attribute |  | :hammer: |
 | [vue/no-restricted-v-bind](./no-restricted-v-bind.md) | disallow specific argument in `v-bind` |  | :hammer: |
+| [vue/no-restricted-v-on](./no-restricted-v-on.md) | disallow specific argument in `v-on` |  | :hammer: |
 | [vue/no-root-v-if](./no-root-v-if.md) | disallow `v-if` directives on root element |  | :hammer: |
 | [vue/no-setup-props-reactivity-loss](./no-setup-props-reactivity-loss.md) | disallow usages that lose the reactivity of `props` passed to `setup` |  | :hammer: |
 | [vue/no-static-inline-styles](./no-static-inline-styles.md) | disallow static inline `style` attributes |  | :hammer: |
diff --git a/docs/rules/no-restricted-static-attribute.md b/docs/rules/no-restricted-static-attribute.md
index 42859050..a0534f56 100644
--- a/docs/rules/no-restricted-static-attribute.md
+++ b/docs/rules/no-restricted-static-attribute.md
@@ -5,6 +5,7 @@ title: vue/no-restricted-static-attribute
 description: disallow specific attribute
 since: v7.0.0
 ---
+
 # vue/no-restricted-static-attribute
 
 > disallow specific attribute
@@ -39,7 +40,8 @@ Alternatively, the rule also accepts objects.
 
 ```json
 {
-  "vue/no-restricted-static-attribute": ["error",
+  "vue/no-restricted-static-attribute": [
+    "error",
     {
       "key": "stlye",
       "message": "Using \"stlye\" is not allowed. Use \"style\" instead."
@@ -95,8 +97,10 @@ The following properties can be specified for the object.
 ## :couple: Related Rules
 
 - [vue/no-restricted-v-bind]
+- [vue/no-restricted-v-on]
 
 [vue/no-restricted-v-bind]: ./no-restricted-v-bind.md
+[vue/no-restricted-v-on]: ./no-restricted-v-on.md
 
 ## :rocket: Version
 
diff --git a/docs/rules/no-restricted-v-bind.md b/docs/rules/no-restricted-v-bind.md
index 8b9332f6..4c1495f4 100644
--- a/docs/rules/no-restricted-v-bind.md
+++ b/docs/rules/no-restricted-v-bind.md
@@ -5,6 +5,7 @@ title: vue/no-restricted-v-bind
 description: disallow specific argument in `v-bind`
 since: v7.0.0
 ---
+
 # vue/no-restricted-v-bind
 
 > disallow specific argument in `v-bind`
@@ -53,7 +54,8 @@ Alternatively, the rule also accepts objects.
 
 ```json
 {
-  "vue/no-restricted-v-bind": ["error",
+  "vue/no-restricted-v-bind": [
+    "error",
     {
       "argument": "/^v-/",
       "message": "Using `:v-xxx` is not allowed. Instead, remove `:` and use it as directive."
@@ -112,8 +114,10 @@ The following properties can be specified for the object.
 ## :couple: Related Rules
 
 - [vue/no-restricted-static-attribute]
+- [vue/no-restricted-v-on]
 
 [vue/no-restricted-static-attribute]: ./no-restricted-static-attribute.md
+[vue/no-restricted-v-on]: ./no-restricted-v-on.md
 
 ## :rocket: Version
 
diff --git a/docs/rules/no-restricted-v-on.md b/docs/rules/no-restricted-v-on.md
new file mode 100644
index 00000000..1b73bd57
--- /dev/null
+++ b/docs/rules/no-restricted-v-on.md
@@ -0,0 +1,111 @@
+---
+pageClass: rule-details
+sidebarDepth: 0
+title: vue/no-restricted-v-on
+description: disallow specific argument in `v-on`
+---
+# vue/no-restricted-v-on
+
+> disallow specific argument in `v-on`
+
+- :exclamation: <badge text="This rule has not been released yet." vertical="middle" type="error"> ***This rule has not been released yet.*** </badge>
+
+## :book: Rule Details
+
+This rule allows you to specify `v-on` argument names that you don't want to use in your application.
+
+## :wrench: Options
+
+This rule takes a list of strings, where each string is a argument name or pattern to be restricted:
+
+```json
+{
+  "vue/no-restricted-v-on": ["error", "foo", "/^bar/"]
+}
+```
+
+<eslint-code-block :rules="{'vue/no-restricted-v-on': ['error', 'foo', '/^bar/']}">
+
+```vue
+<template>
+  <!-- ✓ GOOD -->
+  <div v-on:click="x" />
+  <div @tap="x" />
+
+  <!-- ✗ BAD -->
+  <div v-on:foo="x" />
+  <div @bar-baz="x" />
+</template>
+```
+
+</eslint-code-block>
+
+Alternatively, the rule also accepts objects.
+
+```json
+{
+  "vue/no-restricted-v-on": [
+    "error",
+    {
+      "argument": "foo",
+      "message": "Use \"v-on:x\" instead."
+    },
+    {
+      "argument": "bar",
+      "message": "\"@bar\" is deprecated."
+    }
+  ]
+}
+```
+
+The following properties can be specified for the object.
+
+- `argument` ... Specify the argument name or pattern or `null`. If `null` is specified, it matches `v-on=`.
+- `modifiers` ... Specifies an array of the modifier names. If specified, it will only be reported if the specified modifier is used.
+- `element` ... Specify the element name or pattern. If specified, it will only be reported if used on the specified element.
+- `message` ... Specify an optional custom message.
+
+### `{ "argument": "foo", "modifiers": ["prevent"]  }`
+
+<eslint-code-block :rules="{'vue/no-restricted-v-on': ['error', { argument: 'foo', modifiers: ['prevent'] }]}">
+
+```vue
+<template>
+  <!-- ✓ GOOD -->
+  <div @foo="x" />
+
+  <!-- ✗ BAD -->
+  <div @foo.prevent="x" />
+</template>
+```
+
+</eslint-code-block>
+
+### `{ "argument": "foo", "element": "MyButton"  }`
+
+<eslint-code-block :rules="{'vue/no-restricted-v-on': ['error', { argument: 'foo', element: 'MyButton' }]}">
+
+```vue
+<template>
+  <!-- ✓ GOOD -->
+  <CoolButton @foo="x" />
+
+  <!-- ✗ BAD -->
+  <MyButton @foo="x" />
+</template>
+```
+
+</eslint-code-block>
+
+## :couple: Related Rules
+
+- [vue/no-restricted-static-attribute]
+- [vue/no-restricted-v-bind]
+
+[vue/no-restricted-static-attribute]: ./no-restricted-static-attribute.md
+[vue/no-restricted-v-bind]: ./no-restricted-v-bind.md
+
+## :mag: Implementation
+
+- [Rule source](https://github.com/vuejs/eslint-plugin-vue/blob/master/lib/rules/no-restricted-v-on.js)
+- [Test source](https://github.com/vuejs/eslint-plugin-vue/blob/master/tests/lib/rules/no-restricted-v-on.js)
diff --git a/lib/index.js b/lib/index.js
index 3eb5208c..d9022cab 100644
--- a/lib/index.js
+++ b/lib/index.js
@@ -131,6 +131,7 @@ module.exports = {
     'no-restricted-static-attribute': require('./rules/no-restricted-static-attribute'),
     'no-restricted-syntax': require('./rules/no-restricted-syntax'),
     'no-restricted-v-bind': require('./rules/no-restricted-v-bind'),
+    'no-restricted-v-on': require('./rules/no-restricted-v-on'),
     'no-root-v-if': require('./rules/no-root-v-if'),
     'no-setup-props-destructure': require('./rules/no-setup-props-destructure'),
     'no-setup-props-reactivity-loss': require('./rules/no-setup-props-reactivity-loss'),
diff --git a/lib/rules/no-restricted-v-on.js b/lib/rules/no-restricted-v-on.js
new file mode 100644
index 00000000..2379df34
--- /dev/null
+++ b/lib/rules/no-restricted-v-on.js
@@ -0,0 +1,184 @@
+/**
+ * @author Kamogelo Moalusi <github.com/thesheppard>
+ * See LICENSE file in root directory for full license.
+ */
+'use strict'
+
+const utils = require('../utils')
+const regexp = require('../utils/regexp')
+
+/**
+ * @typedef {object} ParsedOption
+ * @property { (key: VDirectiveKey) => boolean } test
+ * @property {string[]} [modifiers]
+ * @property {boolean} [useElement]
+ * @property {string} [message]
+ */
+
+/**
+ * @param {string} str
+ * @returns {(str: string) => boolean}
+ */
+function buildMatcher(str) {
+  if (regexp.isRegExp(str)) {
+    const re = regexp.toRegExp(str)
+    return (s) => {
+      re.lastIndex = 0
+      return re.test(s)
+    }
+  }
+  return (s) => s === str
+}
+
+/**
+ * @param {any} option
+ * @returns {ParsedOption}
+ */
+function parseOption(option) {
+  if (typeof option === 'string') {
+    const matcher = buildMatcher(option)
+    return {
+      test(key) {
+        return Boolean(
+          key.argument &&
+            key.argument.type === 'VIdentifier' &&
+            matcher(key.argument.rawName)
+        )
+      }
+    }
+  }
+  if (option === null) {
+    return {
+      test(key) {
+        return key.argument === null
+      }
+    }
+  }
+  const parsed = parseOption(option.argument)
+
+  if (option.modifiers) {
+    const argTest = parsed.test
+    parsed.test = (key) => {
+      if (!argTest(key)) {
+        return false
+      }
+      return /** @type {string[]} */ (option.modifiers).every((modName) =>
+        key.modifiers.some((mid) => mid.name === modName)
+      )
+    }
+    parsed.modifiers = option.modifiers
+  }
+  if (option.element) {
+    const argTest = parsed.test
+    const tagMatcher = buildMatcher(option.element)
+    parsed.test = (key) => {
+      if (!argTest(key)) {
+        return false
+      }
+      return tagMatcher(key.parent.parent.parent.rawName)
+    }
+    parsed.useElement = true
+  }
+  parsed.message = option.message
+  return parsed
+}
+
+/**
+ * @param {VDirectiveKey} key
+ * @param {ParsedOption} option
+ */
+function defaultMessage(key, option) {
+  const von = key.name.rawName === '@' ? '' : 'v-on'
+  const arg =
+    key.argument != null && key.argument.type === 'VIdentifier'
+      ? `${key.name.rawName === '@' ? '@' : ':'}${key.argument.rawName}`
+      : ''
+  const mod =
+    option.modifiers != null && option.modifiers.length > 0
+      ? `.${option.modifiers.join('.')}`
+      : ''
+  let element = 'element'
+  if (option.useElement) {
+    element = `<${key.parent.parent.parent.rawName}>`
+  }
+  return `Using \`${von + arg + mod}\` is not allowed on this ${element}.`
+}
+
+module.exports = {
+  meta: {
+    type: 'suggestion',
+    docs: {
+      description: 'disallow specific argument in `v-on`',
+      categories: undefined,
+      url: 'https://eslint.vuejs.org/rules/no-restricted-v-on.html'
+    },
+    fixable: null,
+    schema: {
+      type: 'array',
+      items: {
+        oneOf: [
+          { type: ['string', 'null'] },
+          {
+            type: 'object',
+            properties: {
+              argument: { type: ['string', 'null'] },
+              element: { type: 'string' },
+              message: { type: 'string', minLength: 1 },
+              modifiers: {
+                type: 'array',
+                items: {
+                  type: 'string',
+                  enum: [
+                    'prevent',
+                    'stop',
+                    'capture',
+                    'self',
+                    'once',
+                    'passive'
+                  ]
+                },
+                uniqueItems: true,
+                minItems: 1
+              }
+            },
+            required: ['argument'],
+            additionalProperties: false
+          }
+        ]
+      },
+      uniqueItems: true
+    },
+    messages: {
+      // eslint-disable-next-line eslint-plugin/report-message-format
+      restrictedVOn: '{{message}}'
+    }
+  },
+
+  /** @param {RuleContext} context */
+  create(context) {
+    if (context.options.length === 0) {
+      return {}
+    }
+    /** @type {ParsedOption[]} */
+    const options = context.options.map(parseOption)
+
+    return utils.defineTemplateBodyVisitor(context, {
+      /**
+       * @param {VDirectiveKey} node
+       */
+      "VAttribute[directive=true][key.name.name='on'] > VDirectiveKey"(node) {
+        for (const option of options) {
+          if (option.test(node)) {
+            const message = option.message || defaultMessage(node, option)
+            context.report({
+              node,
+              messageId: 'restrictedVOn',
+              data: { message }
+            })
+            return
+          }
+        }
+      }
+    })
+  }
+}


Test patch:
diff --git a/tests/lib/rules/no-restricted-v-on.js b/tests/lib/rules/no-restricted-v-on.js
new file mode 100644
index 00000000..1d73d5c1
--- /dev/null
+++ b/tests/lib/rules/no-restricted-v-on.js
@@ -0,0 +1,173 @@
+/**
+ * @author Kamogelo Moalusi <github.com/thesheppard>
+ * See LICENSE file in root directory for full license.
+ */
+'use strict'
+
+const RuleTester = require('eslint').RuleTester
+const rule = require('../../../lib/rules/no-restricted-v-on')
+
+const tester = new RuleTester({
+  parser: require.resolve('vue-eslint-parser'),
+  parserOptions: {
+    ecmaVersion: 2020,
+    sourceType: 'module'
+  }
+})
+
+tester.run('no-restricted-v-on', rule, {
+  valid: [
+    {
+      filename: 'test.vue',
+      code: ''
+    },
+    {
+      filename: 'test.vue',
+      code: '<template><div @click="foo"></div></template>'
+    },
+    {
+      filename: 'test.vue',
+      code: '<template><button click="foo"></button></template>'
+    },
+    {
+      filename: 'test.vue',
+      code: '<template><button :click="foo"></button></template>'
+    },
+    {
+      filename: 'test.vue',
+      code: '<template><div @v-model="foo"></div></template>',
+      options: ['foo']
+    },
+    {
+      filename: 'test.vue',
+      code: '<template><div @bar="foo"></div></template>',
+      options: ['foo']
+    },
+    {
+      filename: 'test.vue',
+      code: '<template><div foo="foo"></div></template>',
+      options: ['foo']
+    },
+    {
+      filename: 'test.vue',
+      code: '<template><div @foo="foo"></div></template>',
+      options: [{ argument: 'foo', modifiers: ['prevent'] }]
+    },
+    {
+      filename: 'test.vue',
+      code: '<template><div @foo="foo"></div></template>',
+      options: [{ argument: 'foo', element: 'input' }]
+    }
+  ],
+  invalid: [
+    {
+      filename: 'test.vue',
+      code: '<template><div @test="foo"></div></template>',
+      options: ['test'],
+      errors: [
+        {
+          message: 'Using `@test` is not allowed on this element.',
+          line: 1,
+          column: 16
+        }
+      ]
+    },
+    {
+      filename: 'test.vue',
+      code: '<template><div @foo="bar" @bar="foo"></div></template>',
+      options: ['foo'],
+      errors: ['Using `@foo` is not allowed on this element.']
+    },
+    {
+      filename: 'test.vue',
+      code: '<template><div @foo="bar" @bar="foo"></div></template>',
+      options: ['foo', 'bar'],
+      errors: [
+        'Using `@foo` is not allowed on this element.',
+        'Using `@bar` is not allowed on this element.'
+      ]
+    },
+    {
+      filename: 'test.vue',
+      code: '<template><div @foo="bar" @bar="foo"></div></template>',
+      options: [{ argument: '/^(foo|bar)$/' }],
+      errors: [
+        'Using `@foo` is not allowed on this element.',
+        'Using `@bar` is not allowed on this element.'
+      ]
+    },
+    {
+      filename: 'test.vue',
+      code: '<template><div @foo.once="foo" /><div @foo="foo" /></template>',
+      options: [{ argument: 'foo', modifiers: ['once'] }],
+      errors: ['Using `@foo.once` is not allowed on this element.']
+    },
+    {
+      filename: 'test.vue',
+      code: '<template><div @v-on="foo" @foo.prevent="bar" /><div @foo="foo" v-on="listener" /></template>',
+      options: ['/^v-/', { argument: 'foo', modifiers: ['prevent'] }, null],
+      errors: [
+        'Using `@v-on` is not allowed on this element.',
+        'Using `@foo.prevent` is not allowed on this element.',
+        'Using `v-on` is not allowed on this element.'
+      ]
+    },
+    {
+      filename: 'test.vue',
+      code: '<template><div v-on="foo" /></template>',
+      options: [null],
+      errors: ['Using `v-on` is not allowed on this element.']
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        <div @v-on @foo.once="bar" />
+        <MyButton @foo="foo" @bar="bar" />
+      </template>`,
+      options: ['/^v-/', { argument: 'foo', element: `/^My/` }],
+      errors: [
+        'Using `@v-on` is not allowed on this element.',
+        'Using `@foo` is not allowed on this <MyButton>.'
+      ]
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        <div @foo="x" />
+      </template>`,
+      options: ['/^f/', { argument: 'foo' }],
+      errors: ['Using `@foo` is not allowed on this element.']
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        <div @foo="x" />
+      </template>`,
+      options: [{ argument: 'foo', message: 'foo' }],
+      errors: ['foo']
+    },
+    {
+      filename: 'test.vue',
+      code: '<template><div @foo="foo"></div></template>',
+      options: [{ argument: 'foo', element: 'div' }],
+      errors: [
+        {
+          message: 'Using `@foo` is not allowed on this <div>.'
+        }
+      ]
+    },
+    {
+      filename: 'test.vue',
+      code: '<template><div v-on:foo="foo"></div></template>',
+      options: ['foo'],
+      errors: [
+        {
+          message: 'Using `v-on:foo` is not allowed on this element.'
+        }
+      ]
+    }
+  ]
+})