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 rubrics | 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]+ / ef-time-after-pentecost-sunday- /' 2025-11-02 sunday time-after-pentecost ef-time-after-pentecost-sunday- 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 true nativity 2026-01-02 ef-christmas-1-friday own false true nativity 2026-01-03 ef-christmas-1-saturday votive false true bvm $ 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 true common 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 true common 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 false common 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 true common $ colitur rubrics 2026 | grep '^2026-07-11' 2026-07-11 ef-time-after-pentecost-6-saturday votive false true bvm `--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, gloria, preface -- TAB-separated $ colitur --help | sed -n '/^ rubrics date/{n;p}' 2026-01-01[TAB]ef-circumcision[TAB]own[TAB]true[TAB]true[TAB]nativity $ colitur rubrics 2026 | grep '^2026-01-01' | sed $'s/\t/[TAB]/g' 2026-01-01[TAB]ef-circumcision[TAB]own[TAB]true[TAB]true[TAB]nativity --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 | colitur temporal | colitur day | colitur readings | colitur rubrics | 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 | colitur temporal | colitur day | colitur readings | colitur rubrics | 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 ())] instead of [(said )], 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. 9025 -> 9197 (Gloria, celebrant-rubrics-phase1 Phase 2): {!Liturgical_day.t} gained a [gloria] field, the same seam [creed] already used -- [emit --format sexp] dumps the whole record ({!Sexplib.Sexp.to_string_hum} over [Liturgical_day.sexp_of_t]) unlike [csv]/[json]/[xml] (a curated [View]/[Record] projection that has never included [creed] either, hence their own line counts above are UNCHANGED by this task -- confirmed by this cram file's own diff touching only the [sexp] count). All 365 of 2027's records print a new [(gloria )] token (checked directly, [grep -c gloria]), but the wrap-point mechanics are the same cosmetic reflow the two entries above describe, not a fixed one-line-per-record addition: 2027-01-01's own record wraps [gloria] onto its own new line, while 2027-01-02's fits it on the same line as [creed] and [formulary] -- 172 of 365 records happened to cross a wrap boundary, the rest did not. 9197 -> 9252 (preface, celebrant-rubrics-phase1 Phase 3): {!Liturgical_day.t} gained a [preface] field, the same seam [gloria] just above used -- all 365 of 2027's records print a new [(preface ())] token (checked directly, [grep -c "(preface"]), [(preface ())] on exactly one day (2027's own Good Friday, 26 March -- this engine resolves no Mass at all that day, {!Rite_ef.Rubrics_ef.preface}'s own citation). Same cosmetic reflow mechanics as every entry above, not a fixed one-line-per-record addition. $ colitur emit --format sexp --from 2027 --to 2027 | wc -l 9252 $ 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] --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]