Repository: vuejs/eslint-plugin-vue (js)

Issue:
disallow attributes on <template>
<!--
  Before proposing new rule, please make sure it hasn't been posted already.
  You can see all open propositions here:
  https://github.com/vuejs/eslint-plugin-vue/issues?q=is%3Aopen+is%3Aissue+label%3A%22new+rule+proposition%22
-->

**Please describe what the rule should do:**
<!-- A clear and concise description of what the rule should do. -->

Rule to prevent any attribute on `<template>` tags.

I _think_ it makes sense that the only thing that should be allowed are some directives (eg. `v-if`, `v-for`, `v-slot`) but not `v-bind` or `v-on`. And with the exception of legacy API like `slot="..."`.

**What category should the rule belong to?**
<!-- (place an "X" next to just one item) -->
[ ] Enforces code style (layout)
[x] Warns about a potential error (problem)
[ ] Suggests an alternate way of doing something (suggestion)
[ ] Other (please specify:)

**Provide 2-3 code examples that this rule should warn about:**

```vue
<template>
	<div>
		<template class="heading">
			<h1>Hello world</h1>
		</template>
	</div>
</template>
```

```vue
<template>
	<div>
		<template ref="input">
			<input type="text">
		</template>
	</div>
</template>
```

**Additional context**
<!-- Add any other context or screenshots about the feature request here. -->
I noticed there were mistakes in Vue code where people would accidentally leave `class="..."`, `ref="..."`, etc. on `<template>` tags.

These fail silently so it's easy to go unnoticed.




Pull request description:
Add vue/no-useless-template-attributes rule
close #1637.

This PR adds`vue/no-useless-template-attributes` rule to prevent any useless attribute on `<template>` tags.

Patch:
diff --git a/docs/developer-guide/README.md b/docs/developer-guide/README.md
index f08ecc3a..c5bfcc99 100644
--- a/docs/developer-guide/README.md
+++ b/docs/developer-guide/README.md
@@ -13,8 +13,7 @@ Please include as much detail as possible to help us properly address your issue
 In order to add a new rule or a rule change, you should:
 
 - Create issue on GitHub with description of proposed rule
