summaryrefslogtreecommitdiff
path: root/test/cli.t
blob: 5bf8d833f0a51aca36f0849ac4c9b82e82b9d25a (plain) (blame)
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
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
Easter and its Easter-relative movable feasts for a year:

  $ colitur easter 2026
  easter 2026-04-05
  ash-wednesday 2026-02-18
  palm-sunday 2026-03-29
  ascension 2026-05-14
  pentecost 2026-05-24
  corpus-christi 2026-06-04

A year outside the supported domain is rejected (exit 2):

  $ colitur easter 1000
  colitur: year 1000 out of range 1583..9999
  [2]

No/garbage arguments give a usage error (exit 2):

  $ colitur
  colitur: usage: colitur easter <year> | colitur temporal <year> | colitur day <year> | colitur readings <year> | colitur rubrics <year> | colitur emit --format FMT --from Y --to Y | colitur table --year Y --template FILE | colitur render --template FILE --year Y | colitur publish --from Y --to Y --out DIR | colitur lang --list|--dump CODE|--check FILE | colitur config --show | colitur check FILE | colitur new-overlay  (try: colitur --help)
  [2]

The EF temporal cycle for a year, one line per day:

  $ colitur temporal 2026 | head -3
  2026-01-01 thursday christmastide - ef-circumcision class-1 white
  2026-01-02 friday christmastide - ef-christmas-1-friday class-4 white
  2026-01-03 saturday christmastide - ef-christmas-1-saturday class-4 white

  $ colitur temporal 2026 | wc -l
  365

  $ colitur temporal 2026 | grep -c '^2026-04-05 '
  1

  $ colitur temporal 2026 | grep '^2026-04-05 '
  2026-04-05 sunday paschaltide 1 ef-easter-sunday class-1 white

A year outside the supported domain is rejected (exit 2):

  $ colitur temporal 1000
  colitur: year 1000 out of range 1583..9999
  [2]

The resolved EF calendar for a year (Task 11) -- temporal AND sanctoral,
occurrence and transfers applied: one line per civil-year day,
"YYYY-MM-DD weekday season week slug rank colour [+commemoration-slug]...".

  $ colitur day 2026 | wc -l
  365

Easter is the observed day exactly once, and carries no commemoration (an
impeded I class day admits at most one PRIVILEGED commemoration, RG 111, and
nothing outranks Easter to be impeded by it in the first place):

  $ colitur day 2026 --raw | grep -c '^2026-04-05 '
  1
  $ colitur day 2026 --raw | grep '^2026-04-05 '
  2026-04-05 sunday paschaltide 1 ef-easter-sunday class-1 white

Ash Wednesday: I class (RG 91 entry 7), violet, no numbered week (it falls 4
days before Lent I's own origin -- rite_ef/temporal_ef.ml's [week]):

  $ colitur day 2026 --raw | grep '^2026-02-18 '
  2026-02-18 wednesday lent - ef-ash-wednesday class-1 violet

Christmas: I class, white (RG 91 entry 1):

  $ colitur day 2026 --raw | grep '^2026-12-25 '
  2026-12-25 friday christmastide - ef-nativity class-1 white

All Souls (2 Nov, RG 91 entry 8) end to end: 2 Nov 2025 is a Sunday (verified
independently -- 1 Jan 2025 is a Wednesday, day-of-year 306, (3+305) mod 7 =
0 = Sunday), so entry 8 yields to it (still an ordinary II-class Sunday,
"sunday" in season time-after-pentecost, colour green -- the week number
itself is not re-asserted here, already covered by test_temporal_ef.ml); RG
95 then transfers All Souls (I class, not a Sunday, not a vigil) and RG 96
places it on 3 Nov, the next day that is not I or II class:

  $ colitur day 2025 --raw | grep -c 'commemoration-of-all-souls'
  1
  $ colitur day 2025 --raw | grep '^2025-11-02 ' | sed -E 's/ [0-9]+ ef-time-after-pentecost-sunday-[0-9]+ / <week> ef-time-after-pentecost-sunday-<n> /'
  2025-11-02 sunday time-after-pentecost <week> ef-time-after-pentecost-sunday-<n> class-2 green
  $ colitur day 2025 --raw | grep '^2025-11-03 '
  2025-11-03 monday time-after-pentecost 21 commemoration-of-all-souls class-1 black

(week 21: Pentecost 2025 is 8 June (colitur easter 2025); 8 Jun - 3 Nov is
148 days, floor_div(148, 7) = 21 -- rite_ef/temporal_ef.ml's own [week]
formula, hand-verified before promoting this line, not merely printed and
trusted.)

All Souls observed directly on 2 Nov in a year where it does not fall on a
Sunday: 2 Nov 2026 is a Monday (1 Jan 2026 is a Thursday, same day-of-year
306 offset, (4+305) mod 7 = 1 = Monday):

  $ colitur day 2026 --raw | grep -c 'commemoration-of-all-souls'
  1
  $ colitur day 2026 --raw | grep '^2026-11-02 '
  2026-11-02 monday time-after-pentecost 23 commemoration-of-all-souls class-1 black

(week 23: Pentecost 2026 is 24 May (colitur easter 2026); 24 May - 2 Nov is
162 days, floor_div(162, 7) = 23 -- same formula, same independent check.)

RG 96's Annunciation exception (25 March) is CONDITIONAL, not unconditional
-- fix round 1, coordinator review, register corrected 2026-08-12. In 2057,
25 March is Lent III Sunday (I class, impedes it); 26 March is an ordinary
Lent feria (III class, well before Easter, 22 April 2057), so the GENERAL
RG 96 target -- not the Monday after Low Sunday -- is what governs, since
the general walk never crosses Easter. Before this fix the unconditional
reading sent it to 30 April (Easter + 8) instead:

  $ colitur day 2057 --raw | grep '^2057-03-26 '
  2057-03-26 monday lent 3 annunciation-of-the-blessed-virgin-mary class-1 white +ef-lent-3-monday
  $ colitur day 2057 --raw | grep -c 'annunciation-of-the-blessed-virgin-mary'
  1

A year outside the supported domain is rejected (exit 2):

  $ colitur day 1000
  colitur: year 1000 out of range 1583..9999
  [2]

The reading citations for a year, one line per day: date, the observed day's
slug, then the Epistle and the Gospel, " | "-delimited.

A SEPARATE command rather than extra columns on `colitur day`, for a
mechanical reason: a citation contains spaces and commas ("Ezech 34:11-16",
"Ecclus 51:1-8, 12"), while a `day` row is space-separated with a
variable-length "+slug" commemoration tail, so appending them there would
leave the row unsplittable by field number. `day`'s own format is therefore
byte-identical to what it was before readings existed -- asserted directly,
below.

The third line is worth reading twice: 3 January 2026 is an unoccupied
IV-class Saturday, so the day's office is Our Lady's (RG 78) and its Mass is
hers too -- the second of the Missal's five "Missae de sancta Maria in
sabbato", assigned to Christmas through the Purification (RG 309(a)). It is
NOT the Christmastide feria's Mass, which is what colitur said before that
rule was implemented.

  $ colitur readings 2026 --raw | head -3
  2026-01-01 ef-circumcision | Titus 2:11-15 | Luke 2:21
  2026-01-02 ef-christmas-1-friday | Titus 2:11-15 | Luke 2:21
  2026-01-03 ef-christmas-1-saturday | Titus 3:4-7 | Luke 2:15-20

  $ colitur readings 2026 | wc -l
  365

2 January reads the Circumcision's own Mass, not its own: the Missal's
Mass-propers rubric printed under the Sanctissimi Nominis Iesu heading --
"Diebus ferialibus a 2 ad 5 ianuarii Missa dicitur ut die 1 ianuarii, cum
Gloria et praefatione de Nativitate, sine Credo et Communicantes proprio"
(docs/research/scan1.txt:6523-6526, corroborated scan2.txt:7216). Note this
is NOT RG 17(a), which fixes only WHEN the Holy Name is kept; the two are
different rules sharing a date window. Hence the identical citation on the
1st and the 2nd above, which is correct and not a duplicated row.

One line per step of the reading-resolution chain, the same four cases
test_golden.ml pins against the photographic scans (each literal there was
read out of the Missal before being compared with colitur -- these assert
that the CLI path agrees with the library path, nothing more):

  $ colitur readings 2038 --raw | grep '^2038-03-08 '
  2038-03-08 john-of-god | Ecclus 31:8-11 | Matt 22:34-46

  $ colitur readings 2026 --raw | grep '^2026-02-23 '
  2026-02-23 ef-lent-1-monday | Ezech 34:11-16 | Matt 25:31-46

  $ colitur readings 2025 --raw | grep '^2025-12-01 '
  2025-12-01 ef-advent-1-monday | Rom 13:11-14 | Luke 21:25-33

  $ colitur readings 2038 --raw | grep '^2038-03-06 '
  2038-03-06 sts-felicitas-perpetua | Ecclus 51:1-8, 12 | Matt 13:44-52

