Repository: vuejs/eslint-plugin-vue
Pull request #1466 (2021-03-25 19:53:50)

New: Add `vue/no-invalid-model-keys` rule
This PR adds the rule `vue/no-invalid-model-keys` which check `model` prop keys

REF: https://github.com/vuejs/eslint-plugin-vue/issues/824

Alex-Sokolov: @ota-meshi thanks for review and suggestions! Could you check again please?

Patch:
diff --git a/docs/rules/max-attributes-per-line.md b/docs/rules/max-attributes-per-line.md
index 69bd7794..dac9d2d7 100644
--- a/docs/rules/max-attributes-per-line.md
+++ b/docs/rules/max-attributes-per-line.md
@@ -57,7 +57,10 @@ There is a configurable number of attributes that are acceptable in one-line cas
 ```json
 {
   "vue/max-attributes-per-line": ["error", {
-    "singleline": 1,
+    "singleline": {
+      "max": 1,
+      "allowFirstLine": true
+    },      
     "multiline": {
       "max": 1,
       "allowFirstLine": false
@@ -66,7 +69,8 @@ There is a configurable number of attributes that are acceptable in one-line cas
 }
 ```
 
-- `singleline` (`number`) ... The number of maximum attributes per line when the opening tag is in a single line. Default is `1`.
+- `singleline.max` (`number`) ... The number of maximum attributes per line when the opening tag is in a single line. Default is `1`.
+- `singleline.allowFirstLine` (`boolean`) ... If `true`, it allows attributes on the same line as that tag name. Default is `true`.
 - `multiline.max` (`number`) ... The max number of attributes per line when the opening tag is in multiple lines. Default is `1`. This can be `{ multiline: 1 }` instead of `{ multiline: { max: 1 }}` if you don't configure `allowFirstLine` property.
 - `multiline.allowFirstLine` (`boolean`) ... If `true`, it allows attributes on the same line as that tag name. Default is `false`.
 
@@ -86,6 +90,24 @@ There is a configurable number of attributes that are acceptable in one-line cas
 
 </eslint-code-block>
 
+### `"singleline": 1, "allowFirstLine": false`
+
+<eslint-code-block fix :rules="{'vue/max-attributes-per-line': ['error', {singleline: { allowFirstLine: false }}]}">
+
+```vue
+<template>
+  <!-- ✓ GOOD -->
+  <MyComponent
+    lorem="1"
+  />
+
+  <!-- ✗ BAD -->
+  <MyComponent lorem="1" />
+</template>
+```
+
+</eslint-code-block>
+
 ### `"multiline": 2`
 
 <eslint-code-block fix :rules="{'vue/max-attributes-per-line': ['error', {multiline: 2}]}">
