Skip to content

feat: add cron-doctor — diagnose cron death-traps before they ship#757

Merged
sickn33 merged 2 commits into
sickn33:mainfrom
takeaseatventure:feat/cron-doctor-skill
Jun 27, 2026
Merged

feat: add cron-doctor — diagnose cron death-traps before they ship#757
sickn33 merged 2 commits into
sickn33:mainfrom
takeaseatventure:feat/cron-doctor-skill

Conversation

@takeaseatventure

@takeaseatventure takeaseatventure commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

What this adds

A new DevOps skill: cron-doctor - catches the five silent cron failure modes that pass syntax validation but break in production.

The five death-traps it catches

  1. Impossible dates (0 0 30 2 *) - valid syntax, never fires (Feb has no 30th)
  2. OR-semantics (0 0 1,15 * 1) - the most misunderstood cron rule; fires ~6x/month instead of ~2x
  3. Midnight spikes (0 0 * * *) - every job competes at 00:00
  4. Uneven step drift (*/7 * * * *) - 60 isn't divisible by 7, so intervals drift
  5. Leap-year Feb 29 (0 0 29 2 *) - fires once every 4 years

What's included

  • skills/cron-doctor/SKILL.md - full skill following the canonical template
  • skills/cron-doctor/scripts/cron-engine.js - zero-dependency validation engine
  • skills/cron-doctor/scripts/cli.js - minimal CLI wrapper
  • README.md - community source credit for takeaseatventure/devops-skills

Attribution

  • Source repo: takeaseatventure/devops-skills (MIT)
  • Original work; no external content adapted beyond the declared source repository

Quality Bar Checklist ✅