The Advent line above is the ferial resumption (chain step 3) and is the one
worth reading twice: 1 December 2025 is the Monday after Advent I, and
Advent's ferias -- unlike Lent's -- have no Mass of their own, so the
citation shown IS Advent I Sunday's ("ad Romanos. Rom. 13, 11-14" /
"dum Lucam. Luc. 21, 25-33", scan1.txt:4878 + 4912). A regression that
stopped walking back would change this line and nothing else.

Every day of the year carries both parts -- no "-" anywhere. This is the CLI
view of the invariant Validate asserts over the whole 1583..9999 domain
("citations" / "citations-unresolved"), so it is a spot-check of one year
rather than the guarantee itself:

  $ colitur readings 2026 | grep -c ' | - \| | -$'
  0
  [1]

`colitur day`'s own format is unaffected by any of the above:

  $ colitur day 2026 --raw | head -1
  2026-01-01 thursday christmastide - ef-circumcision class-1 white

A year outside the supported domain is rejected (exit 2):

  $ colitur readings 1000
  colitur: year 1000 out of range 1583..9999
  [2]

Rubrics (Task 4, celebrant-rubrics-phase1): the day's own Mass formulary, one
row per day: date, the formulary's slug (which Mass is actually said, not
always the day's own), and how that was decided
(proper/own/preceding-sunday/common/votive). TAB-separated -- not space like
`day`, not " | " like `readings` -- because a resolved formulary NAME (a
later column, not this one) can itself contain both spaces and punctuation a
citation never does, and TAB is what stays unambiguous either way. A
separate command for the same mechanical reason `readings` is: `day`'s row
is fixed-width space-separated with a variable-length "+slug" tail.

  $ colitur rubrics 2026 | head -3
  2026-01-01	ef-circumcision	own	true
  2026-01-02	ef-christmas-1-friday	own	false
  2026-01-03	ef-christmas-1-saturday	votive	false

  $ colitur rubrics 2026 | wc -l
  365

1 January reads "own", not "proper": the Circumcision is a TEMPORAL-origin
office (Rite_ef.Temporal_ef, not the sanctoral Layer), so step 1 of the
resolution chain (a SANCTORAL observed celebration's own proper) does not
apply to it -- step 2 does (the day's own temporal slug in the lectionary),
tagged `own`. Contrast a real sanctoral saint with his own proper:

  $ colitur rubrics 2038 | grep '^2038-03-08'
  2038-03-08	john-of-god	proper	false

A saint with no proper of his own says his assigned Common (step 4):

  $ colitur rubrics 2038 | grep '^2038-03-06'
  2038-03-06	common-of-non-virgins-1	common	false

A weekday with no proper of its own resumes the preceding Sunday's, never
its own observed slug -- 1 December 2025 is the Monday after Advent I, and
Advent's ferias have no Mass of their own (step 3):

  $ colitur rubrics 2025 | grep '^2025-12-01'
  2025-12-01	ef-advent-sunday-1	preceding-sunday	false

3 January 2026 above ("votive") is the RG 78/309(a) Saturday Mass of Our
Lady, said IN PLACE of the day's own office's Mass while the office (an
unoccupied IV-class Saturday) itself is kept unchanged -- distinct from
`own` precisely because a votive Mass is not the day's own, even though
`said` names the identical slug either way.

A year outside the supported domain is rejected (exit 2):

  $ colitur rubrics 1000
  colitur: year 1000 out of range 1583..9999
  [2]

--overlay is accepted, the same reasoning `readings` gets it: an overlay can
change which celebration is observed, hence which Mass formulary is said.
Baseline, the unoccupied Saturday says Our Lady's votive Mass; with the
diocesan overlay's local patron observed instead (no proper or Common of his
own in the fixture), the chain falls all the way back to step 3:

  $ colitur rubrics 2026 --overlay fixtures/overlay-example-diocesan.sexp | grep '^2026-07-11'
  2026-07-11	ef-time-after-pentecost-sunday-6	preceding-sunday	false
  $ colitur rubrics 2026 | grep '^2026-07-11'
  2026-07-11	ef-time-after-pentecost-6-saturday	votive	false

`--lang`/`--raw`/`--sigla-*` are refused rather than silently ignored, unlike
`readings`: this row resolves no display name and no citation for any of
them to affect.

  $ colitur rubrics 2026 --lang en
  colitur: --lang/--raw have no effect on `rubrics`; refusing rather than ignoring them
  [2]

  $ colitur rubrics 2026 --raw
  colitur: --lang/--raw have no effect on `rubrics`; refusing rather than ignoring them
  [2]

  $ colitur rubrics 2026 --sigla-style en
  colitur: --sigla-style/--sigla-book/--sigla-tradition have no effect on `rubrics`; refusing rather than ignoring them
  [2]

`colitur day` and `colitur readings` are unaffected by any of the above:

  $ colitur day 2026 --raw | head -1
  2026-01-01 thursday christmastide - ef-circumcision class-1 white
  $ colitur readings 2026 --raw | head -1
  2026-01-01 ef-circumcision | Titus 2:11-15 | Luke 2:21

An explicit COLITUR_DATA_DIR that does not hold the data is an error (exit 2),
never a silent fall-back to some other copy. A packager or operator who names
a directory has stated an intent; quietly calendaring off a different one
because theirs was wrong is the silent substitution this project refuses
everywhere else. The first version of this resolution DID fall through, and a
deliberately bogus value produced a full, plausible, entirely un-flagged year:

  $ COLITUR_DATA_DIR=/nonexistent/xyz colitur readings 2026
  colitur: COLITUR_DATA_DIR is set to /nonexistent/xyz, which contains no sanctoral.sexp
  colitur: refusing to fall back to another data directory -- unset it, or point it at one
  [2]

An empty or half-populated installed directory is a different case and DOES
fall through to the build tree: nobody stated an intent there, and a failed or
partially removed install must not shadow a working tree and then fail later
with a confusing per-file error. (Exercised directly in the task, by creating
_build/default/share/colitur/ef and confirming the year still resolves; not
reproduced here because the cram sandbox's own exe path makes the layout
awkward to stage without asserting on dune internals.)

Help and usage are different things, and the difference is the Unix
convention rather than a preference. Asking for help is a request that
SUCCEEDED: it goes to standard output and exits 0, so it can be piped into a
pager or grepped. Being invoked wrongly is an error: a one-liner to standard
error, exit 2, leaving stdout clean for whatever the caller was really trying
to capture.

  $ colitur --help | head -1
  colitur -- deterministic liturgical calendar engine (Roman rite, 1962)

  $ colitur -h | head -1
  colitur -- deterministic liturgical calendar engine (Roman rite, 1962)

  $ colitur --help > /dev/null
  $ colitur -h > /dev/null

Nothing on stderr, and exit 0 (an exit other than 0 would print a [N] line):

  $ colitur --help 2>&1 >/dev/null

The error path is the mirror image -- nothing on stdout, exit 2:

  $ colitur bogus 2>/dev/null
  [2]

Every command the help lists is a command the binary actually accepts. This
is the check that catches help text drifting away from the dispatch:

  $ for c in easter temporal day readings rubrics; do colitur $c 2026 > /dev/null || echo "$c FAILED"; done

`rubrics`' own field count drifted once already (Task 5 landed a fourth
column, creed; --help's "output formats" section and its worked example
still showed three, and stayed wrong for a whole review round because
nothing here pinned the help BODY, only command dispatch above). This
compares --help's own worked example against what `rubrics` actually
prints for the identical day, so the two cannot silently drift apart
again in either direction:

  $ colitur --help | grep '^  rubrics   date'
    rubrics   date, formulary slug, source, creed -- TAB-separated

  $ colitur --help | sed -n '/^  rubrics   date/{n;p}'
              2026-01-01[TAB]ef-circumcision[TAB]own[TAB]true

  $ colitur rubrics 2026 | grep '^2026-01-01' | sed $'s/\t/[TAB]/g'
  2026-01-01[TAB]ef-circumcision[TAB]own[TAB]true

--version prints the version alone, to standard output, exit 0. Deliberately
not embedded in the help text above: this pin would then have to be edited
every release, for no gain. The Makefile's release target asserts the built
binary reports the version it just wrote, so the two cannot drift.

The SHAPE is pinned here, not the value. Pinning the literal would mean
editing this test on every release for no gain, and the value is already
asserted where it can actually drift: the Makefile's release target requires
the freshly built binary to report the version it just wrote.

  $ colitur --version | grep -cE '^[0-9]+\.[0-9]+\.[0-9]+$'
  1

  $ test "$(colitur --version)" = "$(colitur -V)" && echo same
  same

Agreement with the package metadata (dune-project's own (version ...), which
generates colitur.opam) is NOT asserted here: the cram sandbox has no source
tree to read dune-project from. It is asserted in the Makefile's release
target instead, which rewrites both and then requires the freshly built
binary to report the version it just wrote -- the point in time where a drift
between the two could actually be introduced.

A user-supplied overlay is applied ON TOP of the shipped universal calendar,
never instead of it. That ordering is the point: data/ef/adjustments.sexp
carries RG 110's own 30 June companion, the Major Litanies, St Barbara and
Rogation Wednesday, and a user file that replaced it would silently drop all
four while appearing merely to add a local feast.

