Repository: vuejs/eslint-plugin-vue
Pull request #1560 (2021-07-05 14:37:51)

Add `vue/valid-define-props` rule
This PR adds `vue/valid-define-props` rule.

`vue/valid-define-props` rule reports `defineProps` compiler macros in the following cases:

- `defineProps` are referencing locally declared variables.
- `defineProps` has both a literal type and an argument. e.g. `defineProps<{/*props*/}>({/*props*/})`
- `defineProps` has been called multiple times.
- Props are defined in both `defineProps` and `export default {}`.
- Props are not defined in either `defineProps` or `export default {}`.

Related to #1248


Patch:
diff --git a/docs/rules/README.md b/docs/rules/README.md
index 1a0a5fe3..ae82c5d0 100644
--- a/docs/rules/README.md
+++ b/docs/rules/README.md
@@ -331,6 +331,7 @@ For example:
 | [vue/v-for-delimiter-style](./v-for-delimiter-style.md) | enforce `v-for` directive's delimiter style | :wrench: |
 | [vue/v-on-event-hyphenation](./v-on-event-hyphenation.md) | enforce v-on event naming style on custom components in template | :wrench: |
 | [vue/v-on-function-call](./v-on-function-call.md) | enforce or forbid parentheses after method calls without arguments in `v-on` directives | :wrench: |
+| [vue/valid-define-props](./valid-define-props.md) | enforce valid `defineProps` compiler macro |  |
 | [vue/valid-next-tick](./valid-next-tick.md) | enforce valid `nextTick` function calls | :wrench: |
 
 ### Extension Rules
