Repository: vuejs/eslint-plugin-vue
Pull request #1568 (2021-07-15 07:20:44)

Add `vue/require-expose` rule
This PR adds `vue/require-expose` rule.

`vue/require-expose` rule that enforces the component to explicitly declare the exposed properties to the component using `expose`.

- [Vue RFCs - 0042-expose-api](https://github.com/vuejs/rfcs/blob/master/active-rfcs/0042-expose-api.md)



Patch:
diff --git a/docs/.vuepress/config.js b/docs/.vuepress/config.js
index fb7719ff..c0a40616 100644
--- a/docs/.vuepress/config.js
+++ b/docs/.vuepress/config.js
@@ -155,7 +155,10 @@ module.exports = {
       { text: 'User Guide', link: '/user-guide/' },
       { text: 'Developer Guide', link: '/developer-guide/' },
       { text: 'Rules', link: '/rules/' },
-      { text: 'Demo', link: 'https://mysticatea.github.io/vue-eslint-demo' }
+      {
+        text: 'Demo',
+        link: 'https://ota-meshi.github.io/eslint-plugin-vue-demo/'
+      }
     ],
 
     sidebar: {
diff --git a/docs/rules/README.md b/docs/rules/README.md
index 3577668f..59e013e2 100644
--- a/docs/rules/README.md
+++ b/docs/rules/README.md
@@ -325,6 +325,7 @@ For example:
 | [vue/padding-line-between-blocks](./padding-line-between-blocks.md) | require or disallow padding lines between blocks | :wrench: |
 | [vue/require-direct-export](./require-direct-export.md) | require the component to be directly exported |  |
 | [vue/require-emit-validator](./require-emit-validator.md) | require type definitions in emits |  |
+| [vue/require-expose](./require-expose.md) | require declare public properties using `expose` |  |
 | [vue/require-name-property](./require-name-property.md) | require a name property in Vue components |  |
 | [vue/script-indent](./script-indent.md) | enforce consistent indentation in `<script>` | :wrench: |
 | [vue/sort-keys](./sort-keys.md) | enforce sort-keys in a manner that is compatible with order-in-components |  |
diff --git a/docs/rules/require-expose.md b/docs/rules/require-expose.md
new file mode 100644
index 00000000..f402c3cd
--- /dev/null
+++ b/docs/rules/require-expose.md
@@ -0,0 +1,120 @@
+---
+pageClass: rule-details
+sidebarDepth: 0
+title: vue/require-expose
+description: require declare public properties using `expose`
+---
+# vue/require-expose
+
+> require declare public properties using `expose`
+
+- :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 enforces the component to explicitly declare the exposed properties to the component using `expose`. You can use `expose` to control the internal properties of a component so that they cannot be referenced externally.
+
+The `expose` API was officially introduced in Vue 3.2.
+
+<eslint-code-block :rules="{'vue/require-expose': ['error']}">
+
+```vue
+<script>
+/* ✓ GOOD */
+export default {
+  expose: ['increment'],
+  data() {
+    return { count: 0 }
+  },
+  methods: {
+    increment() {
+      this.count++
+    }
+  }
+}
+</script>
+```
+
+</eslint-code-block>
+
+<eslint-code-block :rules="{'vue/require-expose': ['error']}">
+
+```vue
+<script>
+/* ✗ BAD */
+export default {
+  data() {
+    return { count: 0 }
+  },
+  methods: {
+    increment() {
+      this.count++
+    }
+  }
+}
+</script>
+```
+
+</eslint-code-block>
+
+<eslint-code-block :rules="{'vue/require-expose': ['error']}">
+
+```vue
+<script>
+/* ✓ GOOD */
+import { ref } from 'vue'
+
+export default {
+  setup(props, { expose }) {
+    const count = ref(0)
+
+    function increment() {
+      count.value++
+    }
+    // public
+    expose({
+      increment
+    })
+    // private
+    return { count }
+  }
+}
+</script>
+```
+
+</eslint-code-block>
+
+<eslint-code-block :rules="{'vue/require-expose': ['error']}">
+
+```vue
+<script>
+/* ✗ BAD */
+import { ref } from 'vue'
+
+export default {
+  setup(props) {
+    const count = ref(0)
+
+    function increment() {
+      count.value++
+    }
+    return { increment, count }
+  }
+}
+</script>
+```
+
+</eslint-code-block>
+
+## :wrench: Options
+
+Nothing.
+
+## :books: Further Reading
+
+- [Vue RFCs - 0042-expose-api](https://github.com/vuejs/rfcs/blob/master/active-rfcs/0042-expose-api.md)
+
+## :mag: Implementation
+
+- [Rule source](https://github.com/vuejs/eslint-plugin-vue/blob/master/lib/rules/require-expose.js)
+- [Test source](https://github.com/vuejs/eslint-plugin-vue/blob/master/tests/lib/rules/require-expose.js)
diff --git a/lib/index.js b/lib/index.js
index 8bf6cad4..752a52dd 100644
--- a/lib/index.js
+++ b/lib/index.js
@@ -145,6 +145,7 @@ module.exports = {
     'require-direct-export': require('./rules/require-direct-export'),
     'require-emit-validator': require('./rules/require-emit-validator'),
     'require-explicit-emits': require('./rules/require-explicit-emits'),
+    'require-expose': require('./rules/require-expose'),
     'require-name-property': require('./rules/require-name-property'),
     'require-prop-type-constructor': require('./rules/require-prop-type-constructor'),
     'require-prop-types': require('./rules/require-prop-types'),
diff --git a/lib/rules/no-invalid-model-keys.js b/lib/rules/no-invalid-model-keys.js
index 7985fd25..f86587ff 100644
--- a/lib/rules/no-invalid-model-keys.js
+++ b/lib/rules/no-invalid-model-keys.js
@@ -6,10 +6,6 @@
 
 const utils = require('../utils')
 
-/**
- * @typedef {import('../utils').GroupName} GroupName
- */
-
 // ------------------------------------------------------------------------------
 // Rule Definition
 // ------------------------------------------------------------------------------
diff --git a/lib/rules/no-unsupported-features.js b/lib/rules/no-unsupported-features.js
index 821a8237..821a23ae 100644
--- a/lib/rules/no-unsupported-features.js
+++ b/lib/rules/no-unsupported-features.js
@@ -129,7 +129,7 @@ module.exports = {
      * @returns {boolean} `true` if it's supporting.
      */
     function isNotSupportingVersion(aCase) {
-      return !semver.subset(versionRange, getSemverRange(aCase.supported))
+      return !semverSubset(versionRange, getSemverRange(aCase.supported))
     }
 
     /** @type {TemplateListener} */
@@ -163,3 +163,208 @@ module.exports = {
     )
   }
 }
+
+// TODO replace semver.subset() in the major version.
+/**
+ * semver.subset()
+ *
+ * We need to use a copy of the semver source code until a major version upgrade.
+ *
+ * @see https://github.com/npm/node-semver/blob/e79ac3a450e8bb504e78b8159e3efc70895699b8/ranges/subset.js#L43
+ * @license ISC at Isaac Z. Schlueter and Contributors
+ * https://github.com/npm/node-semver/blob/master/LICENSE
+ *
+ * @param {semver.Range} sub
+ * @param {semver.Range} dom
+ */
+function semverSubset(sub, dom) {
+  if (sub === dom) return true
+
+  sub = new semver.Range(sub)
+  dom = new semver.Range(dom)
+  let sawNonNull = false
+
+  // eslint-disable-next-line no-labels
+  OUTER: for (const simpleSub of sub.set) {
+    for (const simpleDom of dom.set) {
+      const isSub = simpleSubset(simpleSub, simpleDom)
+      sawNonNull = sawNonNull || isSub !== null
+      // eslint-disable-next-line no-labels
+      if (isSub) continue OUTER
+    }
+    if (sawNonNull) return false
+  }
+  return true
+}
+
+/**
+ * @license ISC at Isaac Z. Schlueter and Contributors
+ * https://github.com/npm/node-semver/blob/master/LICENSE
+ * @param {readonly semver.Comparator[]} sub
+ * @param {readonly semver.Comparator[]} dom
+ */
+function simpleSubset(sub, dom) {
+  if (sub === dom) return true
+
+  /**
+   * @param {semver.Comparator} c
+   */
+  function isAny(c) {
+    return Object.keys(c.semver).length === 0
+  }
+
+  if (sub.length === 1 && isAny(sub[0])) {
+    if (dom.length === 1 && isAny(dom[0])) return true
+    else sub = [new semver.Comparator('>=0.0.0')]
+  }
+
+  if (dom.length === 1 && isAny(dom[0])) {
+    dom = [new semver.Comparator('>=0.0.0')]
+  }
+
+  const eqSet = new Set()
+  let gt, lt
+  for (const c of sub) {
+    if (c.operator === '>' || c.operator === '>=') gt = higherGT(gt, c)
+    else if (c.operator === '<' || c.operator === '<=') lt = lowerLT(lt, c)
+    else eqSet.add(c.semver)
+  }
+
+  if (eqSet.size > 1) return null
+
+  let gtltComp
+  if (gt && lt) {
+    gtltComp = semver.compare(gt.semver, lt.semver)
+    if (gtltComp > 0) return null
+    else if (gtltComp === 0 && (gt.operator !== '>=' || lt.operator !== '<='))
+      return null
+  }
+
+  // will iterate one or zero times
+  for (const eq of eqSet) {
+    if (gt && !semver.satisfies(eq, String(gt))) return null
+
+    if (lt && !semver.satisfies(eq, String(lt))) return null
+
+    for (const c of dom) {
+      if (!semver.satisfies(eq, String(c))) return false
+    }
+
+    return true
+  }
+
+  let higher, lower
+  let hasDomLT, hasDomGT
+  // if the subset has a prerelease, we need a comparator in the superset
+  // with the same tuple and a prerelease, or it's not a subset
+  let needDomLTPre = lt && lt.semver.prerelease.length ? lt.semver : false
+  let needDomGTPre = gt && gt.semver.prerelease.length ? gt.semver : false
+  // exception: <1.2.3-0 is the same as <1.2.3
+  if (
+    needDomLTPre &&
+    needDomLTPre.prerelease.length === 1 &&
+    lt &&
+    lt.operator === '<' &&
+    needDomLTPre.prerelease[0] === 0
+  ) {
+    needDomLTPre = false
+  }
+
+  for (const c of dom) {
+    hasDomGT = hasDomGT || c.operator === '>' || c.operator === '>='
+    hasDomLT = hasDomLT || c.operator === '<' || c.operator === '<='
+    if (gt) {
+      if (needDomGTPre) {
+        if (
+          c.semver.prerelease &&
+          c.semver.prerelease.length &&
+          c.semver.major === needDomGTPre.major &&
+          c.semver.minor === needDomGTPre.minor &&
+          c.semver.patch === needDomGTPre.patch
+        ) {
+          needDomGTPre = false
+        }
+      }
+      if (c.operator === '>' || c.operator === '>=') {
+        higher = higherGT(gt, c)
+        if (higher === c && higher !== gt) return false
+      } else if (
+        gt.operator === '>=' &&
+        !semver.satisfies(gt.semver, String(c))
+      )
+        return false
+    }
+    if (lt) {
+      if (needDomLTPre) {
+        if (
+          c.semver.prerelease &&
+          c.semver.prerelease.length &&
+          c.semver.major === needDomLTPre.major &&
+          c.semver.minor === needDomLTPre.minor &&
+          c.semver.patch === needDomLTPre.patch
+        ) {
+          needDomLTPre = false
+        }
+      }
+      if (c.operator === '<' || c.operator === '<=') {
+        lower = lowerLT(lt, c)
+        if (lower === c && lower !== lt) return false
+      } else if (
+        lt.operator === '<=' &&
+        !semver.satisfies(lt.semver, String(c))
+      )
+        return false
+    }
+    if (!c.operator && (lt || gt) && gtltComp !== 0) return false
+  }
+
+  // if there was a < or >, and nothing in the dom, then must be false
+  // UNLESS it was limited by another range in the other direction.
+  // Eg, >1.0.0 <1.0.1 is still a subset of <2.0.0
+  if (gt && hasDomLT && !lt && gtltComp !== 0) return false
+
+  if (lt && hasDomGT && !gt && gtltComp !== 0) return false
+
+  // we needed a prerelease range in a specific tuple, but didn't get one
+  // then this isn't a subset.  eg >=1.2.3-pre is not a subset of >=1.0.0,
+  // because it includes prereleases in the 1.2.3 tuple
+  if (needDomGTPre || needDomLTPre) return false
+
+  return true
+}
+
+/**
+ * @license ISC at Isaac Z. Schlueter and Contributors
+ * https://github.com/npm/node-semver/blob/master/LICENSE
+ * @param {semver.Comparator | void} a
+ * @param {semver.Comparator} b
+ */
+const higherGT = (a, b) => {
+  if (!a) return b
+  const comp = semver.compare(a.semver, b.semver)
+  return comp > 0
+    ? a
+    : comp < 0
+    ? b
+    : b.operator === '>' && a.operator === '>='
+    ? b
+    : a
+}
+
+/**
+ * @license ISC at Isaac Z. Schlueter and Contributors
+ * https://github.com/npm/node-semver/blob/master/LICENSE
+ * @param {semver.Comparator | void} a
+ * @param {semver.Comparator} b
+ */
+const lowerLT = (a, b) => {
+  if (!a) return b
+  const comp = semver.compare(a.semver, b.semver)
+  return comp < 0
+    ? a
+    : comp > 0
+    ? b
+    : b.operator === '<' && a.operator === '<='
+    ? b
+    : a
+}
diff --git a/lib/rules/no-unused-properties.js b/lib/rules/no-unused-properties.js
index 9a38c2a7..0e5b0b3e 100644
--- a/lib/rules/no-unused-properties.js
+++ b/lib/rules/no-unused-properties.js
@@ -53,6 +53,7 @@ const GROUP_COMPUTED_PROPERTY = 'computed'
 const GROUP_METHODS = 'methods'
 const GROUP_SETUP = 'setup'
 const GROUP_WATCHER = 'watch'
+const GROUP_EXPOSE = 'expose'
 
 const PROPERTY_LABEL = {
   props: 'property',
@@ -60,7 +61,12 @@ const PROPERTY_LABEL = {
   computed: 'computed property',
   methods: 'method',
   setup: 'property returned from `setup()`',
-  watch: 'watch'
+
+  // not use
+  watch: 'watch',
+  provide: 'provide',
+  inject: 'inject',
+  expose: 'expose'
 }
 
 // ------------------------------------------------------------------------------
@@ -900,51 +906,57 @@ module.exports = {
         onVueObjectEnter(node) {
           const container = getVueComponentPropertiesContainer(node)
 
-          for (const watcher of utils.iterateProperties(
+          for (const watcherOrExpose of utils.iterateProperties(
             node,
-            new Set([GROUP_WATCHER])
+            new Set([GROUP_WATCHER, GROUP_EXPOSE])
           )) {
-            // Process `watch: { foo /* <- this */ () {} }`
-            const segments = watcher.name.split('.')
-            container.usedProperties.addUsed(segments[0], (context) => {
-              return buildChainTracker(segments)(context)
-              /**
-               * @param {string[]} baseSegments
-               * @returns {UsedPropertiesTracker}
-               */
-              function buildChainTracker(baseSegments) {
-                return () => {
-                  const subSegments = baseSegments.slice(1)
-                  const usedProps = new UsedProperties()
-                  if (subSegments.length) {
-                    usedProps.addUsed(
-                      subSegments[0],
-                      buildChainTracker(subSegments)
-                    )
+            if (watcherOrExpose.groupName === GROUP_WATCHER) {
+              const watcher = watcherOrExpose
+              // Process `watch: { foo /* <- this */ () {} }`
+              const segments = watcher.name.split('.')
+              container.usedProperties.addUsed(segments[0], (context) => {
+                return buildChainTracker(segments)(context)
+                /**
+                 * @param {string[]} baseSegments
+                 * @returns {UsedPropertiesTracker}
+                 */
+                function buildChainTracker(baseSegments) {
+                  return () => {
+                    const subSegments = baseSegments.slice(1)
+                    const usedProps = new UsedProperties()
+                    if (subSegments.length) {
+                      usedProps.addUsed(
+                        subSegments[0],
+                        buildChainTracker(subSegments)
+                      )
+                    }
+                    return usedProps
                   }
-                  return usedProps
                 }
-              }
-            })
+              })
 
-            // Process `watch: { x: 'foo' /* <- this */  }`
-            if (watcher.type === 'object') {
-              const property = watcher.property
-              if (property.kind === 'init') {
-                for (const handlerValueNode of utils.iterateWatchHandlerValues(
-                  property
-                )) {
-                  if (
-                    handlerValueNode.type === 'Literal' ||
-                    handlerValueNode.type === 'TemplateLiteral'
-                  ) {
-                    const name = utils.getStringLiteralValue(handlerValueNode)
-                    if (name != null) {
-                      container.usedProperties.addUsed(name, null)
+              // Process `watch: { x: 'foo' /* <- this */  }`
+              if (watcher.type === 'object') {
+                const property = watcher.property
+                if (property.kind === 'init') {
+                  for (const handlerValueNode of utils.iterateWatchHandlerValues(
+                    property
+                  )) {
+                    if (
+                      handlerValueNode.type === 'Literal' ||
+                      handlerValueNode.type === 'TemplateLiteral'
+                    ) {
+                      const name = utils.getStringLiteralValue(handlerValueNode)
+                      if (name != null) {
+                        container.usedProperties.addUsed(name, null)
+                      }
                     }
                   }
                 }
               }
+            } else if (watcherOrExpose.groupName === GROUP_EXPOSE) {
+              const expose = watcherOrExpose
+              container.usedProperties.addUsed(expose.name, null)
             }
           }
           container.properties.push(...utils.iterateProperties(node, groups))
diff --git a/lib/rules/require-explicit-emits.js b/lib/rules/require-explicit-emits.js
index 34b557b4..30371946 100644
--- a/lib/rules/require-explicit-emits.js
+++ b/lib/rules/require-explicit-emits.js
@@ -18,7 +18,12 @@
 // Requirements
 // ------------------------------------------------------------------------------
 
-const { findVariable } = require('eslint-utils')
+const {
+  findVariable,
+  isOpeningBraceToken,
+  isClosingBraceToken,
+  isOpeningBracketToken
+} = require('eslint-utils')
 const utils = require('../utils')
 const { capitalize } = require('../utils/casing')
 
@@ -53,34 +58,6 @@ const FIX_EMITS_AFTER_OPTIONS = [
   'renderTriggered',
   'errorCaptured'
 ]
-
-/**
- * Check whether the given token is a left brace.
- * @param {Token} token The token to check.
- * @returns {boolean} `true` if the token is a left brace.
- */
-function isLeftBrace(token) {
-  return token != null && token.type === 'Punctuator' && token.value === '{'
-}
-
-/**
- * Check whether the given token is a right brace.
- * @param {Token} token The token to check.
- * @returns {boolean} `true` if the token is a right brace.
- */
-function isRightBrace(token) {
-  return token != null && token.type === 'Punctuator' && token.value === '}'
-}
-
-/**
- * Check whether the given token is a left bracket.
- * @param {Token} token The token to check.
- * @returns {boolean} `true` if the token is a left bracket.
- */
-function isLeftBracket(token) {
-  return token != null && token.type === 'Punctuator' && token.value === '['
-}
-
 // ------------------------------------------------------------------------------
 // Rule Definition
 // ------------------------------------------------------------------------------
@@ -486,7 +463,7 @@ function buildSuggest(define, emits, nameNode, context) {
     const emitsOptionValue = emitsOption.value
     if (emitsOptionValue.type === 'ArrayExpression') {
       const leftBracket = /** @type {Token} */ (
-        sourceCode.getFirstToken(emitsOptionValue, isLeftBracket)
+        sourceCode.getFirstToken(emitsOptionValue, isOpeningBracketToken)
       )
       return [
         {
@@ -504,7 +481,7 @@ function buildSuggest(define, emits, nameNode, context) {
       ]
     } else if (emitsOptionValue.type === 'ObjectExpression') {
       const leftBrace = /** @type {Token} */ (
-        sourceCode.getFirstToken(emitsOptionValue, isLeftBrace)
+        sourceCode.getFirstToken(emitsOptionValue, isOpeningBraceToken)
       )
       return [
         {
@@ -548,10 +525,10 @@ function buildSuggest(define, emits, nameNode, context) {
           )
         } else {
           const objectLeftBrace = /** @type {Token} */ (
-            sourceCode.getFirstToken(object, isLeftBrace)
+            sourceCode.getFirstToken(object, isOpeningBraceToken)
           )
           const objectRightBrace = /** @type {Token} */ (
-            sourceCode.getLastToken(object, isRightBrace)
+            sourceCode.getLastToken(object, isClosingBraceToken)
           )
           return fixer.insertTextAfter(
             objectLeftBrace,
@@ -583,10 +560,10 @@ function buildSuggest(define, emits, nameNode, context) {
           )
         } else {
           const objectLeftBrace = /** @type {Token} */ (
-            sourceCode.getFirstToken(object, isLeftBrace)
+            sourceCode.getFirstToken(object, isOpeningBraceToken)
           )
           const objectRightBrace = /** @type {Token} */ (
-            sourceCode.getLastToken(object, isRightBrace)
+            sourceCode.getLastToken(object, isClosingBraceToken)
           )
           return fixer.insertTextAfter(
             objectLeftBrace,
diff --git a/lib/rules/require-expose.js b/lib/rules/require-expose.js
new file mode 100644
index 00000000..02c3a4d0
--- /dev/null
+++ b/lib/rules/require-expose.js
@@ -0,0 +1,370 @@
+/**
+ * @fileoverview Require `expose` in Vue components
+ * @author Yosuke Ota <https://github.com/ota-meshi>
+ */
+'use strict'
+
+// ------------------------------------------------------------------------------
+// Requirements
+// ------------------------------------------------------------------------------
+
+const {
+  findVariable,
+  isOpeningBraceToken,
+  isClosingBraceToken
+} = require('eslint-utils')
+const utils = require('../utils')
+const { getVueComponentDefinitionType } = require('../utils')
+
+const FIX_EXPOSE_BEFORE_OPTIONS = [
+  'name',
+  'components',
+  'directives',
+  'extends',
+  'mixins',
+  'provide',
+  'inject',
+  'inheritAttrs',
+  'props',
+  'emits'
+]
+
+/**
+ * @param {Property | SpreadElement} node
+ * @returns {node is ObjectExpressionProperty}
+ */
+function isExposeProperty(node) {
+  return (
+    node.type === 'Property' &&
+    utils.getStaticPropertyName(node) === 'expose' &&
+    !node.computed
+  )
+}
+
+/**
+ * Get the callee member node from the given CallExpression
+ * @param {CallExpression} node CallExpression
+ */
+function getCalleeMemberNode(node) {
+  const callee = utils.skipChainExpression(node.callee)
+
+  if (callee.type === 'MemberExpression') {
+    const name = utils.getStaticPropertyName(callee)
+    if (name) {
+      return { name, member: callee }
+    }
+  }
+  return null
+}
+
+module.exports = {
+  meta: {
+    type: 'suggestion',
+    docs: {
+      description: 'require declare public properties using `expose`',
+      categories: undefined,
+      url: 'https://eslint.vuejs.org/rules/require-expose.html'
+    },
+    fixable: null,
+    schema: [],
+    messages: {
+      requireExpose:
+        'The public properties of the component must be explicitly declared using `expose`. If the component does not have public properties, declare it empty.',
+
+      addExposeOptionForEmpty:
+        'Add the `expose` option to give an empty array.',
+      addExposeOptionForAll:
+        'Add the `expose` option to declare all properties.'
+    }
+  },
+  /** @param {RuleContext} context */
+  create(context) {
+    if (utils.isScriptSetup(context)) {
+      return {}
+    }
+
+    /**
+     * @typedef {object} SetupContext
+     * @property {Set<Identifier>} exposeReferenceIds
+     * @property {Set<Identifier>} contextReferenceIds
+     */
+
+    /** @type {Map<ObjectExpression, SetupContext>} */
+    const setupContexts = new Map()
+    /** @type {Set<ObjectExpression>} */
+    const calledExpose = new Set()
+
+    /**
+     * @typedef {FunctionExpression | FunctionDeclaration | ArrowFunctionExpression} FunctionNode
+     */
+    /**
+     * @typedef {object} ScopeStack
+     * @property {ScopeStack | null} upper
+     * @property {FunctionNode} functionNode
+     * @property {boolean} returnFunction
+     */
+    /**
+     * @type {ScopeStack | null}
+     */
+    let scopeStack = null
+    /** @type {Map<FunctionNode, ObjectExpression>} */
+    const setupFunctions = new Map()
+    /** @type {Set<ObjectExpression>} */
+    const setupRender = new Set()
+
+    /**
+     * @param {Expression} node
+     * @returns {boolean}
+     */
+    function isFunction(node) {
+      if (
+        node.type === 'ArrowFunctionExpression' ||
+        node.type === 'FunctionExpression'
+      ) {
+        return true
+      }
+      if (node.type === 'Identifier') {
+        const variable = findVariable(context.getScope(), node)
+        if (variable) {
+          for (const def of variable.defs) {
+            if (def.type === 'FunctionName') {
+              return true
+            }
+            if (def.type === 'Variable') {
+              if (def.node.init) {
+                return isFunction(def.node.init)
+              }
+            }
+          }
+        }
+      }
+      return false
+    }
+    return utils.defineVueVisitor(context, {
+      onSetupFunctionEnter(node, { node: vueNode }) {
+        setupFunctions.set(node, vueNode)
+        const contextParam = node.params[1]
+        if (!contextParam) {
+          // no arguments
+          return
+        }
+        if (contextParam.type === 'RestElement') {
+          // cannot check
+          return
+        }
+        if (contextParam.type === 'ArrayPattern') {
+          // cannot check
+          return
+        }
+        /** @type {Set<Identifier>} */
+        const contextReferenceIds = new Set()
+        /** @type {Set<Identifier>} */
+        const exposeReferenceIds = new Set()
+        if (contextParam.type === 'ObjectPattern') {
+          const exposeProperty = utils.findAssignmentProperty(
+            contextParam,
+            'expose'
+          )
+          if (!exposeProperty) {
+            return
+          }
+          const exposeParam = exposeProperty.value
+          // `setup(props, {emit})`
+          const variable =
+            exposeParam.type === 'Identifier'
+              ? findVariable(context.getScope(), exposeParam)
+              : null
+          if (!variable) {
+            return
+          }
+          for (const reference of variable.references) {
+            if (!reference.isRead()) {
+              continue
+            }
+            exposeReferenceIds.add(reference.identifier)
+          }
+        } else if (contextParam.type === 'Identifier') {
+          // `setup(props, context)`
+          const variable = findVariable(context.getScope(), contextParam)
+          if (!variable) {
+            return
+          }
+          for (const reference of variable.references) {
+            if (!reference.isRead()) {
+              continue
+            }
+            contextReferenceIds.add(reference.identifier)
+          }
+        }
+        setupContexts.set(vueNode, {
+          contextReferenceIds,
+          exposeReferenceIds
+        })
+      },
+      CallExpression(node, { node: vueNode }) {
+        if (calledExpose.has(vueNode)) {
+          // already called
+          return
+        }
+        // find setup context
+        const setupContext = setupContexts.get(vueNode)
+        if (setupContext) {
+          const { contextReferenceIds, exposeReferenceIds } = setupContext
+          if (
+            node.callee.type === 'Identifier' &&
+            exposeReferenceIds.has(node.callee)
+          ) {
+            // setup(props,{expose}) {expose()}
+            calledExpose.add(vueNode)
+          } else {
+            const expose = getCalleeMemberNode(node)
+            if (
+              expose &&
+              expose.name === 'expose' &&
+              expose.member.object.type === 'Identifier' &&
+              contextReferenceIds.has(expose.member.object)
+            ) {
+              // setup(props,context) {context.emit()}
+              calledExpose.add(vueNode)
+            }
+          }
+        }
+      },
+      /** @param {FunctionNode} node */
+      ':function'(node) {
+        scopeStack = {
+          upper: scopeStack,
+          functionNode: node,
+          returnFunction: false
+        }
+
+        if (node.type === 'ArrowFunctionExpression' && node.expression) {
+          if (isFunction(node.body)) {
+            scopeStack.returnFunction = true
+          }
+        }
+      },
+      ReturnStatement(node) {
+        if (!scopeStack) {
+          return
+        }
+        if (!scopeStack.returnFunction && node.argument) {
+          if (isFunction(node.argument)) {
+            scopeStack.returnFunction = true
+          }
+        }
+      },
+      ':function:exit'(node) {
+        if (scopeStack && scopeStack.returnFunction) {
+          const vueNode = setupFunctions.get(node)
+          if (vueNode) {
+            setupRender.add(vueNode)
+          }
+        }
+        scopeStack = scopeStack && scopeStack.upper
+      },
+      onVueObjectExit(component, { type }) {
+        if (calledExpose.has(component)) {
+          // `expose` function is called
+          return
+        }
+        if (setupRender.has(component)) {
+          // `setup` function is render function
+          return
+        }
+        if (type === 'definition') {
+          const defType = getVueComponentDefinitionType(component)
+          if (defType === 'mixin') {
+            return
+          }
+        }
+
+        if (component.properties.some(isExposeProperty)) {
+          // has `expose`
+          return
+        }
+
+        context.report({
+          node: component,
+          messageId: 'requireExpose',
+          suggest: buildSuggest(component, context)
+        })
+      }
+    })
+  }
+}
+
+/**
+ * @param {ObjectExpression} object
+ * @param {RuleContext} context
+ * @returns {Rule.SuggestionReportDescriptor[]}
+ */
+function buildSuggest(object, context) {
+  const propertyNodes = object.properties.filter(utils.isProperty)
+
+  const sourceCode = context.getSourceCode()
+  const beforeOptionNode = propertyNodes.find((p) =>
+    FIX_EXPOSE_BEFORE_OPTIONS.includes(utils.getStaticPropertyName(p) || '')
+  )
+  const allProps = [
+    ...new Set(
+      utils.iterateProperties(
+        object,
+        new Set(['props', 'data', 'computed', 'setup', 'methods', 'watch'])
+      )
+    )
+  ]
+  return [
+    {
+      messageId: 'addExposeOptionForEmpty',
+      fix: buildFix('expose: []')
+    },
+    ...(allProps.length
+      ? [
+          {
+            messageId: 'addExposeOptionForAll',
+            fix: buildFix(
+              `expose: [${allProps
+                .map((p) => JSON.stringify(p.name))
+                .join(', ')}]`
+            )
+          }
+        ]
+      : [])
+  ]
+
+  /**
+   * @param {string} text
+   */
+  function buildFix(text) {
+    /**
+     * @param {RuleFixer} fixer
+     */
+    return (fixer) => {
+      if (beforeOptionNode) {
+        return fixer.insertTextAfter(beforeOptionNode, `,\n${text}`)
+      } else if (object.properties.length) {
+        const after = propertyNodes[0] || object.properties[0]
+        return fixer.insertTextAfter(
+          sourceCode.getTokenBefore(after),
+          `\n${text},`
+        )
+      } else {
+        const objectLeftBrace = /** @type {Token} */ (
+          sourceCode.getFirstToken(object, isOpeningBraceToken)
+        )
+        const objectRightBrace = /** @type {Token} */ (
+          sourceCode.getLastToken(object, isClosingBraceToken)
+        )
+        return fixer.insertTextAfter(
+          objectLeftBrace,
+          `\n${text}${
+            objectLeftBrace.loc.end.line < objectRightBrace.loc.start.line
+              ? ''
+              : '\n'
+          }`
+        )
+      }
+    }
+  }
+}
diff --git a/lib/rules/require-render-return.js b/lib/rules/require-render-return.js
index 8d3b2096..8350f920 100644
--- a/lib/rules/require-render-return.js
+++ b/lib/rules/require-render-return.js
@@ -33,7 +33,7 @@ module.exports = {
     return utils.compositingVisitors(
       utils.defineVueVisitor(context, {
         onRenderFunctionEnter(node) {
-          renderFunctions.set(node.parent.value, node.parent.key)
+          renderFunctions.set(node, node.parent.key)
         }
       }),
       utils.executeOnFunctionsWithoutReturn(true, (node) => {
diff --git a/lib/utils/index.js b/lib/utils/index.js
index b697a0d6..b0c18959 100644
--- a/lib/utils/index.js
+++ b/lib/utils/index.js
@@ -23,7 +23,7 @@
  * @typedef { {key: string | null, value: BlockStatement | null} } ComponentComputedProperty
  */
 /**
- * @typedef { 'props' | 'data' | 'computed' | 'setup' | 'watch' | 'methods' } GroupName
+ * @typedef { 'props' | 'data' | 'computed' | 'setup' | 'watch' | 'methods' | 'provide' | 'inject' | 'expose' } GroupName
  * @typedef { { type: 'array',  name: string, groupName: GroupName, node: Literal | TemplateLiteral } } ComponentArrayPropertyData
  * @typedef { { type: 'object', name: string, groupName: GroupName, node: Identifier | Literal | TemplateLiteral, property: Property } } ComponentObjectPropertyData
  * @typedef { ComponentArrayPropertyData | ComponentObjectPropertyData } ComponentPropertyData
@@ -1414,21 +1414,21 @@ module.exports = {
   /**
    * Find all functions which do not always return values
    * @param {boolean} treatUndefinedAsUnspecified
-   * @param { (node: ESNode) => void } cb Callback function
+   * @param { (node: ArrowFunctionExpression | FunctionExpression | FunctionDeclaration) => void } cb Callback function
    * @returns {RuleListener}
    */
   executeOnFunctionsWithoutReturn(treatUndefinedAsUnspecified, cb) {
     /**
      * @typedef {object} FuncInfo
-     * @property {FuncInfo} funcInfo
+     * @property {FuncInfo | null} funcInfo
      * @property {CodePath} codePath
      * @property {boolean} hasReturn
      * @property {boolean} hasReturnValue
-     * @property {ESNode} node
+     * @property {ArrowFunctionExpression | FunctionExpression | FunctionDeclaration} node
      */
 
-    /** @type {FuncInfo} */
-    let funcInfo
+    /** @type {FuncInfo | null} */
+    let funcInfo = null
 
     /** @param {CodePathSegment} segment */
     function isReachable(segment) {
@@ -1436,6 +1436,9 @@ module.exports = {
     }
 
     function isValidReturn() {
+      if (!funcInfo) {
+        return true
+      }
       if (
         funcInfo.codePath &&
         funcInfo.codePath.currentSegments.some(isReachable)
@@ -1451,30 +1454,38 @@ module.exports = {
        * @param {ESNode} node
        */
       onCodePathStart(codePath, node) {
-        funcInfo = {
-          codePath,
-          funcInfo,
-          hasReturn: false,
-          hasReturnValue: false,
-          node
+        if (
+          node.type === 'ArrowFunctionExpression' ||
+          node.type === 'FunctionExpression' ||
+          node.type === 'FunctionDeclaration'
+        ) {
+          funcInfo = {
+            codePath,
+            funcInfo,
+            hasReturn: false,
+            hasReturnValue: false,
+            node
+          }
         }
       },
       onCodePathEnd() {
-        funcInfo = funcInfo.funcInfo
+        funcInfo = funcInfo && funcInfo.funcInfo
       },
       /** @param {ReturnStatement} node */
       ReturnStatement(node) {
-        funcInfo.hasReturn = true
-        funcInfo.hasReturnValue = Boolean(node.argument)
+        if (funcInfo) {
+          funcInfo.hasReturn = true
+          funcInfo.hasReturnValue = Boolean(node.argument)
+        }
       },
       /** @param {ArrowFunctionExpression} node */
       'ArrowFunctionExpression:exit'(node) {
-        if (!isValidReturn() && !node.expression) {
+        if (funcInfo && !isValidReturn() && !node.expression) {
           cb(funcInfo.node)
         }
       },
       'FunctionExpression:exit'() {
-        if (!isValidReturn()) {
+        if (funcInfo && !isValidReturn()) {
           cb(funcInfo.node)
         }
       }
diff --git a/package.json b/package.json
index f6feb58d..5821b250 100644
--- a/package.json
+++ b/package.json
@@ -55,7 +55,7 @@
   "dependencies": {
     "eslint-utils": "^2.1.0",
     "natural-compare": "^1.4.0",
-    "semver": "^7.3.2",
+    "semver": "^6.3.0",
     "vue-eslint-parser": "^7.8.0"
   },
   "devDependencies": {


Test patch:
diff --git a/tests/lib/rules/no-unused-properties.js b/tests/lib/rules/no-unused-properties.js
index f9d61e4b..4af8091a 100644
--- a/tests/lib/rules/no-unused-properties.js
+++ b/tests/lib/rules/no-unused-properties.js
@@ -1535,6 +1535,33 @@ tester.run('no-unused-properties', rule, {
           ignorePublicMembers: true
         }
       ]
+    },
+
+    // expose
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        {{a}}
+      </template>
+      <script>
+      export default {
+        expose: ['a', 'b', 'c', 'd'],
+        props: ['a'],
+        data() {
+          return {
+            b: 42,
+          }
+        },
+        computed:{
+          c() {},
+        },
+        methods:{
+          d() {},
+        }
+      }
+      </script>`,
+      options: allOptions
     }
   ],
 
@@ -2412,6 +2439,42 @@ tester.run('no-unused-properties', rule, {
       props.b
       </script>`,
       errors: ["'c' of property found, but never used."]
+    },
+
+    // expose
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        {{a}}
+      </template>
+      <script>
+      export default {
+        expose: ['a', 'c', 'e', 'g'],
+        props: ['a', 'b'],
+        data() {
+          return {
+            c: 42,
+            d: 42
+          }
+        },
+        computed:{
+          e() {},
+          f() {}
+        },
+        methods:{
+          g() {},
+          h() {}
+        }
+      }
+      </script>`,
+      options: allOptions,
+      errors: [
+        "'b' of property found, but never used.",
+        "'d' of data found, but never used.",
+        "'f' of computed property found, but never used.",
+        "'h' of method found, but never used."
+      ]
     }
   ]
 })
diff --git a/tests/lib/rules/require-expose.js b/tests/lib/rules/require-expose.js
new file mode 100644
index 00000000..ca78e55b
--- /dev/null
+++ b/tests/lib/rules/require-expose.js
@@ -0,0 +1,384 @@
+/**
+ * @fileoverview Require `expose` in Vue components
+ * @author Yosuke Ota <https://github.com/ota-meshi>
+ */
+'use strict'
+
+const rule = require('../../../lib/rules/require-expose')
+const RuleTester = require('eslint').RuleTester
+
+const tester = new RuleTester({
+  parser: require.resolve('vue-eslint-parser'),
+  parserOptions: {
+    ecmaVersion: 2020,
+    sourceType: 'module'
+  }
+})
+
+tester.run('require-expose', rule, {
+  valid: [
+    {
+      filename: 'ValidComponent.vue',
+      code: `
+      <script>
+        export default { // TODO
+          setup:() => function () {}
+        }
+      </script>
+      `
+    },
+    {
+      filename: 'ValidComponent.vue',
+      code: `
+      <script>
+        export default {
+          expose: ['foo'],
+          methods: {
+            foo() {},
+            bar() {},
+          }
+        }
+      </script>
+      `
+    },
+    {
+      filename: 'ValidComponent.vue',
+      code: `
+      <script>
+        export default {
+          expose: [],
+          methods: {
+            foo() {},
+            bar() {},
+          }
+        }
+      </script>
+      `
+    },
+    {
+      filename: 'ValidComponent.vue',
+      code: `
+      <script>
+        export default {
+          setup(_, {expose}) {
+            expose({
+              foo() {}
+            })
+            return {
+              bar() {}
+            }
+          }
+        }
+      </script>
+      `
+    },
+    {
+      filename: 'ValidComponent.vue',
+      code: `
+      <script>
+        export default {
+          setup(_, {expose}) {
+            expose()
+            return {
+              foo() {},
+              bar() {}
+            }
+          }
+        }
+      </script>
+      `
+    },
+    {
+      filename: 'ValidComponent.vue',
+      code: `
+      <script>
+        export default {
+          setup(_, ctx) {
+            ctx.expose({
+              foo() {}
+            })
+            return {
+              bar() {}
+            }
+          }
+        }
+      </script>
+      `
+    },
+    {
+      code: `
+      Vue.mixin({
+        methods: {
+          foo () {}
+        }
+      })
+      `
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <script>
+      export default {
+      }
+      </script>
+      <script setup>
+      </script>
+      `
+    },
+    {
+      filename: 'ValidComponent.vue',
+      code: `
+      <script>
+        export default {
+          setup() {
+            return () => h('div')
+          }
+        }
+      </script>
+      `
+    },
+    {
+      filename: 'ValidComponent.vue',
+      code: `
+      <script>
+        export default {
+          setup() {
+            function foo () {}
+            return foo
+          }
+        }
+      </script>
+      `
+    },
+    {
+      filename: 'ValidComponent.vue',
+      code: `
+      <script>
+        export default {
+          setup() {
+            const foo = function () {}
+            return foo
+          }
+        }
+      </script>
+      `
+    },
+    {
+      filename: 'ValidComponent.vue',
+      code: `
+      <script>
+        export default {
+          setup:() => function () {}
+        }
+      </script>
+      `
+    }
+  ],
+
+  invalid: [
+    {
+      filename: 'ValidComponent.vue',
+      code: `
+      <script>
+        export default {
+          methods: {
+            foo() {},
+            bar() {},
+          }
+        }
+      </script>
+      `,
+      errors: [
+        {
+          message:
+            'The public properties of the component must be explicitly declared using `expose`. If the component does not have public properties, declare it empty.',
+          suggestions: [
+            {
+              desc: 'Add the `expose` option to give an empty array.',
+              output: `
+      <script>
+        export default {
+expose: [],
+          methods: {
+            foo() {},
+            bar() {},
+          }
+        }
+      </script>
+      `
+            },
+            {
+              desc: 'Add the `expose` option to declare all properties.',
+              output: `
+      <script>
+        export default {
+expose: ["foo", "bar"],
+          methods: {
+            foo() {},
+            bar() {},
+          }
+        }
+      </script>
+      `
+            }
+          ]
+        }
+      ]
+    },
+    {
+      filename: 'ValidComponent.vue',
+      code: `
+      <script>
+        export default {
+          setup(_, ctx) {
+            return {
+              foo() {},
+              bar() {}
+            }
+          }
+        }
+      </script>
+      `,
+      errors: [
+        {
+          message:
+            'The public properties of the component must be explicitly declared using `expose`. If the component does not have public properties, declare it empty.',
+          suggestions: [
+            {
+              desc: 'Add the `expose` option to give an empty array.',
+              output: `
+      <script>
+        export default {
+expose: [],
+          setup(_, ctx) {
+            return {
+              foo() {},
+              bar() {}
+            }
+          }
+        }
+      </script>
+      `
+            },
+            {
+              desc: 'Add the `expose` option to declare all properties.',
+              output: `
+      <script>
+        export default {
+expose: ["foo", "bar"],
+          setup(_, ctx) {
+            return {
+              foo() {},
+              bar() {}
+            }
+          }
+        }
+      </script>
+      `
+            }
+          ]
+        }
+      ]
+    },
+
+    {
+      filename: 'ValidComponent.vue',
+      code: `
+      <script>
+        export default {
+          props: ['x'],
+          methods: {
+            foo() {},
+            bar() {},
+          }
+        }
+      </script>
+      `,
+      errors: [
+        {
+          suggestions: [
+            {
+              desc: 'Add the `expose` option to give an empty array.',
+              output: `
+      <script>
+        export default {
+          props: ['x'],
+expose: [],
+          methods: {
+            foo() {},
+            bar() {},
+          }
+        }
+      </script>
+      `
+            },
+            {
+              desc: 'Add the `expose` option to declare all properties.',
+              output: `
+      <script>
+        export default {
+          props: ['x'],
+expose: ["x", "foo", "bar"],
+          methods: {
+            foo() {},
+            bar() {},
+          }
+        }
+      </script>
+      `
+            }
+          ]
+        }
+      ]
+    },
+    {
+      filename: 'ValidComponent.vue',
+      code: `
+      <script>
+        export default {
+        }
+      </script>
+      `,
+      errors: [
+        {
+          suggestions: [
+            {
+              desc: 'Add the `expose` option to give an empty array.',
+              output: `
+      <script>
+        export default {
+expose: []
+        }
+      </script>
+      `
+            }
+          ]
+        }
+      ]
+    },
+    {
+      filename: 'ValidComponent.vue',
+      code: `
+      <script>
+        export default {}
+      </script>
+      `,
+      errors: [
+        {
+          suggestions: [
+            {
+              desc: 'Add the `expose` option to give an empty array.',
+              output: `
+      <script>
+        export default {
+expose: []
+}
+      </script>
+      `
+            }
+          ]
+        }
+      ]
+    }
+  ]
+})