-- Generate a new rule using the [official yeoman generator](https://github.com/eslint/generator-eslint)
-- Run `npm start`
+- Generate a new rule using the `npm run new -- [rule-name]` command
 - Write test scenarios & implement logic
 - Describe the rule in the generated `docs` file
 - Make sure all tests are passing
@@ -38,10 +37,12 @@ After opening [astexplorer.net], select `Vue` as the syntax and `vue-eslint-pars
 Since single file components in Vue are not plain JavaScript, we can't use the default parser, and we had to introduce additional one: `vue-eslint-parser`, that generates enhanced AST with nodes that represent specific parts of the template syntax, as well as what's inside the `<script>` tag.
 
 To know more about certain nodes in produced ASTs, go here:
+
 - [ESTree docs](https://github.com/estree/estree)
 - [vue-eslint-parser AST docs](https://github.com/vuejs/vue-eslint-parser/blob/master/docs/ast.md)
 
 The `vue-eslint-parser` provides few useful parser services, to help traverse the produced AST and access tokens of the template:
+
 - `context.parserServices.defineTemplateBodyVisitor(visitor, scriptVisitor)`
 - `context.parserServices.getTemplateBodyTokenStore()`
 
diff --git a/docs/rules/README.md b/docs/rules/README.md
index 2f89e3a5..544d24db 100644
--- a/docs/rules/README.md
+++ b/docs/rules/README.md
@@ -325,6 +325,7 @@ For example:
 | [vue/no-unused-refs](./no-unused-refs.md) | disallow unused refs |  |
 | [vue/no-use-computed-property-like-method](./no-use-computed-property-like-method.md) | disallow use computed property like method |  |
 | [vue/no-useless-mustaches](./no-useless-mustaches.md) | disallow unnecessary mustache interpolations | :wrench: |
+| [vue/no-useless-template-attributes](./no-useless-template-attributes.md) | disallow useless attribute on `<template>` |  |
 | [vue/no-useless-v-bind](./no-useless-v-bind.md) | disallow unnecessary `v-bind` directives | :wrench: |
 | [vue/no-v-text](./no-v-text.md) | disallow use of v-text |  |
 | [vue/padding-line-between-blocks](./padding-line-between-blocks.md) | require or disallow padding lines between blocks | :wrench: |
diff --git a/docs/rules/no-useless-template-attributes.md b/docs/rules/no-useless-template-attributes.md
new file mode 100644
index 00000000..ec24eb70
--- /dev/null
+++ b/docs/rules/no-useless-template-attributes.md
@@ -0,0 +1,64 @@
+---
+pageClass: rule-details
+sidebarDepth: 0
+title: vue/no-useless-template-attributes
+description: disallow useless attribute on `<template>`
+---
+# vue/no-useless-template-attributes
+
+> disallow useless attribute on `<template>`
+
+- :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 to prevent any useless attribute on `<template>` tags.
+
+<eslint-code-block :rules="{'vue/no-useless-template-attributes': ['error']}">
+
+```vue
+<template>
+  <!-- ✓ GOOD -->
+  <template v-if="foo">...</template>
+  <template v-if="foo">...</template>
+  <template v-else-if="foo">...</template>
+  <template v-else>...</template>
+  <template v-for="i in foo" :key="i">...</template>
+  <template v-slot:foo>...</template>
+  <!-- for Vue<=2.5 -->
+  <template slot="foo">...</template>
+  <template :slot="foo">...</template>
+  <template slot-scope="param">...</template>
+  <!-- for Vue<=2.4 -->
+  <template scope="param">...</template>
+
+  <!-- ✗ BAD -->
+  <template v-if="foo" class="heading">...</template>
+  <template v-for="i in foo" :bar="i">...</template>
+  <template v-slot:foo="foo" ref="input">...</template>
+  <template v-if="foo" @click="click">...</template>
+
+  <!-- Ignore -->
+  <template class="heading">...</template>
+  <template :bar="i">...</template>
+  <template ref="input">...</template>
+  <template @click="click">...</template>
+</template>
+```
+
+</eslint-code-block>
+
+## :wrench: Options
+
+Nothing.
+
+## :couple: Related Rules
+
+- [vue/no-lone-template]
+
+[vue/no-lone-template]: ./no-lone-template.md
+
+## :mag: Implementation
+
+- [Rule source](https://github.com/vuejs/eslint-plugin-vue/blob/master/lib/rules/no-useless-template-attributes.js)
+- [Test source](https://github.com/vuejs/eslint-plugin-vue/blob/master/tests/lib/rules/no-useless-template-attributes.js)
diff --git a/lib/index.js b/lib/index.js
index ef111732..9ddfe4cd 100644
--- a/lib/index.js
+++ b/lib/index.js
@@ -129,6 +129,7 @@ module.exports = {
     'no-use-v-if-with-v-for': require('./rules/no-use-v-if-with-v-for'),
     'no-useless-concat': require('./rules/no-useless-concat'),
     'no-useless-mustaches': require('./rules/no-useless-mustaches'),
+    'no-useless-template-attributes': require('./rules/no-useless-template-attributes'),
     'no-useless-v-bind': require('./rules/no-useless-v-bind'),
     'no-v-for-template-key-on-child': require('./rules/no-v-for-template-key-on-child'),
     'no-v-for-template-key': require('./rules/no-v-for-template-key'),
diff --git a/lib/rules/no-useless-template-attributes.js b/lib/rules/no-useless-template-attributes.js
new file mode 100644
index 00000000..93648083
--- /dev/null
+++ b/lib/rules/no-useless-template-attributes.js
@@ -0,0 +1,121 @@
+/**
+ * @author Yosuke Ota
+ * See LICENSE file in root directory for full license.
+ */
+'use strict'
+
+// ------------------------------------------------------------------------------
+// Requirements
+// ------------------------------------------------------------------------------
+
+const utils = require('../utils')
+
+// ------------------------------------------------------------------------------
+// Helpers
+// ------------------------------------------------------------------------------
+
+// https://github.com/vuejs/vue-next/blob/64e2f4643602c5980361e66674141e61ba60ef70/packages/compiler-core/src/parse.ts#L405
+const SPECIAL_TEMPLATE_DIRECTIVES = new Set([
+  'if',
+  'else',
+  'else-if',
+  'for',
+  'slot'
+])
+
+// ------------------------------------------------------------------------------
+// Rule Definition
+// ------------------------------------------------------------------------------
+
+module.exports = {
+  meta: {
+    type: 'problem',
+    docs: {
+      description: 'disallow useless attribute on `<template>`',
+      // TODO Switch to `vue3-essential` and `essential` in the major version.
+      // categories: ['vue3-essential', 'essential'],
+      categories: undefined,
+      url: 'https://eslint.vuejs.org/rules/no-useless-template-attributes.html'
+    },
+    fixable: null,
+    schema: [],
+    messages: {
+      unexpectedAttr: 'Unexpected useless attribute on `<template>`.',
+      unexpectedDir: 'Unexpected useless directive on `<template>`.'
+    }
+  },
+  /** @param {RuleContext} context */
+  create(context) {
+    /**
+     * @param {VAttribute | VDirective} attr
+     */
+    function getKeyName(attr) {
+      if (attr.directive) {
+        if (attr.key.name.name !== 'bind') {
+          // no v-bind
+          return null
+        }
+        if (
+          !attr.key.argument ||
+          attr.key.argument.type === 'VExpressionContainer'
+        ) {
+          // unknown
+          return null
+        }
+        return attr.key.argument.name
+      }
+      return attr.key.name
+    }
+
+    /**
+     * @param {VAttribute | VDirective} attr
+     */
+    function isFragmentTemplateAttribute(attr) {
+      if (attr.directive) {
+        const directiveName = attr.key.name.name
+        if (SPECIAL_TEMPLATE_DIRECTIVES.has(directiveName)) {
+          return true
+        }
+        if (directiveName === 'slot-scope') {
+          // `slot-scope` is deprecated in Vue.js 2.6
+          return true
+        }
+        if (directiveName === 'scope') {
+          // `scope` is deprecated in Vue.js 2.5
+          return true
+        }
+      }
+
+      const keyName = getKeyName(attr)
+      if (keyName === 'slot') {
+        // `slot` is deprecated in Vue.js 2.6
+        return true
+      }
+
+      return false
+    }
+
+    return utils.defineTemplateBodyVisitor(context, {
+      /** @param {VStartTag} node */
+      "VElement[name='template'][parent.type='VElement'] > VStartTag"(node) {
+        if (!node.attributes.some(isFragmentTemplateAttribute)) {
+          return
+        }
+
+        for (const attr of node.attributes) {
+          if (isFragmentTemplateAttribute(attr)) {
+            continue
+          }
+          const keyName = getKeyName(attr)
+          if (keyName === 'key') {
+            continue
+          }
+          context.report({
+            node: attr,
+            messageId: attr.directive ? 'unexpectedDir' : 'unexpectedAttr'
+          })
+        }
+      }
+    })
+  }
+}
diff --git a/package.json b/package.json
index 8e822058..fc5b22a1 100644
--- a/package.json
+++ b/package.json
@@ -4,6 +4,7 @@
   "description": "Official ESLint plugin for Vue.js",
   "main": "lib/index.js",
   "scripts": {
+    "new": "node tools/new-rule.js",
     "start": "npm run test:base -- --watch --growl",
     "test:base": "mocha \"tests/lib/**/*.js\" --reporter dot",
     "test": "nyc npm run test:base -- \"tests/integrations/*.js\" --timeout 60000",
diff --git a/tools/new-rule.js b/tools/new-rule.js
new file mode 100644
index 00000000..68a8b440
--- /dev/null
+++ b/tools/new-rule.js
@@ -0,0 +1,160 @@
+const path = require('path')
+const fs = require('fs')
+const cp = require('child_process')
+const logger = console
+
+// main
+;((ruleId) => {
+  if (ruleId == null) {
+    logger.error('Usage: npm run new <RuleID>')
+    process.exitCode = 1
+    return
+  }
+  if (!/^[\w-]+$/u.test(ruleId)) {
+    logger.error("Invalid RuleID '%s'.", ruleId)
+    process.exitCode = 1
+    return
+  }
+
+  const ruleFile = path.resolve(__dirname, `../lib/rules/${ruleId}.js`)
+  const testFile = path.resolve(__dirname, `../tests/lib/rules/${ruleId}.js`)
+  const docFile = path.resolve(__dirname, `../docs/rules/${ruleId}.md`)
+
+  fs.writeFileSync(
+    ruleFile,
+    `/**
+ * @author *****your name*****
+ * See LICENSE file in root directory for full license.
+ */
+'use strict'
+
+// ------------------------------------------------------------------------------
+// Requirements
+// ------------------------------------------------------------------------------
+
+// ...
+
+// ------------------------------------------------------------------------------
+// Helpers
+// ------------------------------------------------------------------------------
+
+// ...
+
+// ------------------------------------------------------------------------------
+// Rule Definition
+// ------------------------------------------------------------------------------
+
+module.exports = {
+  meta: {
+    type: 'problem',
+    docs: {
+      description: '',
+      categories: undefined,
+      url: ''
+    },
+    fixable: null,
+    schema: [],
+    messages: {
+      // ...
+    }
+  },
+  /** @param {RuleContext} context */
+  create(context) {
+    // ...
+
+    return utils.defineTemplateBodyVisitor(context, {
+      // ...
+    })
+  }
+}
+`
+  )
+  fs.writeFileSync(
+    testFile,
+    `/**
+ * @author *****your name*****
+ * See LICENSE file in root directory for full license.
+ */
+'use strict'
+
+const RuleTester = require('eslint').RuleTester
+const rule = require('../../../lib/rules/${ruleId}')
+
+const tester = new RuleTester({
+  parser: require.resolve('vue-eslint-parser'),
+  parserOptions: {
+    ecmaVersion: 2020,
+    sourceType: 'module'
+  }
+})
+
+tester.run('${ruleId}', rule, {
+  valid: [
+    {
+      filename: 'test.vue',
+      code: \`
+      <template>
+
+      </template>
+      \`
+    },
+  ],
+  invalid: [
+    {
+      filename: 'test.vue',
+      code: \`
+      <template>
+
+      </template>
+      \`,
+      errors: [
+        {
+          message: '...',
+          line: 'line',
+          column: 'col'
+        },
+      ]
+    }
+  ]
+})
+`
+  )
+  fs.writeFileSync(
+    docFile,
+    `---
+pageClass: rule-details
+sidebarDepth: 0
+title: vue/${ruleId}
+description: xxx
+---
+# vue/${ruleId}
+
+> xxx
+
+- :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 ....
+
+<eslint-code-block :rules="{'vue/${ruleId}': ['error']}">
+
+\`\`\`vue
+<template>
+
+</template>
+\`\`\`
+
+</eslint-code-block>
+
+## :wrench: Options
+
+Nothing.
+
+`
+  )
+
+  cp.execSync(`code "${ruleFile}"`)
+  cp.execSync(`code "${testFile}"`)
+  cp.execSync(`code "${docFile}"`)
+})(process.argv[2])


Test patch:
diff --git a/tests/lib/rules/no-useless-template-attributes.js b/tests/lib/rules/no-useless-template-attributes.js
new file mode 100644
index 00000000..7639b0f3
--- /dev/null
+++ b/tests/lib/rules/no-useless-template-attributes.js
@@ -0,0 +1,156 @@
+/**
+ * @author Yosuke Ota
+ * See LICENSE file in root directory for full license.
+ */
+'use strict'
+
+const RuleTester = require('eslint').RuleTester
+const rule = require('../../../lib/rules/no-useless-template-attributes')
+
+const tester = new RuleTester({
+  parser: require.resolve('vue-eslint-parser'),
+  parserOptions: {
+    ecmaVersion: 2020,
+    sourceType: 'module'
+  }
+})
+
+tester.run('no-useless-template-attributes', rule, {
+  valid: [
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        <template v-if="foo">...</template>
+        <template v-else-if="bar">...</template>
+        <template v-else>...</template>
+      </template>
+      `
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        <template v-for="e in list">...</template>
+      </template>
+      `
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        <template v-slot>...</template>
+      </template>
+      `
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        <CoolButton>
+         <template slot="foo">...</template>
+        </CoolButton>
+      </template>
+      `
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        <CoolButton>
+         <template slot-scope="foo">...</template>
+        </CoolButton>
+      </template>
+      `
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        <CoolButton>
+         <template scope="foo">...</template>
+        </CoolButton>
+      </template>
+      `
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        <!-- ignore -->
+        <template foo="a">...</template>
+        <template :foo="a">...</template>
+        <template v-unknown="a">...</template>
+      </template>
+      `
+    },
+    // not template
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        <div v-if="foo" class="heading">...</div>
+        <div v-for="i in foo" :bar="i">...</div>
+        <div v-slot:foo="foo" ref="input">...</div>
+        <div v-if="foo" @click="click">...</div>
+      </template>
+      `
+    }
+  ],
+  invalid: [
+    {
+      filename: 'test.vue',
+      code: `
+      <template>
+        <!-- ✓ GOOD -->
+        <template v-if="foo">...</template>
+        <template v-if="foo">...</template>
+        <template v-else-if="foo">...</template>
+        <template v-else>...</template>
+        <template v-for="i in foo" :key="i">...</template>
+        <template v-slot:foo>...</template>
+        <!-- for Vue<=2.5 -->
+        <template slot="foo">...</template>
+        <template :slot="foo">...</template>
+        <template slot-scope="param">...</template>
+        <!-- for Vue<=2.4 -->
+        <template scope="param">...</template>
+
+        <!-- ✗ BAD -->
+        <template v-if="foo" class="heading">...</template>
+        <template v-for="i in foo" :bar="i">...</template>
+        <template v-slot:foo="foo" ref="input">...</template>
+        <template v-if="foo" @click="click">...</template>
+
+        <!-- Ignore -->
+        <template class="heading">...</template>
+        <template :bar="i">...</template>
+        <template ref="input">...</template>
+        <template @click="click">...</template>
+      </template>
+      `,
+      errors: [
+        {
+          message: 'Unexpected useless attribute on `<template>`.',
+          line: 18,
+          column: 30
+        },
+        {
+          message: 'Unexpected useless directive on `<template>`.',
+          line: 19,
+          column: 36
+        },
+        {
+          message: 'Unexpected useless attribute on `<template>`.',
+          line: 20,
+          column: 36
+        },
+        {
+          message: 'Unexpected useless directive on `<template>`.',
+          line: 21,
+          column: 30
+        }
+      ]
+    }
+  ]
+})