A fixed-date local patron, observed on its own day:

  $ colitur day 2026 --overlay fixtures/overlay-example-diocesan.sexp --raw | grep '^2026-07-11 '
  2026-07-11 saturday time-after-pentecost 6 example-local-patron class-3 white +pius-i

A MOVABLE one -- the capability the Date_spec work existed to unlock. "The
first Sunday of October" is the shape a patronal or dedication feast usually
takes, and it lands on a different civil date every year:

  $ for y in 2026 2027 2028; do colitur day $y --overlay fixtures/overlay-example-diocesan.sexp --raw | grep 'example-dedication'; done
  2026-10-04 sunday time-after-pentecost 19 example-dedication class-1 white +ef-time-after-pentecost-sunday-19
  2027-10-03 sunday time-after-pentecost 20 example-dedication class-1 white +ef-time-after-pentecost-sunday-20
  2028-10-01 sunday time-after-pentecost 17 example-dedication class-1 white +ef-time-after-pentecost-sunday-17

The shipped calendar is untouched without the flag -- the same date, no local
feast, the ordinary Sunday restored:

  $ colitur day 2026 --raw | grep '^2026-10-04 '
  2026-10-04 sunday time-after-pentecost 19 ef-time-after-pentecost-sunday-19 class-2 green

And the shipped overlay's own entries survive alongside the user's, which is
what "on top of, not instead of" means in practice:

  $ colitur day 2024 --overlay fixtures/overlay-example-diocesan.sexp | grep -c 'rogation-wednesday'
  1

An unreadable overlay is fatal, not a warning: a calendar that failed to load
is not something to carry on past.

  $ colitur day 2026 --overlay /nonexistent/diocese.sexp
  colitur: failed to load /nonexistent/diocese.sexp: /nonexistent/diocese.sexp: No such file or directory
  [2]

--overlay is refused on the commands it could not affect, rather than accepted
and silently ignored. `easter` reads no calendar data at all, and `temporal`
runs the temporal cycle before any sanctoral layer exists:

  $ colitur easter 2026 --overlay fixtures/overlay-example-diocesan.sexp
  colitur: --overlay has no effect on `easter` (it reads no sanctoral data); refusing rather than ignoring it
  [2]

  $ colitur temporal 2026 --overlay fixtures/overlay-example-diocesan.sexp
  colitur: --overlay has no effect on `temporal` (it reads no sanctoral data); refusing rather than ignoring it
  [2]

A flag needing a value, given none:

  $ colitur day 2026 --overlay
  colitur: --overlay needs a file path
  colitur: usage: colitur easter <year> | colitur temporal <year> | colitur day <year> | colitur readings <year> | colitur rubrics <year> | colitur emit --format FMT --from Y --to Y | colitur table --year Y --template FILE | colitur render --template FILE --year Y | colitur publish --from Y --to Y --out DIR | colitur lang --list|--dump CODE|--check FILE | colitur config --show | colitur check FILE | colitur new-overlay  (try: colitur --help)
  [2]

An unknown option is rejected rather than treated as a positional word:

  $ colitur day 2026 --diocese
  colitur: unknown option --diocese
  colitur: usage: colitur easter <year> | colitur temporal <year> | colitur day <year> | colitur readings <year> | colitur rubrics <year> | colitur emit --format FMT --from Y --to Y | colitur table --year Y --template FILE | colitur render --template FILE --year Y | colitur publish --from Y --to Y --out DIR | colitur lang --list|--dump CODE|--check FILE | colitur config --show | colitur check FILE | colitur new-overlay  (try: colitur --help)
  [2]

The shipped example overlay is runnable documentation, and it must actually
load -- an example that silently rotted would be worse than none. The cram
sandbox cannot reach data/, so the assertion that it loads and applies lives
in test_lectionary_ef.ml, which reads it from the source tree directly.

`new-overlay` prints a starter file to stdout for redirection, and what it
prints must itself be valid -- a template that does not load is worse than no
template, because it teaches the wrong shape. Round-tripped here rather than
pinned line by line, so editing the template's prose does not fail this test
while a syntax error in it still does:

  $ colitur new-overlay > starter.sexp
  $ colitur check starter.sexp
  starter.sexp: ok -- overlay my-parish, 2 directive(s): 2 add, 0 suppress, 0 replace, 0 edit
    every directive found its target
    add      my-local-patron
    add      my-dedication

`check` exits 2 when a directive matches nothing, so it is usable in a
Makefile or a pre-commit hook, not merely readable:

  $ printf '((id p) (directives ((Suppress no-such-slug))))' > bad.sexp
  $ colitur check bad.sexp
  bad.sexp: ok -- overlay p, 1 directive(s): 0 add, 1 suppress, 0 replace, 0 edit
    MATCHED NOTHING: overlay p: suppress no-such-slug: slug not present; nothing to suppress
    suppress no-such-slug
  [2]

`citations` and `layer` may be omitted from an added celebration; they default
to empty and to the overlay's own id:

  $ printf '((id tiny) (directives ((Add ((date (Fixed (month 5) (day 20))) (cel ((slug tiny-feast) (names ((en "Tiny"))) (rank Class3) (status Feast) (colour White) (subject Saint))))))))' > tiny.sexp
  $ colitur check tiny.sexp
  tiny.sexp: ok -- overlay tiny, 1 directive(s): 1 add, 0 suppress, 0 replace, 0 edit
    every directive found its target
    add      tiny-feast

`convert` turns the flat INI form into the S-expression one and verifies its
own output before emitting it. The full pipeline, INI to a resolved day:

  $ printf '[overlay]\nid = my-parish\n[our-patron]\ndate = 07-11\nrank = class-3\ncolour = white\nname.en = St Example\n' > p.ini
  $ colitur convert p.ini > p.sexp
  $ colitur check p.sexp
  p.sexp: ok -- overlay my-parish, 1 directive(s): 1 add, 0 suppress, 0 replace, 0 edit
    every directive found its target
    add      our-patron
  $ colitur day 2026 --overlay p.sexp --raw | grep '^2026-07-11'
  2026-07-11 saturday time-after-pentecost 6 our-patron class-3 white +pius-i

What the INI form cannot express is refused by name, not dropped silently:

  $ printf '[overlay]\nid = x\n[y]\nreplace = yes\n' > r.ini
  $ colitur convert r.ini
  colitur: r.ini: section [y]: replace is not expressible in the INI form -- it needs a whole entry, which is what the S-expression form is for (see colitur-overlay(5)). Suppress plus a fresh section is usually what you want instead.
  [2]

The two shipped real-calendar examples must load and convert. They are
transcriptions, so nothing here asserts they are liturgically right -- only
that what we ship parses, converts and applies, which is the part we control:

  $ colitur convert ../data/ef/examples/poland.ini > pl.sexp
  $ colitur check pl.sexp | head -2
  pl.sexp: ok -- overlay poland-1964, 17 directive(s): 17 add, 0 suppress, 0 replace, 0 edit
    every directive found its target

  $ colitur convert ../data/ef/examples/benedictine.ini > ben.sexp
  $ colitur check ben.sexp
  ben.sexp: ok -- overlay benedictine-nursia, 2 directive(s): 2 add, 0 suppress, 0 replace, 0 edit
    every directive found its target
    add      transitus-of-our-holy-father-benedict
    add      maurus-abbot

An overlay feast competes under the ordinary rules: the Benedictine Transitus
is I class and takes 21 March, with the Lenten feria commemorated rather than
displaced silently.

  $ colitur day 2026 --overlay ben.sexp --raw | grep '^2026-03-21'
  2026-03-21 saturday lent 4 transitus-of-our-holy-father-benedict class-1 white +ef-lent-4-saturday

CSV emits a header and one row per day:

  $ colitur emit --format csv --from 2027 --to 2027 | head -2
  date,rite,season,season_name,week,slug,name,weekday,rank,rank_name,colour,colour_name,subject,first,gospel,comms
  2027-01-01,ef,christmastide,Tempus Nativitatis,,ef-circumcision,In Octava Nativitatis Domini,Feria VI,class-1,I classis,white,albus,temporal,Tit 2:11-15,Luc 2:21,

  $ colitur emit --format csv --from 2027 --to 2027 | wc -l
  366

