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

Issue:
Rule Proposal: `vue/no-implicit-coercion`
<!--
  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:**

It would be nice to make the core rule [no-implicit-coercion](https://eslint.org/docs/rules/no-implicit-coercion) work in `<template>` expressions.

**What category should the rule belong to?**
<!-- (place an "X" next to just one item) -->
[] Enforces code style (layout)
[ ] Warns about a potential error (problem)
[X] 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 :data-foo="!!foo" />
  <div :data-foo="~foo.indexOf('.')" />
  <div :data-foo="+foo" />
  <div :data-foo="1 * foo" />
  <div :data-foo="'' + foo" />
  <div :data-foo="`` + foo" />
  <!-- if the `disallowTemplateShorthand` option is enabled -->
  <div :data-foo="`${foo}`" />
</template>
```


Pull request description:
Add `vue/no-implicit-coercion` rule
Closes #2638 

Patch:
diff --git a/docs/rules/no-implicit-coercion.md b/docs/rules/no-implicit-coercion.md
new file mode 100644
index 00000000..0e222c32
--- /dev/null
+++ b/docs/rules/no-implicit-coercion.md
@@ -0,0 +1,31 @@
+---
+pageClass: rule-details
+sidebarDepth: 0
+title: vue/no-implicit-coercion
+description: Disallow shorthand type conversions in `<template>`
+---
+
+# vue/no-implicit-coercion
+
+> Disallow shorthand type conversions in `<template>`
+
+- :wrench: The `--fix` option on the [command line](https://eslint.org/docs/user-guide/command-line-interface#fixing-problems) can automatically fix some of the problems reported by this rule.
+
+This rule is the same rule as core [no-implicit-coercion] rule but it applies to the expressions in `<template>`.
+
+## :books: Further Reading
+
+- [no-implicit-coercion]
+
+[no-implicit-coercion]: https://eslint.org/docs/rules/no-implicit-coercion
+
+## :rocket: Version
+
+This rule was introduced in eslint-plugin-vue v9.33.0
+
+## :mag: Implementation
+
+- [Rule source](https://github.com/vuejs/eslint-plugin-vue/blob/master/lib/rules/no-implicit-coercion.js)
+- [Test source](https://github.com/vuejs/eslint-plugin-vue/blob/master/tests/lib/rules/no-implicit-coercion.js)
+
+<sup>Taken with ❤️ [from ESLint core](https://eslint.org/docs/latest/rules/no-implicit-coercion)</sup>
diff --git a/lib/index.js b/lib/index.js
index ce562f45..ecb8ce5e 100644
--- a/lib/index.js
+++ b/lib/index.js
@@ -126,6 +126,7 @@ const plugin = {
     'no-export-in-script-setup': require('./rules/no-export-in-script-setup'),
     'no-expose-after-await': require('./rules/no-expose-after-await'),
     'no-extra-parens': require('./rules/no-extra-parens'),
+    'no-implicit-coercion': require('./rules/no-implicit-coercion'),
     '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'),
diff --git a/lib/rules/no-implicit-coercion.js b/lib/rules/no-implicit-coercion.js
new file mode 100644
index 00000000..b0cda39b
--- /dev/null
+++ b/lib/rules/no-implicit-coercion.js
@@ -0,0 +1,12 @@
+/**
+ * @author lozinsky <https://github.com/lozinsky>
+ * See LICENSE file in root directory for full license.
+ */
+'use strict'
+
+const utils = require('../utils')
+
+// eslint-disable-next-line internal/no-invalid-meta
+module.exports = utils.wrapCoreRule('no-implicit-coercion', {
+  applyDocument: true
+})


Test patch:
diff --git a/tests/lib/rules/no-implicit-coercion.js b/tests/lib/rules/no-implicit-coercion.js
new file mode 100644
index 00000000..4b49bb3d
--- /dev/null
+++ b/tests/lib/rules/no-implicit-coercion.js
@@ -0,0 +1,263 @@
+/**
+ * @author lozinsky <https://github.com/lozinsky>
+ * See LICENSE file in root directory for full license.
+ */
+'use strict'
+
+const { RuleTester, ESLint } = require('../../eslint-compat')
+const semver = require('semver')
+const rule = require('../../../lib/rules/no-implicit-coercion')
+
+/**
+ * @param {string} suggestedCode
+ * @returns {string}
+ */
+function getExpectedErrorMessage(suggestedCode) {
+  return semver.gte(ESLint.version, '9.0.0')
+    ? `Unexpected implicit coercion encountered. Use \`${suggestedCode}\` instead.`
+    : `use \`${suggestedCode}\` instead.`
+}
+
+const tester = new RuleTester({
+  languageOptions: {
+    parser: require('vue-eslint-parser'),
+    ecmaVersion: 2020,
+    sourceType: 'module'
+  }
+})
+
+tester.run('no-implicit-coercion', rule, {
+  valid: [
+    `<template><div :data-foo="Boolean(foo)" /></template>`,
+    `<template><div :data-foo="foo.indexOf('.') !== -1" /></template>`,
+    {
+      filename: 'test.vue',
+      code: `<template><div :data-foo="!!foo" /></template>`,
+      options: [
+        {
+          boolean: false
+        }
+      ]
+    },
+    {
+      filename: 'test.vue',
+      code: `<template><div :data-foo="~foo.indexOf('.')" /></template>`,
+      options: [
+        {
+          boolean: false
+        }
+      ]
+    },
+    `<template><div :data-foo="Number(foo)" /></template>`,
+    ...(semver.gte(ESLint.version, '8.28.0')
+      ? [`<template><div :data-foo="foo * 1/4" /></template>`]
+      : []),
+    {
+      filename: 'test.vue',
+      code: `<template><div :data-foo="+foo" /></template>`,
+      options: [
+        {
+          number: false
+        }
+      ]
+    },
+    {
+      filename: 'test.vue',
+      code: `<template><div :data-foo="1 * foo" /></template>`,
+      options: [
+        {
+          number: false
+        }
+      ]
+    },
+    `<template><div :data-foo="String(foo)" /></template>`,
+    `<template><div :data-foo="\`\${foo}\`" /></template>`,
+    {
+      filename: 'test.vue',
+      code: `<template><div :data-foo="'' + foo" /></template>`,
+      options: [
+        {
+          string: false
+        }
+      ]
+    },
+    {
+      filename: 'test.vue',
+      code: `<template><div :data-foo="\`\` + foo" /></template>`,
+      options: [
+        {
+          string: false
+        }
+      ]
+    },
+    {
+      filename: 'test.vue',
+      code: `<template><div :data-foo="!!foo" /></template>`,
+      options: [
+        {
+          allow: ['!!']
+        }
+      ]
+    },
+    {
+      filename: 'test.vue',
+      code: `<template><div :data-foo="~foo.indexOf('.')" /></template>`,
+      options: [
+        {
+          allow: ['~']
+        }
+      ]
+    }
+  ],
+  invalid: [
+    {
+      filename: 'test.vue',
+      code: `<template><div :data-foo="!!foo" /></template>`,
+      output: `<template><div :data-foo="Boolean(foo)" /></template>`,
+      errors: [
+        {
+          message: getExpectedErrorMessage('Boolean(foo)'),
+          line: 1,
+          column: 27
+        }
+      ]
+    },
+    {
+      filename: 'test.vue',
+      code: `<template><div :data-foo="~foo.indexOf('.')" /></template>`,
+      output: null,
+      errors: [
+        {
+          message: getExpectedErrorMessage("foo.indexOf('.') !== -1"),
+          line: 1,
+          column: 27
+        }
+      ]
+    },
+    {
+      filename: 'test.vue',
+      code: `<template><div :data-foo="+foo" /></template>`,
+      output: semver.gte(ESLint.version, '9.0.0')
+        ? null
+        : `<template><div :data-foo="Number(foo)" /></template>`,
+      errors: [
+        {
+          message: getExpectedErrorMessage('Number(foo)'),
+          line: 1,
+          column: 27,
+          suggestions: semver.gte(ESLint.version, '9.0.0')
+            ? [
+                {
+                  messageId: 'useRecommendation',
+                  data: { recommendation: 'Number(foo)' },
+                  output: '<template><div :data-foo="Number(foo)" /></template>'
+                }
+              ]
+            : []
+        }
+      ]
+    },
+    {
+      filename: 'test.vue',
+      code: `<template><div :data-foo="1 * foo" /></template>`,
+      output: semver.gte(ESLint.version, '9.0.0')
+        ? null
+        : `<template><div :data-foo="Number(foo)" /></template>`,
+      errors: [
+        {
+          message: getExpectedErrorMessage('Number(foo)'),
+          line: 1,
+          column: 27,
+          suggestions: semver.gte(ESLint.version, '9.0.0')
+            ? [
+                {
+                  messageId: 'useRecommendation',
+                  data: { recommendation: 'Number(foo)' },
+                  output: '<template><div :data-foo="Number(foo)" /></template>'
+                }
+              ]
+            : []
+        }
+      ]
+    },
+    {
+      filename: 'test.vue',
+      code: `<template><div :data-foo="'' + foo" /></template>`,
+      output: semver.gte(ESLint.version, '9.0.0')
+        ? null
+        : `<template><div :data-foo="String(foo)" /></template>`,
+      errors: [
+        {
+          message: getExpectedErrorMessage('String(foo)'),
+          line: 1,
+          column: 27,
+          suggestions: semver.gte(ESLint.version, '9.0.0')
+            ? [
+                {
+                  messageId: 'useRecommendation',
+                  data: { recommendation: 'String(foo)' },
+                  output: '<template><div :data-foo="String(foo)" /></template>'
+                }
+              ]
+            : []
+        }
+      ]
+    },
+    {
+      filename: 'test.vue',
+      code: `<template><div :data-foo="\`\` + foo" /></template>`,
+      output: semver.gte(ESLint.version, '9.0.0')
+        ? null
+        : `<template><div :data-foo="String(foo)" /></template>`,
+      errors: [
+        {
+          message: getExpectedErrorMessage('String(foo)'),
+          line: 1,
+          column: 27,
+          suggestions: semver.gte(ESLint.version, '9.0.0')
+            ? [
+                {
+                  messageId: 'useRecommendation',
+                  data: { recommendation: 'String(foo)' },
+                  output: '<template><div :data-foo="String(foo)" /></template>'
+                }
+              ]
+            : []
+        }
+      ]
+    },
+    ...(semver.gte(ESLint.version, '7.24.0')
+      ? [
+          {
+            filename: 'test.vue',
+            code: `<template><div :data-foo="\`\${foo}\`" /></template>`,
+            output: semver.gte(ESLint.version, '9.0.0')
+              ? null
+              : `<template><div :data-foo="String(foo)" /></template>`,
+            options: [
+              {
+                disallowTemplateShorthand: true
+              }
+            ],
+            errors: [
+              {
+                message: getExpectedErrorMessage('String(foo)'),
+                line: 1,
+                column: 27,
+                suggestions: semver.gte(ESLint.version, '9.0.0')
+                  ? [
+                      {
+                        messageId: 'useRecommendation',
+                        data: { recommendation: 'String(foo)' },
+                        output:
+                          '<template><div :data-foo="String(foo)" /></template>'
+                      }
+                    ]
+                  : []
+              }
+            ]
+          }
+        ]
+      : [])
+  ]
+})