import path from 'path'; /** * ESLint rule to enforce proper migration file naming conventions. * * Migration files must: * 1. Be named with yyyymmddhhmm or yyyymmddhhmmss prefix (e.g., 202410311430_description.cjs or 20241031143045_description.cjs) * 2. Have timestamps that are not in the future (compared to current date) * * This rule only applies to files in directories containing "migration" in the path. * * IMPORTANT: Migrations before 2025-11-18 are grandfathered in and will not be checked. * This prevents errors on existing legacy migrations that don't follow the naming convention. */ const MIGRATION_FILENAME_PATTERN = /^(\d{12}(?:\d{2})?)_.*\.cjs$/; // Only enforce the rule for migrations on or after this date (yyyymmdd format) const CUTOFF_DATE = '20251118'; function parseTimestamp(timestampStr) { // Parse yyyymmddhhmm or yyyymmddhhmmss format const year = parseInt(timestampStr.substring(0, 4), 10); const month = parseInt(timestampStr.substring(4, 6), 10); const day = parseInt(timestampStr.substring(6, 8), 10); const hour = parseInt(timestampStr.substring(8, 10), 10); const minute = parseInt(timestampStr.substring(10, 12), 10); const second = timestampStr.length >= 14 ? parseInt(timestampStr.substring(12, 14), 10) : 0; // Validate ranges before creating Date object // This prevents JavaScript from auto-correcting invalid dates (e.g., month 13 -> Jan next year) if (month < 1 || month > 12) { return { valid: false, date: null }; } if (day < 1 || day > 31) { return { valid: false, date: null }; } if (hour < 0 || hour > 23) { return { valid: false, date: null }; } if (minute < 0 || minute > 59) { return { valid: false, date: null }; } if (second < 0 || second > 59) { return { valid: false, date: null }; } const date = new Date(year, month - 1, day, hour, minute, second); // JS months are 0-indexed // Verify the date components weren't rolled over by JavaScript // E.g., Feb 31 becomes Mar 3, so we need to check that didn't happen if ( date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day || date.getHours() !== hour || date.getMinutes() !== minute || date.getSeconds() !== second ) { return { valid: false, date: null }; } return { valid: true, date }; } function isFutureDate(date) { const now = new Date(); return date > now; } export default { meta: { type: "problem", docs: { description: "Enforce proper migration file naming with yyyymmddhhmm prefix and prevent future timestamps", recommended: true, }, schema: [], // no options messages: { invalidFormat: "Migration file '{{filename}}' must be named with yyyymmddhhmm or yyyymmddhhmmss prefix followed by underscore and description (e.g., 202410311430_add_users_table.cjs or 20241031143045_add_users_table.cjs)", futureTimestamp: "Migration file '{{filename}}' has a timestamp in the future ({{timestamp}}). Migration timestamps must not be later than the current date.", invalidTimestamp: "Migration file '{{filename}}' has an invalid timestamp '{{timestamp}}' that cannot be parsed as a valid date.", }, }, create(context) { const filename = context.getFilename(); const basename = path.basename(filename); const dirname = path.dirname(filename); // Only check files in directories that contain "migration" in the path if (!dirname.includes('migration')) { return {}; } // Only check .cjs files (migration files are CommonJS) if (!basename.endsWith('.cjs')) { return {}; } // Run the check once per file (on Program node) return { Program(node) { // Extract the date prefix (first 8 digits = yyyymmdd) to check against cutoff const datePrefix = basename.substring(0, 8); // Skip validation for migrations before the cutoff date (grandfathered in) if (datePrefix < CUTOFF_DATE) { return; } const match = basename.match(MIGRATION_FILENAME_PATTERN); if (!match) { context.report({ node, messageId: "invalidFormat", data: { filename: basename, }, }); return; } const timestampStr = match[1]; const result = parseTimestamp(timestampStr); if (!result.valid) { context.report({ node, messageId: "invalidTimestamp", data: { filename: basename, timestamp: timestampStr, }, }); return; } const timestamp = result.date; if (isFutureDate(timestamp)) { const formattedDate = timestamp.toISOString().replace('T', ' ').substring(0, 16); context.report({ node, messageId: "futureTimestamp", data: { filename: basename, timestamp: formattedDate, }, }); } }, }; }, };