PSA/eslint-plugin-custom-rules/migration-filename.js
Hermes 284313f908
Some checks are pending
Bidi Control Character Guard / bidi-control-guard (push) Waiting to run
Circular Dependency Check / Check for new circular dependencies (push) Waiting to run
Citus Migration Smoke / Combined migrations on single-node Citus (push) Waiting to run
E2E Fresh Install Tests / fresh-install-e2e (push) Waiting to run
ext-v2 guardrails / Run ext-v2 guard and ESLint (push) Waiting to run
Integration Tests / Check for relevant changes (push) Waiting to run
Integration Tests / ${{ (github.event_name == 'schedule' || github.event.inputs.suite == 'full') && 'Full integration suite' || 'Tier-1 integration subset' }} (push) Blocked by required conditions
Mobile checks / Mobile lint + typecheck (push) Waiting to run
Mobile checks / Mobile unit tests (push) Waiting to run
Mobile checks / Mobile dependency audit (report) (push) Waiting to run
Mobile checks / Mobile reproducibility checks (push) Waiting to run
Secrets guard (env backups) / Ensure no tracked env backup files (push) Waiting to run
Temporal Readiness / fast-readiness (push) Waiting to run
Temporal Readiness / docker-parity (push) Waiting to run
TypeScript Type Check / Nx affected typecheck (push) Waiting to run
Unit Tests / Skipped-test budget (push) Waiting to run
Unit Tests / Nx affected unit tests (push) Waiting to run
Unit Tests / Server unit coverage (informational) (push) Waiting to run
Validate Tenant Management Schema / Check for relevant changes (push) Waiting to run
Validate Tenant Management Schema / Validate Tenant Management Schema (push) Blocked by required conditions
EE Workflows Build Guard / ee-workflows-build-guard (push) Waiting to run
Initial import of AlgaPSA codebase from PSA server
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz

Source: /opt/alga-psa on psa.joliet.tech
2026-06-22 16:12:17 -05:00

159 lines
5.0 KiB
JavaScript

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,
},
});
}
},
};
},
};