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 | colitur temporal | colitur day | colitur readings | 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 | grep -c '^2026-04-05 ' 1 $ colitur day 2026 | 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 | 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 | 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 | grep -c 'commemoration-of-all-souls' 1 $ colitur day 2025 | grep '^2025-11-02 ' | sed -E 's/ [0-9]+ ef-time-after-pentecost-sunday-[0-9]+ / ef-time-after-pentecost-sunday- /' 2025-11-02 sunday time-after-pentecost ef-time-after-pentecost-sunday- class-2 green $ colitur day 2025 | 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 | grep -c 'commemoration-of-all-souls' 1 $ colitur day 2026 | 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 | 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 | 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 | 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 | grep '^2038-03-08 ' 2038-03-08 john-of-god | Ecclus 31:8-11 | Matt 22:34-46 $ colitur readings 2026 | grep '^2026-02-23 ' 2026-02-23 ef-lent-1-monday | Ezech 34:11-16 | Matt 25:31-46 $ colitur readings 2025 | grep '^2025-12-01 ' 2025-12-01 ef-advent-1-monday | Rom 13:11-14 | Luke 21:25-33 $ colitur readings 2038 | 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 | 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] 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; do colitur $c 2026 > /dev/null || echo "$c FAILED"; done --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 | 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 | 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 | 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 | colitur temporal | colitur day | colitur readings | 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 | colitur temporal | colitur day | colitur readings | 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 | 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 | 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,week,slug,rank,colour,subject,name_la,name_en,first,gospel,comms 2027-01-01,ef,christmastide,,ef-circumcision,class-1,white,temporal,,,Titus 2:11-15,Luke 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-20 {"rite":"ef","year": $ 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: $ colitur emit --format sexp --from 2027 --to 2027 | wc -l 8472 $ colitur emit --format xml --from 2027 --to 2027 | head -2 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] 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 | head -1 2027-01-01 friday christmastide - ef-circumcision class-1 white $ colitur readings 2027 | head -1 2027-01-01 ef-circumcision | Titus 2:11-15 | Luke 2:21 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 and escapes data -- Sts. Peter & Paul (29 June) and its vigil are the only two 2035 entries whose English name needs LaTeX escaping: $ printf '{{#days}}{{name.en}}\n{{/days}}' > /tmp/t.tex $ colitur table --year 2035 --template /tmp/t.tex | grep -c 'Peter \\& Paul' 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|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 six valid ones: $ colitur table --year 2027 --template /tmp/t.txt --flavour bogus colitur: unknown flavour "bogus" (want latex, groff, html, xml, ics or none) [2] 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] 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]