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