Address `vue/require-explicit-emits` offenses
<!--IssueSummary start--> <details> <summary> Everyone can contribute. [Help move this issue forward](https://handbook.gitlab.com/handbook/marketing/developer-relations/contributor-success/community-contributors-workflows/#contributor-links) while earning points, leveling up and collecting rewards. </summary> - [Collaborate/take over this issue](https://contributors.gitlab.com/manage-issue?action=work&projectId=278964&issueIid=583392) </details> <!--IssueSummary end--> # :exclamation: COMMUNITY CONTRIBUTORS, PLEASE ONLY PICK UP ONE DIRECTORY OF <20 FILES AT A TIME TO KEEP MERGE REQUESTS MANAGEABLE :exclamation: The [`vue/require-explicit-emits` ESLint rule](https://eslint.vuejs.org/rules/require-explicit-emits.html) was enabled in https://gitlab.com/gitlab-org/gitlab/-/merge_requests/214221+ and uncovered many offenses This epic tracks the progress for addressing existing offenses in the files that are currently ignored in [`.eslint_todo/vue-require-explicit-emits.mjs`](https://gitlab.com/gitlab-org/gitlab/-/blob/740b73bc9be4a8723b7edfe86cb3d61205698438/.eslint_todo/vue-require-explicit-emits.mjs). Example from the [docs](https://eslint.vuejs.org/rules/require-explicit-emits.html) ```vue <script> export default { emits: ['good'], methods: { foo() { // ✓ GOOD this.$emit('good') // ✗ BAD this.$emit('bad') } } } </script> ``` ### Implementation <details> <summary>Technical Details</summary> I used Duo to create a script for this (https://gitlab.com/gitlab-org/gitlab/-/merge_requests/215109#note_2940594287) Input: Based on my comment in https://gitlab.com/gitlab-org/gitlab/-/merge_requests/215109#note_2940586234, create a script I can run on a directory of files that does what my comment requests and what https://gitlab.com/gitlab-org/gitlab/-/merge_requests/215109 does Output: Perfect! Now I understand what you need. Your comment outlines a script that should: 1. Find `emit(` instances in Vue files 2. Capture the emitted event names into an array 3. Remove duplicates 4. Find the `props` object (or `components` if `props` doesn't exist) 5. Insert an `emits:` line with the array 6. Prettify the output Here's a Node.js script that does this: [gitlab-fix-require-explicit-emits-eslint.js](/uploads/f04058c126bbcdac89bd8e82ded73e33/gitlab-fix-require-explicit-emits-eslint.js) <details> <summary>Script that just updates the Vue files</summary> ```javascript #!/usr/bin/env node const fs = require('fs'); const path = require('path'); /** * Extracts emit event names from Vue file content * Looks for patterns like: this.$emit('event-name') or emit('event-name') */ function extractEmits(content) { const emitPattern = /(?:this\.\$)?emit\(['"`]([^'"`]+)['"`]/g; const emits = new Set(); let match; while ((match = emitPattern.exec(content)) !== null) { emits.add(match[1]); } return Array.from(emits).sort(); } /** * Finds the position to insert the emits property * Looks for 'props' object first, then 'components', then 'data' */ function findInsertPosition(content) { const propsMatch = content.match(/^\s*props\s*:\s*\{/m); const componentsMatch = content.match(/^\s*components\s*:\s*\{/m); const dataMatch = content.match(/^\s*data\s*\(\s*\)\s*\{/m); if (propsMatch) { // Find the closing brace of props object const startIndex = propsMatch.index + propsMatch[0].length; return findClosingBrace(content, startIndex); } if (componentsMatch) { const startIndex = componentsMatch.index + componentsMatch[0].length; return findClosingBrace(content, startIndex); } if (dataMatch) { return dataMatch.index; } // Fallback: find the first object property in export default const exportMatch = content.match(/export\s+default\s*\{/); if (exportMatch) { return exportMatch.index + exportMatch[0].length; } return -1; } /** * Finds the closing brace of an object starting at a given position */ function findClosingBrace(content, startIndex) { let braceCount = 1; let i = startIndex; while (i < content.length && braceCount > 0) { if (content[i] === '{') braceCount++; if (content[i] === '}') braceCount--; i++; } return i - 1; // Position of closing brace } /** * Inserts emits property into Vue component */ function insertEmits(content, emits) { if (emits.length === 0) return content; const insertPos = findInsertPosition(content); if (insertPos === -1) return content; // Format the emits array const emitsStr = emits.length === 1 ? `['${emits[0]}']` : `[\n '${emits.join("',\n '")}'${emits.length > 1 ? '\n ' : ''},\n ]`; const emitsLine = `\n emits: ${emitsStr},`; // Find the line after the closing brace to insert on a new line let insertIndex = insertPos + 1; while (insertIndex < content.length && content[insertIndex] !== '\n') { insertIndex++; } return content.slice(0, insertIndex) + emitsLine + content.slice(insertIndex); } /** * Processes a single Vue file */ async function processFile(filePath) { try { let content = fs.readFileSync(filePath, 'utf-8'); // Skip if emits already exists if (/^\s*emits\s*:/m.test(content)) { console.log(`⏭️ ${filePath} - already has emits`); return; } const emits = extractEmits(content); if (emits.length === 0) { console.log(`⏭️ ${filePath} - no emits found`); return; } content = insertEmits(content, emits); fs.writeFileSync(filePath, content, 'utf-8'); console.log(`✅ ${filePath} - added emits: [${emits.join(', ')}]`); } catch (error) { console.error(`❌ Error processing ${filePath}:`, error.message); } } /** * Recursively processes all Vue files in a directory */ async function processDirectory(dirPath) { const files = fs.readdirSync(dirPath); for (const file of files) { const filePath = path.join(dirPath, file); const stat = fs.statSync(filePath); if (stat.isDirectory()) { await processDirectory(filePath); } else if (file.endsWith('.vue')) { await processFile(filePath); } } } // Main execution const targetDir = process.argv[2] || '.'; if (!fs.existsSync(targetDir)) { console.error(`❌ Directory not found: ${targetDir}`); process.exit(1); } console.log(`🔍 Processing Vue files in: ${path.resolve(targetDir)}\n`); processDirectory(targetDir).then(() => { console.log('\n✨ Done!'); }).catch(error => { console.error('Fatal error:', error); process.exit(1); }); ``` </details> > Do everything in the implementation plan in one script Here's the enhanced script that automates the full implementation plan for the `vue/require-explicit-emits` issue: <details> <summary>Script that does everything</summary> ```javascript #!/usr/bin/env node const fs = require('fs'); const path = require('path'); const { execSync } = require('child_process'); /** * Extracts emit event names from Vue file content * Looks for patterns like: this.$emit('event-name') or emit('event-name') */ function extractEmits(content) { const emitPattern = /(?:this\.\$)?emit\(['"` ]([^'"` ]+)['"` ]/g; const emits = new Set(); let match; while ((match = emitPattern.exec(content)) !== null) { emits.add(match[1]); } return Array.from(emits).sort(); } /** * Finds the position to insert the emits property * Looks for 'props' object first, then 'components', then 'data' */ function findInsertPosition(content) { const propsMatch = content.match(/^\s*props\s*:\s*\{/m); const componentsMatch = content.match(/^\s*components\s*:\s*\{/m); const dataMatch = content.match(/^\s*data\s*\(\s*\)\s*\{/m); if (propsMatch) { const startIndex = propsMatch.index + propsMatch[0].length; return findClosingBrace(content, startIndex); } if (componentsMatch) { const startIndex = componentsMatch.index + componentsMatch[0].length; return findClosingBrace(content, startIndex); } if (dataMatch) { return dataMatch.index; } const exportMatch = content.match(/export\s+default\s*\{/); if (exportMatch) { return exportMatch.index + exportMatch[0].length; } return -1; } /** * Finds the closing brace of an object starting at a given position */ function findClosingBrace(content, startIndex) { let braceCount = 1; let i = startIndex; while (i < content.length && braceCount > 0) { if (content[i] === '{') braceCount++; if (content[i] === '}') braceCount--; i++; } return i - 1; } /** * Inserts emits property into Vue component */ function insertEmits(content, emits) { if (emits.length === 0) return content; const insertPos = findInsertPosition(content); if (insertPos === -1) return content; const emitsStr = emits.length === 1 ? `['${emits[0]}']` : `[\n '${emits.join("',\n '")}'${emits.length > 1 ? '\n ' : ''},\n ]`; const emitsLine = `\n emits: ${emitsStr},`; let insertIndex = insertPos + 1; while (insertIndex < content.length && content[insertIndex] !== '\n') { insertIndex++; } return content.slice(0, insertIndex) + emitsLine + content.slice(insertIndex); } /** * Processes a single Vue file */ function processFile(filePath) { try { let content = fs.readFileSync(filePath, 'utf-8'); if (/^\s*emits\s*:/m.test(content)) { console.log(`↩️ ${path.relative(process.cwd(), filePath)} - already has emits`); return null; } const emits = extractEmits(content); if (emits.length === 0) { console.log(`↩️ ${path.relative(process.cwd(), filePath)} - no emits found`); return null; } content = insertEmits(content, emits); fs.writeFileSync(filePath, content, 'utf-8'); console.log(`✓ ${path.relative(process.cwd(), filePath)} - added emits: [${emits.join(', ')}]`); return filePath; } catch (error) { console.error(`✗ Error processing ${filePath}:`, error.message); return null; } } /** * Recursively processes all Vue files in a directory */ function processDirectory(dirPath) { const files = fs.readdirSync(dirPath); const processedFiles = []; for (const file of files) { const filePath = path.join(dirPath, file); const stat = fs.statSync(filePath); if (stat.isDirectory()) { processedFiles.push(...processDirectory(filePath)); } else if (file.endsWith('.vue')) { const result = processFile(filePath); if (result) processedFiles.push(result); } } return processedFiles; } /** * Remove files from eslint todo */ function removeFromEslintTodo(updatedFiles) { const todoPath = '.eslint_todo/vue-require-explicit-emits.mjs'; if (!fs.existsSync(todoPath)) { console.log(`⚠️ ${todoPath} not found`); return; } let content = fs.readFileSync(todoPath, 'utf8'); updatedFiles.forEach(filePath => { const relativePath = path.relative(process.cwd(), filePath).replace(/\\/g, '/'); content = content.replace(new RegExp(`['"\`]${relativePath}['"\`],?\\s*`, 'g'), ''); }); fs.writeFileSync(todoPath, content, 'utf8'); console.log(`✓ Removed ${updatedFiles.length} files from ${todoPath}`); } /** * Execute shell command */ function exec(command, description) { try { console.log(`\n📋 ${description}`); console.log(` Running: ${command}`); execSync(command, { stdio: 'inherit' }); console.log(`✓ ${description} completed`); } catch (error) { console.error(`✗ ${description} failed`); throw error; } } /** * Main function */ function main() { const targetDir = process.argv[2]; if (!targetDir) { console.error('Usage: node fix-vue-emits.js <directory>'); process.exit(1); } if (!fs.existsSync(targetDir)) { console.error(`Directory not found: ${targetDir}`); process.exit(1); } console.log(`\n🚀 Processing Vue files in: ${path.resolve(targetDir)}\n`); // Step 1: Extract emits and add to Vue files console.log('Step 1: Extracting emits and adding to Vue components...'); const updatedFiles = processDirectory(targetDir); if (updatedFiles.length === 0) { console.log('No files were updated'); return; } console.log(`\n✓ Updated ${updatedFiles.length} files\n`); // Step 2: Remove from eslint todo console.log('Step 2: Removing files from .eslint_todo/vue-require-explicit-emits.mjs...'); removeFromEslintTodo(updatedFiles); // Step 3: Stage all changes exec('git add .', 'Step 3: Staging all changes'); // Step 4: Run Prettier exec('yarn run lint:prettier:staged:fix', 'Step 4: Running Prettier on staged files'); // Step 5: Stage new changes exec('git add .', 'Step 5: Staging Prettier changes'); // Step 6: Run ESLint exec( `node scripts/frontend/eslint.js --no-warn-ignored --no-error-on-unmatched-pattern --format gitlab .eslint_todo/vue-require-explicit-emits.mjs ${targetDir}`, 'Step 6: Running ESLint to verify fixes' ); console.log('\n✅ All steps completed!'); console.log('\nNext steps:'); console.log('1. Review the changes: git diff HEAD'); console.log('2. Create a merge request with the description:'); console.log(' Related to: https://gitlab.com/gitlab-org/gitlab/-/issues/583392'); } main(); ``` </details> **Usage:** ```bash node gitlab-fix-eslint-vue-require-explicit-emits.js ./path/to/directory ``` This script now: 1. Extracts all `emit()` calls from Vue files and adds them to an `emits` property 2. Removes processed files from `.eslint_todo/vue-require-explicit-emits.mjs` 3. Stages changes with `git add .` 4. Runs Prettier on staged files 5. Stages Prettier changes 6. Runs ESLint to verify all fixes 7. Provides next steps for creating the merge request </details> #### Steps 1. Pick a directory of files from [.eslint_todo/vue-require-explicit-emits.mjs](https://gitlab.com/gitlab-org/gitlab/-/blob/master/.eslint_todo/vue-require-explicit-emits.mjs) 2. Download the script to fix the eslint rule in that directory [gitlab-fix-eslint-vue-require-explicit-emits.js]([gitlab-fix-require-explicit-emits-eslint.js](/uploads/587552f9db55e2e4c73fb19c7dc208dd/gitlab-fix-require-explicit-emits-eslint.js)) 3. Navigate to `path/to/gdk/gitlab` and run the script ```bash node path/to/gitlab-fix-eslint-vue-require-explicit-emits.js ./path/to/directory ``` 4. Open a merge request. For details with how to open a merge request, see [Contribute to GitLab](https://docs.gitlab.com/development/contributing/first_contribution/). In the merge request description, include this text: ```md Related to: https://gitlab.com/gitlab-org/gitlab/-/issues/583392 ``` 5. Add a video/gif/screenshot of the affected components working as intended to the merge request ### Support Support contact: @aturinske
issue