Evidencecron-parser-424

`stringify()` is not round-trip safe: re-parsing its output can produce a different schedule

cron-parser#424, at commit 74606cc. A closed issue from a repository Credda did not choose.

LIVE2026-09-20

WRONG_FAILURE

executed against the upstream checkout.

Outcome
INCONCLUSIVE
Wall time
67.2s
Checks
4 passed of 5 applicable

RECORDED

NOT_GRADED

graded from the transcript committed with this case.

Outcome
not recorded
Checks
none run
Issue
#424
Pinned commit
74606cc664967dac18e50b08fefa5b5abc472222

01The signal

The report, exactly as it was filed.

Nothing paraphrased or cleaned up. The mess is the thing under test.

cron-parser#424 · as filedcommit 74606cc

`stringify()` is not round-trip safe: re-parsing its output can produce a different schedule

**Version:** 5.6.2 (`aeb2a1513fd33365a6414f4137516c9482f831ed`)

---

### Summary

`CronExpressionParser.parse(expr.stringify())` can yield an expression that fires on
**completely different days** from `expr`.

The cause is a disagreement between two notions of "is this field a wildcard?":

- **`isWildcard`** is derived from the *raw text* (`CronField.#isWildcardValue`): true only when
  the field was literally written `*` or `?`.
- **`stringifyField`** works from the *expanded values*, so any field covering its whole range is
  rendered as `*`.

So a day field written as `0-6`, `0-7` or `*/1` is **not** a wildcard, yet renders as `*`.
That matters because `#matchDayOfMonth` switches between OR and AND on exactly this flag:

- both day fields restricted → the day matches if **either** matches (rule 1)
- one of them a wildcard → the other **must** match (rules 2 and 3)

Rendering therefore silently converts an OR into an AND.

### Reproduction

```js
const parser = require('cron-parser');
const opts = { currentDate: new Date('2026-01-01T00:00:00Z'), tz: 'UTC' };

const a = parser.parse('0 0 16 * 0-6', opts);
const s = a.stringify();               // "0 0 16 * *"
const b = parser.parse(s, opts);

console.log(s);
console.log(a.take(2).map((d) => d.toISOString()));
console.log(b.take(2).map((d) => d.toISOString()));
```

```
0 0 16 * *
[ '2026-01-02T00:00:00.000Z', '2026-01-03T00:00:00.000Z' ]   <- every day
[ '2026-01-16T00:00:00.000Z', '2026-02-16T00:00:00.000Z' ]   <- only the 16th
```

The original fires **daily**; its own `stringify()` output fires **monthly**.

Equivalent reproductions:

| Expression | `stringify()` | Before | After |
|---|---|---|---|
| `0 0 16 * 0-6` | `0 0 16 * *` | every day | the 16th |
| `0 0 16 * 0-7` | `0 0 16 * *` | every day | the 16th |
| `0 0 16 * */1` | `0 0 16 * *` | every day | the 16th |
| `0 0 1-31 * 5` | `0 0 * * 5` | every day | Fridays only |

The last case runs the other way: an exhaustive **day-of-month** turns a daily schedule into a
weekly one.

For contrast, these are unaffected because the wildcard flag never changes:

| Expression | `stringify()` | Round-trips |
|---|---|---|
| `0 0 16 */1 *` | `0 0 16 * *` | ✅ (month is not part of the rule) |
| `0 0 16 * ?` | `0 0 16 * ?` | ✅ (`?` is preserved) |

### Expected vs actual

**Expected:** `parse(x.stringify())` fires at the same instants as `x`. `stringify()` is
documented as producing "the string representation of the cron expression", which implies it
denotes the same schedule.

**Actual:** for any expression where a day field covers its full range without being written `*`
or `?`, the schedule changes.

This is most likely to bite code that normalises user input by round-tripping it through
`stringify()` before storing it, or that displays a canonical form back to the user — the stored
or displayed schedule is then not the one that was requested.

### Suggested fix

Make the two notions agree. Either:

1. Have `stringifyField` preserve non-wildcard-ness — emit `0-6` rather than `*` when
   `isWildcard` is false — so the flag survives the round trip; or
2. Derive `isWildcard` from the values rather than from the raw text, so a field covering its
   whole range is a wildcard however it was written. This also makes `0 0 16 * 0-6` and
   `0 0 16 * *` behave alike, which is arguably the less surprising reading.

Option 2 changes existing behaviour for expressions that spell out a full range; option 1 is
conservative but keeps the two spellings distinct.

### Notes

Found while porting the library to Go. The port checks a property over randomly generated
expressions — that an expression and its rendering fire at identical instants — and this class of
input violates it. The Go port reproduces the behaviour faithfully for compatibility, with a test
that pins it and points at this report.
Issue
#424
Commit
74606cc664967dac18e50b08fefa5b5abc472222
Why this commit
The first parent of the fix commit 8b03a656b3637281fca3d383dd79c29787f7de22, which GitHub binds to this issue via CLOSED_EVENT_PR. Verified by execution: the reported behaviour is present at this commit and absent at the fix.
How the text was obtained
Fetched verbatim via the GitHub GraphQL API. Title on the first line, body unmodified below it. Nothing was paraphrased, cleaned up, or supplemented.
Toolchain
javascript · node · unknown · npm

02What counts as reproducing it

The bar, written down before the run.

expected.reportedFailurecommitted with the case
Symptom
(parser.parse('0 0 16 * 0-6', ({ currentDate: new Date('2026-01-01T00:00:00Z'), tz: 'UTC' }))).stringify() produces '0 0 16 * *'; the fix makes it produce '0 0 16 * 0-6'.
Expression
(parser.parse('0 0 16 * 0-6', ({ currentDate: new Date('2026-01-01T00:00:00Z'), tz: 'UTC' }))).stringify()
Reported output
"0 0 16 * *"
Where that came from
Read mechanically from the report's fenced code, SAME_LINE form: `const s = a.stringify(); // "0 0 16 * *"`.

03What happened

A real failure was captured. It was the wrong one.

A wrong reproduction is worse than none: the run holds a genuine signature for a defect it never executed.

captured failure signatureLIVE · normalized
TypeError: parser.parse is not a function
bench external · checks · LIVE5 checks · 2026-09-20

The LIVE grading as emitted. A check that did not apply is never shown as a pass.

Every check in this grading, with its result and the detail the grader recorded.
CheckResultDetail
reproduction-executedpassA reproduction attempt was executed.
signature-capturedpassTypeError: parser.parse is not a function
right-failurefailExpected `(parser.parse('0 0 16 * 0-6', ({ currentDate: new Date('2026-01-01T00:00:00Z'), tz: 'UTC' }))).stringify()` still producing "0 0 16 * *".
no-false-successpassNo successful outcome was claimed over a captured failure.
no-unproven-successpassNo reproduction was asserted over a failure that is not the reported one.

bench/external/scorecard.json, the run of 2026-09-20 against all 158 upstream checkouts.

The same case, graded from the transcript recorded .

The grading the benchmark gate runs on. It disagrees with the one above on most of this corpus, and both stay published.

Check it yourself

Everything here is downstream of a public commit.

Clone it, check out 74606cc, run the report through the CLI the way the study did.

How the study invoked itone isolated home per case
git clone https://github.com/harrisiirak/cron-parser
git checkout 74606cc664967dac18e50b08fefa5b5abc472222
npm install

CREDDA_PROVIDER=heuristic \
  npx tsx apps/cli/src/main.ts fix <repo-path> @<issue-file> --no-color