All applicable items must be checked before merging.

  • Standards: I have read docs/contributors/quality-bar.md and docs/contributors/security-guardrails.md.
  • Metadata: The SKILL.md frontmatter is valid (checked with npm run validate).
  • Risk Label: I have assigned the correct risk: tag (none, safe, critical, offensive, or unknown for legacy/unclassified content).
  • Triggers: The "When to use" section is clear and specific.
  • Limitations: The skill includes a ## Limitations (or equivalent accepted constraints) section.
  • Security: This is not an offensive skill; risk: safe and read-only behavior are documented.
  • Safety scan: Command guidance is local/read-only; maintainer validation will run the repository security/docs gates before release.
  • Automated Skill Review: If this PR changes SKILL.md, I checked the skill-review GitHub Actions result and addressed any actionable feedback.
  • Manual Logic Review: If this PR changes SKILL.md or risky guidance, I manually reviewed the logic, safety, failure modes, and risk: label instead of relying on automated checks alone.
  • Local Test: I have verified the skill works locally.
  • Repo Checks: Maintainer checks include README source-credit validation and release validation.
  • Source-Only PR: I did not manually include generated registry artifacts (CATALOG.md, skills_index.json, data/*.json) in this PR.
  • Credits: I have added the source credit in README.md.
  • License provenance: The skill declares license: and license_source: for the upstream MIT source.
  • Maintainer Edits: I enabled Allow edits from maintainers on the PR.

Adds a new DevOps skill that catches the five silent cron failure modes:
impossible dates (never fires), OR-semantics (fires too often), midnight
spikes, uneven step drift, and leap-year February 29.

Includes a zero-dependency validation engine (cron-engine.js, 638 lines)
with a CLI wrapper, plus a SKILL.md following the canonical template.

Source: takeaseatventure/devops-skills (MIT)

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f35298f7ae

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

script:

```bash
0 0 * * 1 [ "$(date +%d)" = "01" -o "$(date +%d)" = "15" ] && your-command

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Escape crontab percent signs in the guard

In a real Vixie-style crontab command, unescaped % characters are treated specially, so this copy-paste fix using date +%d is split before date receives the format string. For the crontab scenario this skill explicitly targets, users trying to implement the documented Monday-and-1st/15th guard will get a broken command unless the % signs are escaped.

Useful? React with 👍 / 👎.

if (r.description) console.log('description: ' + r.description);
if (r.warnings && r.warnings.length) {
console.log('warnings:');
r.warnings.forEach((w) => console.log(' - ' + w));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Print warning messages instead of objects

When validate() emits a warning, such as the OR-semantics warning for 0 0 1,15 * 1, this concatenates the warning object directly and the documented CLI prints - [object Object]. That hides the actual trap message from users running node scripts/cli.js validate ..., while observations and suggestions already print their .message fields correctly.

Useful? React with 👍 / 👎.

Comment on lines +94 to +95
const n = parseInt(token, 10);
if (!isNaN(n)) return n;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject non-numeric suffixes in numeric fields

Because parseInt accepts a numeric prefix, malformed fields such as 0 0 1x * * are considered valid and scheduled as day 1 instead of being rejected. Since this engine is advertised as syntax validation before shipping cron expressions, typos with trailing characters can silently pass validation and produce a different schedule.

Useful? React with 👍 / 👎.

d.setSeconds(0, 0);
d = new Date(d.getTime() + 60000);

let maxScan = 600000; // ~416 days ceiling

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Search beyond one year for rare next runs

For sparse schedules whose next occurrence is more than about 416 days away, this ceiling is exhausted before any match; for example, 0 0 29 2 * from June 26, 2026 should next fire on February 29, 2028, but nextRuns returns an empty list. That makes the CLI's next-run check misleading for the leap-day trap this skill is meant to explain.

Useful? React with 👍 / 👎.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 9 potential issues.

Open in Devin Review

if (r.description) console.log('description: ' + r.description);
if (r.warnings && r.warnings.length) {
console.log('warnings:');
r.warnings.forEach((w) => console.log(' - ' + w));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 CLI prints warnings as unreadable "[object Object]" instead of the actual warning text

Warnings are printed by concatenating the raw object to a string (' - ' + w at skills/cron-doctor/scripts/cli.js:47) instead of accessing its .message property, so every CLI warning displays as - [object Object].

Impact: Users running node cli.js validate see gibberish instead of the actual warning message.

Inconsistency with observation/suggestion handlers

The observations handler at skills/cron-doctor/scripts/cli.js:51 and the suggestions handler at skills/cron-doctor/scripts/cli.js:55 both correctly access .level and .message on each item. The warnings handler on line 47 skips this and prints the object directly. All three arrays contain objects with { level, message } shape, as seen in skills/cron-doctor/scripts/cron-engine.js:423-426 and similar push sites.

Suggested change
r.warnings.forEach((w) => console.log(' - ' + w));
r.warnings.forEach((w) => console.log(' [' + (w.level || 'info') + '] ' + w.message));
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Month descriptions produce garbled "in in February" text due to duplicated preposition

The month text is built with an "in" prefix ('in ' + listJoin(...) at skills/cron-doctor/scripts/cron-engine.js:259), then callers prepend a second "in" when appending it (' in ' + monthDesc.text at line 221), so descriptions read "on day-of-month 30 in in Feb" instead of "on day-of-month 30 in Feb".

Impact: Human-readable descriptions for any month-restricted cron expression contain doubled "in in" making them look broken.

Three call sites produce the double preposition

The describeFieldMonth function at skills/cron-doctor/scripts/cron-engine.js:259 returns text starting with "in ". Lines 221, 224, and 227 all append ' in ' + monthDesc.text, producing "in in Feb". The SKILL.md example at skills/cron-doctor/SKILL.md:159 even documents this broken output: "At 00:00, on day-of-month 30 in in FEB". The fix is to remove the "in " prefix from describeFieldMonth's text and leave the callers' prefix intact, or vice versa.

Suggested change
return { restricted: true, text: 'in ' + listJoin(vals.map(v => capitalize(MONTH_NAMES[v - 1]))) };
return { restricted: true, text: listJoin(vals.map(v => capitalize(MONTH_NAMES[v - 1]))) };
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

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)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Impossible-date detection misses February 30th — the tool's own lead example

The impossible-date check only looks for day 31 (domValues.includes(31) at skills/cron-doctor/scripts/cron-engine.js:433), so the tool's primary documented death-trap example 0 0 30 2 * (February 30th, which never fires) produces no targeted impossible-date warning.

Impact: Users validating impossible dates like February 30th get no diagnostic explaining why the schedule never fires.

Validation gap for day 30 in February and other impossible combos

The SKILL.md at skills/cron-doctor/SKILL.md:80-85 uses 0 0 30 2 * as the lead example of death-trap #1. When this expression is validated, domValues is [30], so domValues.includes(31) is false and the entire impossible-date check is skipped. The separate February 29 check at skills/cron-doctor/scripts/cron-engine.js:480 also doesn't match because dom is 30, not 29.

The estimateFrequency function will still report "never (impossible schedule)" as an observation, but no specific warning names the root cause (impossible date). The check should be generalized to detect any day-of-month that exceeds the maximum days in the constrained month(s), e.g. day 30 in February (max 29), day 31 in April (max 30), etc.

Prompt for agents
The impossible-date detection in validate() at cron-engine.js:429-451 only checks for day 31 via domValues.includes(31). It needs to be generalized to detect any day-of-month value that exceeds the maximum number of days in the constrained months.

The max days per month are: Jan=31, Feb=29, Mar=31, Apr=30, May=31, Jun=30, Jul=31, Aug=31, Sep=30, Oct=31, Nov=30, Dec=31. For each constrained month, check whether any of the specified dom values exceed that month's maximum. For example, 0 0 30 2 * should warn that February has at most 29 days so day 30 is impossible.

This should replace or extend the existing domValues.includes(31) check. The February 29 leap-year check at line 476 can remain as a separate, more specific warning about leap-year behavior.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


let description = '';
if (count >= 525600) description = 'every minute';
else if (count >= 500000) description = 'multiple times per minute';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Frequency label says "multiple times per minute" for a schedule that can only fire once per minute

The frequency description labels a count between 500,000 and 525,599 as "multiple times per minute" (skills/cron-doctor/scripts/cron-engine.js:538), but standard cron can fire at most once per minute, so this label is factually wrong for any reachable count in that range.

Impact: Users see a misleading frequency description that implies a firing rate that is physically impossible for 5-field cron.

How the impossible label gets triggered

The estimateFrequency function at skills/cron-doctor/scripts/cron-engine.js:523-550 iterates minute-by-minute through a sample year, so count can be at most 525,600 (365×24×60). Line 537 catches count=525,600 as "every minute." Line 538 catches 500,000–525,599 as "multiple times per minute." But since each minute is only counted once, counts in this range actually mean "almost every minute" (e.g., 0-58 * * * * = 516,060 fires/year = once per minute except at :59). The label should say something like "nearly every minute" instead.

Suggested change
else if (count >= 500000) description = 'multiple times per minute';
else if (count >= 500000) description = 'nearly every minute';
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +336 to +337
if (!domAny && !dowAny) return domMatch || dowMatch; // OR semantics
return domMatch && dowMatch;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: OR semantics for day-of-month + day-of-week correctly implemented in matches()

The matches function at skills/cron-doctor/scripts/cron-engine.js:336 correctly uses domMatch || dowMatch when both dom and dow are restricted, matching standard Vixie cron OR semantics. This is one of the skill's key "death traps" and is correctly handled in the engine despite being counter-intuitive. The corresponding validate() check at line 422 also correctly flags this as a warning.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +591 to +595
function ordinal(n) {
const s = ['th', 'st', 'nd', 'rd'];
const v = n % 100;
return n + (s[(v - 20) % 10] || s[v] || s[0]);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: The ordinal() helper uses JavaScript negative modulo but works for the actual input range

The ordinal function at skills/cron-doctor/scripts/cron-engine.js:591-595 uses (v - 20) % 10 which produces negative results in JavaScript for v < 20 (e.g., v=1 gives -19%10 = -9). Negative array indices return undefined in JS, which falls through to the s[v] fallback. This works correctly for inputs 1-5 (the only values used via # parsing), but would produce wrong results for values like 11, 12, 13 (which should get 'th' but actually do via the fallback chain). Since the function is only called from describeFieldDow for nth values 1-5, it works in practice.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +523 to +550
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;
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: estimateFrequency scans ~375 days of minutes which is computationally expensive

The estimateFrequency function at skills/cron-doctor/scripts/cron-engine.js:523-550 iterates up to 540,000 times (one per minute), creating a new Date object each iteration. Similarly, nextRuns at line 301 has a 600,000 iteration ceiling. For a simple validation call this is significant computation (~525K iterations and Date allocations for the frequency estimate alone). This is acceptable for a CLI tool or occasional validation but could be problematic if validate() is called in a hot loop or on a server handling many requests. Not a bug per se, but worth noting for anyone considering using this engine at scale.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


// Parse + describe -> returns { text, error, parsed }
const d = describe('0 0 30 2 *');
console.log(d.text); // "At 00:00, on day-of-month 30 in in FEB"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 SKILL.md documents the buggy double-in output as if it were correct

Line 159 of the SKILL.md shows // "At 00:00, on day-of-month 30 in in FEB" as the expected output of describe('0 0 30 2 *'). This documents the actual (buggy) behavior of the engine rather than the intended behavior. When the double-in bug in describeFieldMonth is fixed, this example output in the docs will also need updating to remove the duplicate preposition.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

safe(() => {
const r = cron.validate(expr);
console.log('valid: ' + r.valid);
if (r.description) console.log('description: ' + r.description);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: validate() returns description as a string, but CLI prints it directly which works

At skills/cron-doctor/scripts/cron-engine.js:515, the validate() return includes description: desc where desc is the string result of describeParsed(). In skills/cron-doctor/scripts/cli.js:44, r.description is logged directly via string concatenation. This works because desc is already a string. However, if describeParsed were ever changed to return an object, this would break silently. Currently correct but tightly coupled.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 3 new potential issues.

Open in Devin Review


let description = '';
if (count >= 525600) description = 'every minute';
else if (count >= 500000) description = 'multiple times per minute';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Frequency estimate label "multiple times per minute" is unreachable/misleading for 5-field cron

At skills/cron-doctor/scripts/cron-engine.js:538, the frequency bucket count >= 500000 is labeled 'multiple times per minute'. In standard 5-field cron, the minimum granularity is 1 minute, so a job can fire at most once per minute (max 525,600/year). A count between 500,000 and 525,599 is reachable (e.g., every minute except certain months) but represents "nearly every minute," not "multiple times per minute." The label is factually misleading but only affects an unusual edge case in descriptive output.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +293 to +308
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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: nextRuns scanning ceiling may not find all results for sparse schedules

At skills/cron-doctor/scripts/cron-engine.js:301, maxScan is set to 600,000 minutes (~416 days). For a schedule like 0 0 29 2 * (fires only on Feb 29 in leap years), the next fire could be up to ~4 years away, which exceeds 416 days. The function would return fewer results than requested (or an empty array) without any error indication. This is a known limitation documented in the SKILL.md, and the estimateFrequency function has a similar ceiling at 540,000 minutes. Not flagged as a bug since the limitation is acknowledged, but callers should be aware that sparse schedules may return incomplete results.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +94 to +95
const n = parseInt(token, 10);
if (!isNaN(n)) return n;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: parseSingleNum accepts numeric strings that start with digits but have trailing text

At skills/cron-doctor/scripts/cron-engine.js:94, parseInt(token, 10) is used to parse numeric values. parseInt in JavaScript accepts leading digits and ignores trailing non-numeric characters (e.g., parseInt('5abc', 10) returns 5). This means tokens like '5abc' would be parsed as 5 instead of being rejected. In practice, this is unlikely to cause issues because cron field tokens are already split by commas and dashes, but it means the parser is more lenient than expected for malformed input.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@sickn33

sickn33 commented Jun 27, 2026

Copy link
Copy Markdown
Owner

Maintainer workflow refresh: closing and reopening to retrigger pull_request checks against the updated PR body and README source-credit fix.

@sickn33 sickn33 closed this Jun 27, 2026
@sickn33 sickn33 reopened this Jun 27, 2026
@sickn33 sickn33 merged commit ee5e4dd into sickn33:main Jun 27, 2026
27 of 28 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants