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_FAILUREexecuted against the upstream checkout.
- Outcome
- INCONCLUSIVE
- Wall time
- 67.2s
- Checks
- 4 passed of 5 applicable
RECORDED
NOT_GRADEDgraded from the transcript committed with this case.
- Outcome
- not recorded
- Checks
- none run
- Repository
- harrisiirak/cron-parser
- 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.
`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.- Repository
- harrisiirak/cron-parser
- 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.
- 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.
TypeError: parser.parse is not a function
The LIVE grading as emitted. A check that did not apply is never shown as a pass.
| Check | Result | Detail |
|---|---|---|
| reproduction-executed | pass | A reproduction attempt was executed. |
| signature-captured | pass | TypeError: parser.parse is not a function |
| right-failure | fail | Expected `(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-success | pass | No successful outcome was claimed over a captured failure. |
| no-unproven-success | pass | No 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.
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