File size: 68,288 Bytes
11fcbc8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b5e683a
11fcbc8
b5e683a
11fcbc8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d5bb6ee
11fcbc8
 
b5e683a
11fcbc8
b5e683a
 
11fcbc8
b5e683a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
<!doctype html><html lang="en"><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Cron Schedule Lint for crontab, Kubernetes CronJob, GitHub Actions, Spring and EventBridge</title>
<meta name="description" content="Names every schedule line in the file you have open that fires at the wrong hour, twice, or never at all - and prints the next three times each one really fires, in the zone that actually applies.">
<style>
:root{color-scheme:light dark}
body{margin:0;font:16px/1.6 system-ui,-apple-system,"Segoe UI",sans-serif;
 background:#fbfbfa;color:#16161d}
@media(prefers-color-scheme:dark){body{background:#14141a;color:#e8e8ea}}
.wrap{max-width:880px;margin:0 auto;padding:28px 20px 64px}
h1{font-size:1.7rem;line-height:1.25;margin:.2em 0 .3em}
p.sub{margin:0 0 1.4em;opacity:.8}
textarea{width:100%;min-height:230px;font:13px/1.5 ui-monospace,Menlo,Consolas,monospace;
 padding:12px;border:1px solid #c9c9d1;border-radius:10px;background:transparent;color:inherit;
 box-sizing:border-box}
button{font:600 15px/1 inherit;padding:12px 22px;border:0;border-radius:10px;
 background:#1d4ed8;color:#fff;cursor:pointer;margin:12px 0}
button:hover{background:#1a43bd}
table{border-collapse:collapse;width:100%;margin-top:14px;font-size:14px}
th,td{border-bottom:1px solid #d8d8e0;padding:8px 6px;text-align:left;vertical-align:top}
th{font-weight:600;opacity:.75}
td.n{width:64px;font:13px ui-monospace,monospace;opacity:.7}
.ok{padding:14px;border-radius:10px;background:#e8f5ec;color:#14532d;margin-top:14px}
@media(prefers-color-scheme:dark){.ok{background:#15321f;color:#bbf7d0}}
.next{margin-top:30px;padding:18px;border:1px solid #d8d8e0;border-radius:12px}
.cta{margin:4px 0 18px;padding:12px 14px;border-left:3px solid #1d4ed8;font-size:14px;line-height:1.55}
.cta a{font-weight:600}
.zone{margin:10px 0 0;font-size:14px;opacity:.85}
.zone select{font:inherit;padding:5px 8px;border-radius:8px;border:1px solid #c9c9d1;background:transparent;color:inherit}
.next h2{font-size:1.05rem;margin:0 0 .5em}
.next ul{margin:.3em 0 1em 1.1em;padding:0}
</style>
<div class="wrap">
<h1>Cron Schedule Lint for crontab, Kubernetes CronJob, GitHub Actions, Spring and EventBridge</h1>
<p class="sub">Names every schedule line in the file you have open that fires at the wrong hour, twice, or never at all - and prints the next three times each one really fires, in the zone that actually applies.</p>
<textarea id="src" placeholder="Paste your crontab, workflow, CronJob, wrangler.toml or beat file here"># crontab  -  the schedule an assistant wrote for our billing box
CRON_TZ=America/New_York
0 0 31 * * /opt/billing/monthly_invoice.sh
30 1 * * * /opt/etl/nightly_rollup.sh
0 0 1 * 1 /opt/reports/board_pack.sh
*/7 * * * * /usr/local/bin/queue_probe
0 2 * * * python /opt/jobs/reconcile.py
15 4 * * * /opt/backup/dump.sh &gt; /backup/db-$(date +%Y-%m-%d).sql
@reboot /opt/agent/start.sh
0 0 30 2 * /opt/audit/leap_day_check.sh
</textarea>
<p class="zone"><label for="zone">Time zone used where the file does not name one (spec.timeZone, CRON_TZ and UTC-only platforms always win over this): </label><select id="zone"><option value="UTC">UTC</option><option value="America/New_York" selected>America/New_York</option><option value="America/Chicago">America/Chicago</option><option value="America/Denver">America/Denver</option><option value="America/Los_Angeles">America/Los_Angeles</option><option value="America/Sao_Paulo">America/Sao_Paulo</option><option value="Europe/London">Europe/London</option><option value="Europe/Berlin">Europe/Berlin</option><option value="Europe/Madrid">Europe/Madrid</option><option value="Europe/Warsaw">Europe/Warsaw</option><option value="Africa/Lagos">Africa/Lagos</option><option value="Asia/Kolkata">Asia/Kolkata</option><option value="Asia/Singapore">Asia/Singapore</option><option value="Asia/Tokyo">Asia/Tokyo</option><option value="Asia/Seoul">Asia/Seoul</option><option value="Australia/Sydney">Australia/Sydney</option><option value="Pacific/Auckland">Pacific/Auckland</option></select></p>
<button id="go">Find the schedules that misfire</button>
<p id="cta-top" class="cta">Free, with no key, for the file above: every rule and every run time. <a href="https://getreadystack.com">Get the full version - $29</a> for the whole repository as one calendar, the CSV/JSON/HTML export and the CI gate. $29 once - one licence key per person or team seat. Healthchecks.io Business, the ordinary cron monitor, is $20 every month and only tells you after a run was already missed.</p>
<div id="out"></div>
<div class="next"><h2>In the full version</h2><ul><li>Every schedule in the repository merged into one calendar instead of the single file you have open</li><li>The whole calendar as CSV, JSON or HTML for a change review</li><li>Machine output that exits non-zero so a pipeline can stop a merge that adds a schedule which never fires</li><li><a href="https://getreadystack.com">Get the full version - $29</a></li></ul></div>
</div>
<script>
/* ── CRONX ──────────────────────────────────────────────────────────────────
   One brain. This exact block is injected, byte for byte, into ext/extension.js
   and into index.html. It does the calendar arithmetic the regex rules cannot:
   which real instants a schedule fires at, in which zone, and on which dates
   that wall-clock time does not exist or happens twice.
   No dependencies. Uses Intl time-zone data, which ships with Node and browsers.
   ------------------------------------------------------------------------- */
/* ── CRONX ──────────────────────────────────────────────────────────────────
   One brain. This exact block is injected, byte for byte, into ext/extension.js
   and into index.html. It does the calendar arithmetic the regex rules cannot:
   which real instants a schedule fires at, in which zone, and on which dates
   that wall-clock time does not exist or happens twice.
   No dependencies. Uses Intl time-zone data, which ships with Node and browsers.
   ------------------------------------------------------------------------- */
/* ── CRONX ──────────────────────────────────────────────────────────────────
   One brain. This exact block is injected, byte for byte, into ext/extension.js
   and into index.html. It does the calendar arithmetic the regex rules cannot:
   which real instants a schedule fires at, in which zone, and on which dates
   that wall-clock time does not exist or happens twice.
   No dependencies. Uses Intl time-zone data, which ships with Node and browsers.
   ------------------------------------------------------------------------- */
var CRONX = (function () {
  'use strict';
  var DAY = 86400000;
  var DOW_NAMES = { SUN: 0, MON: 1, TUE: 2, WED: 3, THU: 4, FRI: 5, SAT: 6 };
  var MON_NAMES = { JAN: 1, FEB: 2, MAR: 3, APR: 4, MAY: 5, JUN: 6,
                    JUL: 7, AUG: 8, SEP: 9, OCT: 10, NOV: 11, DEC: 12 };
  // dialect -> how many fields, where the 5 calendar fields sit, how Sunday is numbered
  var DIALECT = {
    crontab:     { n: [5],    off: 0, sunday: 0, label: 'crontab / CronJob / Actions' },
    spring:      { n: [6],    off: 1, sunday: 0, label: 'Spring @Scheduled' },
    quartz:      { n: [6, 7], off: 1, sunday: 1, label: 'Quartz' },
    eventbridge: { n: [6],    off: 0, sunday: 1, label: 'EventBridge cron()' },
    nodecron:    { n: [5, 6], off: -1, sunday: 0, label: 'node-cron' }
  };
  var SHORTHAND = {
    '@yearly': '0 0 1 1 *', '@annually': '0 0 1 1 *', '@monthly': '0 0 1 * *',
    '@weekly': '0 0 * * 0', '@daily': '0 0 * * *', '@midnight': '0 0 * * *',
    '@hourly': '0 * * * *'
  };

  /* ---- time zone maths -------------------------------------------------- */
  var _fmt = {};
  function validZone(z) {
    if (!z || typeof z !== 'string') return false;
    try { new Intl.DateTimeFormat('en-US', { timeZone: z }); return true; } catch (e) { return false; }
  }
  function fmt(zone) {
    if (!_fmt[zone]) {
      _fmt[zone] = new Intl.DateTimeFormat('en-US', {
        timeZone: zone, hourCycle: 'h23', year: 'numeric', month: '2-digit',
        day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit'
      });
    }
    return _fmt[zone];
  }
  function parts(zone, ms) {
    var p = {}, a = fmt(zone).formatToParts(new Date(ms)), i;
    for (i = 0; i < a.length; i++) {
      if (a[i].type !== 'literal') p[a[i].type] = parseInt(a[i].value, 10);
    }
    if (p.hour === 24) p.hour = 0;
    return p;
  }
  function offsetMin(zone, ms) {
    var p = parts(zone, ms);
    return (Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second)
            - Math.floor(ms / 1000) * 1000) / 60000;
  }
  function localToUtc(zone, y, mo, d, h, mi) {
    var naive = Date.UTC(y, mo - 1, d, h, mi, 0);
    var o1 = offsetMin(zone, naive), ms = naive - o1 * 60000;
    var o2 = offsetMin(zone, ms);
    if (o2 !== o1) ms = naive - o2 * 60000;
    return ms;
  }
  // returns the instant, or null when that wall-clock minute does not exist in that zone
  function existsLocal(zone, y, mo, d, h, mi) {
    var ms = localToUtc(zone, y, mo, d, h, mi), p = parts(zone, ms);
    return (p.year === y && p.month === mo && p.day === d
            && p.hour === h && p.minute === mi) ? ms : null;
  }
  var _tr = {};
  // every offset change in the zone over the window, narrowed to the minute
  function transitions(zone, fromMs, days) {
    var key = zone + '|' + Math.floor(fromMs / DAY) + '|' + days;
    if (_tr[key]) return _tr[key];
    var out = [], step = 3600000, prev = offsetMin(zone, fromMs), end = fromMs + days * DAY, t, o;
    for (t = fromMs + step; t <= end; t += step) {
      o = offsetMin(zone, t);
      if (o !== prev) {
        var lo = t - step, hi = t, mid;
        while (hi - lo > 60000) {
          mid = lo + Math.floor((hi - lo) / 120000) * 60000;
          if (mid <= lo) mid = lo + 60000;
          if (offsetMin(zone, mid) === prev) lo = mid; else hi = mid;
        }
        out.push({ ms: hi, from: prev, to: o });
        prev = o;
      }
    }
    _tr[key] = out;
    return out;
  }

  /* ---- field expansion --------------------------------------------------- */
  function num(s, names) {
    s = String(s).trim().toUpperCase();
    if (names && Object.prototype.hasOwnProperty.call(names, s)) return names[s];
    if (!/^\d+$/.test(s)) return null;
    return parseInt(s, 10);
  }
  function expand(tok, lo, hi, names) {
    tok = String(tok).trim();
    var raw = tok, special = /[LW#]/i.test(tok);
    if (special) return null;
    if (tok === '?') tok = '*';
    var chunks = tok.split(','), seen = {}, i, j, stepUsed = 0, wildcard = (tok === '*');
    for (i = 0; i < chunks.length; i++) {
      var p = chunks[i], step = 1, m = p.match(/^(.*)\/(\d+)$/);
      if (m) { p = m[1]; step = parseInt(m[2], 10); if (!step) return null; stepUsed = step; }
      var a, b;
      if (p === '*' || p === '') { a = lo; b = hi; }
      else {
        var r = p.split('-');
        a = num(r[0], names);
        b = (r.length > 1) ? num(r[1], names) : (m ? hi : a);
        if (a === null || b === null) return null;
      }
      if (a < lo || b > hi) return null;
      if (a > b) {
        for (j = a; j <= hi; j += step) seen[j] = 1;
        for (j = lo; j <= b; j += step) seen[j] = 1;
      } else {
        for (j = a; j <= b; j += step) seen[j] = 1;
      }
    }
    var vals = Object.keys(seen).map(Number).sort(function (x, y) { return x - y; });
    if (!vals.length) return null;
    return { vals: vals, raw: raw, restricted: !(raw === '*' || raw === '?'),
             step: stepUsed, wildcard: wildcard, span: hi - lo + 1 };
  }

  /* ---- parse ------------------------------------------------------------- */
  function parse(expr, dialect) {
    var d = DIALECT[dialect] || DIALECT.crontab;
    var e = String(expr).trim();
    if (SHORTHAND[e.toLowerCase()]) { e = SHORTHAND[e.toLowerCase()]; d = DIALECT.crontab; }
    var f = e.split(/\s+/);
    if (d.n.indexOf(f.length) < 0) return { ok: false, why: 'fields', got: f.length, want: d.n };
    var off = d.off;
    if (off === -1) off = (f.length === 6) ? 1 : 0;          // node-cron: seconds are optional
    var mi = expand(f[off], 0, 59, null);
    var hr = expand(f[off + 1], 0, 23, null);
    var dom = expand(f[off + 2], 1, 31, null);
    var mon = expand(f[off + 3], 1, 12, MON_NAMES);
    var dowLo = d.sunday === 1 ? 1 : 0, dowHi = d.sunday === 1 ? 7 : 7;
    var dowNames = {}, k;
    for (k in DOW_NAMES) if (Object.prototype.hasOwnProperty.call(DOW_NAMES, k)) {
      dowNames[k] = DOW_NAMES[k] + (d.sunday === 1 ? 1 : 0);
    }
    var dow = expand(f[off + 4], dowLo, dowHi, dowNames);
    if (!mi || !hr || !dom || !mon || !dow) return { ok: false, why: 'field-value' };
    // normalise day-of-week to 0=Sunday .. 6=Saturday
    var norm = {}, i;
    for (i = 0; i < dow.vals.length; i++) {
      var v = dow.vals[i];
      norm[d.sunday === 1 ? (v - 1) : (v === 7 ? 0 : v)] = 1;
    }
    dow.vals = Object.keys(norm).map(Number).sort(function (x, y) { return x - y; });
    return { ok: true, mi: mi, hr: hr, dom: dom, mon: mon, dow: dow,
             dialect: dialect || 'crontab', label: d.label, expr: e };
  }

  function dayMatches(s, y, mo, d) {
    if (s.mon.vals.indexOf(mo) < 0) return false;
    var w = new Date(Date.UTC(y, mo - 1, d)).getUTCDay();
    var dOK = s.dom.vals.indexOf(d) >= 0, wOK = s.dow.vals.indexOf(w) >= 0;
    if (s.dom.restricted && s.dow.restricted) return dOK || wOK;   // POSIX: union, not intersection
    if (s.dom.restricted) return dOK;
    if (s.dow.restricted) return wOK;
    return true;
  }
  function pad(n) { return (n < 10 ? '0' : '') + n; }
  function stamp(y, mo, d, h, mi) {
    return y + '-' + pad(mo) + '-' + pad(d) + ' ' + pad(h) + ':' + pad(mi);
  }

  function nextRuns(s, zone, fromMs, count) {
    var p = parts(zone, fromMs), cur = Date.UTC(p.year, p.month - 1, p.day), out = [], k;
    for (k = 0; k < 1500 && out.length < count; k++) {
      var dt = new Date(cur + k * DAY);
      var yy = dt.getUTCFullYear(), mm = dt.getUTCMonth() + 1, dd = dt.getUTCDate();
      if (!dayMatches(s, yy, mm, dd)) continue;
      for (var a = 0; a < s.hr.vals.length && out.length < count; a++) {
        for (var b = 0; b < s.mi.vals.length && out.length < count; b++) {
          var H = s.hr.vals[a], M = s.mi.vals[b];
          var ms = existsLocal(zone, yy, mm, dd, H, M);
          if (ms === null || ms <= fromMs) continue;
          out.push({ ms: ms, txt: stamp(yy, mm, dd, H, M) });
        }
      }
    }
    return out;
  }
  function daysMatching(s, fromMs, days) {
    var d0 = new Date(fromMs), cur = Date.UTC(d0.getUTCFullYear(), d0.getUTCMonth(), d0.getUTCDate());
    var n = 0, k;
    for (k = 0; k < days; k++) {
      var dt = new Date(cur + k * DAY);
      if (dayMatches(s, dt.getUTCFullYear(), dt.getUTCMonth() + 1, dt.getUTCDate())) n++;
    }
    return n;
  }
  function monthsWithoutADay(s) {
    var LEN = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
    var NM = ['January', 'February', 'March', 'April', 'May', 'June', 'July',
              'August', 'September', 'October', 'November', 'December'];
    if (!s.dom.restricted || s.dow.restricted) return [];
    var out = [], m, i, hit;
    for (m = 0; m < 12; m++) {
      if (s.mon.vals.indexOf(m + 1) < 0) continue;
      hit = false;
      for (i = 0; i < s.dom.vals.length; i++) if (s.dom.vals[i] <= LEN[m]) { hit = true; break; }
      if (!hit) out.push(NM[m]);
    }
    return out;
  }
  // wall-clock minutes that do not exist, or happen twice, in the window
  function dstHits(s, zone, fromMs, days) {
    var tr = transitions(zone, fromMs, days), res = [], i, q;
    for (i = 0; i < tr.length; i++) {
      var t = tr[i], delta = t.to - t.from, n = Math.abs(delta);
      if (!n) continue;
      var base = (delta > 0) ? (t.ms + t.from * 60000) : (t.ms + t.to * 60000);
      for (q = 0; q < n; q++) {
        var nz = new Date(base + q * 60000);
        var yy = nz.getUTCFullYear(), mm = nz.getUTCMonth() + 1, dd = nz.getUTCDate();
        var H = nz.getUTCHours(), M = nz.getUTCMinutes();
        if (s.mi.vals.indexOf(M) >= 0 && s.hr.vals.indexOf(H) >= 0 && dayMatches(s, yy, mm, dd)) {
          res.push({ kind: delta > 0 ? 'gap' : 'dup', at: stamp(yy, mm, dd, H, M) });
        }
      }
    }
    return res;
  }

  /* ---- pulling schedules out of a file ----------------------------------- */
  var EXPR = "[0-9A-Za-z*?,/\\-]+(?:\\s+[0-9A-Za-z*?,/\\-]+){4,6}";
  function docZone(text, fallback) {
    var m = String(text).match(/^[ \t-]*(?:timeZone|time_zone)\s*:\s*["']?([A-Za-z]+\/[A-Za-z_+\-0-9\/]+)/m);
    if (m && validZone(m[1])) return { zone: m[1], why: 'spec.timeZone in this file' };
    m = String(text).match(/^\s*(?:CRON_TZ|TZ)\s*=\s*["']?([A-Za-z]+\/[A-Za-z_+\-0-9\/]+)/m);
    if (m && validZone(m[1])) return { zone: m[1], why: 'CRON_TZ in this file' };
    return { zone: fallback, why: 'the zone you picked' };
  }
  function findSchedules(text, fallbackZone) {
    var lines = String(text).split(/\r?\n/), out = [], i, m;
    var dz = docZone(text, fallbackZone);
    for (i = 0; i < lines.length; i++) {
      var L = lines[i], n = i + 1, hit = null;
      if (/^\s*#/.test(L) && !/cron/i.test(L)) continue;
      if ((m = L.match(new RegExp("-\\s*cron\\s*:\\s*['\"]\\s*(" + EXPR + ")\\s*['\"]")))) {
        hit = { expr: m[1], dialect: 'crontab', zone: 'UTC', src: 'GitHub Actions', forced: true };
      } else if ((m = L.match(new RegExp("\"schedule\"\\s*:\\s*\"\\s*(" + EXPR + ")\\s*\"")))) {
        hit = { expr: m[1], dialect: 'crontab', zone: 'UTC', src: 'Vercel cron', forced: true };
      } else if ((m = L.match(new RegExp("^\\s*schedule\\s*:\\s*['\"]?\\s*(" + EXPR + ")\\s*['\"]?\\s*$")))) {
        hit = { expr: m[1], dialect: 'crontab', zone: dz.zone, src: 'Kubernetes CronJob', forced: false };
      } else if ((m = L.match(new RegExp("^\\s*crons\\s*=\\s*\\[\\s*\"\\s*(" + EXPR + ")\\s*\"")))) {
        hit = { expr: m[1], dialect: 'crontab', zone: 'UTC', src: 'Cloudflare Workers', forced: true };
      } else if ((m = L.match(new RegExp("cron\\(\\s*(" + EXPR + ")\\s*\\)")))) {
        hit = { expr: m[1], dialect: 'eventbridge', zone: 'UTC', src: 'EventBridge rule', forced: true };
      } else if ((m = L.match(new RegExp("@Scheduled\\s*\\([^)]*cron\\s*=\\s*\"\\s*(" + EXPR + ")\\s*\"")))) {
        hit = { expr: m[1], dialect: 'spring', zone: dz.zone, src: 'Spring @Scheduled', forced: false };
      } else if ((m = L.match(new RegExp("cronSchedule\\s*\\(\\s*\"\\s*(" + EXPR + ")\\s*\"")))) {
        hit = { expr: m[1], dialect: 'quartz', zone: dz.zone, src: 'Quartz', forced: false };
      } else if ((m = L.match(new RegExp("cron\\.schedule\\s*\\(\\s*['\"]\\s*(" + EXPR + ")\\s*['\"]")))) {
        hit = { expr: m[1], dialect: 'nodecron', zone: dz.zone, src: 'node-cron', forced: false };
      } else if ((m = L.match(/^\s*(@(?:yearly|annually|monthly|weekly|daily|midnight|hourly))\s+\S/))) {
        hit = { expr: m[1], dialect: 'crontab', zone: dz.zone, src: 'crontab', forced: false };
      } else if ((m = L.match(new RegExp("^\\s*([0-9*/,\\-]+\\s+[0-9*/,\\-]+\\s+[0-9A-Za-z*/,\\-]+\\s+[0-9A-Za-z*/,\\-]+\\s+[0-9A-Za-z*/,\\-]+)\\s+\\S")))) {
        hit = { expr: m[1], dialect: 'crontab', zone: dz.zone, src: 'crontab', forced: false };
      }
      if (hit) { hit.line = n; hit.zoneWhy = hit.forced ? 'this platform runs schedules in UTC only' : dz.why; out.push(hit); }
    }
    return out;
  }

  /* ---- the findings the regex table cannot produce ------------------------ */
  function analyzeText(text, fallbackZone, nowMs) {
    var zone0 = validZone(fallbackZone) ? fallbackZone : 'UTC';
    var now = nowMs || Date.now();
    var found = findSchedules(text, zone0), out = [], i;
    for (i = 0; i < found.length; i++) {
      var f = found[i], s = parse(f.expr, f.dialect);
      if (!s.ok) {
        // a wrong FIELD COUNT is already named by the rule table; a wrong VALUE is not
        if (s.why === 'field-value') {
          out.push({ line: f.line, sev: 'error', engine: true,
            msg: f.src + ' \u00b7 ' + f.expr + ' \u2014 this cannot be read as '
               + (DIALECT[f.dialect] || DIALECT.crontab).label + '. A field is out of range '
               + '(minute 0-59, hour 0-23, day 1-31, month 1-12), or it uses L, W or # '
               + 'which only Quartz understands. Nothing runs.' });
        }
        continue;
      }
      var runs = nextRuns(s, f.zone, now, 3);
      var perYear = daysMatching(s, now, 365) * s.hr.vals.length * s.mi.vals.length;
      if (!runs.length) {
        out.push({ line: f.line, sev: 'error', engine: true,
          msg: f.src + ' \u00b7 ' + f.expr + ' \u2014 this schedule never fires. '
             + 'No date in the next four years satisfies it.' });
        continue;
      }
      // ⛔runs may hold fewer than three: a leap-day schedule has one hit in four years.
      var when = [];
      for (var w = 0; w < runs.length; w++) when.push(runs[w].txt);
      out.push({ line: f.line, sev: 'info', engine: true,
        msg: f.src + ' \u00b7 ' + f.expr + ' \u2014 fires next at ' + when.join(', ')
           + (runs.length < 3 ? ' (and not again inside the next four years)' : '')
           + ' ' + f.zone + ' (' + f.zoneWhy + ') \u00b7 ' + perYear
           + (perYear === 1 ? ' run' : ' runs') + ' in the next 365 days.' });
      var skipped = monthsWithoutADay(s);
      if (skipped.length) {
        out.push({ line: f.line, sev: 'error', engine: true,
          msg: 'Day-of-month ' + s.dom.raw + ' never occurs in ' + skipped.join(', ')
             + '. This schedule is silently absent in ' + skipped.length
             + ' month' + (skipped.length > 1 ? 's' : '') + ' of every year.' });
      }
      if (s.dom.restricted && s.dow.restricted) {
        out.push({ line: f.line, sev: 'error', engine: true,
          msg: 'Day-of-month and day-of-week are both restricted, so POSIX cron takes the UNION: '
             + 'this fires on day ' + s.dom.raw + ' of the month OR on weekday ' + s.dow.raw
             + ', which is ' + perYear + ' runs a year, not the one you meant. '
             + 'Leave one of the two fields as *.' });
      }
      var fields = [['minute', s.mi], ['hour', s.hr], ['day-of-month', s.dom], ['month', s.mon]];
      for (var q = 0; q < fields.length; q++) {
        var fld = fields[q][1];
        if (fld.step > 1 && fld.raw.indexOf('*/') === 0 && (fld.span % fld.step) !== 0) {
          var last = fld.vals[fld.vals.length - 1];
          out.push({ line: f.line, sev: 'warn', engine: true,
            msg: 'Step ' + fld.raw + ' does not divide the ' + fields[q][0] + ' field. '
               + 'It restarts at the top of every cycle, so the gap between the last run ('
               + last + ') and the next is ' + (fld.span - last + fld.vals[0]) + ', not ' + fld.step + '.' });
        }
      }
      var hits = dstHits(s, f.zone, now, 366);
      for (var h = 0; h < hits.length && h < 2; h++) {
        out.push({ line: f.line, sev: 'error', engine: true,
          msg: hits[h].kind === 'gap'
            ? ('On ' + hits[h].at + ' the clock in ' + f.zone + ' jumps forward and this wall-clock '
               + 'time does not exist at all. Move the job outside the transition hour.')
            : ('On ' + hits[h].at + ' the clock in ' + f.zone + ' goes back and this wall-clock '
               + 'time happens twice. Whether your scheduler fires once or twice here is '
               + 'implementation-defined - move the job outside the transition hour.') });
      }
    }
    return out;
  }

  return { validZone: validZone, parse: parse, expand: expand, nextRuns: nextRuns, dstHits: dstHits,
           findSchedules: findSchedules, analyzeText: analyzeText, transitions: transitions,
           existsLocal: existsLocal, offsetMin: offsetMin, daysMatching: daysMatching,
           monthsWithoutADay: monthsWithoutADay, dayMatches: dayMatches };
})();
var RULES=[{"pattern": "-\\s*cron:\\s*['\\\"]\\s*(?:\\S+\\s+){5}\\S+\\s*['\\\"]", "flags": "", "sev": "error", "message": "GitHub Actions takes a five-field POSIX cron. Six fields is not a schedule it can read, the workflow simply never runs on time, and the Actions tab shows nothing at all. Drop the leading seconds field.", "fix": "- cron: '0 3 * * *'"}, {"pattern": "@Scheduled\\s*\\([^)]*cron\\s*=\\s*\\\"(?:[^\\s\\\"]+\\s+){4}[^\\s\\\"]+\\\"", "flags": "", "sev": "error", "message": "Spring's @Scheduled cron takes six fields, seconds first. A five-field Unix expression is rejected at startup with 'Cron expression must consist of 6 fields'. Prepend a 0 for the seconds field.", "fix": "@Scheduled(cron = \"0 0 3 * * *\")"}, {"pattern": "cron\\(\\s*(?:[^\\s)]+\\s+){4}[^\\s)]+\\s*\\)", "flags": "", "sev": "error", "message": "Amazon EventBridge cron() takes six fields and the sixth is the YEAR, not seconds: cron(minutes hours day-of-month month day-of-week year). A five-field Unix expression is rejected when the rule is created.", "fix": "cron(0 3 * * ? *)"}, {"pattern": "cron\\(\\s*[^\\s)]+\\s+[^\\s)]+\\s+(?!\\?)[^\\s)]+\\s+[^\\s)]+\\s+(?!\\?)[^\\s)]+\\s+[^\\s)]+\\s*\\)", "flags": "", "sev": "error", "message": "EventBridge will not accept a value or a * in both day-of-month and day-of-week; one of the two must be a ?. Note also that EventBridge numbers the week 1-7 with 1 = Sunday, so a Unix 1 (Monday) becomes Sunday here.", "fix": "cron(0 3 * * ? *)"}, {"pattern": "CronScheduleBuilder\\.cronSchedule\\s*\\(|org\\.quartz\\.CronExpression|new\\s+CronTrigger\\b", "flags": "", "sev": "warn", "message": "Quartz numbers the week 1-7 with 1 = Sunday, one place off from Unix cron, and it requires a ? in either day-of-month or day-of-week. A Unix expression pasted here runs on the wrong weekday rather than failing.", "fix": ""}, {"pattern": "^\\s*schedule:\\s*['\\\"]?(?:[^\\s'\\\"]+\\s+){5}[^\\s'\\\"]+", "flags": "", "sev": "error", "message": "A Kubernetes CronJob schedule is five fields. The API server rejects six, so kubectl apply fails - or, in a Helm template rendered at deploy time, the release fails and the old CronJob keeps running the old schedule.", "fix": "schedule: \"0 3 * * *\""}, {"pattern": "^\\s*concurrencyPolicy:\\s*[\\\"']?Allow", "flags": "", "sev": "warn", "message": "Allow is also what you get when the field is absent. The moment one run takes longer than the interval, the next run starts anyway and the two overlap - two exports, two invoices, two emails to the same customer. Forbid or Replace is usually what was meant.", "fix": "concurrencyPolicy: Forbid"}, {"pattern": "^\\s*startingDeadlineSeconds:\\s*(?:[1-9]\\d{5,})\\b", "flags": "", "sev": "error", "message": "A startingDeadlineSeconds window this long lets the CronJob controller count more than 100 missed starts. At 100 it stops scheduling this CronJob permanently and logs one line - 'Cannot determine if job needs to be started: too many missed start time (> 100)' - with no event, no alert and no restart.", "fix": "startingDeadlineSeconds: 200"}, {"pattern": "^\\s*suspend:\\s*true", "flags": "", "sev": "warn", "message": "A suspended CronJob keeps accumulating missed start times. Resume it after more than 100 missed intervals and the controller refuses to schedule it again at all, so a job paused over a long freeze never comes back on its own.", "fix": ""}, {"pattern": "^\\s*restartPolicy:\\s*[\\\"']?Always", "flags": "", "sev": "error", "message": "A Job pod template may only use Never or OnFailure. Always is rejected by the API server, so this CronJob is never created and nothing anywhere reports a missing schedule.", "fix": "restartPolicy: OnFailure"}, {"pattern": "^\\s*(?:CRON_TZ|TZ)\\s*=\\s*[A-Za-z]", "flags": "", "sev": "warn", "message": "CRON_TZ is a Vixie/cronie extension. busybox crond, which is the cron in every Alpine image, ignores it and runs in the container's own zone. Set the zone inside the job's command instead if the image is not running cronie.", "fix": ""}, {"pattern": "^\\s*ENV\\s+TZ\\s*[=\\s]", "flags": "", "sev": "warn", "message": "Setting TZ in an image that has no tzdata package changes nothing: both glibc and musl fall back to UTC when /usr/share/zoneinfo is missing, so every schedule in the container silently runs on UTC. Add tzdata (apk add --no-cache tzdata, or apt-get install -y tzdata).", "fix": "RUN apk add --no-cache tzdata"}, {"pattern": "cron:.*#.*\\b(?:EST|EDT|PST|PDT|CST|CDT|CET|CEST|BST|IST|KST|JST|AEST|local time)\\b", "flags": "i", "sev": "error", "message": "GitHub Actions runs every scheduled workflow on UTC and offers no per-schedule zone. A comment naming a local zone is right for half the year: on 2026-10-25 in Europe and 2026-11-01 in the United States the local hour this really fires at moves by one, and nothing in the file changes.", "fix": ""}, {"pattern": "^\\s*crons\\s*=\\s*\\[", "flags": "", "sev": "warn", "message": "Cloudflare Workers cron triggers run on UTC only; there is no per-trigger time zone. A 09:00 local job needs its expression changed by hand on every DST date, or it drifts an hour twice a year.", "fix": ""}, {"pattern": "\\\"schedule\\\"\\s*:\\s*\\\"[^\\\"]*\\*/", "flags": "", "sev": "error", "message": "Vercel runs cron jobs on UTC only, and on the Hobby plan any expression that resolves to more often than once a day fails at deploy time rather than at runtime, so the deployment itself is rejected.", "fix": ""}, {"pattern": "crontab\\s*\\(", "flags": "", "sev": "info", "message": "Celery beat runs its schedules on UTC unless the timezone setting is set. Once it is set, the beat schedule moves with DST, so a job written for 02:30 local silently does not run, or runs twice, on the transition day.", "fix": ""}, {"pattern": "^\\s*schedule:\\s*$", "flags": "", "sev": "warn", "message": "A scheduled workflow in a public repository is disabled automatically after 60 days with no repository activity. GitHub sends one email to whoever last enabled it and shows nothing in the Actions tab; the workflow just stops.", "fix": ""}, {"pattern": "-\\s*cron:\\s*['\\\"]\\s*\\*/[1-4]\\s", "flags": "", "sev": "error", "message": "GitHub Actions will not run a schedule more often than every 5 minutes. A shorter interval does not error - it is simply not honoured.", "fix": "- cron: '*/5 * * * *'"}, {"pattern": "-\\s*cron:\\s*['\\\"]\\s*0\\s+", "flags": "", "sev": "info", "message": "Every scheduled workflow on GitHub that fires at minute 0 lands in the same queue. GitHub's own documentation warns that runs during periods of high load may be delayed, and delayed runs are dropped rather than queued. Move the minute off 0.", "fix": "- cron: '7 3 * * *'"}, {"pattern": "catchup\\s*=\\s*True", "flags": "", "sev": "error", "message": "With a start_date in the past this queues one DAG run for every interval between that date and now, the moment the DAG is unpaused - hundreds of runs at once, every one of them consuming worker time and downstream API quota.", "fix": "catchup=False"}, {"pattern": "start_date\\s*=\\s*(?:[A-Za-z_.]*\\.)?now\\s*\\(|days_ago\\s*\\(", "flags": "", "sev": "error", "message": "A start_date evaluated when the file is parsed moves every time the scheduler re-reads it, so the first interval never closes and the DAG never runs. Pin a fixed datetime instead.", "fix": "start_date=datetime(2026, 1, 1)"}, {"pattern": "^\\s*OnCalendar\\s*=", "flags": "", "sev": "info", "message": "A systemd timer fires within AccuracySec of the stated time and AccuracySec defaults to 1min, so this is not an exact-second schedule. Without Persistent=true a run missed while the machine was down never happens at all.", "fix": "AccuracySec=1s\nPersistent=true"}, {"pattern": "^\\s*@reboot\\b", "flags": "", "sev": "warn", "message": "@reboot fires on every start of the cron daemon, not once per machine boot. In a container that restarts - a rolling deploy, an OOM kill, a node drain - this runs again each time.", "fix": ""}, {"pattern": "^\\s*@(?:daily|midnight)\\b", "flags": "", "sev": "info", "message": "@daily is 0 0 * * * in the daemon's own zone. Midnight is the busiest minute on every shared runner and the exact instant the date rolls over, so a job that stamps 'yesterday' is one minute of clock skew away from stamping the wrong day.", "fix": "17 0 * * *"}, {"pattern": "cron\\.schedule\\s*\\(\\s*['\\\"](?:\\S+\\s+){5}\\S+['\\\"]", "flags": "", "sev": "info", "message": "node-cron reads a six-field expression as seconds first. The identical string copied into a crontab file or a Kubernetes CronJob means something completely different, because there the first field is minutes.", "fix": ""}, {"pattern": "^\\s*[\\d*/,\\-]+\\s+[\\d*/,\\-]+\\s+(?:\\?\\s|\\S+\\s+\\?\\s|\\S+\\s+\\S+\\s+\\?\\s)", "flags": "", "sev": "error", "message": "? is a Quartz and EventBridge character. Vixie cron, cronie, busybox crond and Kubernetes CronJob all reject it, and a crontab that fails to load leaves the previous crontab running.", "fix": ""}, {"pattern": "^\\s*[\\d*/,\\-]+\\s+[\\d*/,\\-]+\\s+\\S*[LW#]\\S*\\s", "flags": "", "sev": "error", "message": "L (last), W (nearest weekday) and # (nth weekday) are Quartz extensions. POSIX cron, Kubernetes CronJob and GitHub Actions do not understand them, so 'last day of month' has to be written as a guard inside the command.", "fix": ""}, {"pattern": "^\\s*(?:[\\d*/,\\-]+\\s+){5}.*[^\\\\]%", "flags": "", "sev": "error", "message": "In a crontab an unescaped % ends the command; everything after it is fed to the job on standard input. date +%Y-%m-%d therefore runs as date + and the filename comes out empty. Escape each one as \\%.", "fix": "date +\\%Y-\\%m-\\%d"}, {"pattern": "^\\s*(?:[\\d*/,\\-]+\\s+){5}(?:python[23]?|node|npm|npx|php|ruby|java|aws|kubectl|docker|psql|pg_dump)\\b", "flags": "", "sev": "error", "message": "cron runs with a minimal PATH of /usr/bin:/bin and none of your shell profile, so a bare python, node, aws or kubectl that works in your terminal is 'command not found' at 03:00 - and the error goes to local mail that nobody reads. Use the absolute path.", "fix": "/usr/local/bin/python3"}];
function scan(t){var L=String(t).split(/\r?\n/),h=[],i,r,re;
 for(i=0;i<L.length;i++){for(var k=0;k<RULES.length;k++){r=RULES[k];
  try{re=new RegExp(r.pattern,r.flags||'')}catch(e){continue}
  if(re.test(L[i]))h.push({line:i+1,msg:r.message,sev:r.sev||'warn'})}}
 var zs=document.getElementById('zone'),z=zs?zs.value:'UTC';
 var comp=CRONX.analyzeText(t,z);
 for(var c=0;c<comp.length;c++)h.push({line:comp[c].line,msg:comp[c].msg,sev:comp[c].sev});
 h.sort(function(a,b){return a.line-b.line});
 return h}
document.getElementById('zone').onchange=function(){document.getElementById('go').click()};
document.getElementById('go').onclick=function(){
 var t=document.getElementById('src').value,o=document.getElementById('out');
 if(!t.trim()){o.innerHTML='';return}
 var h=scan(t);
 if(!h.length){o.innerHTML='<div class="ok">No schedule in this file fires at the wrong time.</div>';return}
 var s='<table><tr><th>Line</th><th>Finding</th></tr>';
 for(var i=0;i<h.length;i++){s+='<tr><td class="n">'+h[i].line+'</td><td>'+
   String(h[i].msg).replace(/&/g,'&amp;').replace(/</g,'&lt;')+'</td></tr>'}
 o.innerHTML=s+'</table>'};
document.getElementById('go').click();
</script>
</html>


<section data-yj-install="free" style="margin:2rem auto 1rem;max-width:46rem;padding:1.25rem 1.25rem;border:1px solid #cfe3d6;border-radius:12px;background:#f6fbf8;font:16px/1.6 system-ui,-apple-system,Segoe UI,sans-serif"><h2 style="margin:0 0 .4rem;font-size:1.2rem">Install free</h2><p style="margin:0 0 .8rem;color:#41505f">Free for the file open in your editor - no key, no limit. The workspace sweep and the report ask for a key.</p><a href="https://marketplace.visualstudio.com/items?itemName=ReadyStack.cron-schedule-lint" rel="noopener" data-yj-install-door="mp" onclick="try{navigator.sendBeacon('/api/ev',new Blob([JSON.stringify({t:'install',slug:'cron-schedule-lint',src:'mp',path:location.pathname})],{type:'application/json'}))}catch(e){}" style="display:inline-block;margin:0 .5rem .5rem 0;padding:.6rem 1rem;border-radius:8px;background:#0f7b3f;color:#fff;text-decoration:none;font-weight:600">VS Code Marketplace</a><a href="https://open-vsx.org/extension/readystack/cron-schedule-lint" rel="noopener" data-yj-install-door="ovsx" onclick="try{navigator.sendBeacon('/api/ev',new Blob([JSON.stringify({t:'install',slug:'cron-schedule-lint',src:'ovsx',path:location.pathname})],{type:'application/json'}))}catch(e){}" style="display:inline-block;margin:0 .5rem .5rem 0;padding:.6rem 1rem;border-radius:8px;background:#0f7b3f;color:#fff;text-decoration:none;font-weight:600">Open VSX (Cursor, VSCodium)</a><a href="https://www.npmjs.com/package/@readystack/cron-schedule-lint" rel="noopener" data-yj-install-door="npm" onclick="try{navigator.sendBeacon('/api/ev',new Blob([JSON.stringify({t:'install',slug:'cron-schedule-lint',src:'npm',path:location.pathname})],{type:'application/json'}))}catch(e){}" style="display:inline-block;margin:0 .5rem .5rem 0;padding:.6rem 1rem;border-radius:8px;background:#0f7b3f;color:#fff;text-decoration:none;font-weight:600">npm</a><a href="https://www.npmjs.com/package/@readystack/cron-schedule-lint" rel="noopener" data-yj-install-door="mcp" onclick="try{navigator.sendBeacon('/api/ev',new Blob([JSON.stringify({t:'install',slug:'cron-schedule-lint',src:'mcp',path:location.pathname})],{type:'application/json'}))}catch(e){}" style="display:inline-block;margin:0 .5rem .5rem 0;padding:.6rem 1rem;border-radius:8px;background:#0f7b3f;color:#fff;text-decoration:none;font-weight:600">MCP server (npx)</a><a href="https://hub.docker.com/r/getreadystack/cron-schedule-lint" rel="noopener" data-yj-install-door="docker" onclick="try{navigator.sendBeacon('/api/ev',new Blob([JSON.stringify({t:'install',slug:'cron-schedule-lint',src:'docker',path:location.pathname})],{type:'application/json'}))}catch(e){}" style="display:inline-block;margin:0 .5rem .5rem 0;padding:.6rem 1rem;border-radius:8px;background:#0f7b3f;color:#fff;text-decoration:none;font-weight:600">Docker Hub</a><pre style="margin:.5rem 0 0;padding:.6rem .8rem;background:#f3f5f8;border-radius:8px;font-size:.9rem;overflow:auto">ext install ReadyStack.cron-schedule-lint
npx @readystack/cron-schedule-lint &lt;file&gt;
npx @readystack/cron-schedule-lint --mcp</pre><p style="margin:.6rem 0 0;font-size:.92rem;color:#41505f"><a href="https://getreadystack.com/api/buy/cl/polar_cl_l6iN1uWt0FwWu7tBsczD0jWpP2vxFM54Wdwqb3KPi1G" rel="nofollow" style="color:#0f7b3f;text-decoration:underline">Team? One key for every ReadyStack linter, 5 seats, $149 once</a></p></section>
<section data-yj-alert="rule" style="margin:1rem auto 1.5rem;max-width:46rem;padding:1.1rem 1.25rem;border:1px solid #d9dee6;border-radius:12px;background:#fbfcfd;font:15px/1.55 system-ui,-apple-system,Segoe UI,sans-serif"><b style="font-size:1.05rem">Get the full rule table as a PDF</b><div style="opacity:.78;margin:.35rem 0 .8rem">All 29 rules Cron Schedule Lint for crontab, Kubernetes CronJob, GitHub Actions, Spring and EventBridge checks, each with its source and date, on printable pages you can hand to your team or auditor. We email it now, and once more only if a rule changes. No newsletter.</div><div style="display:flex;gap:8px;flex-wrap:wrap"><input id="yjae" type="email" placeholder="you@example.com" style="flex:1;min-width:210px;padding:11px 13px;border:1px solid rgba(140,150,165,.34);border-radius:8px;background:#fff;color:inherit"><button id="yjab" style="padding:11px 18px;border-radius:8px;border:0;cursor:pointer;background:#0f7b3f;color:#fff;font:600 15px system-ui,sans-serif">Email me the PDF</button></div><div id="yjao" hidden style="margin-top:.6rem;color:#0f7b3f">Sent. It arrives within the hour - check spam too.</div><script>(function(){var b=document.getElementById("yjab"),e=document.getElementById("yjae");if(!b||!e)return;b.addEventListener("click",function(){var v=e.value.trim();if(!/.+@.+\..+/.test(v))return;try{localStorage.setItem("yj_em","1");}catch(_){}fetch(location.origin+"/api/subscribe",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({email:v,slug:"cron-schedule-lint",lang:"en",source:"rule_table"+((b.closest&&b.closest("section")&&b.closest("section").getAttribute("data-yj-moved"))?"_at_result":"")})}).then(function(r){var o=document.getElementById("yjao");o.hidden=false;if(!r.ok){o.textContent="Could not save - please try again.";}}).catch(function(){var o=document.getElementById("yjao");o.hidden=false;o.textContent="Could not save - please try again.";});});})();</script><script>(function(){var e=document.getElementById("yjae"),s=e&&e.closest?e.closest("section"):null,o=document.getElementById("out");if(!s||!o)return;var d=false;function mv(ev){if(d)return;if(ev&&ev.target&&s.contains(ev.target))return;d=true;var b=o;while(b.parentNode&&b.parentNode!==document.body&&b.parentNode.tagName!=="MAIN"&&b.parentNode.tagName!=="ARTICLE")b=b.parentNode;if(!b.parentNode||b===s||b.contains(s))return;try{b.parentNode.insertBefore(s,b.nextSibling);s.setAttribute("data-yj-moved","1");}catch(_){}}document.addEventListener("input",mv,true);document.addEventListener("change",mv,true);})();</script></section>

<section data-yj-cta="paid" style="margin:3rem auto;max-width:46rem;padding:1.5rem 1.25rem;border:1px solid #d8dee9;border-radius:12px;background:#fbfcfe;font:16px/1.6 system-ui,-apple-system,Segoe UI,sans-serif"><h2 style="margin:0 0 .5rem;font-size:1.25rem">Get the complete version <span class="yj-price">$29</span></h2><p style="margin:.25rem 0 1rem;color:#41505f">This page is the working piece. The full pack has everything below.</p><p style="margin:0 0 1rem;color:#41505f">Names every schedule line in the file you have open that fires at the wrong hour, twice, or never at all - and prints the next three times each one really fires, in the zone that actually ap</p><p style="margin:0 0 1rem;color:#5b6b7c;font-size:.94rem">Healthchecks.io Business, the ordinary cron monitor, is $20 every month for 100 jobs - $240 a year, forever - and by design it can only tell you after a run was already missed.</p><a href="https://getreadystack.com/api/buy/cl/polar_cl_t0Tavjz6GbjmBsKk04ImLmG6Y3VDIbuP0pht50WAFPX" rel="nofollow" style="display:inline-block;padding:.7rem 1.3rem;border-radius:8px;background:#1b64f2;color:#fff;text-decoration:none;font-weight:600">Buy the full version — $29</a></section>
<!--yj-share-s101--><div style="margin:28px 0 8px;text-align:center"><button id="yjcp" type="button" style="font:inherit;padding:10px 18px;border:1px solid #8884;border-radius:10px;background:transparent;cursor:pointer">Copy link</button><button id="yjimg" type="button" style="font:inherit;padding:10px 18px;margin-left:8px;border:1px solid #8884;border-radius:10px;background:transparent;cursor:pointer">Save result as image</button></div><script>(function(){var b=document.getElementById("yjimg");if(!b)return;b.addEventListener("click",function(){var o=document.getElementById("out");var txt=(o&&(o.innerText||o.textContent)||"").trim();if(!txt){b.textContent="(no result yet)";setTimeout(function(){b.textContent="Save result as image";},1500);return;}var lines=txt.split(String.fromCharCode(10)).filter(function(x){return x.trim();}).slice(0,10);var W=1200,P=56,c=document.createElement("canvas");c.width=W;var lh=38,H=P*2+70+lines.length*lh+60;c.height=H;var x=c.getContext("2d");x.fillStyle="#1a1512";x.fillRect(0,0,W,H);x.fillStyle="#f3e9d2";x.font="600 34px system-ui,Segoe UI,Arial";var t=(document.title||"").split("|")[0].trim();x.fillText(t.slice(0,54),P,P+34);x.font="22px ui-monospace,Menlo,Consolas,monospace";x.fillStyle="#e8dcc4";for(var i=0;i<lines.length;i++){x.fillText(lines[i].slice(0,88),P,P+70+i*lh+22);}x.font="600 22px system-ui,Segoe UI,Arial";x.fillStyle="#c9a227";var d=location.hostname.replace("www.","");x.fillText(d,W-P-x.measureText(d).width,H-P+6);c.toBlob(function(bl){var u=URL.createObjectURL(bl),a=document.createElement("a");a.href=u;a.download=(t||"result").replace(/[^a-z0-9]+/gi,"-").toLowerCase()+"-"+d+".png";document.body.appendChild(a);a.click();setTimeout(function(){URL.revokeObjectURL(u);a.remove();},2000);},"image/png");});})();</script><script>(function(){var b=document.getElementById("yjcp");if(!b)return;var t=b.textContent;b.addEventListener("click",function(){var u=location.href.split("#")[0];function d(){b.textContent="Link copied";setTimeout(function(){b.textContent=t;},1800);}if(navigator.clipboard&&navigator.clipboard.writeText){navigator.clipboard.writeText(u).then(d,function(){prompt("",u);});}else{prompt("",u);}});})();</script><div class="yj-home-find"><div class="yj-lgbar"><a href="/" class="yj-lg on">EN</a><a href="/de/" class="yj-lg">DE</a><a href="/ja/" class="yj-lg">JA</a><a href="/es/" class="yj-lg">ES</a><a href="/pt/" class="yj-lg">PT</a></div><section class="yj-find"><h2>Find a tool</h2><input id="yjq" type="search" autocomplete="off" placeholder="Type a product name, format or task"><div id="yjr"></div></section><style>.yj-lgbar{position:absolute;top:14px;right:18px;display:flex;gap:6px;z-index:60}.yj-lg{font:600 12px/1 system-ui,sans-serif;padding:6px 9px;border-radius:4px;text-decoration:none;color:#8b96a3;border:1px solid rgba(140,150,165,.28)}.yj-lg:hover{color:#e8eaed;border-color:rgba(160,175,195,.6)}.yj-lg.on{color:#0e1116;background:#cfd6de;border-color:#cfd6de}.yj-find{max-width:760px;margin:34px auto 8px;padding:0 20px}.yj-find h2{font:600 20px/1.3 system-ui,sans-serif;margin:0 0 10px}#yjq{width:100%;box-sizing:border-box;padding:13px 15px;font:400 15.5px system-ui,sans-serif;border:1px solid rgba(140,150,165,.34);border-radius:8px;background:rgba(255,255,255,.04);color:inherit}#yjq:focus{outline:none;border-color:#7FD1C4}#yjr{margin-top:10px;display:flex;flex-direction:column;gap:6px}#yjr a{display:flex;justify-content:space-between;gap:12px;padding:11px 14px;border:1px solid rgba(140,150,165,.22);border-radius:6px;text-decoration:none;font:400 14.5px/1.4 system-ui,sans-serif;color:inherit}#yjr a:hover{border-color:#7FD1C4}#yjr .pr{font:600 13px system-ui,sans-serif;color:#5fbf9a;white-space:nowrap}</style><script>(function(){var q=document.getElementById("yjq"),r=document.getElementById("yjr"),D=null;function load(){if(D)return Promise.resolve(D);return fetch("/search-index.json").then(function(x){return x.json();}).then(function(j){D=j;return j;}).catch(function(){D=[];return D;});}function esc(t){return String(t).replace(/[&<>"]/g,function(c){return {"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c];});}function run(){var v=q.value.trim().toLowerCase();if(v.length<2){r.innerHTML="";return;}load().then(function(j){var w=v.split(/\s+/),o=[];for(var i=0;i<j.length&&o.length<12;i++){var t=j[i].t.toLowerCase();var ok=true;for(var k=0;k<w.length;k++){if(t.indexOf(w[k])<0){ok=false;break;}}if(ok)o.push(j[i]);}r.innerHTML=o.length?o.map(function(x){return "<a href=\""+esc(x.u)+"\">"+"<span>"+esc(x.t)+"</span>"+(x.p?"<span class=\"pr\">$"+x.p+"</span>":"")+"</a>";}).join(""):"";});}q.addEventListener("input",run);q.addEventListener("focus",load);})();</script></div>


<script data-yj="yj-view-beacon">(function(){try{var p=location.pathname;var k='yjv:'+p;if(sessionStorage.getItem(k))return;sessionStorage.setItem(k,'1');fetch('/api/ev',{method:'POST',keepalive:true,headers:{'Content-Type':'application/json'},body:JSON.stringify({path:p,lang:"en",ref:(document.referrer||''),src:(function(){try{return (new URLSearchParams(location.search).get('ref')||'').replace(/[^a-z_]/g,'').slice(0,12)}catch(e){return ''}})()})}).catch(function(){});}catch(e){}})();</script>

<script data-yj="yj-use-beacon">(function(){try{var o=document.getElementById('out');if(!o)return;var p=location.pathname,k='yju:'+p,sent=false;function fire(){if(sent||sessionStorage.getItem(k))return;if(!(o.textContent||'').replace(/\s+/g,'').length)return;sent=true;sessionStorage.setItem(k,'1');fetch('/api/ev',{method:'POST',keepalive:true,headers:{'Content-Type':'application/json'},body:JSON.stringify({path:p,lang:"en",t:'use',src:(function(){try{return (new URLSearchParams(location.search).get('ref')||'').replace(/[^a-z_]/g,'').slice(0,12)}catch(e){return ''}})()})}).catch(function(){});}var mo=new MutationObserver(function(){fire();if(sent)mo.disconnect();});mo.observe(o,{childList:true,characterData:true,subtree:true});}catch(e){}})();</script>
<section data-yj="yj-facts-v1" style="max-width:920px;margin:48px auto 0;padding:0 20px;font-family:system-ui,sans-serif"><style>.yj-article p{font:400 15.5px/1.7 system-ui,sans-serif;color:#26313b;margin:0 0 12px;max-width:72ch}.yj-article h2,.yj-article h3,.yj-article h4{font:600 17px/1.35 system-ui,sans-serif;color:#1b1f24;margin:22px 0 8px}.yj-article table{border-collapse:collapse;margin:10px 0 16px;font:14px/1.45 system-ui,sans-serif}.yj-article th,.yj-article td{border:1px solid #e2e6ea;padding:6px 10px;text-align:left}.yj-article th{background:#f4f6f8}.yj-article pre{background:#0f1720;color:#e6edf3;padding:12px 14px;border-radius:8px;overflow:auto;font:13px/1.5 ui-monospace,Menlo,monospace}.yj-article code{font:13px ui-monospace,Menlo,monospace;background:#f1f3f5;padding:1px 4px;border-radius:3px}.yj-article pre code{background:none;padding:0}.yj-article ul,.yj-article ol{font:15px/1.6 system-ui,sans-serif;color:#26313b}</style><p style="font:400 13px/1.5 system-ui,sans-serif;color:#6b7680;margin:0 0 14px"><time datetime="2026-09-10">Updated 2026-09-10</time> · ReadyStack</p><script type="application/ld+json">{"@context":"https://schema.org","@type":"WebPage","dateModified":"2026-09-10","author":{"@type":"Organization","name":"ReadyStack"}}</script><h2 style="font:600 21px/1.3 system-ui,sans-serif;margin:0 0 6px;color:#1b1f24">Worked example</h2><p style="font:400 14.5px/1.5 system-ui,sans-serif;margin:0 0 16px;color:#5b6570">Real numbers from this tool, line by line.</p><div class="yj-article"><p><img src="https://getreadystack.com/img/promo/sku34034_result_card.jpg" alt="Five schedules that never fire on time, and not one of them is a syntax error" loading="lazy" style="max-width:100%;border-radius:6px"></p>
<p><strong>Five schedules that never fire on time: that is what came back from one crontab a platform engineer in Berlin had already reviewed and signed off.</strong> <code>crontab -e</code> accepted every line. <code>kubectl apply</code> accepted every line. The workflow file is valid YAML. Nothing errors, nothing alerts, nothing logs - the job simply is not there on the morning somebody goes looking for it.</p>
<pre><code>0 0 31 * *   /opt/billing/monthly_invoice.sh
30 1 * * *   /opt/etl/nightly_rollup.sh
0 0 1 * 1    /opt/reports/board_pack.sh
0 2 * * *    /opt/jobs/reconcile.py
- cron: &#x27;0 3 * * *&#x27;   # 3am EST</code></pre>
<p>Read line by line, with a calendar and a time zone database instead of a syntax parser:</p>
<pre><code>0 0 31 * *    day 31 never occurs in February, April, June, September or November
              -&gt; 7 runs in the next 365 days, not 12. Five monthly invoice runs are missing.
30 1 * * *    01:30 America/New_York happens TWICE on 2026-11-01
              -&gt; whether it runs once or twice there is implementation-defined.
0 0 1 * 1     day-of-month and day-of-week are BOTH set, so POSIX cron takes the union
              -&gt; the 1st of the month OR every Monday = 62 runs a year, not 12.
0 2 * * *     02:00 America/New_York does not exist at all on 2027-03-14
              -&gt; the clock goes 01:59 -&gt; 03:00. There is no 02:00 to fire at.
0 3 * * *     GitHub Actions runs schedules on UTC only, whatever the comment says
              -&gt; 03:00 UTC is 23:00 EDT today and 22:00 EST from 2026-11-01.</code></pre>
<h3>The union rule is in the POSIX specification, and almost nobody knows it</h3>
<p>If both the day-of-month and the day-of-week field are restricted, cron fires when <strong>either</strong> matches - not when both do. Paul Vixie acknowledged the behaviour and declined to change it. So <code>0 0 1 * 1</code>, which reads like &quot;the first Monday of the month&quot;, is really &quot;the first of the month, plus every Monday&quot;. Sixty-two board packs a year instead of twelve. In pure POSIX cron the intersection cannot be expressed at all; it has to be a guard inside the command.</p>
<h3>The date nobody put in the code</h3>
<p><code>30 1 * * *</code> is a perfectly ordinary nightly rollup. On <strong>2026-11-01</strong> the United States leaves daylight saving time and the clock in America/New_York goes back at 02:00, so 01:00 to 01:59 happens twice that morning. Europe already did it a week earlier, on <strong>2026-10-25</strong>. And in the southern hemisphere the sign flips: in Pacific/Auckland the clock jumps <strong>forward</strong> on <strong>2026-09-27</strong>, so a payroll cutoff written for 02:30 has no 02:30 to run at that day.</p>
<p>None of those dates is in the repository. Nobody edited anything. The calendar moved.</p>
<h3>Six fields, five fields, and the year</h3>
<p>The same five numbers mean different things depending on who reads them.</p>
<ul><li><strong>Spring <code>@Scheduled</code></strong> takes six fields, seconds first. A five-field Unix expression is refused</li></ul>
<p>at startup with <code>Cron expression must consist of 6 fields</code>.</p>
<ul><li><strong>Quartz</strong> also takes six (or seven), and numbers the week 1-7 with <strong>1 = Sunday</strong>. A Unix <code>1</code></li></ul>
<p>means Monday. Pasted across, the job runs on the wrong day and never complains.</p>
<ul><li><strong>Amazon EventBridge <code>cron()</code></strong> takes six fields and the sixth is the <strong>year</strong>, not seconds - and</li></ul>
<p>it refuses a value or a <code>*</code> in both day-of-month and day-of-week; one of the two must be a <code>?</code>.</p>
<ul><li><strong>GitHub Actions</strong>, <strong>Kubernetes CronJob</strong>, <strong>Vercel</strong> and <strong>Cloudflare Workers</strong> all take five,</li></ul>
<p>and all four run on <strong>UTC</strong> with no per-schedule zone (a CronJob can be given <code>spec.timeZone</code>, stable since Kubernetes 1.27; the other three cannot).</p>
<h3>Why the assistant cannot see it</h3>
<p>An assistant writes a cron expression from the shape of the sentence you gave it. It has no calendar, so it cannot know that day 31 is absent from five months. It has no clock, so it cannot know that the wall time it just wrote occurs twice on a specific Sunday in your zone. And it answers &quot;every Monday the 1st&quot; with <code>0 0 1 * 1</code> because that is what the fields look like they say.</p>
<p>Nor does the failure surface later. A Kubernetes CronJob that misses more than 100 start times stops scheduling <strong>permanently</strong> and logs one line - *Cannot determine if job needs to be started: too many missed start time (&gt; 100)* - with no event and no alert. A scheduled workflow in a public GitHub repository is disabled automatically after 60 days without repository activity; GitHub sends a single email and shows nothing in the Actions tab. A crontab command containing an unescaped <code>%</code> silently loses everything after it, because in a crontab <code>%</code> ends the command.</p>
<h3>What a cron monitor does not tell you</h3>
<p>A monitor answers a different question. It watches a job it already knows about and tells you, afterwards, that the ping did not arrive. It cannot tell you that the expression you are about to merge has no 30th of February in it. Healthchecks.io Business is $20 every month for 100 jobs - $240 a year, forever - and it still only speaks after the run was already missed.</p>
<h3>What it is</h3>
<p>A VS Code and Open VSX extension, and a free one-page web version that runs the identical <strong>29 rules and the identical calendar engine</strong>. Open a crontab, a CronJob, a workflow file, a <code>wrangler.toml</code>, a <code>vercel.json</code>, a systemd timer, a beat schedule or a <code>@Scheduled</code> annotation, and every schedule line comes back with the next three instants it really fires at, how many times it fires in a year, and the exact dates when that wall-clock time does not exist or happens twice.</p>
<p>Checking the file you have open is free and complete - all 29 rules, no key, no limit, no watermark, nothing behind a signup. The licence covers a different job: every schedule in the repository merged into one calendar, that calendar exported as CSV, JSON or HTML for a change review, and machine output that exits non-zero so a pipeline can stop a merge that adds a schedule which never fires. $29 once, one licence key per person or team seat, 7-day full refund.</p></div><h2 style="font:600 19px/1.3 system-ui,sans-serif;margin:30px 0 10px;color:#1b1f24">15 seconds — what it actually does</h2><video controls muted playsinline preload="none" poster="/img/promo/sku34034_result_card.jpg" style="width:100%;max-width:360px;border-radius:10px;display:block;margin:0 auto"><source src="/media/sku34034_short.mp4" type="video/mp4"></video><h2 style="font:600 21px/1.3 system-ui,sans-serif;margin:34px 0 6px;color:#1b1f24">Questions people ask</h2><details style="border-bottom:1px solid #e6e9ed;padding:10px 0"><summary style="cursor:pointer;font:600 15px/1.4 system-ui,sans-serif;color:#1b1f24">What does Cron Schedule Lint actually do?</summary><p style="margin:8px 0 2px;font:400 14.5px/1.6 system-ui,sans-serif;color:#33404b">It reads the crontab, Kubernetes CronJob, GitHub Actions workflow, Spring annotation, wrangler.toml or systemd timer you have open and, for every schedule line, prints the next three instants it really fires at, how many times it fires in a year, and the exact dates when that wall-clock time does not exist or happens twice. Twenty-nine rules ship with it.</p></details><details style="border-bottom:1px solid #e6e9ed;padding:10px 0"><summary style="cursor:pointer;font:600 15px/1.4 system-ui,sans-serif;color:#1b1f24">Who is this for?</summary><p style="margin:8px 0 2px;font:400 14.5px/1.6 system-ui,sans-serif;color:#33404b">Backend, platform and SRE engineers who own the schedules nobody watches: the monthly invoice run, the nightly rollup, the export a finance team waits for. It is aimed at the person reviewing a schedule an assistant generated, who will be asked three weeks later why November&#x27;s invoices never went out.</p></details><details style="border-bottom:1px solid #e6e9ed;padding:10px 0"><summary style="cursor:pointer;font:600 15px/1.4 system-ui,sans-serif;color:#1b1f24">Why can I not just check the expression on a cron explainer site?</summary><p style="margin:8px 0 2px;font:400 14.5px/1.6 system-ui,sans-serif;color:#33404b">Because a syntax explainer holds no calendar and no time zone. It cannot tell you that 0 0 31 * * skips five months of every year, that 01:30 in America/New_York happens twice on 2026-11-01, or that 02:30 in Pacific/Auckland does not exist at all on 2026-09-27. Those answers need the time zone database, not a parser.</p></details><details style="border-bottom:1px solid #e6e9ed;padding:10px 0"><summary style="cursor:pointer;font:600 15px/1.4 system-ui,sans-serif;color:#1b1f24">What is free and what needs a licence key?</summary><p style="margin:8px 0 2px;font:400 14.5px/1.6 system-ui,sans-serif;color:#33404b">Checking the file you have open is free and complete: all twenty-nine rules run, every schedule gets its real run times and its transition dates, and nothing is hidden, watermarked or time-limited. The licence covers a different job - every schedule in the repository merged into one calendar, a CSV, JSON or HTML export, and machine output a CI step can fail on.</p></details><details style="border-bottom:1px solid #e6e9ed;padding:10px 0"><summary style="cursor:pointer;font:600 15px/1.4 system-ui,sans-serif;color:#1b1f24">How does $29 compare with a cron monitor?</summary><p style="margin:8px 0 2px;font:400 14.5px/1.6 system-ui,sans-serif;color:#33404b">The extension is 29 US dollars once,. Healthchecks.io Business, the ordinary cron monitor, is $20 every month for 100 jobs, which is $240 a year, forever - and by design it can only alert you after a run was already missed. This one answers before the schedule ships.</p></details><details style="border-bottom:1px solid #e6e9ed;padding:10px 0"><summary style="cursor:pointer;font:600 15px/1.4 system-ui,sans-serif;color:#1b1f24">Why not just ask ChatGPT or another AI chat?</summary><p style="margin:8px 0 2px;font:400 14.5px/1.6 system-ui,sans-serif;color:#33404b">A general AI chat answers from training data with a cutoff date, cannot read your repository and names no rule version. Crontab Lint - cron expression checker (crontab, K8s CronJob checks the file you open against 29 rules from a rule set dated 2026-09-20, and points at the exact line. For a filing, an audit or a client you need that dated result on your own files.</p></details><script type="application/ld+json" data-yj="yj-faq-ld">{"@context": "https://schema.org", "@type": "FAQPage", "mainEntity": [{"@type": "Question", "name": "What does Cron Schedule Lint actually do?", "acceptedAnswer": {"@type": "Answer", "text": "It reads the crontab, Kubernetes CronJob, GitHub Actions workflow, Spring annotation, wrangler.toml or systemd timer you have open and, for every schedule line, prints the next three instants it really fires at, how many times it fires in a year, and the exact dates when that wall-clock time does not exist or happens twice. Twenty-nine rules ship with it."}}, {"@type": "Question", "name": "Who is this for?", "acceptedAnswer": {"@type": "Answer", "text": "Backend, platform and SRE engineers who own the schedules nobody watches: the monthly invoice run, the nightly rollup, the export a finance team waits for. It is aimed at the person reviewing a schedule an assistant generated, who will be asked three weeks later why November's invoices never went out."}}, {"@type": "Question", "name": "Why can I not just check the expression on a cron explainer site?", "acceptedAnswer": {"@type": "Answer", "text": "Because a syntax explainer holds no calendar and no time zone. It cannot tell you that 0 0 31 * * skips five months of every year, that 01:30 in America/New_York happens twice on 2026-11-01, or that 02:30 in Pacific/Auckland does not exist at all on 2026-09-27. Those answers need the time zone database, not a parser."}}, {"@type": "Question", "name": "What is free and what needs a licence key?", "acceptedAnswer": {"@type": "Answer", "text": "Checking the file you have open is free and complete: all twenty-nine rules run, every schedule gets its real run times and its transition dates, and nothing is hidden, watermarked or time-limited. The licence covers a different job - every schedule in the repository merged into one calendar, a CSV, JSON or HTML export, and machine output a CI step can fail on."}}, {"@type": "Question", "name": "How does $29 compare with a cron monitor?", "acceptedAnswer": {"@type": "Answer", "text": "The extension is 29 US dollars once,. Healthchecks.io Business, the ordinary cron monitor, is $20 every month for 100 jobs, which is $240 a year, forever - and by design it can only alert you after a run was already missed. This one answers before the schedule ships."}}, {"@type": "Question", "name": "Why not just ask ChatGPT or another AI chat?", "acceptedAnswer": {"@type": "Answer", "text": "A general AI chat answers from training data with a cutoff date, cannot read your repository and names no rule version. Crontab Lint - cron expression checker (crontab, K8s CronJob checks the file you open against 29 rules from a rule set dated 2026-09-20, and points at the exact line. For a filing, an audit or a client you need that dated result on your own files."}}]}</script><div id="yj-ask" style="margin:36px 0 0;padding:18px 18px 16px;border:1px solid #e2e6ea;border-radius:10px;background:#fbfbfc"><h2 style="font:600 19px/1.3 system-ui,sans-serif;margin:0 0 4px;color:#1b1f24">Ask about this tool</h2><p style="font:400 13.5px/1.5 system-ui,sans-serif;margin:0 0 12px;color:#5b6570">One question, answered by the person who built it. Your email only if you want the answer sent.</p><form onsubmit="return yjAsk(event)" style="display:grid;gap:8px"><textarea name="question" required minlength="10" maxlength="2000" rows="3" placeholder="Your question" style="font:14px/1.5 system-ui,sans-serif;padding:10px;border:1px solid #cfd6dd;border-radius:6px"></textarea><div style="display:flex;gap:8px;flex-wrap:wrap"><input type="email" name="email" placeholder="Email (optional)" style="flex:1;min-width:200px;font:14px system-ui,sans-serif;padding:9px 10px;border:1px solid #cfd6dd;border-radius:6px"><button type="submit" style="font:600 14px system-ui,sans-serif;padding:9px 18px;border:0;border-radius:6px;background:#1b1f24;color:#fff;cursor:pointer">Send</button></div><p id="yj-ask-msg" style="font:13px system-ui,sans-serif;margin:2px 0 0;color:#0a6238;min-height:1em"></p></form><script>function yjAsk(e){e.preventDefault();var f=e.target,m=document.getElementById("yj-ask-msg");m.style.color="#0a6238";fetch(location.origin+"/api/ask",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({sku:34034,slug:"cron-schedule-lint",lang:"en",question:f.question.value,email:f.email.value,page:location.pathname})}).then(function(r){if(!r.ok)throw 0;m.textContent="Thanks \u2014 noted. Answers land on this page and, if you left an email, in your inbox.";f.question.value="";}).catch(function(){m.style.color="#a8551f";m.textContent="Could not send. Please try again in a minute.";});return false;}</script></div></section><section class="yj-related" style="max-width:920px;margin:56px auto 40px;padding:0 20px"><h2 style="font:600 21px/1.3 system-ui,sans-serif;margin:0 0 6px;color:#1b1f24">Same job, next tool</h2><p style="font:400 14.5px/1.5 system-ui,sans-serif;margin:0 0 20px;color:#5b6570">Free in the browser. The full version is a one-time licence.</p><div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:14px"><a class="yj-rel-c" href="https://getreadystack.com/tools/ssdf-attestation-workflow-audit"><span class="yj-rel-t">SSDF Attestation Audit for GitHub Actions</span><span class="yj-rel-p">$29</span></a><a class="yj-rel-c" href="https://getreadystack.com/tools/project-dashboard-template-tableau"><span class="yj-rel-t">Project Dashboard Template for Tableau — PMO Schedule, Budget &amp; Risk (.twb, co</span><span class="yj-rel-p">$120</span></a><a class="yj-rel-c" href="https://getreadystack.com/tools/actions-deprecation-lint-2026"><span class="yj-rel-t">GitHub Actions Deprecation Lint: the 2026 runner and action EOL dates</span><span class="yj-rel-p">$29</span></a><a class="yj-rel-c" href="https://getreadystack.com/tools/webflow-restaurant-template"><span class="yj-rel-t">Webflow Restaurant Template — delivered as self-contained HTML you own: 26 pag</span><span class="yj-rel-p">$49</span></a><a class="yj-rel-c" href="https://getreadystack.com/tools/cloud-cost-landmine-lint"><span class="yj-rel-t">Cloud Cost Landmine Lint for Terraform, CloudFormation and Kubernetes</span><span class="yj-rel-p">$29</span></a><a class="yj-rel-c" href="https://getreadystack.com/tools/crypto-export-notice-lint"><span class="yj-rel-t">Crypto Export Notice Lint (EAR 742.15)</span><span class="yj-rel-p">$29</span></a></div><style>.yj-rel-c{display:flex;flex-direction:column;gap:8px;padding:15px 16px;border:1px solid #e2e6ea;border-radius:6px;text-decoration:none;background:#fff}.yj-rel-c:hover{border-color:#b9c2cb;background:#fafbfc}.yj-rel-t{font:500 14.5px/1.42 system-ui,sans-serif;color:#1b1f24}.yj-rel-p{font:600 13px system-ui,sans-serif;color:#0a6238}</style></section>