Repository: rrd108/vue-mess-detector
Pull request #67 (2024-07-21 15:30:50)

feat: add vue-caution implicit-parent-child-communication rule


Patch:
diff --git a/src/analyzer.ts b/src/analyzer.ts
index 897ce25..1a81d4d 100644
--- a/src/analyzer.ts
+++ b/src/analyzer.ts
@@ -22,6 +22,7 @@ import { checkFunctionSize, reportFunctionSize } from './rules/rrd/functionSize'
 import { checkParameterCount, reportParameterCount } from './rules/rrd/parameterCount'
 import { checkShortVariableName, reportShortVariableName } from './rules/rrd/shortVariableName'
 import { checkSimpleComputed, reportSimpleComputed } from './rules/vue-strong/simpleComputed'
+import { checkImplicitParentChildCommunication, reportImplicitParentChildCommunication } from './rules/vue-caution/implicitParentChildCommunication'
 
 let filesCount = 0
 
@@ -90,6 +91,7 @@ export const analyze = (dir: string) => {
       checkParameterCount(script, filePath)
       checkShortVariableName(script, filePath)
       checkSimpleComputed(script, filePath)
+      checkImplicitParentChildCommunication(script, filePath)
     }
 
     descriptor.styles.forEach(style => {
@@ -126,7 +128,9 @@ export const analyze = (dir: string) => {
   errors += reportSimpleComputed()
 
   // vue-reccomended rules
+
   // vue-caution rules
+  errors += reportImplicitParentChildCommunication()
 
   // rrd rules
   errors += reportScriptLength()
diff --git a/src/rules/vue-caution/implicitParentChildCommunication.ts b/src/rules/vue-caution/implicitParentChildCommunication.ts
new file mode 100644
index 0000000..c518893
--- /dev/null
+++ b/src/rules/vue-caution/implicitParentChildCommunication.ts
@@ -0,0 +1,55 @@
+import { SFCScriptBlock } from '@vue/compiler-sfc';
+import { BG_RESET, BG_WARN, TEXT_WARN, TEXT_RESET, TEXT_INFO } from '../asceeCodes';
+import getLineNumber from '../getLineNumber';
+import { getUniqueFilenameCount } from '../../helpers';
+
+type ImplicitParentChildCommunicationFile = { filename: string, message: string };
+
+const implicitParentChildCommunicationFiles: ImplicitParentChildCommunicationFile[] = [];
+
+const checkImplicitParentChildCommunication = (script: SFCScriptBlock, filePath: string) => {
+  const propsRegex = /defineProps\(([^)]+)\)/;
+  const vModelRegex = /v-model\s*=\s*"([^"]+)"/;
+  const parentRegex = /\$parent|getCurrentInstance/g;
+
+  // Extract defined props
+  const propsMatch = script.content.match(propsRegex);
+  
+  // Check for props mutation
+  const vModelMatch = script.content.match(vModelRegex);
+  if (vModelMatch) {
+    const vModelProp = vModelMatch[1].split('.')[0];
+    const definedProps = propsMatch ? propsMatch[1] : '';
+
+    // Check if matched prop is inside `v-model` directive
+    if (definedProps.includes(vModelProp)) {
+      const lineNumber = getLineNumber(script.content.trim(), definedProps);
+      implicitParentChildCommunicationFiles.push({ filename: filePath, message: `${filePath}#${lineNumber} ${BG_WARN}(${vModelProp})${BG_RESET}` })
+    }
+  }
+
+  // Check for $parent / getCurrentInstance
+  const parentMatch = script.content.match(parentRegex);
+  if (parentMatch) {
+    const lineNumber = getLineNumber(script.content.trim(), parentMatch[0]);
+    implicitParentChildCommunicationFiles.push({ filename: filePath, message: `${filePath}#${lineNumber} ${BG_WARN}(${parentMatch[0]})${BG_RESET}` })
+  }
+}
+
+const reportImplicitParentChildCommunication = () => {
+  if (implicitParentChildCommunicationFiles.length > 0) {
+    // Count only non duplicated objects (by its `filename` property)
+    const fileCount = getUniqueFilenameCount<ImplicitParentChildCommunicationFile>(implicitParentChildCommunicationFiles, 'filename');
+    
+    console.log(
+      `\n${TEXT_INFO}vue-caution${TEXT_RESET} ${BG_WARN}implicit parent-child communication${BG_RESET} detected in ${fileCount} files.`
+    )
+    console.log(`👉 ${TEXT_WARN}Avoid implicit parent-child communication to maintain clear and predictable component behavior.${TEXT_RESET} See: https://vuejs.org/style-guide/rules-use-with-caution.html#implicit-parent-child-communication`)
+    implicitParentChildCommunicationFiles.forEach(file => {
+      console.log(`- ${file.message} 🚨`)
+    })
+  }
+  return implicitParentChildCommunicationFiles.length;
+}
+
+export { checkImplicitParentChildCommunication, reportImplicitParentChildCommunication };
\ No newline at end of file


Test patch:
diff --git a/src/rules/vue-caution/implicitParentChildCommunication.test.ts b/src/rules/vue-caution/implicitParentChildCommunication.test.ts
new file mode 100644
index 0000000..6b07c4d
--- /dev/null
+++ b/src/rules/vue-caution/implicitParentChildCommunication.test.ts
@@ -0,0 +1,146 @@
+import { describe, expect, it, vi } from 'vitest';
+import { SFCScriptBlock } from '@vue/compiler-sfc';
+import { BG_ERR, BG_RESET, BG_WARN } from '../asceeCodes'
+import { checkImplicitParentChildCommunication, reportImplicitParentChildCommunication } from './implicitParentChildCommunication';
+
+const mockConsoleLog = vi.spyOn(console, 'log').mockImplementation(() => {});
+
+describe('checkImplicitParentChildCommunication', () => {
+  it('should not report files where there is no implicit parent-child communication', () => {
+    const script = { 
+      content: `
+        <script setup>
+        defineProps({
+          todo: {
+            type: Object,
+            required: true
+          }
+        })
+
+        const emit = defineEmits(['input'])
+        </script>
+
+        <template>
+          <input :value="todo.text" @input="emit('input', $event.target.value)" />
+        </template>
+      `
+    } as SFCScriptBlock;
+    const filename = 'no-implicit-pcc.vue'
+    checkImplicitParentChildCommunication(script, filename)
+    expect(reportImplicitParentChildCommunication()).toBe(0)
+    expect(mockConsoleLog).not.toHaveBeenCalled()
+  })
+
+  it('should report files where there is a prop mutation', () => {
+    const script = { 
+      content: `
+        <script setup>
+        defineProps({
+          todo: {
+            type: Object,
+            required: true
+          }
+        })
+        </script>
+
+        <template>
+          <input v-model="todo.text" />
+        </template>
+      `
+    } as SFCScriptBlock;
+    const filename = 'props-mutation.vue'
+    const lineNumber = 7
+    checkImplicitParentChildCommunication(script, filename)
+    expect(reportImplicitParentChildCommunication()).toBe(1)
+    expect(mockConsoleLog).toHaveBeenCalled()
+    expect(mockConsoleLog).toHaveBeenLastCalledWith(
+      `- ${filename}#${lineNumber} ${BG_WARN}(todo)${BG_RESET} 🚨`
+    )
+  })
+
+  it('should report files where $parent/getCurrentInstance is present', () => {
+    const script = {
+      content: `
+        <script setup>
+        import { getCurrentInstance } from 'vue'
+
+        const props = defineProps({
+          todo: {
+            type: Object,
+            required: true
+          }
+        })
+
+        const instance = getCurrentInstance()
+
+        function removeTodo() {
+          const parent = instance.parent
+          if (!parent) return
+
+          parent.props.todos = parent.props.todos.filter((todo) => {
+            return todo.id !== props.todo.id
+          })
+        }
+        </script>
+
+        <template>
+          <span>
+            {{ todo.text }}
+            <button @click="removeTodo">×</button>
+          </span>
+        </template>
+      `
+    } as SFCScriptBlock;
+    const filename = "parent-instance.vue"
+    const lineNumber = 2
+    checkImplicitParentChildCommunication(script, filename)
+    expect(reportImplicitParentChildCommunication()).toBe(2)
+    expect(mockConsoleLog).toHaveBeenCalled()
+    expect(mockConsoleLog).toHaveBeenLastCalledWith(
+      `- ${filename}#${lineNumber} ${BG_WARN}(getCurrentInstance)${BG_RESET} 🚨`
+    )
+  })
+
+  it('should report files where props mutation & $parent/getCurrentInstance are present', () => {
+    const script = {
+      content: `
+        <script setup>
+        import { getCurrentInstance } from 'vue'
+
+        const props = defineProps({
+          todo: {
+            type: Object,
+            required: true
+          }
+        })
+
+        const instance = getCurrentInstance()
+
+        function removeTodo() {
+          const parent = instance.parent
+          if (!parent) return
+
+          parent.props.todos = parent.props.todos.filter((todo) => {
+            return todo.id !== props.todo.id
+          })
+        }
+        </script>
+
+        <template>
+          <span>
+            {{ todo.text }}
+            <input v-model="todo.text" />
+          </span>
+        </template>
+      `
+    } as SFCScriptBlock;
+    const filename = "complex-sample.vue"
+    const lineNumber = 2
+    checkImplicitParentChildCommunication(script, filename)
+    expect(reportImplicitParentChildCommunication()).toBe(4)
+    expect(mockConsoleLog).toHaveBeenCalled()
+    expect(mockConsoleLog).toHaveBeenLastCalledWith(
+      `- ${filename}#${lineNumber} ${BG_WARN}(getCurrentInstance)${BG_RESET} 🚨`
+    )
+  })
+})
\ No newline at end of file