JSON is one object, ICS one VCALENDAR:

  $ colitur emit --format json --from 2027 --to 2027 | cut -c1-75
  {"rite":"ef","rite_name":"Missale Romanum, editio typica 1962","year":"2027

  $ colitur emit --format ics --from 2027 --to 2027 | head -1 | cat -A | head -1
  BEGIN:VCALENDAR^M$

Two runs are byte-identical (no clock read anywhere):

  $ colitur emit --format ics --from 2027 --to 2027 > /tmp/a.ics
  $ colitur emit --format ics --from 2027 --to 2027 > /tmp/b.ics
  $ cmp /tmp/a.ics /tmp/b.ics && echo identical
  identical

A multi-year range concatenates years in order, one header for the whole
CSV run rather than one per year:

  $ colitur emit --format csv --from 2027 --to 2028 | grep -c '^2028-'
  366

  $ colitur emit --format csv --from 2027 --to 2028 | wc -l
  732

sexp and xml are also available. This line count moved twice in the same
whole-branch review fix round, for two different reasons, both cosmetic:
[emit --format sexp] pretty-prints with [Sexplib.Sexp.to_string_hum], a
column-width wrapping printer, not a fixed-shape one, so any change to a
value's own rendered WIDTH can shift where it wraps.

9010 -> 9011 (RG 476(f)): 2027's All Souls' Day (2 November) record grew
a single wrapped line when its own [creed] field's value changed from
[true] to [false] (the Creed is never said at a Requiem Mass; see
rubrics_ef.ml), because "false" is one character longer than "true" and
pushed that one line's rendered width over the wrap threshold.

9011 -> 9025 (Mass_formulary.t.said honesty, Fix 3): [said] gained an
[option] (its own .mli has the full account) -- every day's formulary
record now prints [(said (<slug>))] instead of [(said <slug>)], one
character wider, and a [Votive] day (the RG 78 Saturday Mass of Our
Lady) prints [(said ())] instead of naming a slug at all, since the
data genuinely names none for the Mass actually said. Both changes ripple
across many lines' own wrap points, not just the days whose DATA changed
-- confirmed directly (diffed the full sexp output line by line): every
difference is exactly this [said] shape change or a consequent wrap
shift, nothing else. Not a claim that [emit]'s FORMAT changed, only that
individual records' pretty-printed SHAPE did:

  $ colitur emit --format sexp --from 2027 --to 2027 | wc -l
  9025

  $ colitur emit --format xml --from 2027 --to 2027 | head -2
  <?xml version="1.0" encoding="UTF-8"?>
  <calendar rite="ef" year="2027">

An unknown format is a usage error on stderr, exit 2:

  $ colitur emit --format yaml --from 2027 --to 2027
  colitur: unknown format "yaml" (want csv, json, sexp, xml or ics)
  [2]

--dtstamp is the only user string that reaches ICS output unescaped and
unvalidated -- it must be exactly RFC 5545's UTC DATE-TIME form (8 digits,
"T", 6 digits, "Z") or refused outright, rather than either silently
emitting a malformed DTSTAMP or, worse, letting an embedded CRLF inject
extra lines into every VEVENT:

  $ colitur emit --format ics --from 2027 --to 2027 --dtstamp hello
  colitur: --dtstamp "hello" is not RFC 5545 UTC form (want 8 digits, 'T', 6 digits, 'Z', e.g. 20270101T000000Z)
  [2]

  $ colitur emit --format ics --from 2027 --to 2027 --dtstamp "$(printf 'X\r\nBEGIN:VEVENT\r\nUID:evil')"
  colitur: --dtstamp "X\r\nBEGIN:VEVENT\r\nUID:evil" is not RFC 5545 UTC form (want 8 digits, 'T', 6 digits, 'Z', e.g. 20270101T000000Z)
  [2]

