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

Issue:
Rule Proposal: `vue/enforce-style-attribute`
<!--
  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. -->
Many projects agree on using one specific way of writing CSS. For example, using CSS Modules, strictly using Scoped CSS, or a combination of the two, which may be rare but could happen when migrating from one style to another. 

This rule helps developers enforce one or either of the two attributes in SFCs.

**What category should the rule belong to?**
<!-- (place an "X" next to just one item) -->
[x] Enforces code style (layout)
[ ] 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:**

#### Option `either`: No warning as long as one attribute is specified. Does not enforce one specific style.
```vue
<!-- ✓ GOOD -->
<style scoped></style>
<style lang="scss" src="../path/to/style.scss" scoped></style>

<!-- ✓ GOOD -->
<style module></style>

<!-- ✗ BAD -->
<style></style>
```

#### Option `scoped`: Style tags must have the `scoped` attribute.
```vue
<!-- ✓ GOOD -->
<style scoped></style>
<style lang="scss" src="../path/to/style.scss" scoped></style>

<!-- ✗ BAD -->
<style module></style>
<style></style>
```

#### Option `module`: Style tags must have the `module` attribute.
```vue
<!-- ✓ GOOD -->
<style module></style>


<!-- ✗ BAD -->
<style scoped></style>
<style lang="scss" src="../path/to/style.scss" scoped></style>
<style></style>
```