diff --git a/docs/rules/no-invalid-model-keys.md b/docs/rules/no-invalid-model-keys.md
new file mode 100644
index 00000000..b69cbf95
--- /dev/null
+++ b/docs/rules/no-invalid-model-keys.md
@@ -0,0 +1,103 @@
+---
+pageClass: rule-details
+sidebarDepth: 0
+title: vue/no-invalid-model-keys
+description: require valid keys in model option
+---
+# vue/no-invalid-model-keys
+
+> require valid keys in model option
+
+
+## :book: Rule Details
+
+This rule is aimed at preventing invalid keys in model option.
+
+<eslint-code-block :rules="{'vue/no-invalid-model-keys': ['error']}">
+```vue
+<script>
+/* ✓ GOOD */
+export default {
+  model: {
+    prop: 'list',
+  }
+}
+</script>
+```
+</eslint-code-block>
+
+<eslint-code-block :rules="{'vue/no-invalid-model-keys': ['error']}">
+```vue
+<script>
+/* ✓ GOOD */
+export default {
+  model: {
+    event: 'update'
+  }
+}
+</script>
+```
+</eslint-code-block>
+
+<eslint-code-block :rules="{'vue/no-invalid-model-keys': ['error']}">
+```vue
+<script>
+/* ✓ GOOD */
+export default {
+  model: {
+    prop: 'list',
+    event: 'update'
+  }
+}
+</script>
+```
+</eslint-code-block>
+
+<eslint-code-block :rules="{'vue/no-invalid-model-keys': ['error']}">
+```vue
+<script>
+/* ✗ BAD */
+export default {
+  model: {
+    prop: 'list',
+    events: 'update'
+  }
+}
+</script>
+```
+</eslint-code-block>
+
+<eslint-code-block :rules="{'vue/no-invalid-model-keys': ['error']}">
+```vue
+<script>
+/* ✗ BAD */
+export default {
+  model: {
+    props: 'list',
+    events: 'update'
+  }
+}
+</script>
+```
+</eslint-code-block>
+
+<eslint-code-block :rules="{'vue/no-invalid-model-keys': ['error']}">
+```vue
+<script>
+/* ✗ BAD */
+export default {
+  model: {
+    name: 'checked',
+    props: 'list',
+    event: 'update'
+  }
+}
+</script>
+```
+</eslint-code-block>
+
+
+## :mag: Implementation
+
+- [Rule source](https://github.com/vuejs/eslint-plugin-vue/blob/master/lib/rules/no-invalid-model-keys.js)
+- [Test source](https://github.com/vuejs/eslint-plugin-vue/blob/master/tests/lib/rules/no-invalid-model-keys.js)
diff --git a/lib/index.js b/lib/index.js
index cc18e305..a5f63231 100644
--- a/lib/index.js
+++ b/lib/index.js
@@ -81,6 +81,7 @@ module.exports = {
     'no-empty-component-block': require('./rules/no-empty-component-block'),
     'no-empty-pattern': require('./rules/no-empty-pattern'),
     'no-extra-parens': require('./rules/no-extra-parens'),
+    'no-invalid-model-keys': require('./rules/no-invalid-model-keys'),
     'no-irregular-whitespace': require('./rules/no-irregular-whitespace'),
     'no-lifecycle-after-await': require('./rules/no-lifecycle-after-await'),
     'no-lone-template': require('./rules/no-lone-template'),
diff --git a/lib/rules/max-attributes-per-line.js b/lib/rules/max-attributes-per-line.js
index e379df47..d50a78d6 100644
--- a/lib/rules/max-attributes-per-line.js
+++ b/lib/rules/max-attributes-per-line.js
@@ -34,6 +34,9 @@ module.exports = {
                   max: {
                     type: 'number',
                     minimum: 1
+                  },
+                  allowFirstLine: {
+                    type: 'boolean'
                   }
                 },
                 additionalProperties: false
@@ -72,7 +75,8 @@ module.exports = {
     const configuration = parseOptions(context.options[0])
     const multilineMaximum = configuration.multiline
     const singlelinemMaximum = configuration.singleline
-    const canHaveFirstLine = configuration.allowFirstLine
+    const canHaveSinglelineFirstLine = configuration.singlelineAllowFirstLine
+    const canHaveMultilineFirstLine = configuration.multilineAllowFirstLine
     const template =
       context.parserServices.getTemplateBodyTokenStore &&
       context.parserServices.getTemplateBodyTokenStore()
@@ -83,16 +87,22 @@ module.exports = {
 
         if (!numberOfAttributes) return
 
-        if (
-          utils.isSingleLine(node) &&
-          numberOfAttributes > singlelinemMaximum
-        ) {
-          showErrors(node.attributes.slice(singlelinemMaximum))
+        if (utils.isSingleLine(node)) {
+          if (
+            !canHaveSinglelineFirstLine &&
+            node.attributes[0].loc.start.line === node.loc.start.line
+          ) {
+            showErrors([node.attributes[0]])
+          }
+
+          if (numberOfAttributes > singlelinemMaximum) {
+            showErrors(node.attributes.slice(singlelinemMaximum))
+          }
         }
 
         if (!utils.isSingleLine(node)) {
           if (
-            !canHaveFirstLine &&
+            !canHaveMultilineFirstLine &&
             node.attributes[0].loc.start.line === node.loc.start.line
           ) {
             showErrors([node.attributes[0]])
@@ -114,27 +124,36 @@ module.exports = {
     function parseOptions(options) {
       const defaults = {
         singleline: 1,
+        singlelineAllowFirstLine: true,
         multiline: 1,
-        allowFirstLine: false
+        multilineAllowFirstLine: false
       }
 
       if (options) {
         if (typeof options.singleline === 'number') {
           defaults.singleline = options.singleline
-        } else if (options.singleline && options.singleline.max) {
-          defaults.singleline = options.singleline.max
+        } else if (typeof options.singleline === 'object') {
+          if (typeof options.singleline.max === 'number') {
+            defaults.singleline = options.singleline.max
+          }
+
+          if (typeof options.singleline.allowFirstLine === 'boolean') {
+            defaults.singlelineAllowFirstLine =
+              options.singleline.allowFirstLine
+          }
         }
 
         if (options.multiline) {
           if (typeof options.multiline === 'number') {
             defaults.multiline = options.multiline
           } else if (typeof options.multiline === 'object') {
-            if (options.multiline.max) {
+            if (typeof options.multiline.max === 'number') {
               defaults.multiline = options.multiline.max
             }
 
-            if (options.multiline.allowFirstLine) {
-              defaults.allowFirstLine = options.multiline.allowFirstLine
+            if (typeof options.multiline.allowFirstLine === 'boolean') {
+              defaults.multilineAllowFirstLine =
+                options.multiline.allowFirstLine
             }
           }
         }
diff --git a/lib/rules/no-invalid-model-keys.js b/lib/rules/no-invalid-model-keys.js
new file mode 100644
index 00000000..3a4b7ccf
--- /dev/null
+++ b/lib/rules/no-invalid-model-keys.js
@@ -0,0 +1,56 @@
+/**
+ * @fileoverview Requires valid keys in model option.
+ * @author Alex Sokolov
+ */
+'use strict'
+
+const utils = require('../utils')
+
+/**
+ * @typedef {import('../utils').GroupName} GroupName
+ */
+
+// ------------------------------------------------------------------------------
+// Rule Definition
+// ------------------------------------------------------------------------------
+/** @type {GroupName[]} */
+const GROUP_NAMES = ['model']
+
+const VALID_MODEL_KEYS = ['prop', 'event']
+
+module.exports = {
+  meta: {
+    type: 'problem',
+    docs: {
+      description: 'require valid keys in model option',
+      categories: undefined,
+      url: 'https://eslint.vuejs.org/rules/no-invalid-model-keys.html'
+    },
+    fixable: null,
+    schema: []
+  },
+  /** @param {RuleContext} context */
+  create(context) {
+    const groups = new Set(GROUP_NAMES)
+
+    // ----------------------------------------------------------------------
+    // Public
+    // ----------------------------------------------------------------------
+
+    return utils.executeOnVue(context, (obj) => {
+      const properties = utils.iterateProperties(obj, groups)
+
+      for (const o of properties) {
+        if (VALID_MODEL_KEYS.indexOf(o.name) === -1) {
+          context.report({
+            node: o.node,
+            message: "Invalid key '{{name}}' in model option.",
+            data: {
+              name: o.name
+            }
+          })
+        }
+      }
+    })
+  }
+}
diff --git a/lib/rules/require-valid-default-prop.js b/lib/rules/require-valid-default-prop.js
index ef9453b2..e40cad86 100644
--- a/lib/rules/require-valid-default-prop.js
+++ b/lib/rules/require-valid-default-prop.js
@@ -49,10 +49,11 @@ function getPropertyNode(obj, name) {
 }
 
 /**
- * @param {Expression} node
+ * @param {Expression} targetNode
  * @returns {string[]}
  */
-function getTypes(node) {
+function getTypes(targetNode) {
+  const node = utils.skipTSAsExpression(targetNode)
   if (node.type === 'Identifier') {
     return [node.name]
   } else if (node.type === 'ArrayExpression') {


Test patch:
diff --git a/tests/lib/rules/max-attributes-per-line.js b/tests/lib/rules/max-attributes-per-line.js
index fa721002..aedc6d6d 100644
--- a/tests/lib/rules/max-attributes-per-line.js
+++ b/tests/lib/rules/max-attributes-per-line.js
@@ -299,6 +299,19 @@ petname="Snoopy" extra="foo">
           line: 3
         }
       ]
+    },
+    {
+      code: `<template><component name="John Doe"></component></template>`,
+      options: [{ singleline: { allowFirstLine: false } }],
+      output: `<template><component
+name="John Doe"></component></template>`,
+      errors: [
+        {
+          message: "'name' should be on a new line.",
+          type: 'VAttribute',
+          line: 1
+        }
+      ]
     }
   ]
 })
diff --git a/tests/lib/rules/no-invalid-model-keys.js b/tests/lib/rules/no-invalid-model-keys.js
new file mode 100644
index 00000000..ebe4de97
--- /dev/null
+++ b/tests/lib/rules/no-invalid-model-keys.js
@@ -0,0 +1,151 @@
+/**
+ * @fileoverview Prevents invalid keys in model option.
+ * @author Alex Sokolov
+ */
+'use strict'
+
+// ------------------------------------------------------------------------------
+// Requirements
+// ------------------------------------------------------------------------------
+
+const rule = require('../../../lib/rules/no-invalid-model-keys')
+const RuleTester = require('eslint').RuleTester
+
+// ------------------------------------------------------------------------------
+// Tests
+// ------------------------------------------------------------------------------
+
+const ruleTester = new RuleTester({
+  parserOptions: {
+    ecmaVersion: 2018,
+    sourceType: 'module'
+  }
+})
+ruleTester.run('no-invalid-model-keys', rule, {
+  valid: [
+    {
+      filename: 'test.vue',
+      code: `
+        export default {
+          model: {
+            prop: 'list'
+          }
+        }
+      `
+    },
+    {
+      filename: 'test.vue',
+      code: `
+        export default {
+          model: {
+            event: 'update'
+          }
+        }
+      `
+    },
+    {
+      filename: 'test.vue',
+      code: `
+        export default {
+          model: {
+            prop: 'list',
+            event: 'update'
+          }
+        }
+      `
+    }
+  ],
+
+  invalid: [
+    {
+      filename: 'test.vue',
+      code: `
+        export default {
+          model: {
+            props: 'list'
+          }
+        }
+      `,
+      errors: ["Invalid key 'props' in model option."]
+    },
+    {
+      filename: 'test.vue',
+      code: `
+        export default {
+          model: {
+            events: 'update'
+          }
+        }
+      `,
+      errors: ["Invalid key 'events' in model option."]
+    },
+    {
+      filename: 'test.vue',
+      code: `
+        export default {
+          model: {
+            props: 'list',
+            event: 'update'
+          }
+        }
+      `,
+      errors: ["Invalid key 'props' in model option."]
+    },
+    {
+      filename: 'test.vue',
+      code: `
+        export default {
+          model: {
+            prop: 'list',
+            events: 'update'
+          }
+        }
+      `,
+      errors: ["Invalid key 'events' in model option."]
+    },
+    {
+      filename: 'test.vue',
+      code: `
+        export default {
+          model: {
+            props: 'list',
+            events: 'update'
+          }
+        }
+      `,
+      errors: [
+        "Invalid key 'props' in model option.",
+        "Invalid key 'events' in model option."
+      ]
+    },
+    {
+      filename: 'test.vue',
+      code: `
+        export default {
+          model: {
+            prop: 'checked',
+            props: 'list',
+            event: 'update'
+          }
+        }
+      `,
+      errors: ["Invalid key 'props' in model option."]
+    },
+    {
+      filename: 'test.vue',
+      code: `
+        export default {
+          model: {
+            name: 'checked',
+            props: 'list',
+            event: 'update'
+          }
+        }
+      `,
+      errors: [
+        "Invalid key 'name' in model option.",
+        "Invalid key 'props' in model option."
+      ]
+    }
+  ]
+})
diff --git a/tests/lib/rules/require-valid-default-prop.js b/tests/lib/rules/require-valid-default-prop.js
index 58adccad..836f06d5 100644
--- a/tests/lib/rules/require-valid-default-prop.js
+++ b/tests/lib/rules/require-valid-default-prop.js
@@ -207,6 +207,51 @@ ruleTester.run('require-valid-default-prop', rule, {
         }
       }`,
       parserOptions
+    },
+    {
+      filename: 'test.vue',
+      code: `export default Vue.extend({
+          props: {
+            foo: {
+              type: Array as PropType<string[]>,
+              default: () => []
+            }
+          }
+        });
+      `,
+      parserOptions: { ecmaVersion: 6, sourceType: 'module' },
+      parser: require.resolve('@typescript-eslint/parser'),
+      errors: errorMessage('function')
+    },
+    {
+      filename: 'test.vue',
+      code: `export default Vue.extend({
+          props: {
+            foo: {
+              type: Object as PropType<{ [key: number]: number }>,
+              default: () => {}
+            }
+          }
+        });
+      `,
+      parserOptions: { ecmaVersion: 6, sourceType: 'module' },
+      parser: require.resolve('@typescript-eslint/parser'),
+      errors: errorMessage('function')
+    },
+    {
+      filename: 'test.vue',
+      code: `export default Vue.extend({
+          props: {
+            foo: {
+              type: Function as PropType<() => number>,
+              default: () => 10
+            }
+          }
+        });
+      `,
+      parserOptions: { ecmaVersion: 6, sourceType: 'module' },
+      parser: require.resolve('@typescript-eslint/parser'),
+      errors: errorMessage('function')
     }
   ],
 
@@ -781,6 +826,51 @@ ruleTester.run('require-valid-default-prop', rule, {
       }`,
       parserOptions,
       errors: errorMessage('string')
+    },
+    {
+      filename: 'test.vue',
+      code: `export default Vue.extend({
+          props: {
+            foo: {
+              type: Array as PropType<string[]>,
+              default: []
+            }
+          }
+        });
+      `,
+      parserOptions: { ecmaVersion: 6, sourceType: 'module' },
+      parser: require.resolve('@typescript-eslint/parser'),
+      errors: errorMessage('function')
+    },
+    {
+      filename: 'test.vue',
+      code: `export default Vue.extend({
+          props: {
+            foo: {
+              type: Object as PropType<{ [key: number]: number }>,
+              default: {}
+            }
+          }
+        });
+      `,
+      parserOptions: { ecmaVersion: 6, sourceType: 'module' },
+      parser: require.resolve('@typescript-eslint/parser'),
+      errors: errorMessage('function')
+    },
+    {
+      filename: 'test.vue',
+      code: `export default Vue.extend({
+          props: {
+            foo: {
+              type: Function as PropType<() => number>,
+              default: 10
+            }
+          }
+        });
+      `,
+      parserOptions: { ecmaVersion: 6, sourceType: 'module' },
+      parser: require.resolve('@typescript-eslint/parser'),
+      errors: errorMessage('function')
     }
   ]
 })