cronli5 0.8.2 → 0.8.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cronli5",
3
- "version": "0.8.2",
3
+ "version": "0.8.3",
4
4
  "description": "Cron Like I'm Five: A Cron to English Utility",
5
5
  "repository": {
6
6
  "type": "git",
@@ -223,6 +223,16 @@ const stepOrdinals: {[interval: number]: string} = {
223
223
  12: 'zwölften'
224
224
  };
225
225
 
226
+ // Dative ordinals for "in jeder N-ten Minute" — the step intervals a minute
227
+ // cadence can take. The interval-2 step keeps its own "jeder zweiten Minute"
228
+ // idiom and never reaches the confinement helper; a lookup miss falls back to
229
+ // the cardinal "alle N Minuten" form, which still confines.
230
+ const minuteStepOrdinals: {[interval: number]: string} = {
231
+ 3: 'dritten', 4: 'vierten', 5: 'fünften', 6: 'sechsten', 7: 'siebten',
232
+ 8: 'achten', 9: 'neunten', 10: 'zehnten', 12: 'zwölften',
233
+ 15: 'fünfzehnten', 20: 'zwanzigsten', 30: 'dreißigsten'
234
+ };
235
+
226
236
  // Confine a cadence to a clean hour stride: "in jeder zweiten Stunde", with
227
237
  // the start named when it is not midnight ("…ab 1 Uhr" for an odd stride).
228
238
  function everyNthHour(segment: StepSegment): string {
@@ -717,6 +727,65 @@ function isEveryOtherMinuteSeconds(
717
727
  return minuteStep.startToken === '*' && minuteStep.interval === 2;
718
728
  }
719
729
 
730
+ // The minute field's step stride for the confinement frame, or null when the
731
+ // minute is not a stepped cadence. A `step`-shaped field reads its segment; a
732
+ // `list`-shaped field the core enumerated from a uneven step (`2/7` → 2,9,…,58)
733
+ // recovers the progression from its values.
734
+ function minuteStride(
735
+ schedule: Schedule
736
+ ): {start: number; interval: number; last: number} | null {
737
+ if (schedule.shapes.minute === 'step') {
738
+ const segment = stepSegment(schedule, 'minute');
739
+ const start = segment.startToken === '*' ? 0 : +segment.startToken;
740
+
741
+ return {interval: segment.interval, last:
742
+ segment.fires[segment.fires.length - 1], start};
743
+ }
744
+
745
+ const values = singleValues(segmentsOf(schedule, 'minute'));
746
+
747
+ return values && arithmeticStep(values);
748
+ }
749
+
750
+ // A stepped minute under a wildcard/stepped second and wildcard hour: bind the
751
+ // second cadence to the minute cadence as a CONFINEMENT ("jede Sekunde in jeder
752
+ // sechsten Minute ab Minute 4 jeder Stunde"), never the comma juxtaposition
753
+ // that reads as two independent cadences. The cadence is ORDINAL ("in jeder
754
+ // sechsten Minute") — the cardinal "alle 6 Minuten" is what fuels the misread —
755
+ // and the start/bound mirror the standalone minute cadence.
756
+ function minuteStepConfinement(
757
+ schedule: Schedule,
758
+ stride: {start: number; interval: number; last: number}
759
+ ): string {
760
+ const ordinal = minuteStepOrdinals[stride.interval];
761
+ const head = ordinal ?
762
+ 'in jeder ' + ordinal + ' Minute' :
763
+ 'alle ' + stride.interval + ' Minuten';
764
+
765
+ const tail = chooseStride({...stride, cycle: 60}, {
766
+ bare: () => '',
767
+ offset: () => ' ab Minute ' + stride.start,
768
+ bounded: () => ' von Minute ' + stride.start + ' bis ' + stride.last
769
+ });
770
+
771
+ return secondsLead(schedule) + ' ' + head + tail + ' jeder Stunde';
772
+ }
773
+
774
+ // Whether a stepped minute fills a wildcard hour under a wildcard/stepped
775
+ // second — the shape the confinement frame above handles.
776
+ function isSteppedMinuteSeconds(
777
+ schedule: Schedule,
778
+ plan: Extract<PlanNode, {kind: 'composeSeconds'}>
779
+ ): boolean {
780
+ return (plan.rest.kind === 'minuteFrequency' ||
781
+ plan.rest.kind === 'multipleMinutes') &&
782
+ (schedule.shapes.second === 'wildcard' ||
783
+ schedule.shapes.second === 'step') &&
784
+ schedule.shapes.hour === 'wildcard' &&
785
+ schedule.pattern.minute !== '*/2' &&
786
+ minuteStride(schedule) !== null;
787
+ }
788
+
720
789
  function renderComposeSeconds(
721
790
  schedule: Schedule,
722
791
  plan: Extract<PlanNode, {kind: 'composeSeconds'}>,
@@ -749,6 +818,14 @@ function renderComposeSeconds(
749
818
  clockMinuteGenitive(plan.rest.times, opts.style.sep);
750
819
  }
751
820
 
821
+ // A stepped minute under a wildcard/stepped second + wildcard hour confines
822
+ // the second cadence to the ordinal minute cadence ("jede Sekunde in jeder
823
+ // sechsten Minute ab Minute 4 jeder Stunde"), never the comma juxtaposition
824
+ // that reads as two independent cadences.
825
+ if (isSteppedMinuteSeconds(schedule, plan)) {
826
+ return minuteStepConfinement(schedule, minuteStride(schedule)!);
827
+ }
828
+
752
829
  // A wildcard second under a minute */2 with a wildcard hour binds in the
753
830
  // genitive ("jede Sekunde jeder zweiten Minute").
754
831
  if (isEveryOtherMinuteSeconds(schedule, plan)) {
@@ -677,6 +677,67 @@ const stepOrdinals: Record<number, string> = {
677
677
  2: 'other', 3: 'third', 4: 'fourth', 6: 'sixth', 8: 'eighth', 12: 'twelfth'
678
678
  };
679
679
 
680
+ // Spelled ordinals for "every Nth minute" — the step intervals a minute
681
+ // cadence can take (2 reads idiomatically as "other"). A lookup miss falls back
682
+ // to the suffixed numeric ordinal, so an unusually large interval still reads.
683
+ const spelledOrdinals: Record<number, string> = {
684
+ 2: 'other', 3: 'third', 4: 'fourth', 5: 'fifth', 6: 'sixth', 7: 'seventh',
685
+ 8: 'eighth', 9: 'ninth', 10: 'tenth', 11: 'eleventh', 12: 'twelfth',
686
+ 15: 'fifteenth', 20: 'twentieth', 30: 'thirtieth'
687
+ };
688
+
689
+ // The ordinal word for a cadence interval ("sixth", "seventh"), spelled where
690
+ // known and suffixed-numeric ("13th") otherwise.
691
+ function ordinalWord(interval: number): string {
692
+ return spelledOrdinals[interval] ?? getOrdinal(interval);
693
+ }
694
+
695
+ // A stepped minute under a seconds lead reads as a CONFINEMENT of that cadence,
696
+ // not a juxtaposed clause (a comma there reads as two independent cadences) nor
697
+ // a wall of enumerated minutes: "during every Nth minute" plus the step's
698
+ // offset/bound. The cadence is ORDINAL ("every sixth minute"); the cardinal
699
+ // ("every six minutes") is the form that reads as a separate cadence. The
700
+ // offset/bound mirrors the standalone minute cadence: a clean stride from the
701
+ // top names no offset, an offset-clean stride names only its start ("from four
702
+ // minutes past the hour"), and an uneven one pins both endpoints ("from 2
703
+ // through 58 minutes past the hour").
704
+ function minuteStrideConfinement(stride: {start: number; interval: number;
705
+ last: number}, opts: NormalizedOptions): string {
706
+ const base = ' during every ' + ordinalWord(stride.interval) + ' minute';
707
+
708
+ return chooseStride({...stride, cycle: 60}, {
709
+ bare: () => base,
710
+ offset: () => base + ' from ' + getNumber(stride.start, opts) + ' ' +
711
+ pluralize(stride.start, 'minute') + ' past the hour',
712
+ bounded: () => {
713
+ const num = seriesNumber();
714
+
715
+ return base + ' from ' + num(stride.start) + through(opts) +
716
+ num(stride.last) + ' ' + pluralize(stride.last, 'minute') +
717
+ ' past the hour';
718
+ }
719
+ });
720
+ }
721
+
722
+ // The minute field's step stride for the confinement frame, or null when the
723
+ // minute is not a stepped cadence. A `step`-shaped field (`*/6`) reads its
724
+ // segment directly; a `list`-shaped field the core enumerated from an uneven
725
+ // step (`2/7` → 2,9,…,58) recovers the progression from its values.
726
+ function minuteStride(schedule: Schedule):
727
+ {start: number; interval: number; last: number} | null {
728
+ if (schedule.shapes.minute === 'step') {
729
+ const segment = stepSegment(schedule, 'minute');
730
+ const start = segment.startToken === '*' ? 0 : +segment.startToken;
731
+
732
+ return {interval: segment.interval, last:
733
+ segment.fires[segment.fires.length - 1], start};
734
+ }
735
+
736
+ const values = singleValues(segmentsOf(schedule, 'minute'));
737
+
738
+ return values && arithmeticStep(values);
739
+ }
740
+
680
741
  // Confine a cadence to a clean hour stride: "during every other hour", with
681
742
  // the start named when it is not midnight ("…from 1 a.m." for an odd stride).
682
743
  function everyNthHour(segment: StepSegment, opts: NormalizedOptions): string {
@@ -1078,12 +1139,22 @@ function minuteConfinement(schedule: Schedule,
1078
1139
  return '';
1079
1140
  }
1080
1141
 
1081
- if (isCadenceField(minute)) {
1082
- // The gate admits only the `*/2` "every other minute" step here; other
1083
- // minute steps defer to the existing renderer.
1142
+ if (minute === '*/2') {
1143
+ // The `*/2` clean step reads idiomatically as "every other minute" with no
1144
+ // offset; other minute steps take the ordinal stride-cadence below.
1084
1145
  return ' of every other minute';
1085
1146
  }
1086
1147
 
1148
+ // A stepped minute (a clean `*/n`, an offset `m/n`, or a uneven step the core
1149
+ // enumerated to an arithmetic list) confines as "during every Nth minute"
1150
+ // plus the step's offset/bound — the ordinal cadence, not the cardinal that
1151
+ // reads as a separate cadence, nor a wall of enumerated ":NN" minutes.
1152
+ const stride = minuteStride(schedule);
1153
+
1154
+ if (stride) {
1155
+ return minuteStrideConfinement(stride, opts);
1156
+ }
1157
+
1087
1158
  // A minute single/range/list under the seconds lead. The minute reads as a
1088
1159
  // ":NN" clock-minute confinement, never "N minutes past the hour" (that is
1089
1160
  // the minute-lead clock-point form).
@@ -1119,10 +1190,11 @@ function hourConfinement(schedule: Schedule, opts: NormalizedOptions): string {
1119
1190
 
1120
1191
  if (hour === '*') {
1121
1192
  // A pinned minute confinement ("during minute :00") repeats across every
1122
- // hour, so the hour is named as the unit of recurrence; a stepped minute
1123
- // ("of every other minute") or absent minute already implies all hours.
1193
+ // hour, so the hour is named as the unit of recurrence; a minute cadence
1194
+ // ("of every other minute", "during every sixth minute …") or an absent
1195
+ // minute already implies all hours, so the hour is not restated.
1124
1196
  const minutePinned = schedule.pattern.minute !== '*' &&
1125
- !isCadenceField(schedule.pattern.minute);
1197
+ !isCadenceField(schedule.pattern.minute) && !minuteStride(schedule);
1126
1198
 
1127
1199
  return minutePinned ? ' of every hour' : '';
1128
1200
  }
@@ -1216,24 +1288,28 @@ function confinementEligible(schedule: Schedule,
1216
1288
  }
1217
1289
 
1218
1290
  if (lead.secondLead) {
1219
- // A minute STEP is supported only as the `*/2` "every other minute" idiom,
1220
- // and only where it fills the coarser field: a contiguous hour range or a
1221
- // single hour both close on the minute's real last fire, which the
1222
- // windowing renderer already speaks. The `*/2` step fills both, so it keeps
1223
- // the "of every other minute" confinement; other steps defer entirely. A
1224
- // contiguous hour range (`hour === 'range'`) is left to that windowing
1225
- // renderer rather than this confinement frame, which closes on the top of
1226
- // the next hour.
1291
+ // A minute STEP confines as an ordinal cadence ("during every sixth minute
1292
+ // from four minutes past the hour"), but only where it fills the coarser
1293
+ // field: under a WILDCARD hour the step repeats every hour, so the cadence
1294
+ // is the whole confinement. A single hour or a contiguous range closes on
1295
+ // the minute's real last fire, which the windowing renderer already speaks,
1296
+ // so those defer. The `*/2` step keeps its "of every other minute" idiom.
1227
1297
  if (minuteStep) {
1228
- return minute === '*/2' && schedule.shapes.hour !== 'range';
1298
+ return minute === '*/2' ?
1299
+ schedule.shapes.hour !== 'range' :
1300
+ schedule.pattern.hour === '*';
1301
+ }
1302
+
1303
+ // A minute list that is really an arithmetic stride confines as that same
1304
+ // ordinal cadence when it fills a wildcard hour; under a restricted hour it
1305
+ // keeps its existing cadence form. A short explicit minute list crossed
1306
+ // with a discrete hour LIST is a wall of distinct clock times ("9:00 a.m.,
1307
+ // 9:25 a.m., …"), not a single minute confinement, so it stays enumerated.
1308
+ if (isMinuteStride(schedule)) {
1309
+ return schedule.pattern.hour === '*';
1229
1310
  }
1230
1311
 
1231
- // A minute list that is really a stride keeps its cadence form; a short
1232
- // explicit minute list crossed with a discrete hour LIST is a wall of
1233
- // distinct clock times ("9:00 a.m., 9:25 a.m., …"), not a single minute
1234
- // confinement. Both stay with the enumerating renderer.
1235
- if (isMinuteStride(schedule) ||
1236
- schedule.shapes.minute === 'list' && schedule.shapes.hour === 'list') {
1312
+ if (schedule.shapes.minute === 'list' && schedule.shapes.hour === 'list') {
1237
1313
  return false;
1238
1314
  }
1239
1315
 
@@ -115,6 +115,17 @@ const weekdayNames = [
115
115
  const nthWeekdayNames =
116
116
  [null, 'primer', 'segundo', 'tercer', 'cuarto', 'quinto'];
117
117
 
118
+ // Spanish ordinals (masculine) for a stepped-minute cadence under a seconds
119
+ // lead ("cada sexto minuto"). The interval-2 step never reaches here — it keeps
120
+ // its own "de cada dos minutos" idiom — so the colliding "segundo" is unused.
121
+ // A lookup miss falls back to the cardinal-with-"cada" form, which still
122
+ // confines (see `minuteStepOrdinal`).
123
+ const stepOrdinals: Record<number, string> = {
124
+ 3: 'tercer', 4: 'cuarto', 5: 'quinto', 6: 'sexto', 7: 'séptimo',
125
+ 8: 'octavo', 9: 'noveno', 10: 'décimo', 12: 'duodécimo', 15: 'decimoquinto',
126
+ 20: 'vigésimo', 30: 'trigésimo'
127
+ };
128
+
118
129
  // Normalize raw user options.
119
130
  function normalizeOptions(options?: Cronli5Options): Opts {
120
131
  options = options || {};
@@ -269,6 +280,71 @@ function isPinnedMinuteSeconds(
269
280
  schedule.shapes.second === 'step');
270
281
  }
271
282
 
283
+ // The minute field's step stride for the confinement frame, or null when the
284
+ // minute is not a stepped cadence. A `step`-shaped field (`*/6`) reads its
285
+ // segment; a `list`-shaped field the core enumerated from a uneven step (`2/7`
286
+ // → 2,9,…,58) recovers the progression from its values.
287
+ function minuteStride(
288
+ schedule: Schedule
289
+ ): {start: number; interval: number; last: number} | null {
290
+ if (schedule.shapes.minute === 'step') {
291
+ const segment = stepSegment(schedule, 'minute');
292
+ const start = segment.startToken === '*' ? 0 : +segment.startToken;
293
+
294
+ return {interval: segment.interval, last:
295
+ segment.fires[segment.fires.length - 1], start};
296
+ }
297
+
298
+ const values = singleValues(segmentsOf(schedule, 'minute'));
299
+
300
+ return values && arithmeticStep(values);
301
+ }
302
+
303
+ // A stepped minute under a wildcard second and wildcard hour: bind the second
304
+ // cadence to the minute cadence as a CONFINEMENT ("cada segundo en cada sexto
305
+ // minuto a partir del minuto 4 de cada hora"), never the comma juxtaposition
306
+ // that reads as two independent cadences. The cadence is ORDINAL ("cada sexto
307
+ // minuto") — the cardinal "cada seis minutos" is what fuels the misread — and
308
+ // the start/bound mirror the standalone minute cadence: a clean step from the
309
+ // top names no offset, an offset-clean stride names only its start, and a
310
+ // uneven one pins both endpoints ("del minuto 2 al 58"). An interval the
311
+ // ordinal table does not cover keeps the cardinal "cada N" after "en", which
312
+ // still confines.
313
+ function minuteStepConfinement(
314
+ schedule: Schedule,
315
+ stride: {start: number; interval: number; last: number},
316
+ opts: Opts
317
+ ): string {
318
+ const ordinal = stepOrdinals[stride.interval];
319
+ const head = ordinal ?
320
+ 'cada ' + ordinal + ' minuto' :
321
+ 'cada ' + numero(stride.interval, opts) + ' minutos';
322
+
323
+ const tail = chooseStride({...stride, cycle: 60}, {
324
+ bare: () => '',
325
+ offset: () => ' a partir del minuto ' + stride.start,
326
+ bounded: () => ' del minuto ' + stride.start + ' al ' + stride.last
327
+ });
328
+
329
+ return secondsLeadClause(schedule, opts) + ' en ' + head + tail +
330
+ ' de cada hora' + trailingQualifier(schedule, opts);
331
+ }
332
+
333
+ // Whether a stepped minute fills a wildcard hour under a wildcard second — the
334
+ // shape the confinement frame above handles.
335
+ function isSteppedMinuteSeconds(
336
+ schedule: Schedule,
337
+ plan: Extract<PlanNode, {kind: 'composeSeconds'}>
338
+ ): boolean {
339
+ return (plan.rest.kind === 'minuteFrequency' ||
340
+ plan.rest.kind === 'multipleMinutes') &&
341
+ (schedule.shapes.second === 'wildcard' ||
342
+ schedule.shapes.second === 'step') &&
343
+ schedule.shapes.hour === 'wildcard' &&
344
+ schedule.pattern.minute !== '*/2' &&
345
+ minuteStride(schedule) !== null;
346
+ }
347
+
272
348
  function renderComposeSeconds(
273
349
  schedule: Schedule,
274
350
  plan: Extract<PlanNode, {kind: 'composeSeconds'}>,
@@ -312,6 +388,14 @@ function renderComposeSeconds(
312
388
  return dayFrame + ', ' + window + ', ' + cadence;
313
389
  }
314
390
 
391
+ // A stepped minute under a wildcard second + wildcard hour confines the
392
+ // second cadence to the ordinal minute cadence ("cada segundo en cada sexto
393
+ // minuto a partir del minuto 4 de cada hora"), never the comma juxtaposition
394
+ // that reads as two independent cadences.
395
+ if (isSteppedMinuteSeconds(schedule, plan)) {
396
+ return minuteStepConfinement(schedule, minuteStride(schedule)!, opts);
397
+ }
398
+
315
399
  // A wildcard second under a minute */2 with a wildcard hour juxtaposes two
316
400
  // cadences that read as contradictory ("cada segundo, cada dos minutos").
317
401
  // Bind them with the genitive "de" ("cada segundo de cada dos minutos"),
@@ -123,6 +123,17 @@ const nthWeekdayNames: (string | null)[] = [
123
123
  'viidentenä'
124
124
  ];
125
125
 
126
+ // Essive ordinals for "joka N:ntenä minuuttina" — the step intervals a minute
127
+ // cadence can take. The interval-2 step keeps its own "joka toisena minuuttina"
128
+ // idiom and never reaches the confinement helper; a lookup miss falls back to
129
+ // the genitive "N minuutin välein" cadence, which still confines.
130
+ const minuteStepOrdinals: {[interval: number]: string} = {
131
+ 3: 'kolmantena', 4: 'neljäntenä', 5: 'viidentenä', 6: 'kuudentena',
132
+ 7: 'seitsemäntenä', 8: 'kahdeksantena', 9: 'yhdeksäntenä',
133
+ 10: 'kymmenentenä', 12: 'kahdentenatoista', 15: 'viidentenätoista',
134
+ 20: 'kahdentenakymmenentenä', 30: 'kolmantenakymmenentenä'
135
+ };
136
+
126
137
  // Weekdays as stored inflected forms (SUN..SAT): distributive -isin,
127
138
  // elative, illative, and essive. Consonant gradation (keskiviikko →
128
139
  // keskiviikosta) makes stem+suffix logic wrong; store the forms.
@@ -404,6 +415,71 @@ function composeHourCadence(
404
415
  hourRangeCadence(schedule, minute, opts);
405
416
  }
406
417
 
418
+ // The minute field's step stride for the confinement frame, or null when the
419
+ // minute is not a stepped cadence. A `step`-shaped field reads its segment; a
420
+ // `list`-shaped field the core enumerated from a uneven step (`2/7` → 2,9,…,58)
421
+ // recovers the progression from its values.
422
+ function minuteStride(
423
+ schedule: Schedule
424
+ ): {start: number; interval: number; last: number} | null {
425
+ if (schedule.shapes.minute === 'step') {
426
+ const segment = stepSegment(schedule, 'minute');
427
+ const start = segment.startToken === '*' ? 0 : +segment.startToken;
428
+
429
+ return {interval: segment.interval, last:
430
+ segment.fires[segment.fires.length - 1], start};
431
+ }
432
+
433
+ const values = singleValues(segmentsOf(schedule, 'minute'));
434
+
435
+ return values && arithmeticStep(values);
436
+ }
437
+
438
+ // A stepped minute under a wildcard/stepped second and wildcard hour: bind the
439
+ // second cadence to the minute cadence as a CONFINEMENT ("joka sekunti joka
440
+ // kuudentena minuuttina jokaisen tunnin minuutista 4 alkaen"), never the comma
441
+ // juxtaposition that reads as two independent cadences. The cadence is ORDINAL
442
+ // ("joka kuudentena minuuttina") — the cardinal "kuuden minuutin välein" is
443
+ // what fuels the misread — and the start/bound mirror the standalone minute
444
+ // cadence: an offset-clean stride names only its start, a uneven one pins both
445
+ // endpoints ("minuutista 2 minuuttiin 58").
446
+ function minuteStepConfinement(
447
+ schedule: Schedule,
448
+ stride: {start: number; interval: number; last: number},
449
+ opts: NormalizedOptions
450
+ ): string {
451
+ const ordinalForm = minuteStepOrdinals[stride.interval];
452
+ const minute = units.minute;
453
+ const head = ordinalForm ?
454
+ ' joka ' + ordinalForm + ' minuuttina' :
455
+ ' ' + genitive(stride.interval, opts) + ' ' + minute.gen + ' välein';
456
+
457
+ const tail = chooseStride({...stride, cycle: 60}, {
458
+ bare: () => '',
459
+ offset: () => ' ' + minute.anchor + ' ' + minute.ela + ' ' +
460
+ stride.start + ' alkaen',
461
+ bounded: () => ' ' + minute.ela + ' ' + stride.start + ' ' +
462
+ minute.ill + ' ' + stride.last
463
+ });
464
+
465
+ return secondsLeadClause(schedule, opts) + head + tail +
466
+ trailingQualifier(schedule, opts);
467
+ }
468
+
469
+ // Whether a stepped minute fills a wildcard hour under a wildcard/stepped
470
+ // second — the shape the confinement frame above handles.
471
+ function isSteppedMinuteSeconds(
472
+ schedule: Schedule,
473
+ plan: Extract<PlanNode, {kind: 'composeSeconds'}>
474
+ ): boolean {
475
+ return (plan.rest.kind === 'minuteFrequency' ||
476
+ plan.rest.kind === 'multipleMinutes') &&
477
+ (schedule.pattern.second === '*' || schedule.shapes.second === 'step') &&
478
+ schedule.shapes.hour === 'wildcard' &&
479
+ schedule.pattern.minute !== '*/2' &&
480
+ minuteStride(schedule) !== null;
481
+ }
482
+
407
483
  function renderComposeSeconds(
408
484
  schedule: Schedule,
409
485
  plan: Extract<PlanNode, {kind: 'composeSeconds'}>,
@@ -419,6 +495,16 @@ function renderComposeSeconds(
419
495
  return cadence;
420
496
  }
421
497
 
498
+ // A stepped minute under a wildcard/stepped second + wildcard hour confines
499
+ // the second cadence to the ordinal minute cadence ("joka sekunti joka
500
+ // kuudentena minuuttina jokaisen tunnin minuutista 4 alkaen"), never the
501
+ // comma juxtaposition that reads as two independent cadences. Checked before
502
+ // the general minute-step compose path, which keeps the comma form under a
503
+ // restricted hour.
504
+ if (isSteppedMinuteSeconds(schedule, plan)) {
505
+ return minuteStepConfinement(schedule, minuteStride(schedule)!, opts);
506
+ }
507
+
422
508
  // When the rest is a minute-step cadence, the step leads and the second
423
509
  // anchor follows after a comma (the comma marks the granularity boundary
424
510
  // between the two levels, not a flat list).
@@ -127,6 +127,16 @@ const weekdayNames = [
127
127
  const nthWeekdayMasculine =
128
128
  [null, 'premier', 'deuxième', 'troisième', 'quatrième', 'cinquième'];
129
129
 
130
+ // French ordinals (gender-neutral "-ième") for a stepped-minute cadence under a
131
+ // seconds lead ("à la sixième minute"). The interval-2 step keeps its own
132
+ // idiom and never reaches here; a lookup miss falls back to the cardinal-with-
133
+ // preposition form, which still confines (see `minuteStepConfinement`).
134
+ const stepOrdinals: Record<number, string> = {
135
+ 3: 'troisième', 4: 'quatrième', 5: 'cinquième', 6: 'sixième',
136
+ 7: 'septième', 8: 'huitième', 9: 'neuvième', 10: 'dixième',
137
+ 12: 'douzième', 15: 'quinzième', 20: 'vingtième', 30: 'trentième'
138
+ };
139
+
130
140
  // Normalize raw user options.
131
141
  function normalizeOptions(options?: Cronli5Options): Opts {
132
142
  options = options || {};
@@ -279,6 +289,67 @@ function isPinnedMinuteSeconds(
279
289
  schedule.shapes.second === 'step');
280
290
  }
281
291
 
292
+ // The minute field's step stride for the confinement frame, or null when the
293
+ // minute is not a stepped cadence. A `step`-shaped field reads its segment; a
294
+ // `list`-shaped field the core enumerated from a uneven step (`2/7` → 2,9,…,58)
295
+ // recovers the progression from its values.
296
+ function minuteStride(
297
+ schedule: Schedule
298
+ ): {start: number; interval: number; last: number} | null {
299
+ if (schedule.shapes.minute === 'step') {
300
+ const segment = stepSegment(schedule, 'minute');
301
+ const start = segment.startToken === '*' ? 0 : +segment.startToken;
302
+
303
+ return {interval: segment.interval, last:
304
+ segment.fires[segment.fires.length - 1], start};
305
+ }
306
+
307
+ const values = singleValues(segmentsOf(schedule, 'minute'));
308
+
309
+ return values && arithmeticStep(values);
310
+ }
311
+
312
+ // A stepped minute under a wildcard/stepped second and wildcard hour: bind the
313
+ // second cadence to the minute cadence as a CONFINEMENT ("chaque seconde à la
314
+ // sixième minute à partir de la minute 4 de chaque heure"), never the comma
315
+ // juxtaposition that reads as two independent cadences. The cadence is ORDINAL
316
+ // ("à la sixième minute") — the cardinal "toutes les six minutes" is what fuels
317
+ // the misread — and the start/bound mirror the standalone minute cadence.
318
+ function minuteStepConfinement(
319
+ schedule: Schedule,
320
+ stride: {start: number; interval: number; last: number},
321
+ opts: Opts
322
+ ): string {
323
+ const ordinal = stepOrdinals[stride.interval];
324
+ const head = ordinal ?
325
+ 'à la ' + ordinal + ' minute' :
326
+ 'à la minute toutes les ' + numero(stride.interval, opts);
327
+
328
+ const tail = chooseStride({...stride, cycle: 60}, {
329
+ bare: () => '',
330
+ offset: () => ' à partir de la minute ' + stride.start,
331
+ bounded: () => ' de la minute ' + stride.start + ' à ' + stride.last
332
+ });
333
+
334
+ return secondsLeadClause(schedule, opts) + ' ' + head + tail +
335
+ ' de chaque heure' + trailingQualifier(schedule, opts);
336
+ }
337
+
338
+ // Whether a stepped minute fills a wildcard hour under a wildcard/stepped
339
+ // second — the shape the confinement frame above handles.
340
+ function isSteppedMinuteSeconds(
341
+ schedule: Schedule,
342
+ plan: Extract<PlanNode, {kind: 'composeSeconds'}>
343
+ ): boolean {
344
+ return (plan.rest.kind === 'minuteFrequency' ||
345
+ plan.rest.kind === 'multipleMinutes') &&
346
+ (schedule.shapes.second === 'wildcard' ||
347
+ schedule.shapes.second === 'step') &&
348
+ schedule.shapes.hour === 'wildcard' &&
349
+ schedule.pattern.minute !== '*/2' &&
350
+ minuteStride(schedule) !== null;
351
+ }
352
+
282
353
  function renderComposeSeconds(
283
354
  schedule: Schedule,
284
355
  plan: Extract<PlanNode, {kind: 'composeSeconds'}>,
@@ -322,6 +393,14 @@ function renderComposeSeconds(
322
393
  return dayFrame + ', ' + window + ', ' + cadence;
323
394
  }
324
395
 
396
+ // A stepped minute under a wildcard/stepped second + wildcard hour confines
397
+ // the second cadence to the ordinal minute cadence ("chaque seconde à la
398
+ // sixième minute à partir de la minute 4 de chaque heure"), never the comma
399
+ // juxtaposition that reads as two independent cadences.
400
+ if (isSteppedMinuteSeconds(schedule, plan)) {
401
+ return minuteStepConfinement(schedule, minuteStride(schedule)!, opts);
402
+ }
403
+
325
404
  // A wildcard second under a minute */2 with a wildcard hour juxtaposes two
326
405
  // cadences that read as contradictory ("chaque seconde, toutes les deux
327
406
  // minutes"). Bind them with the genitive "de" ("chaque seconde de chaque