diff --git a/docs/rules/valid-define-props.md b/docs/rules/valid-define-props.md
new file mode 100644
index 00000000..c92dac10
--- /dev/null
+++ b/docs/rules/valid-define-props.md
@@ -0,0 +1,133 @@
+---
+pageClass: rule-details
+sidebarDepth: 0
+title: vue/valid-define-props
+description: enforce valid `defineProps` compiler macro
+---
+# vue/valid-define-props
+
+> enforce valid `defineProps` compiler macro
+
+- :exclamation: <badge text="This rule has not been released yet." vertical="middle" type="error"> ***This rule has not been released yet.*** </badge>
+
+This rule checks whether `defineProps` compiler macro is valid.
+
+## :book: Rule Details
+
+This rule reports `defineProps` compiler macros in the following cases:
+
+- `defineProps` are referencing locally declared variables.
+- `defineProps` has both a literal type and an argument. e.g. `defineProps<{/*props*/}>({/*props*/})`
+- `defineProps` has been called multiple times.
+- Props are defined in both `defineProps` and `export default {}`.
+- Props are not defined in either `defineProps` or `export default {}`.
+
+<eslint-code-block :rules="{'vue/valid-define-props': ['error']}">
+
+```vue
+<script setup>
+  /* ✓ GOOD */
+  defineProps({ msg: String })
+</script>
+```
+
+</eslint-code-block>
+
+<eslint-code-block :rules="{'vue/valid-define-props': ['error']}">
+
+```vue
+<script setup>
+  /* ✓ GOOD */
+  defineProps(['msg'])
+</script>
+```
+
+</eslint-code-block>
+
+```vue
+<script setup lang="ts">
+  /* ✓ GOOD */
+  defineProps<{ msg?:string }>()
+</script>
+```
+
+<eslint-code-block :rules="{'vue/valid-define-props': ['error']}">
+
+```vue
+<script>
+  const def = { msg: String }
+</script>
+<script setup>
+  /* ✓ GOOD */
+  defineProps(def)
+</script>
+```
+
+</eslint-code-block>
+
+<eslint-code-block :rules="{'vue/valid-define-props': ['error']}">
+
+```vue
+<script setup>
+  /* ✗ BAD */
+  const def = { msg: String }
+  defineProps(def)
+</script>
+```
+
+</eslint-code-block>
+
+```vue
+<script setup lang="ts">
+  /* ✗ BAD */
+  defineProps<{ msg?:string }>({ msg: String })
+</script>
+```
+
+<eslint-code-block :rules="{'vue/valid-define-props': ['error']}">
+
+```vue
+<script setup>
+  /* ✗ BAD */
+  defineProps({ msg: String })
+  defineProps({ count: Number })
+</script>
+```
+
+</eslint-code-block>
+
+<eslint-code-block :rules="{'vue/valid-define-props': ['error']}">
+
+```vue
+<script>
+  export default {
+    props: { msg: String }
+  }
+</script>
+<script setup>
+  /* ✗ BAD */
+  defineProps({ count: Number })
+</script>
+```
+
+</eslint-code-block>
+
+<eslint-code-block :rules="{'vue/valid-define-props': ['error']}">
+
+```vue
+<script setup>
+  /* ✗ BAD */
+  defineProps()
+</script>
+```
+
+</eslint-code-block>
+
+## :wrench: Options
+
+Nothing.
+
+## :mag: Implementation
+
+- [Rule source](https://github.com/vuejs/eslint-plugin-vue/blob/master/lib/rules/valid-define-props.js)
+- [Test source](https://github.com/vuejs/eslint-plugin-vue/blob/master/tests/lib/rules/valid-define-props.js)
diff --git a/lib/index.js b/lib/index.js
index f07aea55..0fcef5d9 100644
--- a/lib/index.js
+++ b/lib/index.js
@@ -171,6 +171,7 @@ module.exports = {
     'v-on-function-call': require('./rules/v-on-function-call'),
     'v-on-style': require('./rules/v-on-style'),
     'v-slot-style': require('./rules/v-slot-style'),
+    'valid-define-props': require('./rules/valid-define-props'),
     'valid-next-tick': require('./rules/valid-next-tick'),
     'valid-template-root': require('./rules/valid-template-root'),
     'valid-v-bind-sync': require('./rules/valid-v-bind-sync'),
diff --git a/lib/rules/valid-define-props.js b/lib/rules/valid-define-props.js
new file mode 100644
index 00000000..61391665
--- /dev/null
+++ b/lib/rules/valid-define-props.js
@@ -0,0 +1,145 @@
+/**
+ * @author Yosuke Ota <https://github.com/ota-meshi>
+ * See LICENSE file in root directory for full license.
+ */
+'use strict'
+
+const { findVariable } = require('eslint-utils')
+const utils = require('../utils')
+
+module.exports = {
+  meta: {
+    type: 'problem',
+    docs: {
+      description: 'enforce valid `defineProps` compiler macro',
+      // TODO Switch in the major version.
+      // categories: ['vue3-essential'],
+      categories: undefined,
+      url: 'https://eslint.vuejs.org/rules/valid-define-props.html'
+    },
+    fixable: null,
+    schema: [],
+    messages: {
+      hasTypeAndArg:
+        '`defineProps` has both a type-only props and an argument.',
+      referencingLocally:
+        '`defineProps` are referencing locally declared variables.',
+      multiple: '`defineProps` has been called multiple times.',
+      notDefined: 'Props are not defined.',
+      definedInBoth:
+        'Props are defined in both `defineProps` and `export default {}`.'
+    }
+  },
+  /** @param {RuleContext} context */
+  create(context) {
+    const scriptSetup = utils.getScriptSetupElement(context)
+    if (!scriptSetup) {
+      return {}
+    }
+
+    /** @type {Set<Expression | SpreadElement>} */
+    const propsDefExpressions = new Set()
+    let hasDefaultExport = false
+    /** @type {CallExpression[]} */
+    const definePropsNodes = []
+    /** @type {CallExpression | null} */
+    let emptyDefineProps = null
+
+    return utils.compositingVisitors(
+      utils.defineScriptSetupVisitor(context, {
+        onDefinePropsEnter(node) {
+          definePropsNodes.push(node)
+
+          if (node.arguments.length >= 1) {
+            if (node.typeParameters && node.typeParameters.params.length >= 1) {
+              // `defineProps` has both a literal type and an argument.
+              context.report({
+                node,
+                messageId: 'hasTypeAndArg'
+              })
+              return
+            }
+
+            propsDefExpressions.add(node.arguments[0])
+          } else {
+            if (
+              !node.typeParameters ||
+              node.typeParameters.params.length === 0
+            ) {
+              emptyDefineProps = node
+            }
+          }
+        },
+        Identifier(node) {
+          for (const def of propsDefExpressions) {
+            if (utils.inRange(def.range, node)) {
+              const variable = findVariable(context.getScope(), node)
+              if (
+                variable &&
+                variable.references.some((ref) => ref.identifier === node)
+              ) {
+                if (
+                  variable.defs.length &&
+                  variable.defs.every((def) =>
+                    utils.inRange(scriptSetup.range, def.name)
+                  )
+                ) {
+                  //`defineProps` are referencing locally declared variables.
+                  context.report({
+                    node,
+                    messageId: 'referencingLocally'
+                  })
+                }
+              }
+            }
+          }
+        }
+      }),
+      utils.defineVueVisitor(context, {
+        onVueObjectEnter(node, { type }) {
+          if (type !== 'export' || utils.inRange(scriptSetup.range, node)) {
+            return
+          }
+
+          hasDefaultExport = Boolean(utils.findProperty(node, 'props'))
+        }
+      }),
+      {
+        'Program:exit'() {
+          if (!definePropsNodes.length) {
+            return
+          }
+          if (definePropsNodes.length > 1) {
+            // `defineProps` has been called multiple times.
+            for (const node of definePropsNodes) {
+              context.report({
+                node,
+                messageId: 'multiple'
+              })
+            }
+            return
+          }
+          if (emptyDefineProps) {
+            if (!hasDefaultExport) {
+              // Props are not defined.
+              context.report({
+                node: emptyDefineProps,
+                messageId: 'notDefined'
+              })
+            }
+          } else {
+            if (hasDefaultExport) {
+              // Props are defined in both `defineProps` and `export default {}`.
+              for (const node of definePropsNodes) {
+                context.report({
+                  node,
+                  messageId: 'definedInBoth'
+                })
+              }
+            }
+          }
+        }
+      }
+    )
+  }
+}
diff --git a/lib/utils/index.js b/lib/utils/index.js
index 9b86cf6e..b697a0d6 100644
--- a/lib/utils/index.js
+++ b/lib/utils/index.js
@@ -1058,16 +1058,26 @@ module.exports = {
     const hasEmitsEvent =
       visitor.onDefineEmitsEnter || visitor.onDefineEmitsExit
     if (hasPropsEvent || hasEmitsEvent) {
-      /** @type {ESNode | null} */
-      let nested = null
-      scriptSetupVisitor[':function, BlockStatement'] = (node) => {
-        if (!nested) {
-          nested = node
+      /** @type {Expression | null} */
+      let candidateMacro = null
+      /** @param {VariableDeclarator|ExpressionStatement} node */
+      scriptSetupVisitor[
+        'Program > VariableDeclaration > VariableDeclarator, Program > ExpressionStatement'
+      ] = (node) => {
+        if (!candidateMacro) {
+          candidateMacro =
+            node.type === 'VariableDeclarator' ? node.init : node.expression
         }
       }
-      scriptSetupVisitor[':function, BlockStatement:exit'] = (node) => {
-        if (nested === node) {
-          nested = null
+      /** @param {VariableDeclarator|ExpressionStatement} node */
+      scriptSetupVisitor[
+        'Program > VariableDeclaration > VariableDeclarator, Program > ExpressionStatement:exit'
+      ] = (node) => {
+        if (
+          candidateMacro ===
+          (node.type === 'VariableDeclarator' ? node.init : node.expression)
+        ) {
+          candidateMacro = null
         }
       }
       const definePropsMap = new Map()
@@ -1077,11 +1087,16 @@ module.exports = {
        */
       scriptSetupVisitor.CallExpression = (node) => {
         if (
-          !nested &&
+          candidateMacro &&
           inScriptSetup(node) &&
           node.callee.type === 'Identifier'
         ) {
-          if (hasPropsEvent && node.callee.name === 'defineProps') {
+          if (
+            hasPropsEvent &&
+            (candidateMacro === node ||
+              candidateMacro === getWithDefaults(node)) &&
+            node.callee.name === 'defineProps'
+          ) {
             /** @type {(ComponentArrayProp | ComponentObjectProp | ComponentTypeProp)[]} */
             let props = []
             if (node.arguments.length >= 1) {
@@ -1100,7 +1115,11 @@ module.exports = {
             }
             callVisitor('onDefinePropsEnter', node, props)
             definePropsMap.set(node, props)
-          } else if (hasEmitsEvent && node.callee.name === 'defineEmits') {
+          } else if (
+            hasEmitsEvent &&
+            candidateMacro === node &&
+            node.callee.name === 'defineEmits'
+          ) {
             /** @type {(ComponentArrayEmit | ComponentObjectEmit | ComponentTypeEmit)[]} */
             let emits = []
             if (node.arguments.length >= 1) {
@@ -2395,6 +2414,15 @@ function hasWithDefaults(node) {
   )
 }
 
+/**
+ * Get the withDefaults call node from given defineProps call node.
+ * @param {CallExpression} node The node of defineProps
+ * @returns {CallExpression | null}
+ */
+function getWithDefaults(node) {
+  return hasWithDefaults(node) ? node.parent : null
+}
+
 /**
  * Gets a map of the property nodes defined in withDefaults.
  * @param {CallExpression} node The node of defineProps


Test patch:
diff --git a/tests/lib/rules/valid-define-props.js b/tests/lib/rules/valid-define-props.js
new file mode 100644
index 00000000..0bd49f17
--- /dev/null
+++ b/tests/lib/rules/valid-define-props.js
@@ -0,0 +1,156 @@
+/**
+ * @author Yosuke Ota <https://github.com/ota-meshi>
+ * See LICENSE file in root directory for full license.
+ */
+'use strict'
+
+// ------------------------------------------------------------------------------
+// Requirements
+// ------------------------------------------------------------------------------
+
+const RuleTester = require('eslint').RuleTester
+const rule = require('../../../lib/rules/valid-define-props')
+
+// ------------------------------------------------------------------------------
+// Tests
+// ------------------------------------------------------------------------------
+
+const tester = new RuleTester({
+  parser: require.resolve('vue-eslint-parser'),
+  parserOptions: { ecmaVersion: 2015, sourceType: 'module' }
+})
+
+tester.run('valid-define-props', rule, {
+  valid: [
+    {
+      filename: 'test.vue',
+      code: `
+      <script setup>
+        /* ✓ GOOD */
+        defineProps({ msg: String })
+      </script>
+      `
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <script setup>
+        /* ✓ GOOD */
+        defineProps(['msg'])
+      </script>
+      `
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <script setup lang="ts">
+        /* ✓ GOOD */
+        defineProps<{ msg?:string }>()
+      </script>
+      `,
+      parserOptions: { parser: require.resolve('@typescript-eslint/parser') }
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <script>
+        const def = { msg: String }
+      </script>
+      <script setup>
+        /* ✓ GOOD */
+        defineProps(def)
+      </script>
+      `
+    }
+  ],
+  invalid: [
+    {
+      filename: 'test.vue',
+      code: `
+      <script setup>
+        /* ✗ BAD */
+        const def = { msg: String }
+        defineProps(def)
+      </script>
+      `,
+      errors: [
+        {
+          message: '`defineProps` are referencing locally declared variables.',
+          line: 5
+        }
+      ]
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <script setup lang="ts">
+        /* ✗ BAD */
+        defineProps<{ msg?:string }>({ msg: String })
+      </script>
+      `,
+      parserOptions: { parser: require.resolve('@typescript-eslint/parser') },
+      errors: [
+        {
+          message: '`defineProps` has both a type-only props and an argument.',
+          line: 4
+        }
+      ]
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <script setup>
+        /* ✗ BAD */
+        defineProps({ msg: String })
+        defineProps({ count: Number })
+      </script>
+      `,
+      errors: [
+        {
+          message: '`defineProps` has been called multiple times.',
+          line: 4
+        },
+        {
+          message: '`defineProps` has been called multiple times.',
+          line: 5
+        }
+      ]
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <script>
+      export default {
+        props: { msg: String }
+      }
+      </script>
+      <script setup>
+        /* ✗ BAD */
+        defineProps({ count: Number })
+      </script>
+      `,
+      errors: [
+        {
+          message:
+            'Props are defined in both `defineProps` and `export default {}`.',
+          line: 9
+        }
+      ]
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <script setup>
+        /* ✗ BAD */
+        defineProps()
+      </script>
+      `,
+      errors: [
+        {
+          message: 'Props are not defined.',
+          line: 4
+        }
+      ]
+    }
+  ]
+})