Files
playbook/antigravity-awesome-skills/skills/cron-doctor/scripts/cron-engine.js
T
2026-07-02 16:03:10 +00:00

642 lines
21 KiB
JavaScript

'use strict';
// ============================================================================
// cron.js — Cron expression parser, describer, validator, and next-run engine.
// Zero dependencies. Extracted from the DevRef Cron Expression Generator
// (battle-tested in browser) and extended with validate() for Pro insights.
// ============================================================================
const MONTH_NAMES = ['JAN','FEB','MAR','APR','MAY','JUN','JUL','AUG','SEP','OCT','NOV','DEC'];
const DAY_NAMES = ['SUN','MON','TUE','WED','THU','FRI','SAT'];
const FIELDS = [
{ name: 'minute', min: 0, max: 59, key: 'minute' },
{ name: 'hour', min: 0, max: 23, key: 'hour' },
{ name: 'dom', min: 1, max: 31, key: 'dom' },
{ name: 'month', min: 1, max: 12, key: 'month', named: MONTH_NAMES },
{ name: 'dow', min: 0, max: 7, key: 'dow', named: DAY_NAMES },
];
class CronError extends Error {
constructor(message, fieldIndex) {
super(message);
this.name = 'CronError';
this.fieldIndex = fieldIndex;
}
}
// ---- Name resolution ----
function resolveName(token, names) {
if (!names) return null;
const up = token.toUpperCase();
const idx = names.indexOf(up);
return idx === -1 ? null : idx;
}
// ---- Field parsing ----
function parseField(raw, fieldDef, fieldIndex) {
const trimmed = String(raw).trim();
if (trimmed === '') throw new CronError(`Field ${fieldIndex + 1} (${fieldDef.name}) is empty`, fieldIndex);
const out = { raw: trimmed, values: null, special: null };
// Special: day-of-week "#" (nth weekday)
if (fieldDef.key === 'dow' && trimmed.includes('#')) {
const m = trimmed.match(/^([0-7A-Za-z]+)#([1-5])$/);
if (!m) throw new CronError(`Invalid "#" syntax in day-of-week: "${trimmed}"`, fieldIndex);
let dowNum = parseSingleNum(m[1], fieldDef, fieldIndex);
if (dowNum === 7) dowNum = 0;
out.special = { kind: 'hash', dow: dowNum, nth: parseInt(m[2], 10) };
return out;
}
// Special: day-of-week "L" (last weekday)
if (fieldDef.key === 'dow' && /L$/i.test(trimmed)) {
const m = trimmed.match(/^([0-7A-Za-z]+)L$/i);
if (!m) throw new CronError(`Invalid "L" syntax in day-of-week: "${trimmed}"`, fieldIndex);
let dowNum = parseSingleNum(m[1], fieldDef, fieldIndex);
if (dowNum === 7) dowNum = 0;
out.special = { kind: 'dowLast', dow: dowNum };
return out;
}
// Special: day-of-month "L" (last day)
if (fieldDef.key === 'dom' && /^L/i.test(trimmed)) {
const m = trimmed.match(/^L(?:-(\d+))?$/i);
if (!m) throw new CronError(`Invalid "L" syntax in day-of-month: "${trimmed}"`, fieldIndex);
out.special = { kind: 'domLast', offset: m[1] ? parseInt(m[1], 10) : 0 };
return out;
}
// Special: day-of-month "W" (nearest weekday)
if (fieldDef.key === 'dom' && /W$/i.test(trimmed)) {
const m = trimmed.match(/^(\d+)W$/i);
if (!m) throw new CronError(`Invalid "W" syntax in day-of-month: "${trimmed}"`, fieldIndex);
const day = parseInt(m[1], 10);
if (day < fieldDef.min || day > fieldDef.max) {
throw new CronError(`Day-of-month "${day}W" out of range (${fieldDef.min}-${fieldDef.max})`, fieldIndex);
}
out.special = { kind: 'weekday', day: day };
return out;
}
// Standard parsing
const values = new Set();
const items = trimmed.split(',');
for (const item of items) {
parseItem(item, fieldDef, fieldIndex, values);
}
out.values = values;
return out;
}
function parseSingleNum(token, fieldDef, fieldIndex) {
const value = String(token).trim();
if (/^\d+$/.test(value)) return Number(value);
const named = resolveName(value, fieldDef.named);
if (named !== null) {
return fieldDef.key === 'month' ? named + 1 : named;
}
throw new CronError(`Invalid value "${value}" in ${fieldDef.name}`, fieldIndex);
}
function parseItem(item, fieldDef, fieldIndex, values) {
const t = item.trim();
if (t === '') throw new CronError(`Empty item in ${fieldDef.name}`, fieldIndex);
if (t === '*') {
addRange(values, fieldDef.min, fieldDef.max, fieldDef);
return;
}
if (t.includes('/')) {
const stepParts = t.split('/');
if (stepParts.length !== 2) throw new CronError(`Invalid step syntax "${t}" in ${fieldDef.name}`, fieldIndex);
const [base, stepStr] = stepParts;
if (!/^\d+$/.test(stepStr)) throw new CronError(`Invalid step "${stepStr}" in ${fieldDef.name}`, fieldIndex);
const step = Number(stepStr);
if (step < 1) throw new CronError(`Invalid step "${stepStr}" in ${fieldDef.name}`, fieldIndex);
let lo, hi;
if (base === '*' || base === '') {
lo = fieldDef.min; hi = fieldDef.max;
} else if (base.includes('-')) {
const [a, b] = base.split('-');
lo = parseSingleNum(a.trim(), fieldDef, fieldIndex);
hi = parseSingleNum(b.trim(), fieldDef, fieldIndex);
} else {
lo = parseSingleNum(base.trim(), fieldDef, fieldIndex);
hi = fieldDef.max;
}
if (lo > hi) [lo, hi] = [hi, lo];
for (let v = lo; v <= hi; v += step) addOne(values, v, fieldDef, fieldIndex);
return;
}
if (t.includes('-')) {
const parts = t.split('-');
if (parts.length !== 2) throw new CronError(`Invalid range "${t}" in ${fieldDef.name}`, fieldIndex);
const a = parseSingleNum(parts[0].trim(), fieldDef, fieldIndex);
const b = parseSingleNum(parts[1].trim(), fieldDef, fieldIndex);
addRange(values, a, b, fieldDef);
return;
}
const v = parseSingleNum(t, fieldDef, fieldIndex);
addOne(values, v, fieldDef, fieldIndex);
}
function addOne(values, v, fieldDef, fieldIndex) {
if (fieldDef.key === 'dow' && v === 7) { values.add(0); return; }
if (v < fieldDef.min || v > fieldDef.max) {
throw new CronError(`Value ${v} out of range for ${fieldDef.name} (${fieldDef.min}-${fieldDef.max})`, fieldIndex);
}
values.add(v);
}
function addRange(values, lo, hi, fieldDef) {
if (lo > hi) [lo, hi] = [hi, lo];
if (lo < fieldDef.min || hi > fieldDef.max) {
throw new CronError(`Range ${lo}-${hi} out of bounds for ${fieldDef.name} (${fieldDef.min}-${fieldDef.max})`, -1);
}
for (let v = lo; v <= hi; v++) {
if (fieldDef.key === 'dow' && v === 7) { values.add(0); continue; }
values.add(v);
}
}
// ---- Full expression parser ----
function parseCron(expr) {
const parts = String(expr).trim().split(/\s+/);
if (parts.length !== 5) {
throw new CronError(`Expected 5 fields (got ${parts.length}). Format: minute hour day-of-month month day-of-week`, -1);
}
const parsed = {};
for (let i = 0; i < 5; i++) {
parsed[FIELDS[i].key] = parseField(parts[i], FIELDS[i], i);
}
parsed.domRestricted = !/^\s*\*\s*$/.test(parts[2]);
parsed.dowRestricted = !/^\s*\*\s*$/.test(parts[4]);
parsed.parts = parts;
return parsed;
}
// ---- Human-readable description ----
function describe(expr) {
let parsed;
try { parsed = parseCron(expr); } catch (e) { return { text: e.message, error: true }; }
return { text: describeParsed(parsed), error: false, parsed };
}
function describeParsed(p) {
const monthDesc = describeFieldMonth(p.month);
const domDesc = describeFieldDom(p.dom);
const dowDesc = describeFieldDow(p.dow);
const isEveryMin = p.parts[0] === '*';
const isEveryHour = p.parts[1] === '*';
let timePart = '';
if (isEveryMin && isEveryHour) {
timePart = 'At every minute';
} else if (isEveryMin && !isEveryHour) {
const hours = [...(p.hour.values || [])].sort((a, b) => a - b);
if (hours.length > 0) {
timePart = 'Every minute during the ' + hours.map(h => pad2(h)).join(', ') + ' hour' + (hours.length > 1 ? 's' : '');
} else {
timePart = 'Every minute';
}
} else {
timePart = 'At ' + describeTimes(p.minute, p.hour);
}
let dayPart = '';
const domAny = !p.domRestricted;
const dowAny = !p.dowRestricted;
if (domAny && dowAny) {
if (monthDesc.restricted) {
dayPart = ', ' + monthDesc.text + ' of every year';
} else {
dayPart = ', every day';
}
} else if (!domAny && dowAny) {
dayPart = ', on ' + domDesc.text;
if (monthDesc.restricted) dayPart += ' in ' + monthDesc.text;
} else if (domAny && !dowAny) {
dayPart = ', on ' + dowDesc.text;
if (monthDesc.restricted) dayPart += ' in ' + monthDesc.text;
} else {
dayPart = ', on ' + domDesc.text + ' and on ' + dowDesc.text;
if (monthDesc.restricted) dayPart += ' in ' + monthDesc.text;
}
return capitalize(timePart + dayPart);
}
function describeTimes(minuteField, hourField) {
const mins = [...(minuteField.values || [])].sort((a, b) => a - b);
const hours = [...(hourField.values || [])].sort((a, b) => a - b);
if (pIsWildcard(hourField) && !pIsWildcard(minuteField)) {
if (mins.length === 1) return `minute ${mins[0]} of every hour`;
return `minutes ${listJoin(mins)} of every hour`;
}
if (pIsWildcard(minuteField) && pIsWildcard(hourField)) return 'every minute of every hour';
if (pIsWildcard(minuteField)) {
return `every minute during the ${hours.map(h => pad2(h)).join(', ')} hour${hours.length > 1 ? 's' : ''}`;
}
const combos = [];
for (const h of hours) {
for (const m of mins) {
combos.push(formatHM(h, m));
}
}
return listJoin(combos);
}
function describeFieldMonth(field) {
if (pIsWildcard(field)) return { restricted: false, text: 'every month' };
const vals = [...(field.values || [])].sort((a, b) => a - b);
return { restricted: true, text: 'in ' + listJoin(vals.map(v => capitalize(MONTH_NAMES[v - 1]))) };
}
function describeFieldDom(field) {
if (pIsWildcard(field)) return { text: 'every day-of-month' };
if (field.special) {
if (field.special.kind === 'domLast') {
return { text: field.special.offset === 0 ? 'the last day of the month' : `the last day of the month minus ${field.special.offset} days` };
}
if (field.special.kind === 'weekday') {
return { text: `the nearest weekday to day ${field.special.day}` };
}
}
const vals = [...(field.values || [])].sort((a, b) => a - b);
return { text: `day-of-month ${listJoin(vals)}` };
}
function describeFieldDow(field) {
if (pIsWildcard(field)) return { text: 'every day-of-week' };
if (field.special) {
if (field.special.kind === 'hash') {
return { text: `the ${ordinal(field.special.nth)} ${capitalize(DAY_NAMES[field.special.dow])} of the month` };
}
if (field.special.kind === 'dowLast') {
return { text: `the last ${capitalize(DAY_NAMES[field.special.dow])} of the month` };
}
}
const vals = [...(field.values || [])].sort((a, b) => a - b);
return { text: listJoin(vals.map(v => capitalize(DAY_NAMES[v]))) };
}
function pIsWildcard(field) { return field.raw === '*'; }
// ---- Next run calculator ----
function nextRuns(expr, fromDate, count) {
count = count || 10;
const p = parseCron(expr);
const runs = [];
let d = new Date(fromDate.getTime());
d.setSeconds(0, 0);
d = new Date(d.getTime() + 60000);
let maxScan = 600000; // ~416 days ceiling
while (runs.length < count && maxScan-- > 0) {
if (matches(d, p)) {
runs.push(new Date(d.getTime()));
}
d = new Date(d.getTime() + 60000);
}
return runs;
}
function matches(d, p) {
if (!p.minute.values || !p.minute.values.has(d.getMinutes())) return false;
if (!p.hour.values || !p.hour.values.has(d.getHours())) return false;
if (!p.month.values || !p.month.values.has(d.getMonth() + 1)) return false;
const domAny = !p.domRestricted;
const dowAny = !p.dowRestricted;
let domMatch = false, dowMatch = false;
if (domAny) {
domMatch = true;
} else if (p.dom.special) {
domMatch = matchDomSpecial(d, p.dom.special);
} else if (p.dom.values && p.dom.values.has(d.getDate())) {
domMatch = true;
}
if (dowAny) {
dowMatch = true;
} else if (p.dow.special) {
dowMatch = matchDowSpecial(d, p.dow.special);
} else if (p.dow.values) {
dowMatch = p.dow.values.has(d.getDay());
}
if (domAny && dowAny) return true;
if (!domAny && !dowAny) return domMatch || dowMatch; // OR semantics
return domMatch && dowMatch;
}
function matchDomSpecial(d, special) {
if (special.kind === 'domLast') {
const lastDay = lastDayOfMonth(d.getFullYear(), d.getMonth());
const target = special.offset === 0 ? lastDay : lastDay - special.offset;
return d.getDate() === target;
}
if (special.kind === 'weekday') {
return d.getDate() === nearestWeekday(d.getFullYear(), d.getMonth(), special.day);
}
return false;
}
function matchDowSpecial(d, special) {
if (special.kind === 'hash') {
return nthWeekdayMatches(d, special.dow, special.nth);
}
if (special.kind === 'dowLast') {
return lastWeekdayMatches(d, special.dow);
}
return false;
}
function nthWeekdayMatches(d, dow, nth) {
if (d.getDay() !== dow) return false;
const dayOfMonth = d.getDate();
const occurrence = Math.ceil(dayOfMonth / 7);
return occurrence === nth;
}
function lastWeekdayMatches(d, dow) {
if (d.getDay() !== dow) return false;
const lastDay = lastDayOfMonth(d.getFullYear(), d.getMonth());
return d.getDate() + 7 > lastDay;
}
function lastDayOfMonth(year, month) {
return new Date(year, month + 1, 0).getDate();
}
function nearestWeekday(year, month, day) {
const lastDay = lastDayOfMonth(year, month);
const target = Math.min(day, lastDay);
const dt = new Date(year, month, target);
const wd = dt.getDay();
let result = target;
if (wd === 0) {
if (target + 1 <= lastDay) result = target + 1;
else result = target - 2;
} else if (wd === 6) {
if (target - 1 >= 1) result = target - 1;
else result = target + 2;
}
return result;
}
// ============================================================================
// validate() — Pro-tier feature: deeper analysis of a cron expression.
// Returns warnings, observations, and optimization suggestions.
// ============================================================================
function validate(expr) {
let parsed;
try {
parsed = parseCron(expr);
} catch (e) {
return {
valid: false,
error: e.message,
fieldIndex: e.fieldIndex,
warnings: [],
observations: [],
suggestions: [],
};
}
const warnings = [];
const observations = [];
const suggestions = [];
const desc = describeParsed(parsed);
// Check: day-of-month and day-of-week both restricted (OR semantics surprise)
if (parsed.domRestricted && parsed.dowRestricted) {
warnings.push({
level: 'high',
message: 'Both day-of-month and day-of-week are restricted. Cron uses OR semantics for these fields — the job will run when EITHER matches, not both. This is a common source of bugs.',
});
}
// Check: impossible day-of-month values (e.g., 31 in Feb)
const domValues = [...(parsed.dom.values || [])];
if (!parsed.domRestricted && parsed.month.values && ![...parsed.month.values].every(m => m === 2)) {
// skip
} else if (parsed.domRestricted && !parsed.dom.special && domValues.includes(31)) {
const monthsWith31 = [1, 3, 5, 7, 8, 10, 12]; // Jan, Mar, May, Jul, Aug, Oct, Dec
const monthValues = parsed.month.values ? [...parsed.month.values] : [];
const restrictedMonths = parsed.parts[3] !== '*';
if (restrictedMonths) {
const problemMonths = monthValues.filter(m => !monthsWith31.includes(m));
if (problemMonths.length > 0) {
warnings.push({
level: 'medium',
message: `Day 31 is specified but months ${problemMonths.map(m => capitalize(MONTH_NAMES[m - 1])).join(', ')} have fewer than 31 days. The job will never run in those months.`,
});
}
} else {
observations.push({
level: 'info',
message: 'Day 31 will only match in months with 31 days (7 of 12 months). The job effectively skips Feb, Apr, Jun, Sep, and Nov.',
});
}
}
// Check: high-frequency schedules
if (parsed.parts[0] === '*' && parsed.parts[1] === '*') {
observations.push({
level: 'info',
message: 'This expression runs every minute. For production jobs, consider if this frequency is intentional.',
});
}
// Check: step values that don't divide evenly
for (let i = 0; i < 2; i++) {
const part = parsed.parts[i];
if (part.startsWith('*/')) {
const step = parseInt(part.slice(2), 10);
const range = i === 0 ? 60 : 24;
if (range % step !== 0) {
observations.push({
level: 'info',
message: `Step value */${step} in ${FIELDS[i].name} doesn't divide evenly into ${range}. The last interval will be shorter than the rest (e.g., */7 in minutes goes 0,7,14,...,56, then 0 again — not 63).`,
});
}
}
}
// Check: February 29th edge case
if (parsed.domRestricted && !parsed.dom.special) {
const domVals = [...(parsed.dom.values || [])];
const monthVals = parsed.month.values ? [...parsed.month.values] : [];
if (domVals.includes(29) && monthVals.length === 1 && monthVals[0] === 2) {
warnings.push({
level: 'medium',
message: 'February 29th only occurs in leap years. This job will not run at all in non-leap years (3 out of every 4 years).',
});
}
}
// Check: midnight rush
if (parsed.parts[0] === '0' && parsed.parts[1] === '0') {
suggestions.push({
level: 'info',
message: 'Midnight (00:00) is a common schedule and many systems have concurrent job spikes at this time. Consider offsetting to a few minutes past midnight (e.g., 02 0 * * *) to avoid resource contention.',
});
}
// Check: weekend vs weekday
if (parsed.parts[4] === '1-5') {
observations.push({
level: 'info',
message: 'Weekdays only (Mon-Fri). This job will not run on weekends.',
});
}
// Compute frequency estimate
const freq = estimateFrequency(parsed);
if (freq) {
observations.push({
level: 'info',
message: `Approximate frequency: ${freq.description} (~${freq.runsPerYear} runs per year).`,
});
}
return {
valid: true,
description: desc,
warnings,
observations,
suggestions,
parsed,
};
}
function estimateFrequency(parsed) {
try {
// Count runs over a sample year
const start = new Date(2025, 0, 1, 0, 0, 0, 0);
const end = new Date(2026, 0, 1, 0, 0, 0, 0);
let count = 0;
let d = new Date(start.getTime());
let maxScan = 540000; // ~375 days
while (d < end && maxScan-- > 0) {
if (matches(d, parsed)) count++;
d = new Date(d.getTime() + 60000);
}
let description = '';
if (count >= 525600) description = 'every minute';
else if (count >= 500000) description = 'multiple times per minute';
else if (count >= 8000) description = 'hourly or more';
else if (count >= 300) description = 'daily or more';
else if (count >= 40) description = 'weekly or more';
else if (count >= 8) description = 'monthly or more';
else if (count >= 1) description = 'yearly or less';
else description = 'never (impossible schedule)';
return { description, runsPerYear: count };
} catch (e) {
return null;
}
}
// ---- Presets ----
const PRESETS = [
{ label: 'Every minute', cron: '* * * * *' },
{ label: 'Every 5 min', cron: '*/5 * * * *' },
{ label: 'Every 10 min', cron: '*/10 * * * *' },
{ label: 'Every 15 min', cron: '*/15 * * * *' },
{ label: 'Every 30 min', cron: '*/30 * * * *' },
{ label: 'Hourly', cron: '0 * * * *' },
{ label: 'Every 2 hours', cron: '0 */2 * * *' },
{ label: 'Every 6 hours', cron: '0 */6 * * *' },
{ label: 'Every 12 hours', cron: '0 */12 * * *' },
{ label: 'Daily at midnight', cron: '0 0 * * *' },
{ label: 'Daily 9am', cron: '0 9 * * *' },
{ label: 'Twice daily', cron: '0 9,21 * * *' },
{ label: 'Weekdays 9am', cron: '0 9 * * 1-5' },
{ label: 'Weekends 10am', cron: '0 10 * * 0,6' },
{ label: 'Every Monday', cron: '0 0 * * 1' },
{ label: 'Every Friday', cron: '0 0 * * 5' },
{ label: 'Monthly 1st', cron: '0 0 1 * *' },
{ label: 'Quarterly', cron: '0 0 1 */3 *' },
{ label: 'Yearly Jan 1', cron: '0 0 1 1 *' },
];
const COMMON = [
{ label: 'At 14:30', cron: '30 14 * * *' },
{ label: '9am weekdays', cron: '0 9 * * 1-5' },
{ label: 'Every Mon 8am', cron: '0 8 * * 1' },
{ label: 'Last day of month', cron: '0 0 L * *' },
{ label: '15th, weekday', cron: '0 0 15W * *' },
{ label: '3rd Thursday', cron: '0 0 * * 4#3' },
{ label: 'Last Friday', cron: '0 0 * * 5L' },
{ label: 'Business hours', cron: '0 9-17 * * 1-5' },
{ label: 'Backup nightly', cron: '0 2 * * *' },
];
// ---- Helpers ----
function pad2(n) { return String(n).padStart(2, '0'); }
function formatHM(h, m) { return `${pad2(h)}:${pad2(m)}`; }
function capitalize(s) { return s.charAt(0).toUpperCase() + s.slice(1); }
function ordinal(n) {
const s = ['th', 'st', 'nd', 'rd'];
const v = n % 100;
return n + (s[(v - 20) % 10] || s[v] || s[0]);
}
function listJoin(arr) {
if (arr.length === 0) return '';
if (arr.length === 1) return String(arr[0]);
if (arr.length === 2) return `${arr[0]} and ${arr[1]}`;
return arr.slice(0, -1).join(', ') + ', and ' + arr[arr.length - 1];
}
function formatNextRuns(runs, fromDate) {
return runs.map(r => {
const diff = r.getTime() - fromDate.getTime();
const mins = Math.round(diff / 60000);
let rel;
if (mins < 60) rel = `+${mins}m`;
else if (mins < 2880) rel = `+${Math.round(mins / 60)}h`;
else rel = `+${Math.round(mins / 1440)}d`;
return { date: r, relative: rel, formatted: r.toISOString() };
});
}
module.exports = {
CronError,
FIELDS,
MONTH_NAMES,
DAY_NAMES,
PRESETS,
COMMON,
parseCron,
describe,
describeParsed,
nextRuns,
matches,
validate,
estimateFrequency,
formatNextRuns,
parseField,
parseItem,
parseSingleNum,
resolveName,
lastDayOfMonth,
nearestWeekday,
nthWeekdayMatches,
lastWeekdayMatches,
};