Repository: rrd108/vue-mess-detector (ts)

Issue:
add rule (rrd) avoid side effects in computed
```js
const fullName = computed(() => {
  firstName.value = 'Gauranga' // side effect
  return `${firstName.value} ${lastName.value}`
})
```

Computed should not contain any `xyz.value = 123`

The `functionSize` rule has a function to detect the function body of an arrow function, what may be helpful here. If that is the case it should be moved out to helpers/utilities.

Pull request:
feat: add rrd computed side effects rule with tests
### Summary
Add new rule rrd - computed side effects with tests and documentation

### Description
Added all corresponding files for a complete new rule addition

### Related Issues
Fixes #179 

### Type of Change
- [ ] Bug fix (non-breaking change which fixes an issue)
- [X] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to change)
- [X] Documentation update


Patch:
diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts
index cf93dac..f66d0b3 100644
--- a/docs/.vitepress/config.ts
+++ b/docs/.vitepress/config.ts
@@ -72,6 +72,7 @@ export default defineConfig({
                 { text: 'Big v-show', link: '/rules/rrd/big-vshow' },
                 { text: 'Complicated Conditions', link: '/rules/rrd/complicated-conditions' },
                 { text: 'Cyclomatic Complexity', link: '/rules/rrd/cyclomatic-complexity' },
+                { text: 'Computed Side Effects', link: '/rules/rrd/computed-side-effects' },
                 { text: 'Deep Indentation', link: '/rules/rrd/deep-indentation' },
                 { text: 'Else Condition', link: '/rules/rrd/else-condition' },
                 { text: 'Function Size', link: '/rules/rrd/function-size' },
diff --git a/docs/rules/rrd/computed-side-effects.md b/docs/rules/rrd/computed-side-effects.md
new file mode 100644
index 0000000..abd2cbb
--- /dev/null
+++ b/docs/rules/rrd/computed-side-effects.md
@@ -0,0 +1,53 @@
+# Computed Side Effects
+
+Checks if computed properties have side effects.
+
+## ❓ Why it's good to follow this rule?
+
+- **Predictability:** Computed properties without side effects are more predictable, making the code easier to reason about.
+- **Testability:** Pure computed properties are easier to test as they don't modify external state.
+- **Debugging:** When computed properties only calculate and return values, it's easier to debug issues related to reactivity.
+- **Performance:** Vue can better optimize computed properties that don't have side effects.
+- **Maintainability:** Separating calculations from side effects leads to cleaner, more maintainable code.
+- **Reactivity:** Side effects in computed properties can lead to unexpected reactivity issues and hard-to-track bugs.
+
+## 😱 Examples of code for which this rule will throw a warning
+
+::: warning
+The following code contains a computed property with a side effect:
+:::
+
+```vue
+<script setup>
+import { computed } from 'vue'
+
+const someVariable = ref('old value')
+
+const computedValue = computed(() => {
+  someVariable.value = 'new value'
+  return someVariable
+})
+</script>
+```
+
+## 🤩 How to fix it?
+
+::: tip
+Refactor the computed property to remove side effects. Move any state modifications or side effects to methods or watchers.
+:::
+
+```vue
+<script setup>
+import { ref, computed } from 'vue'
+
+const someVariable = ref('old value')
+
+const computedValue = computed(() => someVariable.value)
+
+function updateSomeVariable() {
+someVariable.value = 'new value'
+}
+</script>
+```
+
+In this fixed example, we've removed the side effect from the computed property. The `computedValue` now only returns the current value of `someVariable` without modifying it. We've added a separate function `updateSomeVariable()` to handle the state modification when needed.
\ No newline at end of file
diff --git a/docs/rules/rrd/index.md b/docs/rules/rrd/index.md
index cfe3ee9..6bfa2ea 100644
--- a/docs/rules/rrd/index.md
+++ b/docs/rules/rrd/index.md
@@ -6,6 +6,7 @@ These ruleset is the most opinionated with rules that are not part of the _offic
 - [Big v-show](./big-vshow.md)
 - [Complicated Conditions](./complicated-conditions.md)
 - [Cyclomatic Complexity](./cyclomatic-complexity.md)
+- [Computed Side Effects](./computed-side-effects.md)
 - [Deep Indentation](./deep-indentation.md)
 - [Else Condition](./else-condition.md)
 - [Function Size](./function-size.md)
diff --git a/src/rules/rrd/computedSideEffects.ts b/src/rules/rrd/computedSideEffects.ts
new file mode 100644
index 0000000..3fdb3e7
--- /dev/null
+++ b/src/rules/rrd/computedSideEffects.ts
@@ -0,0 +1,51 @@
+import type { SFCScriptBlock } from '@vue/compiler-sfc'
+import { BG_ERR, BG_RESET, TEXT_INFO, TEXT_RESET, TEXT_WARN } from '../asceeCodes'
+import getLineNumber from '../getLineNumber'
+import type { FileCheckResult, Offense } from '../../types'
+
+const results: FileCheckResult[] = []
+
+const checkComputedSideEffects = (script: SFCScriptBlock | null, filePath: string) => {
+  if (!script) {
+    return
+  }
+
+  const computedRegex = /computed\s*\(\s*\(\s*\)\s*=>\s*\{([\s\S]*?)\}\s*\)/g
+  // eslint-disable-next-line regexp/no-unused-capturing-group
+  const sideEffectRegex = /\b(set|push|pop|shift|unshift|splice|reverse|sort)\b|(?<!=)=(?!=)/
+
+  const matches = [...script.content.matchAll(computedRegex)]
+
+  matches.forEach((match) => {
+    const computedBody = match[1]
+    if (sideEffectRegex.test(computedBody)) {
+      const lineNumber = getLineNumber(script.content.trim(), match[0])
+      const trimmedBody = computedBody.trim()
+      const truncatedBody = trimmedBody.length > 20 ? trimmedBody.slice(0, 20) : trimmedBody
+      results.push({
+        filePath,
+        message: `line #${lineNumber} side effect detected in computed property ${BG_ERR}(${truncatedBody})${BG_RESET}`,
+      })
+    }
+  })
+}
+
+const reportComputedSideEffects = () => {
+  const offenses: Offense[] = []
+
+  if (results.length > 0) {
+    results.forEach((result) => {
+      offenses.push({
+        file: result.filePath,
+        rule: `${TEXT_INFO}rrd ~ computed side effects${TEXT_RESET}`,
+        description: `👉 ${TEXT_WARN}Avoid side effects in computed properties. Computed properties should only derive and return a value.${TEXT_RESET} See: https://vue-mess-detector.webmania.cc/rules/rrd/computed-side-effects.html`,
+        message: `${result.message} 🚨`,
+      })
+    })
+  }
+  return offenses
+}
+
+const resetComputedSideEffects = () => (results.length = 0)
+
+export { checkComputedSideEffects, reportComputedSideEffects, resetComputedSideEffects }
diff --git a/src/rules/rrd/index.ts b/src/rules/rrd/index.ts
index 0132431..744c526 100644
--- a/src/rules/rrd/index.ts
+++ b/src/rules/rrd/index.ts
@@ -2,6 +2,7 @@ export * from './bigVif'
 export * from './bigVshow'
 export * from './complicatedConditions'
 export * from './cyclomaticComplexity'
+export * from './computedSideEffects'
 export * from './deepIndentation'
 export * from './elseCondition'
 export * from './functionSize'
diff --git a/src/rules/rules.ts b/src/rules/rules.ts
index 890045b..4003cf4 100644
--- a/src/rules/rules.ts
+++ b/src/rules/rules.ts
@@ -31,6 +31,7 @@ export const RULES = {
     'bigVshow',
     'complicatedConditions',
     'cyclomaticComplexity',
+    'computedSideEffects',
     'deepIndentation',
     'elseCondition',
     'functionSize',
diff --git a/src/rulesCheck.ts b/src/rulesCheck.ts
index 59e3de9..fce4660 100644
--- a/src/rulesCheck.ts
+++ b/src/rulesCheck.ts
@@ -5,9 +5,8 @@ import { checkElementSelectorsWithScoped, checkImplicitParentChildCommunication
 import { checkGlobalStyle, checkSimpleProp, checkSingleNameComponent, checkVforNoKey, checkVifWithVfor } from './rules/vue-essential'
 import { checkElementAttributeOrder, checkTopLevelElementOrder } from './rules/vue-recommended'
 import { checkComponentFilenameCasing, checkComponentFiles, checkDirectiveShorthands, checkFullWordComponentName, checkMultiAttributeElements, checkPropNameCasing, checkQuotedAttributeValues, checkSelfClosingComponents, checkSimpleComputed, checkTemplateSimpleExpression } from './rules/vue-strong'
-import { checkBigVif, checkBigVshow, checkCyclomaticComplexity, checkDeepIndentation, checkElseCondition, checkFunctionSize, checkHtmlImageElements, checkHtmlLink, checkIfWithoutCurlyBraces, checkMagicNumbers, checkNestedTernary, checkNoPropDestructure, checkNoVarDeclaration, checkParameterCount, checkPlainScript, checkPropsDrilling, checkScriptLength, checkShortVariableName, checkTooManyProps, checkVForWithIndexKey, checkZeroLengthComparison } from './rules/rrd'
+import { checkBigVif, checkBigVshow, checkComplicatedConditions, checkComputedSideEffects, checkCyclomaticComplexity, checkDeepIndentation, checkElseCondition, checkFunctionSize, checkHtmlImageElements, checkHtmlLink, checkIfWithoutCurlyBraces, checkMagicNumbers, checkNestedTernary, checkNoPropDestructure, checkNoVarDeclaration, checkParameterCount, checkPlainScript, checkPropsDrilling, checkScriptLength, checkShortVariableName, checkTooManyProps, checkVForWithIndexKey, checkZeroLengthComparison } from './rules/rrd'
 import { getIsNuxt } from './context'
-import { checkComplicatedConditions } from './rules/rrd/complicatedConditions'
 
 export const checkRules = (descriptor: SFCDescriptor, filePath: string, apply: string[]) => {
   const script = descriptor.scriptSetup || descriptor.script
@@ -49,6 +48,7 @@ export const checkRules = (descriptor: SFCDescriptor, filePath: string, apply: s
     bigVShow: () => checkBigVshow(descriptor.template, filePath),
     complicatedConditions: () => checkComplicatedConditions(descriptor, filePath),
     cyclomaticComplexity: () => checkCyclomaticComplexity(script, filePath),
+    computedSideEffects: () => checkComputedSideEffects(script, filePath),
     deepIndentation: () => checkDeepIndentation(script, filePath),
     elseCondition: () => checkElseCondition(script, filePath),
     functionSize: () => checkFunctionSize(script, filePath),
diff --git a/src/rulesReport.ts b/src/rulesReport.ts
index 3d9ca31..d213caf 100644
--- a/src/rulesReport.ts
+++ b/src/rulesReport.ts
@@ -5,7 +5,7 @@ import { reportElementSelectorsWithScoped, reportImplicitParentChildCommunicatio
 import { reportGlobalStyle, reportSimpleProp, reportSingleNameComponent, reportVforNoKey, reportVifWithVfor } from './rules/vue-essential'
 import { reportElementAttributeOrder, reportTopLevelElementOrder } from './rules/vue-recommended'
 import { reportComponentFilenameCasing, reportComponentFiles, reportDirectiveShorthands, reportFullWordComponentName, reportMultiAttributeElements, reportPropNameCasing, reportQuotedAttributeValues, reportSelfClosingComponents, reportSimpleComputed, reportTemplateSimpleExpression } from './rules/vue-strong'
-import { reportBigVif, reportBigVshow, reportComplicatedConditions, reportCyclomaticComplexity, reportDeepIndentation, reportElseCondition, reportFunctionSize, reportHtmlImageElements, reportHtmlLink, reportIfWithoutCurlyBraces, reportMagicNumbers, reportNestedTernary, reportNoPropDestructure, reportNoVarDeclaration, reportParameterCount, reportPlainScript, reportPropsDrilling, reportScriptLength, reportShortVariableName, reportTooManyProps, reportVForWithIndexKey, reportZeroLengthComparison } from './rules/rrd'
+import { reportBigVif, reportBigVshow, reportComplicatedConditions, reportComputedSideEffects, reportCyclomaticComplexity, reportDeepIndentation, reportElseCondition, reportFunctionSize, reportHtmlImageElements, reportHtmlLink, reportIfWithoutCurlyBraces, reportMagicNumbers, reportNestedTernary, reportNoPropDestructure, reportNoVarDeclaration, reportParameterCount, reportPlainScript, reportPropsDrilling, reportScriptLength, reportShortVariableName, reportTooManyProps, reportVForWithIndexKey, reportZeroLengthComparison } from './rules/rrd'
 
 // TODO move out to types
 interface OutputType {
@@ -66,6 +66,7 @@ export const reportRules = (groupBy: GroupBy, sortBy: SortBy, level: OutputLevel
   processOffenses(reportBigVshow)
   processOffenses(reportComplicatedConditions)
   processOffenses(reportCyclomaticComplexity)
+  processOffenses(reportComputedSideEffects)
   processOffenses(reportDeepIndentation)
   processOffenses(reportElseCondition)
   processOffenses(reportFunctionSize)


Test patch:
diff --git a/src/rules/rrd/computedSideEffects.test.ts b/src/rules/rrd/computedSideEffects.test.ts
new file mode 100644
index 0000000..509819f
--- /dev/null
+++ b/src/rules/rrd/computedSideEffects.test.ts
@@ -0,0 +1,157 @@
+import { beforeEach, describe, expect, it } from 'vitest'
+import type { SFCScriptBlock } from '@vue/compiler-sfc'
+import { BG_ERR, BG_RESET, TEXT_INFO, TEXT_RESET, TEXT_WARN } from '../asceeCodes'
+import { checkComputedSideEffects, reportComputedSideEffects, resetComputedSideEffects } from './computedSideEffects'
+
+describe('checkComputedSideEffects', () => {
+  beforeEach(() => {
+    resetComputedSideEffects()
+  })
+
+  it('should not report files without side effects in computed properties', () => {
+    const script = {
+      content: `
+        <script setup>
+        import { computed, ref } from 'vue'
+
+        const someValue = 2
+
+        const computedValue = computed(() => {
+          return someValue * 2
+        })
+        </script>
+      `,
+    } as SFCScriptBlock
+    const fileName = 'no-side-effects.vue'
+    checkComputedSideEffects(script, fileName)
+    expect(reportComputedSideEffects().length).toBe(0)
+    expect(reportComputedSideEffects()).toStrictEqual([])
+  })
+
+  it('should not report comparison operators as side effects', () => {
+    const script = {
+      content: `
+        <script setup>
+        import { computed } from 'vue'
+
+        const computedValue = computed(() => {
+          return someValue === 2 || otherValue == 3
+        })
+        </script>
+      `,
+    } as SFCScriptBlock
+    const fileName = 'no-side-effects-comparison.vue'
+    checkComputedSideEffects(script, fileName)
+    expect(reportComputedSideEffects().length).toBe(0)
+    expect(reportComputedSideEffects()).toStrictEqual([])
+  })
+
+  it('should report files with array mutation side effects in computed properties', () => {
+    const script = {
+      content: `
+        <script setup>
+          import { computed, ref } from 'vue'
+
+          const someArray = ref([1, 2, 3])
+
+          const computedValue = computed(() => {
+            someArray.value.push(4)
+            return someArray.value.length
+          })
+        </script>
+      `,
+    } as SFCScriptBlock
+    const fileName = 'with-array-mutation.vue'
+    checkComputedSideEffects(script, fileName)
+    expect(reportComputedSideEffects().length).toBe(1)
+    expect(reportComputedSideEffects()).toStrictEqual([{
+      file: fileName,
+      rule: `${TEXT_INFO}rrd ~ computed side effects${TEXT_RESET}`,
+      description: `👉 ${TEXT_WARN}Avoid side effects in computed properties. Computed properties should only derive and return a value.${TEXT_RESET} See: https://vue-mess-detector.webmania.cc/rules/rrd/computed-side-effects.html`,
+      message: `line #6 side effect detected in computed property ${BG_ERR}(someArray.value.push)${BG_RESET} 🚨`,
+    }])
+  })
+
+  it('should report files with assignment side effects in computed properties', () => {
+    const script = {
+      content: `
+        <script setup>
+        import { computed } from 'vue'
+
+        const someVariable = ref('old value')
+
+        const computedValue = computed(() => {
+          someVariable.value = 'new value'
+          return someVariable
+        })
+        </script>
+      `,
+    } as SFCScriptBlock
+    const fileName = 'with-assignment.vue'
+    checkComputedSideEffects(script, fileName)
+    expect(reportComputedSideEffects().length).toBe(1)
+    expect(reportComputedSideEffects()).toStrictEqual([{
+      file: fileName,
+      rule: `${TEXT_INFO}rrd ~ computed side effects${TEXT_RESET}`,
+      description: `👉 ${TEXT_WARN}Avoid side effects in computed properties. Computed properties should only derive and return a value.${TEXT_RESET} See: https://vue-mess-detector.webmania.cc/rules/rrd/computed-side-effects.html`,
+      message: `line #6 side effect detected in computed property ${BG_ERR}(someVariable.value =)${BG_RESET} 🚨`,
+    }])
+  })
+
+  it('should report multiple side effects in different computed properties', () => {
+    const script = {
+      content: `
+        <script setup>
+        import { computed } from 'vue'
+
+        const someArray = ref([1, 2, 3])
+        const otherVariable = ref('old value')
+
+        const computedValue1 = computed(() => {
+          someArray.value.push(newItem)
+          return someArray.length
+        })
+      
+        const computedValue2 = computed(() => {
+          otherVariable.value = 'new value'
+          return otherVariable
+        })
+        </script>
+      `,
+    } as SFCScriptBlock
+    const fileName = 'multiple-side-effects.vue'
+    checkComputedSideEffects(script, fileName)
+    expect(reportComputedSideEffects().length).toBe(2)
+    expect(reportComputedSideEffects()).toStrictEqual([{
+      file: fileName,
+      rule: `${TEXT_INFO}rrd ~ computed side effects${TEXT_RESET}`,
+      description: `👉 ${TEXT_WARN}Avoid side effects in computed properties. Computed properties should only derive and return a value.${TEXT_RESET} See: https://vue-mess-detector.webmania.cc/rules/rrd/computed-side-effects.html`,
+      message: `line #7 side effect detected in computed property ${BG_ERR}(someArray.value.push)${BG_RESET} 🚨`,
+    }, {
+      file: fileName,
+      rule: `${TEXT_INFO}rrd ~ computed side effects${TEXT_RESET}`,
+      description: `👉 ${TEXT_WARN}Avoid side effects in computed properties. Computed properties should only derive and return a value.${TEXT_RESET} See: https://vue-mess-detector.webmania.cc/rules/rrd/computed-side-effects.html`,
+      message: `line #12 side effect detected in computed property ${BG_ERR}(otherVariable.value )${BG_RESET} 🚨`,
+    }])
+  })
+
+  it('should report files with assignment side effects in computed properties in js/ts files', () => {
+    const script = {
+      content: `
+        const computedValue = computed(() => {
+          someVariable.value = 'new value'
+          return someVariable
+        })
+      `,
+    } as SFCScriptBlock
+    const fileName = 'with-assignment-in-jsts.vue'
+    checkComputedSideEffects(script, fileName)
+    expect(reportComputedSideEffects().length).toBe(1)
+    expect(reportComputedSideEffects()).toStrictEqual([{
+      file: fileName,
+      rule: `${TEXT_INFO}rrd ~ computed side effects${TEXT_RESET}`,
+      description: `👉 ${TEXT_WARN}Avoid side effects in computed properties. Computed properties should only derive and return a value.${TEXT_RESET} See: https://vue-mess-detector.webmania.cc/rules/rrd/computed-side-effects.html`,
+      message: `line #1 side effect detected in computed property ${BG_ERR}(someVariable.value =)${BG_RESET} 🚨`,
+    }])
+  })
+})