Fix (patch):
diff --git a/docs/rules/enforce-style-attribute.md b/docs/rules/enforce-style-attribute.md
new file mode 100644
index 000000000..6dc042353
--- /dev/null
+++ b/docs/rules/enforce-style-attribute.md
@@ -0,0 +1,86 @@
+---
+pageClass: rule-details
+sidebarDepth: 0
+title: vue/enforce-style-attribute
+description: enforce or forbid the use of the `scoped` and `module` attributes in SFC top level style tags
+---
+
+# vue/enforce-style-attribute
+
+> enforce or forbid the use of the `scoped` and `module` attributes in SFC top level style tags
+
+- :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 allows you to explicitly allow the use of the `scoped` and `module` attributes on your top level style tags.
+
+### `"scoped"`
+
+<eslint-code-block :rules="{'vue/enforce-style-attribute': ['error', { allow: ['scoped'] }]}">
+
+```vue
+<!-- ✓ GOOD -->
+<style scoped></style>
+<style lang="scss" src="../path/to/style.scss" scoped></style>
+
+<!-- ✗ BAD -->
+<style module></style>
+
+<!-- ✗ BAD -->
+<style></style>
+```
+
+</eslint-code-block>
+
+### `"module"`
+
+<eslint-code-block :rules="{'vue/enforce-style-attribute': ['error', { allow: ['module'] }]}">
+
+```vue
+<!-- ✓ GOOD -->
+<style module></style>
+
+<!-- ✗ BAD -->
+<style scoped></style>
+
+<!-- ✗ BAD -->
+<style></style>
+```
+
+</eslint-code-block>
+
+### `"plain"`
+
+<eslint-code-block :rules="{'vue/enforce-style-attribute': ['error', { allow: ['plain']}]}">
+
+```vue
+<!-- ✓ GOOD -->
+<style></style>
+
+<!-- ✗ BAD -->
+<style scoped></style>
+
+<!-- ✗ BAD -->
+<style module></style>
+```
+
+</eslint-code-block>
+
+## :wrench: Options
+
+```json
+{
+  "vue/enforce-style-attribute": [
+    "error",
+    { "allow": ["scoped", "module", "plain"] }
+  ]
+}
+```
+
+- `"allow"` (`["scoped" | "module" | "plain"]`) Array of attributes to allow on a top level style tag. The option `plain` is used to allow style tags that have neither the `scoped` nor `module` attributes. Default: `["scoped"]`
+
+## :mag: Implementation
+
+- [Rule source](https://github.com/vuejs/eslint-plugin-vue/blob/master/lib/rules/enforce-style-attribute.js)
+- [Test source](https://github.com/vuejs/eslint-plugin-vue/blob/master/tests/lib/rules/enforce-style-attribute.js)
diff --git a/docs/rules/index.md b/docs/rules/index.md
index 71a05d42e..912d5ae46 100644
--- a/docs/rules/index.md
+++ b/docs/rules/index.md
@@ -215,6 +215,7 @@ For example:
 | [vue/define-emits-declaration](./define-emits-declaration.md) | enforce declaration style of `defineEmits` |  | :hammer: |
 | [vue/define-macros-order](./define-macros-order.md) | enforce order of `defineEmits` and `defineProps` compiler macros | :wrench: | :lipstick: |
 | [vue/define-props-declaration](./define-props-declaration.md) | enforce declaration style of `defineProps` |  | :hammer: |
+| [vue/enforce-style-attribute](./enforce-style-attribute.md) | enforce or forbid the use of the `scoped` and `module` attributes in SFC top level style tags |  | :hammer: |
 | [vue/html-button-has-type](./html-button-has-type.md) | disallow usage of button without an explicit type attribute |  | :hammer: |
 | [vue/html-comment-content-newline](./html-comment-content-newline.md) | enforce unified line brake in HTML comments | :wrench: | :lipstick: |
 | [vue/html-comment-content-spacing](./html-comment-content-spacing.md) | enforce unified spacing in HTML comments | :wrench: | :lipstick: |
diff --git a/lib/index.js b/lib/index.js
index 3497a7b4f..3eb5208ce 100644
--- a/lib/index.js
+++ b/lib/index.js
@@ -35,6 +35,7 @@ module.exports = {
     'define-props-declaration': require('./rules/define-props-declaration'),
     'dot-location': require('./rules/dot-location'),
     'dot-notation': require('./rules/dot-notation'),
+    'enforce-style-attribute': require('./rules/enforce-style-attribute'),
     eqeqeq: require('./rules/eqeqeq'),
     'first-attribute-linebreak': require('./rules/first-attribute-linebreak'),
     'func-call-spacing': require('./rules/func-call-spacing'),
diff --git a/lib/rules/enforce-style-attribute.js b/lib/rules/enforce-style-attribute.js
new file mode 100644
index 000000000..b4b39a73f
--- /dev/null
+++ b/lib/rules/enforce-style-attribute.js
@@ -0,0 +1,153 @@
+/**
+ * @author Mussin Benarbia
+ * See LICENSE file in root directory for full license.
+ */
+'use strict'
+
+const { isVElement } = require('../utils')
+
+/**
+ * check whether a tag has the `scoped` attribute
+ * @param {VElement} componentBlock
+ */
+function isScoped(componentBlock) {
+  return componentBlock.startTag.attributes.some(
+    (attribute) => !attribute.directive && attribute.key.name === 'scoped'
+  )
+}
+
+/**
+ * check whether a tag has the `module` attribute
+ * @param {VElement} componentBlock
+ */
+function isModule(componentBlock) {
+  return componentBlock.startTag.attributes.some(
+    (attribute) => !attribute.directive && attribute.key.name === 'module'
+  )
+}
+
+/**
+ * check if a tag doesn't have either the `scoped` nor `module` attribute
+ * @param {VElement} componentBlock
+ */
+function isPlain(componentBlock) {
+  return !isScoped(componentBlock) && !isModule(componentBlock)
+}
+
+function getUserDefinedAllowedAttrs(context) {
+  if (context.options[0] && context.options[0].allow) {
+    return context.options[0].allow
+  }
+  return []
+}
+
+const defaultAllowedAttrs = ['scoped']
+
+module.exports = {
+  meta: {
+    type: 'suggestion',
+    docs: {
+      description:
+        'enforce or forbid the use of the `scoped` and `module` attributes in SFC top level style tags',
+      categories: undefined,
+      url: 'https://eslint.vuejs.org/rules/enforce-style-attribute.html'
+    },
+    fixable: null,
+    schema: [
+      {
+        type: 'object',
+        properties: {
+          allow: {
+            type: 'array',
+            minItems: 1,
+            uniqueItems: true,
+            items: {
+              type: 'string',
+              enum: ['plain', 'scoped', 'module']
+            }
+          }
+        },
+        additionalProperties: false
+      }
+    ],
+    messages: {
+      notAllowedScoped:
+        'The scoped attribute is not allowed. Allowed: {{ allowedAttrsString }}.',
+      notAllowedModule:
+        'The module attribute is not allowed. Allowed: {{ allowedAttrsString }}.',
+      notAllowedPlain:
+        'Plain <style> tags are not allowed. Allowed: {{ allowedAttrsString }}.'
+    }
+  },
+
+  /** @param {RuleContext} context */
+  create(context) {
+    const sourceCode = context.getSourceCode()
+    if (!sourceCode.parserServices.getDocumentFragment) {
+      return {}
+    }
+    const documentFragment = sourceCode.parserServices.getDocumentFragment()
+    if (!documentFragment) {
+      return {}
+    }
+
+    const topLevelElements = documentFragment.children.filter(isVElement)
+    const topLevelStyleTags = topLevelElements.filter(
+      (element) => element.rawName === 'style'
+    )
+
+    if (topLevelStyleTags.length === 0) {
+      return {}
+    }
+
+    const userDefinedAllowedAttrs = getUserDefinedAllowedAttrs(context)
+    const allowedAttrs =
+      userDefinedAllowedAttrs.length > 0
+        ? userDefinedAllowedAttrs
+        : defaultAllowedAttrs
+
+    const allowsPlain = allowedAttrs.includes('plain')
+    const allowsScoped = allowedAttrs.includes('scoped')
+    const allowsModule = allowedAttrs.includes('module')
+    const allowedAttrsString = [...allowedAttrs].sort().join(', ')
+
+    return {
+      Program() {
+        for (const styleTag of topLevelStyleTags) {
+          if (!allowsPlain && isPlain(styleTag)) {
+            context.report({
+              node: styleTag,
+              messageId: 'notAllowedPlain',
+              data: {
+                allowedAttrsString
+              }
+            })
+            return
+          }
+
+          if (!allowsScoped && isScoped(styleTag)) {
+            context.report({
+              node: styleTag,
+              messageId: 'notAllowedScoped',
+              data: {
+                allowedAttrsString
+              }
+            })
+            return
+          }
+
+          if (!allowsModule && isModule(styleTag)) {
+            context.report({
+              node: styleTag,
+              messageId: 'notAllowedModule',
+              data: {
+                allowedAttrsString
+              }
+            })
+            return
+          }
+        }
+      }
+    }
+  }
+}