A well-formed value is threaded through unchanged. (The events themselves
end in CRLF per RFC 5545 -- match the substring, not a `$`-anchored full
line, or a shell that does not mangle the trailing "\r" is doing the
grep-anchor's job for it by accident.)

  $ colitur emit --format ics --from 2027 --to 2027 --dtstamp 20270101T000000Z | grep -c 'DTSTAMP:20270101T000000Z'
  365

emit refuses a reversed range rather than emitting nothing:

  $ colitur emit --format csv --from 2028 --to 2027
  colitur: --from 2028 is after --to 2027
  [2]

emit's own flags have no effect on the other commands, refused rather than
silently ignored, the same discipline --overlay already gets:

  $ colitur day 2027 --format csv
  colitur: --format/--from/--to/--dtstamp have no effect on `day`; refusing rather than ignoring them
  [2]

day and readings are untouched:

  $ colitur day 2027 --raw | head -1
  2027-01-01 friday christmastide - ef-circumcision class-1 white

  $ colitur readings 2027 --raw | head -1
  2027-01-01 ef-circumcision | Titus 2:11-15 | Luke 2:21

Names are Latin by default (Task 6): `day` gains a trailing display name and
`readings` a trailing " | name" field, appended rather than substituted into
the slug's own position -- a name contains spaces, and inserting it there
would break every fixed-position field after it for anyone parsing this row
by column, the same reason `readings` is a separate command in the first
place. 1 January 2027 is the Circumcision, kept within the Nativity octave:

  $ colitur day 2027 | head -2
  2027-01-01 friday christmastide - ef-circumcision class-1 white In Octava Nativitatis Domini
  2027-01-02 saturday christmastide - ef-christmas-1-saturday class-4 white Officium sanctae Mariae in sabbato

  $ colitur readings 2027 | head -1
  2027-01-01 ef-circumcision | Tit 2:11-15 | Luc 2:21 | In Octava Nativitatis Domini

--raw restores the old byte-exact output -- no trailing field at all, not
merely an empty one, because [Lang.raw] is the identity table (lang.mli):
the resolved name equals the slug already printed, so day_line/readings_line
omit the field entirely rather than repeat the slug a second time. This is
not a special case keyed on --raw itself; it is a direct consequence of the
identity table, verified against the pre-branch binary in the task report
rather than merely asserted:

  $ colitur day 2027 --raw | head -1
  2027-01-01 friday christmastide - ef-circumcision class-1 white

  $ colitur readings 2027 --raw | head -1
  2027-01-01 ef-circumcision | Titus 2:11-15 | Luke 2:21

Every citation now renders through a {!Colitur_citation.Sigla.t} (Task 9),
at both places one reaches output -- [View.citation_ref] (`table`/`render`/
`emit`/`publish`) and `readings`' own `part_ref`. The DEFAULT style
normalises the seven duplicate book spellings the shipped data inherited
from lectio onto ONE name, reconstructed from the citation's PARSED
structure, so stray punctuation the parser already treats as noise
(a trailing full stop or semicolon, a comma used as a chapter/verse
separator) does not survive either. Since Task 10 shipped `la.ini`'s own
`[bible]` section, that one name is a real Latin abbreviation sourced from
the Missal scans, not merely a pick between the data's own two raw
spellings: both `3 Kgs.` and `3 Kings` resolve to `3 Reg`, the Vulgate's own
form (lang/la.ini's own `[bible]` header note, kings_3):

  $ colitur readings 2027 | grep -E '3 Reg' | head -3
  2027-02-17 ef-lent-ember-wed | 3 Reg 19:3-8 | Matth 12:38-50 | Feria IV Quatuor Temporum Quadragesimae
  2027-02-23 ef-lent-2-tuesday | 3 Reg 17:8-16 | Matth 23:1-12 | Feria III post Dominicam II in Quadragesima
  2027-03-08 ef-lent-4-monday | 3 Reg 3:16-28 | Ioann 2:13-25 | Feria II post Dominicam IV in Quadragesima

`--raw` passes {!Colitur_citation.Sigla.verbatim}, NEVER a styled `Sigla.t`
built over `Lang.raw` -- the latter would still parse and reformat every
citation, defeating the byte-exact diffing `--raw` exists for. So the
dotted, unnormalised spelling the data actually stores survives `--raw`
untouched, proving the bypass is real and not merely a style that happens
to look unstyled:

  $ colitur readings 2027 --raw | grep -E '3 K(gs|ings)' | head -3
  2027-02-17 ef-lent-ember-wed | 3 Kgs. 19:3-8 | Matt 12:38-50
  2027-02-23 ef-lent-2-tuesday | 3 Kings 17:8-16 | Matt 23:1-12
  2027-03-08 ef-lent-4-monday | 3 Kings 3:16-28 | John 2:13-25

  $ colitur readings 2027 --raw | grep -c '3 Kgs\.'
  1

A language file by path (--lang accepts a bare code OR a file path -- a
value containing '/' or ending ".ini" is read literally rather than looked
up in the installed language directory). The synthetic file below declares
`fallback = la`, so every slug it does not name itself still resolves
through the chain to la.ini's real name, and only the one it DOES name
shows the override:

  $ printf '[meta]\nlang = xx\nfallback = la\n[celebration]\nef-circumcision = TEST FEAST\n' > lang-xx.ini
  $ colitur day 2027 --lang ./lang-xx.ini | head -1
  2027-01-01 friday christmastide - ef-circumcision class-1 white TEST FEAST

A book name's own `[bible]` lookup is TOTAL and returns THE KEY on a miss
(e.g. "luke.abbr"), the same contract every other `Lang` lookup keeps --
rendering that literally into a citation would print "luke.abbr 2:21".
The language file below has no `[bible]` section AT ALL (and, unlike
`lang-xx.ini` above, no `fallback` either, so it can never inherit one
through the chain), pinning the degrade-to-the-data's-own-spelling path
independently of whatever `la.ini`/`en.ini` ship in `[bible]` themselves:
this stays a real witness even after they gain one, where a test relying
on their own current bare state would quietly stop testing anything the
day they do.

  $ printf '[meta]\nlang = yy\n' > lang-no-bible.ini
  $ colitur readings 2027 --lang ./lang-no-bible.ini | head -1
  2027-01-01 ef-circumcision | Titus 2:11-15 | Luke 2:21

An unknown language is a usage error naming what is available, never a
silent fallback to Latin: a booklet quietly printed in the wrong language is
worse than one that refuses to print. The exact "(looked in ...)" path is
not pinned here -- it names the cram sandbox's own build tree, the same
reason the data-directory fallback test above is not reproduced literally
either:

  $ colitur day 2027 --lang nonexistent 2>&1 | grep -c 'no language "nonexistent"'
  1
  $ colitur day 2027 --lang nonexistent >/dev/null 2>&1; echo "exit=$?"
  exit=2

A malformed language file is an error, not a crash:

  $ printf '[celebration\nbroken' > bad-lang.ini
  $ colitur day 2027 --lang ./bad-lang.ini
  colitur: ./bad-lang.ini: line 1: "[celebration" looks like a section header but does not end with ']'
  [2]

--lang/--raw have no effect on a command that reads no display names at
all, refused rather than silently ignored, the same discipline --overlay
already gets:

  $ colitur easter 2026 --lang la
  colitur: --lang/--raw have no effect on `easter`; refusing rather than ignoring them
  [2]

  $ colitur temporal 2026 --raw
  colitur: --lang/--raw have no effect on `temporal`; refusing rather than ignoring them
  [2]

The ICS SUMMARY line is localised too (found while reviewing the published
site: it used to read the kernel's own unlocalised "(class-1, white)" where
the JSON beside it already said "II classis"/"albus" -- emit_ics.ml was
reading the wrong pair of view fields):

  $ colitur emit --format ics --from 2027 --to 2027 | grep '^SUMMARY' | head -1
  SUMMARY:In Octava Nativitatis Domini (I classis\, albus)

A minimal inline template renders -- table computes and renders in one
process (2 January 2027 is a Saturday, not a Sunday, so Holy Name Sunday
falls on the 3rd, not the 2nd, that year):

  $ printf '{{#days}}{{iso}} {{slug}}\n{{/days}}' > /tmp/t.txt
  $ colitur table --year 2027 --template /tmp/t.txt | head -2
  2027-01-01 ef-circumcision
  2027-01-02 ef-christmas-1-saturday

render is the same operation under the name the design used:

  $ colitur render --template /tmp/t.txt --year 2027 | head -2
  2027-01-01 ef-circumcision
  2027-01-02 ef-christmas-1-saturday

Flavour is inferred from the extension. `name` is the RESOLVED display
string now (view.ml), not the old lang-keyed object -- there is no more
`.en`/`.la` to reach, so a plain `{{name}}` is the only correct form; the
old `{{name.en}}` dotted lookup on today's plain string simply finds
nothing, on purpose (this is the change that also removes the shadowing
hazard: a miss on a plain string has no dotted path left to fall back
through). Names are Latin here (no --lang until the CLI wiring task), and
the Missal's own Latin spells Peter and Paul's feast with "et", never an
ampersand, so this no longer doubles as an escaping demonstration --
that property is proved live on 2035 data in test_emit.ml instead
(test_xml_escapes_live_data), where the English table both name sources
still share does carry one:

  $ printf '{{#days}}{{name}}\n{{/days}}' > /tmp/t.tex
  $ colitur table --year 2035 --template /tmp/t.tex | grep -c 'Ss\. Petri et Pauli'
  2

An unknown extension with no --flavour is an error, not a silent fallback:

  $ printf 'x' > /tmp/t.wat
  $ colitur table --year 2027 --template /tmp/t.wat
  colitur: cannot infer a flavour from ".wat"; pass --flavour latex|typst|groff|html|xml|ics|none
  [2]

  $ colitur table --year 2027 --template /tmp/t.wat --flavour none
  x

An unrecognised --flavour value is also an error naming the seven valid ones:

  $ colitur table --year 2027 --template /tmp/t.txt --flavour bogus
  colitur: unknown flavour "bogus" (want latex, typst, groff, html, xml, ics or none)
  [2]

Typst is inferred from .typ, like every other flavour's own extension.
Its metacharacters are escaped the same way latex's own is above -- here
'-', verified live (against the real typst binary) to collapse into an
en dash if left unescaped; a real shipped citation carries one:

  $ printf '{{#days}}{{first}}\n{{/days}}' > /tmp/t.typ
  $ colitur table --year 2027 --template /tmp/t.typ | grep -c '11\\-16'
  1

A malformed template is a clear error, not a crash:

  $ printf '{{#days}}oops' > /tmp/bad.txt
  $ colitur table --year 2027 --template /tmp/bad.txt
  colitur: template /tmp/bad.txt: unclosed section {{#days}}
  [2]

A missing template file is an error:

  $ colitur table --year 2027 --template /tmp/nope.txt
  colitur: cannot read template /tmp/nope.txt
  [2]

Pointing --template at a directory is an error, not a crash: the read
itself is guarded, not only the open (F1, fix round 1). "." is used rather
than a fixed /tmp path so this does not depend on anything outside the
cram sandbox itself:

  $ colitur table --year 2027 --template . --flavour none
  colitur: cannot read template .: Sys_error("Value too large for defined data type")
  [2]

table and render both require --year and --template:

  $ colitur table --year 2027
  colitur: table requires --year YEAR and --template FILE
  [2]

  $ colitur render --template /tmp/t.txt
  colitur: render requires --year YEAR and --template FILE
  [2]

table/render's own flags have no effect on the other commands, refused
rather than silently ignored:

  $ colitur emit --format csv --from 2027 --to 2027 --year 2028
  colitur: --year/--template/--flavour have no effect on `emit`; refusing rather than ignoring them
  [2]

publish writes the documented tree (Task 12): the manifest itself
(.colitur-manifest) is a real file `find` sees too, since it lives in the
same directory as everything else it tracks. Every /tmp/pub* path below is
cleared first, so this section is self-contained across repeat runs:

  $ rm -rf /tmp/pub /tmp/pub1 /tmp/pub2 /tmp/pub3

  $ colitur publish --from 2027 --to 2027 --out /tmp/pub >/dev/null
  $ find /tmp/pub -type f | sed 's|/tmp/pub/||' | sort
  .colitur-manifest
  ef/2027.csv
  ef/2027.ics
  ef/2027.json
  ef/2027.xml
  ef/2027/01/01.json
  ef/2027/01/02.json
  ef/2027/01/03.json
  ef/2027/01/04.json
  ef/2027/01/05.json
  ef/2027/01/06.json
  ef/2027/01/07.json
  ef/2027/01/08.json
  ef/2027/01/09.json
  ef/2027/01/10.json
  ef/2027/01/11.json
  ef/2027/01/12.json
  ef/2027/01/13.json
  ef/2027/01/14.json
  ef/2027/01/15.json
  ef/2027/01/16.json
  ef/2027/01/17.json
  ef/2027/01/18.json
  ef/2027/01/19.json
  ef/2027/01/20.json
  ef/2027/01/21.json
  ef/2027/01/22.json
  ef/2027/01/23.json
  ef/2027/01/24.json
  ef/2027/01/25.json
  ef/2027/01/26.json
  ef/2027/01/27.json
  ef/2027/01/28.json
  ef/2027/01/29.json
  ef/2027/01/30.json
  ef/2027/01/31.json
  ef/2027/02/01.json
  ef/2027/02/02.json
  ef/2027/02/03.json
  ef/2027/02/04.json
  ef/2027/02/05.json
  ef/2027/02/06.json
  ef/2027/02/07.json
  ef/2027/02/08.json
  ef/2027/02/09.json
  ef/2027/02/10.json
  ef/2027/02/11.json
  ef/2027/02/12.json
  ef/2027/02/13.json
  ef/2027/02/14.json
  ef/2027/02/15.json
  ef/2027/02/16.json
  ef/2027/02/17.json
  ef/2027/02/18.json
  ef/2027/02/19.json
  ef/2027/02/20.json
  ef/2027/02/21.json
  ef/2027/02/22.json
  ef/2027/02/23.json
  ef/2027/02/24.json
  ef/2027/02/25.json
  ef/2027/02/26.json
  ef/2027/02/27.json
  ef/2027/02/28.json
  ef/2027/03/01.json
  ef/2027/03/02.json
  ef/2027/03/03.json
  ef/2027/03/04.json
  ef/2027/03/05.json
  ef/2027/03/06.json
  ef/2027/03/07.json
  ef/2027/03/08.json
  ef/2027/03/09.json
  ef/2027/03/10.json
  ef/2027/03/11.json
  ef/2027/03/12.json
  ef/2027/03/13.json
  ef/2027/03/14.json
  ef/2027/03/15.json
  ef/2027/03/16.json
  ef/2027/03/17.json
  ef/2027/03/18.json
  ef/2027/03/19.json
  ef/2027/03/20.json
  ef/2027/03/21.json
  ef/2027/03/22.json
  ef/2027/03/23.json
  ef/2027/03/24.json
  ef/2027/03/25.json
  ef/2027/03/26.json
  ef/2027/03/27.json
  ef/2027/03/28.json
  ef/2027/03/29.json
  ef/2027/03/30.json
  ef/2027/03/31.json
  ef/2027/04/01.json
  ef/2027/04/02.json
  ef/2027/04/03.json
  ef/2027/04/04.json
  ef/2027/04/05.json
  ef/2027/04/06.json
  ef/2027/04/07.json
  ef/2027/04/08.json
  ef/2027/04/09.json
  ef/2027/04/10.json
  ef/2027/04/11.json
  ef/2027/04/12.json
  ef/2027/04/13.json
  ef/2027/04/14.json
  ef/2027/04/15.json
  ef/2027/04/16.json
  ef/2027/04/17.json
  ef/2027/04/18.json
  ef/2027/04/19.json
  ef/2027/04/20.json
  ef/2027/04/21.json
  ef/2027/04/22.json
  ef/2027/04/23.json
  ef/2027/04/24.json
  ef/2027/04/25.json
  ef/2027/04/26.json
  ef/2027/04/27.json
  ef/2027/04/28.json
  ef/2027/04/29.json
  ef/2027/04/30.json
  ef/2027/05/01.json
  ef/2027/05/02.json
  ef/2027/05/03.json
  ef/2027/05/04.json
  ef/2027/05/05.json
  ef/2027/05/06.json
  ef/2027/05/07.json
  ef/2027/05/08.json
  ef/2027/05/09.json
  ef/2027/05/10.json
  ef/2027/05/11.json
  ef/2027/05/12.json
  ef/2027/05/13.json
  ef/2027/05/14.json
  ef/2027/05/15.json
  ef/2027/05/16.json
  ef/2027/05/17.json
  ef/2027/05/18.json
  ef/2027/05/19.json
  ef/2027/05/20.json
  ef/2027/05/21.json
  ef/2027/05/22.json
  ef/2027/05/23.json
  ef/2027/05/24.json
  ef/2027/05/25.json
  ef/2027/05/26.json
  ef/2027/05/27.json
  ef/2027/05/28.json
  ef/2027/05/29.json
  ef/2027/05/30.json
  ef/2027/05/31.json
  ef/2027/06/01.json
  ef/2027/06/02.json
  ef/2027/06/03.json
  ef/2027/06/04.json
  ef/2027/06/05.json
  ef/2027/06/06.json
  ef/2027/06/07.json
  ef/2027/06/08.json
  ef/2027/06/09.json
  ef/2027/06/10.json
  ef/2027/06/11.json
  ef/2027/06/12.json
  ef/2027/06/13.json
  ef/2027/06/14.json
  ef/2027/06/15.json
  ef/2027/06/16.json
  ef/2027/06/17.json
  ef/2027/06/18.json
  ef/2027/06/19.json
  ef/2027/06/20.json
  ef/2027/06/21.json
  ef/2027/06/22.json
  ef/2027/06/23.json
  ef/2027/06/24.json
  ef/2027/06/25.json
  ef/2027/06/26.json
  ef/2027/06/27.json
  ef/2027/06/28.json
  ef/2027/06/29.json
  ef/2027/06/30.json
  ef/2027/07/01.json
  ef/2027/07/02.json
  ef/2027/07/03.json
  ef/2027/07/04.json
  ef/2027/07/05.json
  ef/2027/07/06.json
  ef/2027/07/07.json
  ef/2027/07/08.json
  ef/2027/07/09.json
  ef/2027/07/10.json
  ef/2027/07/11.json
  ef/2027/07/12.json
  ef/2027/07/13.json
  ef/2027/07/14.json
  ef/2027/07/15.json
  ef/2027/07/16.json
  ef/2027/07/17.json
  ef/2027/07/18.json
  ef/2027/07/19.json
  ef/2027/07/20.json
  ef/2027/07/21.json
  ef/2027/07/22.json
  ef/2027/07/23.json
  ef/2027/07/24.json
  ef/2027/07/25.json
  ef/2027/07/26.json
  ef/2027/07/27.json
  ef/2027/07/28.json
  ef/2027/07/29.json
  ef/2027/07/30.json
  ef/2027/07/31.json
  ef/2027/08/01.json
  ef/2027/08/02.json
  ef/2027/08/03.json
  ef/2027/08/04.json
  ef/2027/08/05.json
  ef/2027/08/06.json
  ef/2027/08/07.json
  ef/2027/08/08.json
  ef/2027/08/09.json
  ef/2027/08/10.json
  ef/2027/08/11.json
  ef/2027/08/12.json
  ef/2027/08/13.json
  ef/2027/08/14.json
  ef/2027/08/15.json
  ef/2027/08/16.json
  ef/2027/08/17.json
  ef/2027/08/18.json
  ef/2027/08/19.json
  ef/2027/08/20.json
  ef/2027/08/21.json
  ef/2027/08/22.json
  ef/2027/08/23.json
  ef/2027/08/24.json
  ef/2027/08/25.json
  ef/2027/08/26.json
  ef/2027/08/27.json
  ef/2027/08/28.json
  ef/2027/08/29.json
  ef/2027/08/30.json
  ef/2027/08/31.json
  ef/2027/09/01.json
  ef/2027/09/02.json
  ef/2027/09/03.json
  ef/2027/09/04.json
  ef/2027/09/05.json
  ef/2027/09/06.json
  ef/2027/09/07.json
  ef/2027/09/08.json
  ef/2027/09/09.json
  ef/2027/09/10.json
  ef/2027/09/11.json
  ef/2027/09/12.json
  ef/2027/09/13.json
  ef/2027/09/14.json
  ef/2027/09/15.json
  ef/2027/09/16.json
  ef/2027/09/17.json
  ef/2027/09/18.json
  ef/2027/09/19.json
  ef/2027/09/20.json
  ef/2027/09/21.json
  ef/2027/09/22.json
  ef/2027/09/23.json
  ef/2027/09/24.json
  ef/2027/09/25.json
  ef/2027/09/26.json
  ef/2027/09/27.json
  ef/2027/09/28.json
  ef/2027/09/29.json
  ef/2027/09/30.json
  ef/2027/10/01.json
  ef/2027/10/02.json
  ef/2027/10/03.json
  ef/2027/10/04.json
  ef/2027/10/05.json
  ef/2027/10/06.json
  ef/2027/10/07.json
  ef/2027/10/08.json
  ef/2027/10/09.json
  ef/2027/10/10.json
  ef/2027/10/11.json
  ef/2027/10/12.json
  ef/2027/10/13.json
  ef/2027/10/14.json
  ef/2027/10/15.json
  ef/2027/10/16.json
  ef/2027/10/17.json
  ef/2027/10/18.json
  ef/2027/10/19.json
  ef/2027/10/20.json
  ef/2027/10/21.json
  ef/2027/10/22.json
  ef/2027/10/23.json
  ef/2027/10/24.json
  ef/2027/10/25.json
  ef/2027/10/26.json
  ef/2027/10/27.json
  ef/2027/10/28.json
  ef/2027/10/29.json
  ef/2027/10/30.json
  ef/2027/10/31.json
  ef/2027/11/01.json
  ef/2027/11/02.json
  ef/2027/11/03.json
  ef/2027/11/04.json
  ef/2027/11/05.json
  ef/2027/11/06.json
  ef/2027/11/07.json
  ef/2027/11/08.json
  ef/2027/11/09.json
  ef/2027/11/10.json
  ef/2027/11/11.json
  ef/2027/11/12.json
  ef/2027/11/13.json
  ef/2027/11/14.json
  ef/2027/11/15.json
  ef/2027/11/16.json
  ef/2027/11/17.json
  ef/2027/11/18.json
  ef/2027/11/19.json
  ef/2027/11/20.json
  ef/2027/11/21.json
  ef/2027/11/22.json
  ef/2027/11/23.json
  ef/2027/11/24.json
  ef/2027/11/25.json
  ef/2027/11/26.json
  ef/2027/11/27.json
  ef/2027/11/28.json
  ef/2027/11/29.json
  ef/2027/11/30.json
  ef/2027/12/01.json
  ef/2027/12/02.json
  ef/2027/12/03.json
  ef/2027/12/04.json
  ef/2027/12/05.json
  ef/2027/12/06.json
  ef/2027/12/07.json
  ef/2027/12/08.json
  ef/2027/12/09.json
  ef/2027/12/10.json
  ef/2027/12/11.json
  ef/2027/12/12.json
  ef/2027/12/13.json
  ef/2027/12/14.json
  ef/2027/12/15.json
  ef/2027/12/16.json
  ef/2027/12/17.json
  ef/2027/12/18.json
  ef/2027/12/19.json
  ef/2027/12/20.json
  ef/2027/12/21.json
  ef/2027/12/22.json
  ef/2027/12/23.json
  ef/2027/12/24.json
  ef/2027/12/25.json
  ef/2027/12/26.json
  ef/2027/12/27.json
  ef/2027/12/28.json
  ef/2027/12/29.json
  ef/2027/12/30.json
  ef/2027/12/31.json
  index.html
  schema/day-v1.json

  $ ls /tmp/pub/ef/2027/01/*.json | wc -l
  31

  $ test -f /tmp/pub/schema/day-v1.json && echo schema-present
  schema-present

  $ test -f /tmp/pub/index.html && echo index-present
  index-present

Publishing twice is byte-identical -- safe to publish into a git repo:

  $ colitur publish --from 2027 --to 2027 --out /tmp/pub1 >/dev/null
  $ colitur publish --from 2027 --to 2027 --out /tmp/pub2 >/dev/null
  $ diff -r /tmp/pub1 /tmp/pub2 && echo identical
  identical

The published .ics is byte-identical to `emit --format ics` for the same
year -- both walk through the identical Emit_ics.year:

  $ colitur emit --format ics --from 2027 --to 2027 > /tmp/emit-2027.ics
  $ diff /tmp/pub1/ef/2027.ics /tmp/emit-2027.ics && echo ics-identical
  ics-identical

publish never deletes a file it does not own:

  $ touch /tmp/pub1/MY-NOTES.txt
  $ colitur publish --from 2027 --to 2027 --out /tmp/pub1 >/dev/null
  $ test -f /tmp/pub1/MY-NOTES.txt && echo kept
  kept

--prune removes only files a previous run created:

  $ colitur publish --from 2027 --to 2027 --out /tmp/pub1 --prune >/dev/null
  $ test -f /tmp/pub1/MY-NOTES.txt && echo still-kept
  still-kept

  $ colitur publish --from 2028 --to 2028 --out /tmp/pub1 --prune >/dev/null
  $ test -d /tmp/pub1/ef/2027 || echo pruned-2027
  pruned-2027
  $ test -f /tmp/pub1/MY-NOTES.txt && echo notes-survived-prune
  notes-survived-prune

--out is required:

  $ colitur publish --from 2027 --to 2027
  colitur: publish requires --out DIR
  [2]

publish writes many files across a whole year range (mkdir_p/write_file),
same as the template read guarded in commit 6bd741b -- --out is user input
too, and an unwritable parent used to surface as an uncaught
Unix.Unix_error instead of the project's one-line form. The read-only
directory below lives in this test's own cram sandbox, not /tmp: a failed
`rm -rf` of an unwritable directory would otherwise leave it behind in a
shared location, so it is restored to writable before the test ends either
way:

  $ mkdir ro-parent && chmod 555 ro-parent
  $ colitur publish --from 2027 --to 2027 --out ro-parent/sub
  colitur: mkdir: ro-parent/sub: Permission denied
  [2]
  $ chmod 755 ro-parent

publish's own flags have no effect on the other commands, and other
commands' flags have no effect on publish -- refused rather than silently
ignored, the same discipline as everywhere else:

  $ colitur publish --from 2027 --to 2027 --out /tmp/pub3 --format json
  colitur: --format has no effect on `publish`; refusing rather than ignoring it
  [2]

  $ colitur day 2027 --out /tmp/pub3 --prune
  colitur: --out/--prune have no effect on `day`; refusing rather than ignoring them
  [2]

--prune's manifest-driven deletion is hardened against a manifest entry it
did not itself write (fix round 1, F1, CRITICAL): the manifest lives INSIDE
the tree publish writes into, so a bad merge or a hand-edit can put an
arbitrary path in it -- no attacker required. This is the exact CANARY
reproduction the finding was raised with: a ".." entry appended to the
manifest must never let --prune delete outside --out.

  $ rm -rf /tmp/pub-sec /tmp/pub-sec-outside
  $ mkdir -p /tmp/pub-sec-outside
  $ touch /tmp/pub-sec-outside/CANARY.txt
  $ colitur publish --from 2027 --to 2027 --out /tmp/pub-sec >/dev/null
  $ echo '../pub-sec-outside/CANARY.txt' >> /tmp/pub-sec/.colitur-manifest
  $ colitur publish --from 2028 --to 2028 --out /tmp/pub-sec --prune >/dev/null
  colitur: refusing to prune manifest entry "../pub-sec-outside/CANARY.txt" (absolute path or .. component)
  $ test -f /tmp/pub-sec-outside/CANARY.txt && echo canary-survives
  canary-survives

The same run's own legitimate stale entries (2027's files, superseded by
2028) still prune normally -- the hardening does not disable pruning, only
unsafe entries:

  $ test -d /tmp/pub-sec/ef/2027 || echo 2027-pruned-normally
  2027-pruned-normally

An absolute-path entry is refused the same way, not only a ".." one:

  $ echo '/tmp/pub-sec-outside/CANARY.txt' >> /tmp/pub-sec/.colitur-manifest
  $ colitur publish --from 2028 --to 2028 --out /tmp/pub-sec --prune >/dev/null
  colitur: refusing to prune manifest entry "/tmp/pub-sec-outside/CANARY.txt" (absolute path or .. component)
  $ test -f /tmp/pub-sec-outside/CANARY.txt && echo canary-still-survives
  canary-still-survives

A legitimate filename that merely CONTAINS two dots -- but has no ".." path
COMPONENT -- is not caught by the same check, proving it is not
over-broad: it still prunes normally when stale.

  $ touch /tmp/pub-sec/ef/2027..old.json
  $ echo 'ef/2027..old.json' >> /tmp/pub-sec/.colitur-manifest
  $ colitur publish --from 2029 --to 2029 --out /tmp/pub-sec --prune >/dev/null
  $ test -f /tmp/pub-sec/ef/2027..old.json || echo dotted-name-pruned
  dotted-name-pruned

...and that same run is an ordinary --prune cycle in every other respect --
2028's own files, now stale relative to 2029, are gone too:

  $ test -d /tmp/pub-sec/ef/2028 || echo pruned-2028
  pruned-2028

`colitur lang` and `colitur config` (Task 7) -- what makes "anyone can write
a language file" true rather than merely permitted, the same idea the
overlay system already established with `new-overlay` and `check`.

`--list` shows what language files this build can find:

  $ colitur lang --list
  en     (falls back to la)
  la     

`--dump CODE` scaffolds a complete file on stdout, sections in a fixed order
so two dumps of the same table are byte-identical:

  $ colitur lang --dump la > d.ini
  $ head -4 d.ini
  [meta]
  lang = la
  
  [bible]
  $ grep -c '^ef-epiphany ' d.ini
  1

A dump round-trips -- it just IS la.ini's own content, so it covers every
slug the engine can produce over 2020-2045:

  $ colitur lang --check d.ini
  d.ini: 725 of 725 celebrations named, 0 missing, 0 unknown
  d.ini: 104 of 104 book names, 0 missing

`--check` reports what is MISSING (a real slug with no entry):

  $ printf '[meta]\nlang = zz\n[celebration]\nef-epiphany = Test\n' > partial.ini
  $ colitur lang --check partial.ini | tail -2
  partial.ini: 1 of 725 celebrations named, 724 missing, 0 unknown
  partial.ini: 0 of 104 book names, 104 missing

`flavour` decides how a template's output is ESCAPED. It is normally inferred
from the template's own extension, so the key exists for the template whose
extension says nothing -- and getting it wrong yields malformed output rather
than ugly output, which is why there is no silent fallback:

  $ printf '{{#days}}{{name}}\n{{/days}}' > t.noext
  $ colitur table --year 2027 --template t.noext
  colitur: cannot infer a flavour from ".noext"; pass --flavour latex|typst|groff|html|xml|ics|none
  [2]

  $ mkdir -p xdg-fl/colitur && printf '[defaults]\nflavour = html\n' > xdg-fl/colitur/config.ini
  $ XDG_CONFIG_HOME=xdg-fl colitur table --year 2027 --template t.noext | head -1
  In Octava Nativitatis Domini

An INI overlay handed to `--overlay` is DIAGNOSED, not left to fail inside the
sexp reader. The INI form is a source format that `colitur convert` turns into
the S-expression one; feeding it directly otherwise produced "more than one
S-expression in file", which names neither the cause nor the cure.

  $ printf '; a comment header\n\n[overlay]\nid = x\n' > o.ini
  $ colitur day 2027 --overlay o.ini
  colitur: o.ini looks like an INI overlay, not an S-expression one.
  colitur: convert it first:  colitur convert o.ini > overlay.sexp
  [2]

`--check` REJECTS an unknown slug (exit 1), so a typo is visible rather than
silently dead -- its author would otherwise never learn why the name they
wrote never appears:

  $ printf '[meta]\nlang = zz\n[celebration]\nnot-a-real-slug = X\n' > typo.ini
  $ colitur lang --check typo.ini | grep 'unknown slug'
  unknown slug: not-a-real-slug
  $ colitur lang --check typo.ini >/dev/null 2>&1; echo "exit=$?"
  exit=1

`lang` requires exactly one of --list/--dump/--check:

  $ colitur lang
  colitur: lang requires one of --list, --dump CODE or --check FILE
  [2]

  $ colitur lang --list --dump la
  colitur: lang takes only one of --list, --dump CODE or --check FILE
  [2]

`--dump`/`--check`/`--list`/`--show` have no effect on any other command,
refused rather than silently ignored, the same discipline --overlay/--lang
already get:

  $ colitur day 2027 --dump la
  colitur: --dump/--check/--list/--show have no effect on `day`; refusing rather than ignoring them
  [2]

`config --show` reports each effective setting, its resolved value, and
where it came from -- flag, config or default -- via Config.resolve; there
is deliberately no separate provenance function. Run through an isolated
XDG_CONFIG_HOME so this does not depend on whatever the person running the
suite happens to have in their own home directory (the same reason the
data-directory fallback case earlier in this file is not reproduced
literally either):

  $ XDG_CONFIG_HOME=xdg-test colitur config --show
  config file: xdg-test/colitur/config.ini (not found)
  lang             la                       (default)
  template         (none)                   (default)
  format           (none)                   (default)
  flavour          (infer)                  (default)
  sigla_style      la                       (default)
  sigla_book       abbr                     (default)
  sigla_tradition  vulgate                  (default)
  overlay          (none)                   (default)

A flag on the SAME command line previews exactly what it would resolve to
on any other command -- `overlay` has no single scalar value, so it is
listed one line per effective entry instead:

  $ XDG_CONFIG_HOME=xdg-test colitur config --show --lang fr --overlay mine.sexp
  config file: xdg-test/colitur/config.ini (not found)
  lang             fr                       (flag)
  template         (none)                   (default)
  format           (none)                   (default)
  flavour          (infer)                  (default)
  sigla_style      fr                       (default)
  sigla_book       abbr                     (default)
  sigla_tradition  vulgate                  (default)
  overlay          mine.sexp                (flag)

A real config file supplies a default that a command with no explicit flag
then uses. An unrecognised key and an unrecognised section are each warned
about and ignored, never silently dropped and never fatal on their own --
two separate warnings, so a misspelled section (`[deafults]`) reads
differently from a misspelled key inside a recognised one:

  $ mkdir -p xdg-test/colitur
  $ printf '[defaults]\nlang = en\noverlay = /nonexistent/parish.sexp\nbogus = 1\n\n[deafults]\nlang = xx\n' > xdg-test/colitur/config.ini
  $ XDG_CONFIG_HOME=xdg-test colitur config --show
  config file: xdg-test/colitur/config.ini (exists)
  lang             en                       (config)
  template         (none)                   (default)
  format           (none)                   (default)
  flavour          (infer)                  (default)
  sigla_style      en                       (default)
  sigla_book       abbr                     (default)
  sigla_tradition  vulgate                  (default)
  overlay          /nonexistent/parish.sexp (config)
  colitur: xdg-test/colitur/config.ini: unknown setting "bogus" (ignored)
  colitur: xdg-test/colitur/config.ini: unknown section [deafults] (ignored)

`--sigla-style`/`--sigla-book`/`--sigla-tradition` (Task 8) resolve through
the identical flag > config > default precedence as --lang/--template/
--format, each reported by its own row. `sigla_style` defaults to the
RESOLVED language, not a literal "la" -- a booklet that asked for a
different --lang gets its citations in that language too unless told
otherwise. Run through a fresh XDG_CONFIG_HOME, so this does not depend on
the config file the earlier examples left behind:

  $ XDG_CONFIG_HOME=xdg-sigla colitur config --show --sigla-style pl --sigla-book full --sigla-tradition modern
  config file: xdg-sigla/colitur/config.ini (not found)
  lang             la                       (default)
  template         (none)                   (default)
  format           (none)                   (default)
  flavour          (infer)                  (default)
  sigla_style      pl                       (flag)
  sigla_book       full                     (flag)
  sigla_tradition  modern                   (flag)
  overlay          (none)                   (default)

An unrecognised `--sigla-book` is a hard usage ERROR -- exit 2, one line on
stderr -- the same discipline an unrecognised `--lang` gets, never a silent
fallback: `Colitur_citation.Render.with_book` takes a closed variant, not an
arbitrary string, so anything other than "full"/"abbr" could never mean
anything downstream.

STDOUT STAYS EMPTY. The check used to happen partway down the table, so this
exited 2 having already written five rows -- a caller redirecting stdout to a
file got a truncated but plausible-looking report beside a non-zero status.
Validation now runs before anything is printed.

  $ XDG_CONFIG_HOME=xdg-sigla colitur config --show --sigla-book bogus
  colitur: unknown --sigla-book "bogus" (want "full" or "abbr")
  [2]

An unrecognised `--sigla-tradition`, by contrast, is NOT fatal: it is
resolved for real against lang/traditions.ini (the same reader a future
renderer will use), and a name matching no section there degrades to the
Vulgate with a warning on stderr, exactly as an unknown tradition does
everywhere else in this project -- asking for a renumbering is optional the
way asking for a language is not, so a run is not lost over a typo here.
The warning itself carries lang/traditions.ini's own resolved PATH, which is
absolute and build-tree-specific -- grepped for rather than matched in
full, the same way the pre-existing "unknown language" case above already
sidesteps that same non-portability:

  $ XDG_CONFIG_HOME=xdg-sigla colitur config --show --sigla-tradition nonsense 2>/dev/null
  config file: xdg-sigla/colitur/config.ini (not found)
  lang             la                       (default)
  template         (none)                   (default)
  format           (none)                   (default)
  flavour          (infer)                  (default)
  sigla_style      la                       (default)
  sigla_book       abbr                     (default)
  sigla_tradition  nonsense                 (flag)
  overlay          (none)                   (default)
  $ XDG_CONFIG_HOME=xdg-sigla colitur config --show --sigla-tradition nonsense 2>&1 >/dev/null | grep -c 'no tradition "nonsense"; falling back to the Vulgate'
  1

`--sigla-style`/`--sigla-book`/`--sigla-tradition` (Task 9) actually render
a citation on every command that emits one -- `readings`, `table`/`render`,
`emit`, `publish` -- and are refused, rather than silently ignored, on
every command that reads no sanctoral data or renders no citation at all
(`day` included: it prints no `first`/`gospel` field of its own), the same
discipline `--overlay`/`--lang` already get:

  $ colitur day 2027 --sigla-style la > /dev/null
  colitur: --sigla-style/--sigla-book/--sigla-tradition have no effect on `day`; refusing rather than ignoring them
  [2]

  $ colitur easter 2026 --sigla-book full
  colitur: --sigla-style/--sigla-book/--sigla-tradition have no effect on `easter`; refusing rather than ignoring them
  [2]

`--sigla-style` selects a DIFFERENT file's own `[sigla]` section than
`--lang` selects for names -- a booklet may want Polish names but
Latin-convention punctuation. The synthetic file below overrides only
`chapter_verse` (comma instead of colon), leaving `--lang`'s own default
(Latin) name resolution untouched:

  $ printf '[meta]\nlang = zz\n[sigla]\nchapter_verse = "{chapter}, {verses}"\n' > lang-sigla.ini
  $ colitur readings 2027 --sigla-style ./lang-sigla.ini | head -1
  2027-01-01 ef-circumcision | Tit 2, 11-15 | Luc 2, 21 | In Octava Nativitatis Domini

`--sigla-tradition` renumbers which book an id DENOTES (lang/traditions.ini),
independently of style or naming -- `modern` maps `3 Kings` onto the id
`kings_1`, whose own Latin name la.ini marks CONSTRUCTED: the 1962 Missal
uses Vulgate numbering throughout, so it can contain no incipit for a book
that exists only under a later convention. In Latin the modern tradition
therefore only really moves Kings and Esdras -- Osee, Ionas, Ecclesiasticus
and the Apocalypse keep their Vulgate names either way, because modern
numbering is a vernacular convention:

  $ colitur readings 2027 --sigla-tradition modern | grep '^2027-02-17'
  2027-02-17 ef-lent-ember-wed | 1 Reg 19:3-8 | Matth 12:38-50 | Feria IV Quatuor Temporum Quadragesimae

Under an English file the same mapping shows its usual face:

  $ colitur readings 2027 --lang en --sigla-tradition modern | grep '^2027-02-17'
  2027-02-17 ef-lent-ember-wed | 1 Kgs 19:3-8 | Matt 12:38-50 | Lenten Ember Wednesday

`table`/`render`, `emit` and `publish` accept the same three flags too --
smoke-tested for exit status alone here (a minimal inline template, the
same device the table/render examples above use), since their own
byte-for-byte content is already the golden/emit suites' job, not this
file's:

  $ printf '{{#days}}{{first}}\n{{/days}}' > /tmp/t-sigla.txt
  $ colitur table --year 2027 --template /tmp/t-sigla.txt --sigla-book full > /dev/null
  $ colitur render --template /tmp/t-sigla.txt --year 2027 --sigla-book full > /dev/null
  $ colitur emit --format csv --from 2027 --to 2027 --sigla-book full > /dev/null
  $ colitur publish --from 2027 --to 2027 --out /tmp/pub-sigla --sigla-book full > /dev/null

`config` requires --show:

  $ colitur config
  colitur: config requires --show
  [2]

A config file that EXISTS but fails to parse is fatal -- a config the user
wrote and colitur cannot honour is not something to carry on past silently
-- and this is true for every command, not only `config` itself, because
the file is loaded once, unconditionally, before dispatch:

  $ mkdir -p xdg-bad/colitur
  $ printf '[defaults\nbroken' > xdg-bad/colitur/config.ini
  $ XDG_CONFIG_HOME=xdg-bad colitur day 2027 --raw
  colitur: xdg-bad/colitur/config.ini: line 1: "[defaults" looks like a section header but does not end with ']'
  [2]