From 4b1bcb4146230d960ccac0d317f62f8b8ed998a3 Mon Sep 17 00:00:00 2001 From: Shubham Kumar Date: Thu, 15 Jan 2026 21:45:27 +0530 Subject: [PATCH] feat: Add automated MPL-2.0 license header validation (#2995) improve the suggestion improve the code --- .github/workflows/build.yml | 25 +++ .github/workflows/license-headers.yml | 103 ++++++++++++ package.json | 15 +- scripts/add-file-headers.js | 218 +++++++++++++++++++++++++ scripts/check-file-headers.js | 221 ++++++++++++++++++++++++++ 5 files changed, 579 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/license-headers.yml create mode 100644 scripts/add-file-headers.js create mode 100644 scripts/check-file-headers.js diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3af18298b..09166490c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -54,6 +54,31 @@ jobs: - name: Run HTMLHint run: npx htmlhint "src" --config .htmlhintrc + - name: Check file headers + run: | + echo "Checking for MPL-2.0 license headers in changed files..." + + # Get list of changed files (comparing with base branch) + if [ "${{ github.event_name }}" == "pull_request" ]; then + BASE_SHA="${{ github.event.pull_request.base.sha }}" + else + BASE_SHA="${{ github.event.before }}" + fi + + # Get changed files + CHANGED_FILES=$(git diff --name-only --diff-filter=ACMRT $BASE_SHA HEAD | grep -E '\.(ts|js|html|css|scss)$' | grep -v -E '\.(spec\.ts|spec\.js|d\.ts)$' | grep -v -E '(environment\.ts|environment\.prod\.ts|polyfills\.ts|node_modules)' || true) + + if [ -z "$CHANGED_FILES" ]; then + echo "āœ… No relevant files changed" + exit 0 + fi + + echo "šŸ“‹ Checking $(echo "$CHANGED_FILES" | wc -l) changed file(s)..." + echo "$CHANGED_FILES" + + # Run header check on changed files + echo "$CHANGED_FILES" | xargs node scripts/check-file-headers.js + - name: Run production build run: npm run build:prod diff --git a/.github/workflows/license-headers.yml b/.github/workflows/license-headers.yml new file mode 100644 index 000000000..b18ae1b32 --- /dev/null +++ b/.github/workflows/license-headers.yml @@ -0,0 +1,103 @@ +name: License Header Check + +on: + pull_request: + branches: [main, dev, dev-angular-19] + paths: + - 'src/**/*.ts' + - 'src/**/*.js' + - 'src/**/*.html' + - 'src/**/*.css' + - 'src/**/*.scss' + workflow_dispatch: # Allow manual trigger + +jobs: + check-headers: + name: Validate MPL-2.0 Headers + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Use Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Check file headers + run: | + echo "Checking for MPL-2.0 license headers in changed files..." + + # Get list of changed files in PR + BASE_SHA="${{ github.event.pull_request.base.sha }}" + HEAD_SHA="${{ github.event.pull_request.head.sha }}" + + # Get changed files (added, copied, modified, renamed, type changed) + CHANGED_FILES=$(git diff --name-only --diff-filter=ACMRT $BASE_SHA $HEAD_SHA | \ + grep -E '\.(ts|js|html|css|scss)$' | \ + grep -v -E '\.(spec\.ts|spec\.js|d\.ts)$' | \ + grep -v -E '(environment\.ts|environment\.prod\.ts|polyfills\.ts|setup-jest\.ts|jest\.config\.ts|node_modules)' || true) + + if [ -z "$CHANGED_FILES" ]; then + echo "No relevant source files changed in this PR" + exit 0 + fi + + # Count files + FILE_COUNT=$(echo "$CHANGED_FILES" | wc -l) + echo "Checking $FILE_COUNT changed file(s)..." + echo "" + echo "$CHANGED_FILES" + echo "" + + # Run the header check + echo "$CHANGED_FILES" | xargs node scripts/check-file-headers.js + + if [ $? -eq 0 ]; then + echo "" + echo "All changed files have valid MPL-2.0 headers!" + else + echo "" + echo "Header validation failed!" + echo "" + echo "To fix locally:" + echo " npm run headers:add -- " + echo "" + echo " Or add the header manually (see docs/FILE_HEADER_VALIDATION.md)" + exit 1 + fi + + - name: Comment on PR (if failed) + if: failure() && github.event_name == 'pull_request' + uses: actions/github-script@v7 + with: + script: | + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: '## License Header Validation Failed\n\n' + + 'Some files are missing the required MPL-2.0 license header.\n\n' + + '### How to fix:\n\n' + + '1. **Automatically add headers:**\n' + + ' ```bash\n' + + ' npm run headers:add -- \n' + + ' ```\n\n' + + '2. **Or manually add the header:**\n' + + ' ```typescript\n' + + ' /**\n' + + ' * Copyright since 2025 Mifos Initiative\n' + + ' *\n' + + ' * This Source Code Form is subject to the terms of the Mozilla Public\n' + + ' * License, v. 2.0. If a copy of the MPL was not distributed with this\n' + + ' * file, You can obtain one at http://mozilla.org/MPL/2.0/.\n' + + ' */\n' + + ' ```\n\n' + + '3. **Check the build logs above** to see which files need headers.\n\n' + + 'For more information, see [FILE_HEADER_VALIDATION.md](https://github.com/' + context.repo.owner + '/' + context.repo.repo + '/blob/dev/web-app/docs/FILE_HEADER_VALIDATION.md)' + }) diff --git a/package.json b/package.json index 2b3c0192f..f5c53052e 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,9 @@ "playwright": "playwright test", "playwright:ui": "playwright test --ui", "playwright:headed": "playwright test --headed", - "playwright:debug": "playwright test --debug" + "playwright:debug": "playwright test --debug", + "headers:check": "node scripts/check-file-headers.js", + "headers:add": "node scripts/add-file-headers.js" }, "dependencies": { "@angular/animations": "20.3.15", @@ -134,8 +136,15 @@ "npm": ">= 8.11.0" }, "lint-staged": { - "*.{js,jsx,ts,tsx}": "eslint --cache", - "*.{css,scss}": "stylelint", + "*.{js,jsx,ts,tsx}": [ + "node scripts/check-file-headers.js", + "eslint --cache" + ], + "*.{css,scss}": [ + "node scripts/check-file-headers.js", + "stylelint" + ], + "*.html": "node scripts/check-file-headers.js", "*": "prettier --check --ignore-unknown" }, "overrides": { diff --git a/scripts/add-file-headers.js b/scripts/add-file-headers.js new file mode 100644 index 000000000..1f3d66236 --- /dev/null +++ b/scripts/add-file-headers.js @@ -0,0 +1,218 @@ +#!/usr/bin/env node + +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +const fs = require('fs'); +const path = require('path'); + +// Headers to add based on file type +const HEADERS = { + code: `/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +`, + html: ` + +` +}; + +// File extensions +const FILE_EXTENSIONS = { + code: [ + '.ts', + '.js', + '.scss', + '.css' + ], + html: ['.html'] +}; + +const EXCLUDE_PATTERNS = [ + 'node_modules', + 'dist', + 'coverage', + '.angular', + 'e2e', + 'cypress', + 'playwright', + '.husky', + 'setup-jest.ts', + 'jest.config.ts', + 'cypress.config.ts', + 'playwright.config.ts', + 'tailwind.config.js', + 'proxy.conf.js', + 'proxy.localhost.conf.js', + 'version.js', + 'environment.ts', + 'environment.prod.ts', + 'polyfills.ts', + 'typings.d.ts', + 'global.d.ts', + '.spec.ts', + '.spec.js', + 'tsconfig' +]; + +function shouldExclude(filePath) { + const normalizedPath = filePath.replace(/\\/g, '/'); + return EXCLUDE_PATTERNS.some((pattern) => { + if (pattern.startsWith('.') && pattern.lastIndexOf('.') > 0) { + return normalizedPath.endsWith(pattern); + } + return normalizedPath.includes(pattern); + }); +} + +function getHeaderType(filePath) { + const ext = path.extname(filePath).toLowerCase(); + + if (FILE_EXTENSIONS.html.includes(ext)) { + return 'html'; + } + + if (FILE_EXTENSIONS.code.includes(ext)) { + return 'code'; + } + + return null; +} + +/** + * Check if file already has a header + */ +function hasHeader(content, headerType) { + if (headerType === 'code') { + return content.includes('Copyright since 2025 Mifos Initiative') && content.includes('Mozilla Public License'); + } + + if (headerType === 'html') { + return content.includes('Copyright since 2025 Mifos Initiative') && content.includes('Mozilla Public License'); + } + + return false; +} + +/** + * Add header to a file + */ +function addHeaderToFile(filePath, dryRun = false) { + // Skip excluded files + if (shouldExclude(filePath)) { + console.log(`ā­ļø Skipping ${filePath} (excluded)`); + return false; + } + + const headerType = getHeaderType(filePath); + + if (!headerType) { + console.log(`ā­ļø Skipping ${filePath} (unsupported file type)`); + return false; + } + + let content; + try { + content = fs.readFileSync(filePath, 'utf8'); + } catch (error) { + console.error(`āŒ Error reading ${filePath}: ${error.message}`); + return false; + } + + // Skip if file already has a header + if (hasHeader(content, headerType)) { + console.log(`āœ“ ${filePath} (already has header)`); + return false; + } + + // Skip empty files + if (content.trim().length === 0) { + console.log(`ā­ļø Skipping ${filePath} (empty file)`); + return false; + } + + const header = HEADERS[headerType]; + const newContent = header + content; + + if (dryRun) { + console.log(`šŸ” Would add header to: ${filePath}`); + return true; + } + + try { + fs.writeFileSync(filePath, newContent, 'utf8'); + console.log(`āœ… Added header to: ${filePath}`); + return true; + } catch (error) { + console.error(`āŒ Error writing ${filePath}: ${error.message}`); + return false; + } +} + +/** + * Main function + */ +function main() { + const args = process.argv.slice(2); + + // Parse flags + const dryRun = args.includes('--dry-run') || args.includes('-d'); + const files = args.filter((arg) => !arg.startsWith('--') && !arg.startsWith('-')); + + if (files.length === 0) { + console.log('Usage: node add-file-headers.js [options] ...'); + console.log('\nOptions:'); + console.log(' --dry-run, -d Show what would be done without making changes'); + console.log('\nExample:'); + console.log(' node add-file-headers.js --dry-run src/app/app.component.ts'); + console.log(' node add-file-headers.js src/app/**/*.ts'); + process.exit(0); + } + + if (dryRun) { + console.log('šŸ” DRY RUN MODE - No files will be modified\n'); + } + + let modifiedCount = 0; + let skippedCount = 0; + + files.forEach((filePath) => { + if (!fs.existsSync(filePath)) { + console.error(`āŒ File not found: ${filePath}`); + skippedCount++; + return; + } + + const modified = addHeaderToFile(filePath, dryRun); + if (modified) { + modifiedCount++; + } else { + skippedCount++; + } + }); + + console.log('\n' + '='.repeat(50)); + console.log(`Summary: ${modifiedCount} file(s) ${dryRun ? 'would be' : ''} modified, ${skippedCount} skipped`); + + if (dryRun && modifiedCount > 0) { + console.log('\nšŸ’” Run without --dry-run to apply changes'); + } +} + +main(); diff --git a/scripts/check-file-headers.js b/scripts/check-file-headers.js new file mode 100644 index 000000000..0aa2f111a --- /dev/null +++ b/scripts/check-file-headers.js @@ -0,0 +1,221 @@ +#!/usr/bin/env node + +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +const fs = require('fs'); +const path = require('path'); + +// Expected header patterns for different file types +const HEADERS = { + mpl: [ + '/**', + ' * Copyright since 2025 Mifos Initiative', + ' *', + ' * This Source Code Form is subject to the terms of the Mozilla Public', + ' * License, v. 2.0. If a copy of the MPL was not distributed with this', + ' * file, You can obtain one at http://mozilla.org/MPL/2.0/.', + ' */' + ], + html: [ + '' + ] +}; + +// File extensions that should have headers +const FILE_EXTENSIONS = { + code: [ + '.ts', + '.js', + '.scss', + '.css' + ], + html: ['.html'] +}; + +// Files/patterns to exclude from header checks +const EXCLUDE_PATTERNS = [ + 'node_modules', + 'dist', + 'coverage', + '.angular', + 'e2e', + 'cypress', + 'playwright', + '.husky', + 'setup-jest.ts', + 'jest.config.ts', + 'cypress.config.ts', + 'playwright.config.ts', + 'tailwind.config.js', + 'proxy.conf.js', + 'proxy.localhost.conf.js', + 'version.js', + 'environment.ts', + 'environment.prod.ts', + 'polyfills.ts', + 'typings.d.ts', + 'global.d.ts', + '.spec.ts', + '.spec.js', + 'tsconfig' +]; + +/** + * Check if a file should be excluded from header validation + */ +function shouldExclude(filePath) { + const normalizedPath = filePath.replace(/\\/g, '/'); + return EXCLUDE_PATTERNS.some((pattern) => { + if (pattern.startsWith('.') && pattern.lastIndexOf('.') > 0) { + return normalizedPath.endsWith(pattern); + } + + return normalizedPath.includes(pattern); + }); +} + +/** + * Get the expected header for a file based on its extension + */ +function getExpectedHeader(filePath) { + const ext = path.extname(filePath).toLowerCase(); + + if (FILE_EXTENSIONS.html.includes(ext)) { + return HEADERS.html; + } + + if (FILE_EXTENSIONS.code.includes(ext)) { + return HEADERS.mpl; + } + + return null; +} + +/** + * Normalize whitespace for comparison + */ +function normalizeWhitespace(str) { + return str.trim().replace(/\s+/g, ' '); +} + +/** + * Check if file has the correct header + */ +function hasValidHeader(filePath) { + if (shouldExclude(filePath)) { + return true; // Skip excluded files + } + + const expectedHeader = getExpectedHeader(filePath); + if (!expectedHeader) { + return true; // Skip files without defined header requirements + } + + let content; + try { + content = fs.readFileSync(filePath, 'utf8'); + } catch (error) { + console.error(`Error reading file ${filePath}: ${error.message}`); + return false; + } + + // Handle empty files + if (content.trim().length === 0) { + return true; // Allow empty files + } + + const lines = content.split(/\r?\n/); + const headerLineCount = expectedHeader.length; + + let startLine = 0; + if (lines[0] && lines[0].startsWith('#!')) { + startLine = 1; + if (lines[1] !== undefined && lines[1].trim() === '') { + startLine = 2; + } + } + + // Check if file has enough lines + if (lines.length < startLine + headerLineCount) { + return false; + } + + // Compare header lines + for (let i = 0; i < headerLineCount; i++) { + const actualLine = normalizeWhitespace(lines[startLine + i]); + const expectedLine = normalizeWhitespace(expectedHeader[i]); + + if (actualLine !== expectedLine) { + return false; + } + } + + return true; +} + +/** + * Format the expected header for display + */ +function formatExpectedHeader(header) { + return '\n' + header.join('\n') + '\n'; +} + +/** + * Main function + */ +function main() { + const args = process.argv.slice(2); + + if (args.length === 0) { + console.error('Usage: node check-file-headers.js ...'); + process.exit(1); + } + + const filesWithoutHeaders = []; + + args.forEach((filePath) => { + if (!fs.existsSync(filePath)) { + console.error(`File not found: ${filePath}`); + return; + } + + if (!hasValidHeader(filePath)) { + filesWithoutHeaders.push(filePath); + } + }); + + if (filesWithoutHeaders.length > 0) { + console.error('\nāŒ Header validation failed!\n'); + console.error('The following files are missing the required MPL-2.0 license header:\n'); + + filesWithoutHeaders.forEach((file) => { + console.error(` - ${file}`); + const expectedHeader = getExpectedHeader(file); + if (expectedHeader) { + console.error(`\n Expected header:${formatExpectedHeader(expectedHeader)}`); + } + }); + + console.error('\nšŸ’” Please add the MPL-2.0 license header to these files.'); + console.error(' Refer to existing files for the correct format.\n'); + + process.exit(1); + } + + console.log('āœ… All files have valid headers'); + process.exit(0); +} + +main();