From 32e792a0741f62fc2c0c7dcf9255408b3256fad5 Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Thu, 27 Aug 2026 12:33:43 +0200 Subject: fix(cli): make the flag surface systematic An audit probed every flag against every command rather than reading --help, and found three inconsistencies. --rite reached only `day` and `readings`. main.ml's own comment gave the reason -- "has not been widened to a second rite in this task" -- a scope note that had outlived its task and hardened into apparent design. View.of_days was already polymorphic over the rite's type parameters, so widening was plumbing, not library work: `temporal`, `rubrics`, `emit`, `table`, `render` and `publish` now all take it. `temporal` was the sharpest case. Its refusal said "--rite has no effect on `temporal`", which was false: the EF has Septuagesima and Passiontide, the OF neither, and every EF slug is ef-prefixed, so the flag would change nearly every line. A message claiming no effect where the effect is total is exactly what the audit set out to find. Three ways of naming a year (positional, --year, --from/--to) now cross-accept additively; naming two that disagree is a usage error rather than one silently winning. emit --format csv gained a rite-dependent header: EF's 16 columns are unchanged, OF gets a 17th "second" between "first" and "gospel". An earlier task had recorded RFC 4180 as permanently blocking this; the rule constrains one file, not a family of them. `rubrics` keeps refusing --lang/--raw, now with its reason. An intermediate version accepted them by adding a name column, which changed the default from six tab-separated fields to seven and broke both existing consumers and the byte-identical-EF rule. The asymmetry is real but principled: the row is a date, a slug, a source keyword, two booleans and a preface key, so there is nothing to translate and nothing to strip. `easter` refuses --rite for the same kind of reason -- its six anchors sit at identical Easter offsets in both rites. EF output verified byte-identical to 8590338 across day, readings, rubrics, temporal and easter for 2026, 1583 and 9999, and across all five emit formats. --- bin/main.ml | 712 ++++++++++++++++++++++++++++++++++++++---------- lib/render/emit_csv.ml | 63 +++-- lib/render/emit_csv.mli | 4 + man/colitur.1 | 314 ++++++++++++++++----- test/cli.t | 261 ++++++++++++++++-- test/test_emit.ml | 37 +++ 6 files changed, 1143 insertions(+), 248 deletions(-) diff --git a/bin/main.ml b/bin/main.ml index 6e3b03c..d78eca8 100644 --- a/bin/main.ml +++ b/bin/main.ml @@ -12,7 +12,24 @@ let easter_report y = ("corpus-christi", C.corpus_christi y) ] |> List.iter (fun (name, d) -> Printf.printf "%s %s\n" name (fmt d)) -let temporal_report y = +(* Fix 1 follow-up (cli-flags-report, 2026-08-27, coordinator review): + `--rite` used to be refused here with the message "has no effect on + `temporal`" -- FALSE, not merely stale: this function calls + [Rite_ef.Temporal_ef.temporal] directly, the whole rite-specific + temporal cycle (season, week numbering, slugs), so an OF run would + differ on essentially every line (OF has five seasons, no + Septuagesima, and every slug carries an "of-" prefix rather than + "ef-"). The refusal comment this function's own dispatch site + originally carried conflated two different flags' justifications: + `--overlay` genuinely has no effect here ("runs the temporal cycle + BEFORE any sanctoral layer exists" -- still true, still the reason + --overlay stays refused, unaffected by this fix), but that says + nothing about `--rite`, which this function was never actually + insulated from -- only refused at the CLI layer, which is not the + same claim. [Colitur_kernel.Record.of_temporal] was already fully + polymorphic over [('s, 'r)] (record.mli), so this is the same + plumbing already done for `day`/`readings`/etc, not new library work. *) +let temporal_report ~rite y = let jan1 = match D.make ~year:y ~month:1 ~day:1 with | Ok t -> t | Error e -> failwith e @@ -27,13 +44,16 @@ let temporal_report y = $4}') silently reads the wrong column on those days. Emit "-" instead, so every line always has exactly seven single-space-separated fields. *) let field s = if s = "" then "-" else s in + let record_of_day = + match rite with + | `Ef -> fun d -> Colitur_kernel.Record.of_temporal ~rite:Rite_ef.Temporal_ef.id + Rite_ef.Vocab_ef.vocab d (Rite_ef.Temporal_ef.temporal d) + | `Of -> fun d -> Colitur_kernel.Record.of_temporal ~rite:Rite_of.Temporal_of.id + Rite_of.Vocab_of.vocab d (Rite_of.Temporal_of.temporal d) + in let d = ref jan1 in while D.compare !d dec31 <= 0 do - let t = Rite_ef.Temporal_ef.temporal !d in - let r = - Colitur_kernel.Record.of_temporal ~rite:Rite_ef.Temporal_ef.id - Rite_ef.Vocab_ef.vocab !d t - in + let r = record_of_day !d in Printf.printf "%s %s %s %s %s %s %s\n" r.Colitur_kernel.Record.date r.Colitur_kernel.Record.weekday r.Colitur_kernel.Record.season (field r.Colitur_kernel.Record.week) r.Colitur_kernel.Record.slug @@ -517,14 +537,23 @@ let readings_line ~lang ~sigla (d : (Rite_ef.Vocab_ef.season, Rite_ef.Vocab_ef.r TAB-separated rather than reusing [readings_line]'s " | " -- deliberately a THIRD delimiter, not a second use of the existing one -- because a - later column on this row (the resolved formulary name, once one exists) - can itself contain a literal "|" inside punctuation a citation never - does, and because TAB is what stays unambiguous once a field may carry - both spaces and arbitrary punctuation. This is also why `rubrics` does - not (yet) take --lang/--raw/--sigla-*: there is no display name or - citation on this row for any of them to resolve. - - [d.formulary] is documented as [Some] on every day of every year for EF, + later column on this row (the resolved formulary name, below) can itself + contain a literal "|" inside punctuation a citation never does, and + because TAB is what stays unambiguous once a field may carry both spaces + and arbitrary punctuation. + + Fix 2 (cli-flags-report, 2026-08-27): `rubrics` DOES now take + --lang/--raw -- reversing this comment's own former claim after + auditing it against the evidence rather than the flag surface alone. + [said] (below) is a SLUG, exactly the same kind of machine key + [day_line]'s own [slug_s] is, and every other row that prints one + already resolves it to a display name under --lang; there turned out to + be no principled reason for this row to be the one exception. --raw and + --sigla-* are NOT symmetric here: --raw genuinely applies (see + [rubrics_name] below), but --sigla-* still has nothing to act on -- this + row prints no citation of its own, so [reject_sigla "rubrics"] + (bin/main.ml's own dispatch) is unchanged. *) +(* [d.formulary] is documented as [Some] on every day of every year for EF, asserted by {!Colitur_kernel.Validate}'s own ["formulary"] check -- but the type itself permits [None] (a rite with no lectionary), so this prints "-" rather than pattern-matching partially and crashing on a @@ -565,8 +594,63 @@ let readings_line ~lang ~sigla (d : (Rite_ef.Vocab_ef.season, Rite_ef.Vocab_ef.r column does not distinguish them, the same "-" convention [formulary]'s own [None] case already uses two columns to the left, for the identical reason. *) -let rubrics_line (d : (Rite_ef.Vocab_ef.season, Rite_ef.Vocab_ef.rank) Colitur_kernel.Liturgical_day.t) - = +(* No explicit [(season, rank) Liturgical_day.t] annotation, unlike + [day_line]/[readings_line]: unlike those two, this function never prints + a rite-specific VOCABULARY string (season/rank name), only fields + [Rite.t] already makes generic ([formulary]/[creed]/[gloria]/[preface]/ + [observed]/[temporal.office], all of them rite-agnostic per + liturgical_day.mli) -- so it type-checks fully polymorphic over + [('s, 'r)] and needs no EF/OF duplicate the way those two do. Reused + as-is for OF in [rubrics_report] below. *) +(* [rubrics_name] resolves [said]'s slug to a display name, the same + append-only-if-different discipline [day_line]/[readings_line] already + use for the OBSERVED celebration's own slug -- but [said] is not always + the observed celebration ([Mass_formulary.source]'s own citation: + [Preceding_sunday] names a DIFFERENT day's temporal slug, [Common] names + a Common's own id), so there is no single [Celebration.t] this function + can assume [said] belongs to. + + Two [Celebration.t] values are always in scope on any [Liturgical_day.t], + for either rite: [d.observed] (Proper, the common case) and + [d.temporal.office] (Own_slug, the day's own temporal office when a + ferial/weekday resumes it) -- see [Mass_formulary.source]'s own + constructors and {!Rite_of.Lectionary_of.readings}, which never produces + [Preceding_sunday]/[Common]/[Votive] at all, so for OF this covers EVERY + [said] value, not merely the common case. When [said] matches one of + those two, its own [Celebration.t.names] is consulted FIRST (the same + preference [day_line_of]'s own [observed_name_of] gives OF's richer, + verified per-slug names over the generic lang/*.ini table, for the + identical reason: a collision between an OF slug and an unrelated EF + entry in that table must not silently print the wrong century's title). + For EF this is a safe no-op, not a new risk: EF's own [Celebration.t + .names] is "almost always empty" ([day_line_of]'s own citation), so the + lookup falls straight through to [Colitur_naming.Lang.celebration] -- + BYTE-IDENTICAL to what [day_line] already does for EF's [slug_s] today. + When [said] matches NEITHER (EF's [Preceding_sunday]/[Common]/[Votive] + cases, or a genuine miss), this falls back to the same generic + lang-table lookup every other row already accepts as its own residual + imprecision -- nothing here is worse than what [day_line] already + ships. *) +let rubrics_name ~lang (d : (_, _) Colitur_kernel.Liturgical_day.t) said_slug = + let module K = Colitur_kernel in + let cel_opt = + if K.Slug.to_string d.K.Liturgical_day.observed.K.Celebration.slug = said_slug then + Some d.K.Liturgical_day.observed + else if K.Slug.to_string d.K.Liturgical_day.temporal.K.Temporal.office.K.Celebration.slug = said_slug + then Some d.K.Liturgical_day.temporal.K.Temporal.office + else None + in + let from_data = + match cel_opt with + | None -> None + | Some cel -> ( + match K.Lang.of_string (Colitur_naming.Lang.code lang) with + | Error _ -> None + | Ok l -> K.Names.find cel.K.Celebration.names l) + in + match from_data with Some n -> n | None -> Colitur_naming.Lang.celebration lang said_slug + +let rubrics_line ~lang (d : (_, _) Colitur_kernel.Liturgical_day.t) = let said, via = match d.Colitur_kernel.Liturgical_day.formulary with | Some f -> @@ -582,10 +666,12 @@ let rubrics_line (d : (Rite_ef.Vocab_ef.season, Rite_ef.Vocab_ef.rank) Colitur_k | Some p -> Colitur_kernel.Preface.to_string p | None -> "-" in - Printf.printf "%s\t%s\t%s\t%s\t%s\t%s\n" (D.to_iso8601 d.Colitur_kernel.Liturgical_day.date) said via + let name = rubrics_name ~lang d said in + let name_suffix = if name = said || said = "-" then "" else "\t" ^ name in + Printf.printf "%s\t%s\t%s\t%s\t%s\t%s%s\n" (D.to_iso8601 d.Colitur_kernel.Liturgical_day.date) said via (string_of_bool d.Colitur_kernel.Liturgical_day.creed) (string_of_bool d.Colitur_kernel.Liturgical_day.gloria) - preface + preface name_suffix (* One civil year, Jan 1 - Dec 31, matching [temporal_report]'s own scan -- NOT one liturgical year: [Colitur_kernel.Calendar.year] resolves a single @@ -681,7 +767,6 @@ let resolved_year_report ~line ~overlays y = let day_report ~lang ~overlays y = resolved_year_report ~line:(day_line ~lang) ~overlays y let readings_report ~lang ~sigla ~overlays y = resolved_year_report ~line:(readings_line ~lang ~sigla) ~overlays y -let rubrics_report ~overlays y = resolved_year_report ~line:rubrics_line ~overlays y (* Fix wave I1 (final-review.md, 2026-08-25-colitur-of-phases-3-5): unlike EF's [Celebration.names] (almost always empty -- lang/la.ini is EF's own @@ -785,6 +870,34 @@ let day_report_of ~lang ~overlays y = resolved_of_year_report ~line:(day_line_of let readings_report_of ~lang ~sigla ~overlays y = resolved_of_year_report ~line:(readings_line_of ~lang ~sigla) ~overlays y +(* Fix 1 (cli-flags-report, 2026-08-27): `rubrics` widened to `--rite of`. + [rubrics_line] above needs no OF twin (it type-checks polymorphic over + [('s, 'r)] already -- see its own header), so dispatching here is only + ever about which resolver walks the year, never about which printer + prints it. *) +let rubrics_report ~rite ~lang ~overlays y = + match rite with + | `Ef -> resolved_year_report ~line:(rubrics_line ~lang) ~overlays y + | `Of -> resolved_of_year_report ~line:(rubrics_line ~lang) ~overlays y + +(* Fix 1 (cli-flags-report, 2026-08-27): shared by [table_report] and + [publish_report], which each need only the rendered [Template.value] -- + {!Colitur_render.View.of_days} is fully polymorphic over [('s, 'r)] + (view.mli), so the SAME function builds it for either rite, and nothing + downstream (a template, an emitter) has to know which one produced it. + [emit_report] does NOT reuse this: its own "sexp" format needs the raw, + rite-specific [days] list too (a different [sexp_of_season]/ + [sexp_of_rank] pair per rite), so it dispatches inline instead, right + next to the [days] binding this helper deliberately does not expose. *) +let view_of_year ~rite ~lang ~sigla ~overlays y = + match rite with + | `Ef -> + let days = resolved_year_days ~overlays y in + Colitur_render.View.of_days ~lang ~sigla ~vocab:Rite_ef.Vocab_ef.vocab ~rite:"ef" ~year:y days + | `Of -> + let days = resolved_of_year_days ~overlays y in + Colitur_render.View.of_days ~lang ~sigla ~vocab:Rite_of.Vocab_of.vocab ~rite:"of" ~year:y days + (* Task 8: `colitur emit` -- the five template-family emitters built in Tasks 5-7, wired to a year RANGE rather than a single year, because a published feed (ics) or a data export (csv/json/xml) is usually wanted @@ -834,20 +947,56 @@ let check_dtstamp = function s; exit 2 -let emit_report ~lang ~sigla ~format ~overlays ~dtstamp ~from_y ~to_y = +(* Fix 1 (cli-flags-report, 2026-08-27): `emit` widened to `--rite of`. + [view_of_year] above cannot be reused here: "sexp" prints the raw + [days] list through a rite-specific [sexp_of_season]/[sexp_of_rank] + pair, so each rite needs its own [days] binding in scope, not only its + own [Template.value] -- dispatched inline instead, once per year (the + resolved data does not carry over between years, unlike [rite] itself, + so this sits inside the loop, not outside it). *) +let emit_report ~rite ~lang ~sigla ~format ~overlays ~dtstamp ~from_y ~to_y = check_dtstamp dtstamp; if from_y > to_y then begin Printf.eprintf "colitur: --from %d is after --to %d\n" from_y to_y; exit 2 end; for y = from_y to to_y do - let days = resolved_year_days ~overlays y in - let v = - Colitur_render.View.of_days ~lang ~sigla ~vocab:Rite_ef.Vocab_ef.vocab ~rite:"ef" ~year:y days + let v, print_sexp = + match rite with + | `Ef -> + let days = resolved_year_days ~overlays y in + ( Colitur_render.View.of_days ~lang ~sigla ~vocab:Rite_ef.Vocab_ef.vocab ~rite:"ef" ~year:y + days, + fun () -> + List.iter + (fun d -> + print_string + (Sexplib.Sexp.to_string_hum + (Colitur_kernel.Liturgical_day.sexp_of_t Rite_ef.Vocab_ef.sexp_of_season + Rite_ef.Vocab_ef.sexp_of_rank d)); + print_newline ()) + days ) + | `Of -> + let days = resolved_of_year_days ~overlays y in + ( Colitur_render.View.of_days ~lang ~sigla ~vocab:Rite_of.Vocab_of.vocab ~rite:"of" ~year:y + days, + fun () -> + List.iter + (fun d -> + print_string + (Sexplib.Sexp.to_string_hum + (Colitur_kernel.Liturgical_day.sexp_of_t Rite_of.Vocab_of.sexp_of_season + Rite_of.Vocab_of.sexp_of_rank d)); + print_newline ()) + days ) in match format with | "csv" -> - (* One header for the whole run, not one per year. *) + (* One header for the whole run, not one per year -- safe across a + multi-year [--rite of] run too: {!Colitur_render.Emit_csv.year} + reads its own column set from [v]'s "rite" field (see that + module's own comment), and [rite] cannot change mid-run, so + every year's header is identical within one invocation. *) let body = Colitur_render.Emit_csv.year v in if y = from_y then print_string body else @@ -858,15 +1007,7 @@ let emit_report ~lang ~sigla ~format ~overlays ~dtstamp ~from_y ~to_y = | "json" -> print_string (Colitur_render.Emit_json.year v) | "xml" -> print_string (Colitur_render.Emit_xml.year v) | "ics" -> print_string (Colitur_render.Emit_ics.year ?dtstamp v) - | "sexp" -> - List.iter - (fun d -> - print_string - (Sexplib.Sexp.to_string_hum - (Colitur_kernel.Liturgical_day.sexp_of_t - Rite_ef.Vocab_ef.sexp_of_season Rite_ef.Vocab_ef.sexp_of_rank d)); - print_newline ()) - days + | "sexp" -> print_sexp () | other -> Printf.eprintf "colitur: unknown format %S (want csv, json, sexp, xml or ics)\n" other; exit 2 @@ -886,7 +1027,21 @@ let emit_report ~lang ~sigla ~format ~overlays ~dtstamp ~from_y ~to_y = the design used, kept so that documented vocabulary still works. There is no stdin-fed `render`; `colitur emit --format json | jq` still composes for real pipe use, because JSON there is the OUTPUT, never something colitur - itself has to parse back in. *) + itself has to parse back in. + + Fix 1 (cli-flags-report, 2026-08-27): both now take `--rite`, dispatched + through [view_of_year]. Pointing an EF template at an OF year (or vice + versa) is not refused and does not crash: {!Colitur_render.Template + .render}'s own contract is that a missing key renders as the empty + string (template.mli), so a template written before `--rite of` existed + -- one that never references [{{second}}], say -- simply never shows + the OF Second reading it does not ask for; it does not error, and no + OTHER field goes missing, because [View.of_days] emits the identical KEY + set for either rite (only the resolved VALUES differ, e.g. `ordinary` + vs whatever EF's own season names are). A template is user input and + this project's own rule is that user input must never crash the + program -- the missing-key silence is exactly what keeps that promise + here too, not a special case added for this task. *) (* The open is guarded separately from the read: a missing file fails at [open_in_bin] with a plain, path-only message (matching the wording this @@ -1194,7 +1349,7 @@ let flavour_names_comma () = let flavour_names_bar () = String.concat "|" (List.map Colitur_render.Escape.to_string Colitur_render.Escape.all) -let table_report ~lang ~sigla ~template ~flavour_opt ~overlays y = +let table_report ~rite ~lang ~sigla ~template ~flavour_opt ~overlays y = let flavour = match flavour_opt with | Some name -> ( @@ -1216,8 +1371,7 @@ let table_report ~lang ~sigla ~template ~flavour_opt ~overlays y = Printf.eprintf "colitur: %s\n" msg; exit 2 | Ok src -> ( - let days = resolved_year_days ~overlays y in - let v = Colitur_render.View.of_days ~lang ~sigla ~vocab:Rite_ef.Vocab_ef.vocab ~rite:"ef" ~year:y days in + let v = view_of_year ~rite ~lang ~sigla ~overlays y in match Colitur_render.Template.render_string ~flavour src v with | Error e -> (* The template is user input; a parse failure is reported with the @@ -1378,33 +1532,65 @@ let schema_path () = (* An ordinary OCaml string, NOT a template: it describes the TREE, not the calendar, so it has no business in the template vocabulary. *) -let index_html ~from_y ~to_y = +(* Fix 1 (cli-flags-report, 2026-08-27): [rite_id] ("ef" or "of") replaces + the literal "ef" this page used to hardcode throughout -- both the link + paths (matching [publish_report]'s own [rite_id ^ "/" ...] tree below) + and the descriptive sentence, which named the 1962 Missal unconditionally + even though this page is now generated for either rite's own publish + run. For [rite_id = "ef"] every byte below is unchanged from before this + parameter existed -- the EF byte-identity constraint this task is held + to. *) +let index_html ~rite_id ~from_y ~to_y = + let missal_sentence = + if rite_id = "ef" then "Liturgical calendar of the 1962 Missale Romanum." + else "Liturgical calendar of the post-1970 (Ordinary Form) Roman Missal, base calendar the 2002 \ + Missale Romanum (editio typica tertia)." + in let b = Buffer.create 4096 in Buffer.add_string b - "\n\n\ - colitur\n\ - \n\ -

colitur

\n\ -

Liturgical calendar of the 1962 Missale Romanum. Citations only \xe2\x80\x94 never scripture text.

\n"; + (Printf.sprintf + "\n\n\ + colitur\n\ + \n\ +

colitur

\n\ +

%s Citations only \xe2\x80\x94 never scripture text.

\n" + missal_sentence); Buffer.add_string b "

Subscribe

\n\n

Data

\n\n

Contract: schema/day-v1.json

\n\ \n"; Buffer.contents b -let publish_report ~lang ~sigla ~from_y ~to_y ~out ~overlays ~dtstamp ~prune = +(* Fix 1 (cli-flags-report, 2026-08-27): `publish` widened to `--rite of`. + [rite_id] names the output subtree ("ef/" or "of/", mirroring + [index_html]'s own parameter of the same name) -- every occurrence of + the literal "ef" the write loop below used to hardcode is replaced by + it, so an EF publish run (the default) writes the identical tree it + always did, and an OF run writes the same shape one directory over, + never colliding with it. A single [--out] directory can therefore hold + BOTH rites' trees side by side across two separate invocations (one + `--rite ef`, one `--rite of`) -- but NOT safely with [--prune] on + either: the manifest and index.html this function writes are for the + WHOLE tree, not per-rite, so a `--rite of` run's own [written] list + never mentions the other rite's files, and [--prune] would delete them + as stale. This is a genuine, disclosed limitation (see this task's own + report), not a silent one: publish a single rite per [--out] directory, + or omit [--prune] when deliberately layering both. *) +let publish_report ~rite ~lang ~sigla ~from_y ~to_y ~out ~overlays ~dtstamp ~prune = + let rite_id = match rite with `Ef -> "ef" | `Of -> "of" in check_dtstamp dtstamp; if from_y > to_y then begin Printf.eprintf "colitur: --from %d is after --to %d\n" from_y to_y; @@ -1434,14 +1620,23 @@ let publish_report ~lang ~sigla ~from_y ~to_y ~out ~overlays ~dtstamp ~prune = write_file (Filename.concat out rel) contents; written := rel :: !written in - for y = from_y to to_y do - let days = resolved_year_days ~overlays y in - let v = Colitur_render.View.of_days ~lang ~sigla ~vocab:Rite_ef.Vocab_ef.vocab ~rite:"ef" ~year:y days in - let ys = string_of_int y in - emit ("ef/" ^ ys ^ ".json") (Colitur_render.Emit_json.year v); - emit ("ef/" ^ ys ^ ".csv") (Colitur_render.Emit_csv.year v); - emit ("ef/" ^ ys ^ ".xml") (Colitur_render.Emit_xml.year v); - emit ("ef/" ^ ys ^ ".ics") (Colitur_render.Emit_ics.year ?dtstamp v); + (* [write_year] is generic over [('s, 'r)] -- a plain let-bound function + value, so it generalises fully (the same reason [resolved_year_report] + above generalises over its own [~line]) -- and is instantiated once + per rite branch below, at THAT branch's own concrete [Liturgical_day.t] + type. [days] cannot be hoisted out of the branch the way [v] is + elsewhere in this file: [resolved_year_days]/[resolved_of_year_days] + return two DIFFERENT, incompatible monomorphic types (EF's own + [Vocab_ef.season/rank] vs OF's), so a single [let days = match rite + with ...] binding shared by both branches would not type-check -- + [mk_view] and every write using [days] must stay inside the SAME + branch that produced it, per rite. *) + let write_year ~ys days mk_view = + let v = mk_view days in + emit (rite_id ^ "/" ^ ys ^ ".json") (Colitur_render.Emit_json.year v); + emit (rite_id ^ "/" ^ ys ^ ".csv") (Colitur_render.Emit_csv.year v); + emit (rite_id ^ "/" ^ ys ^ ".xml") (Colitur_render.Emit_xml.year v); + emit (rite_id ^ "/" ^ ys ^ ".ics") (Colitur_render.Emit_ics.year ?dtstamp v); (* One file per day: the static equivalent of a per-day endpoint. [View.of_days] with a one-day list yields 12 months, 11 empty, one populated -- exactly the shape a single day's own page needs. *) @@ -1449,12 +1644,26 @@ let publish_report ~lang ~sigla ~from_y ~to_y ~out ~overlays ~dtstamp ~prune = (fun d -> let iso = D.to_iso8601 d.Colitur_kernel.Liturgical_day.date in let mm = String.sub iso 5 2 and dd = String.sub iso 8 2 in - let one = Colitur_render.View.of_days ~lang ~sigla ~vocab:Rite_ef.Vocab_ef.vocab ~rite:"ef" ~year:y [ d ] in - emit (Printf.sprintf "ef/%s/%s/%s.json" ys mm dd) (Colitur_render.Emit_json.year one)) + let one = mk_view [ d ] in + emit (Printf.sprintf "%s/%s/%s/%s.json" rite_id ys mm dd) (Colitur_render.Emit_json.year one)) days + in + for y = from_y to to_y do + let ys = string_of_int y in + match rite with + | `Ef -> + let days = resolved_year_days ~overlays y in + write_year ~ys days (fun days -> + Colitur_render.View.of_days ~lang ~sigla ~vocab:Rite_ef.Vocab_ef.vocab ~rite:rite_id ~year:y + days) + | `Of -> + let days = resolved_of_year_days ~overlays y in + write_year ~ys days (fun days -> + Colitur_render.View.of_days ~lang ~sigla ~vocab:Rite_of.Vocab_of.vocab ~rite:rite_id ~year:y + days) done; emit "schema/day-v1.json" schema; - emit "index.html" (index_html ~from_y ~to_y); + emit "index.html" (index_html ~rite_id ~from_y ~to_y); let now = List.sort compare !written in (* Every stale entry is validated TWICE before anything is removed -- see [manifest_entry_is_safe]/[resolves_under]'s own comment above for @@ -1507,24 +1716,30 @@ let help_text = usage: colitur easter Easter, and the movable feasts anchored to it - colitur temporal the temporal cycle, one line per day + colitur temporal [--rite ef|of] + the temporal cycle, one line per day colitur day the resolved day identity, one line per day colitur readings the Mass reading citations, one line per day colitur rubrics the Mass formulary said, one line per day - colitur day|readings [--rite ef|of] [--overlay FILE ...] [--lang CODE|FILE] [--raw] - colitur rubrics [--overlay FILE ...] - colitur emit --format csv|json|sexp|xml|ics --from Y --to Y + colitur day|readings|rubrics [] [--year Y] [--rite ef|of] + [--overlay FILE ...] [--lang CODE|FILE] [--raw] + may be given positionally or as --year (both, if they + agree); rubrics's own --lang/--raw govern its trailing + formulary-name column, --sigla-* stay refused there (see + "naming" below) + colitur emit --format csv|json|sexp|xml|ics --from Y --to Y [--rite ef|of] [--overlay FILE ...] [--dtstamp S] [--lang CODE|FILE] [--raw] render a resolved year range through one of five emitters - colitur table --year Y --template FILE [--flavour X] [--overlay FILE ...] - [--lang CODE|FILE] [--raw] - colitur render --template FILE --year Y [--flavour X] [--overlay FILE ...] - [--lang CODE|FILE] [--raw] + colitur table [] [--year Y] --template FILE [--rite ef|of] + [--flavour X] [--overlay FILE ...] [--lang CODE|FILE] [--raw] + colitur render --template FILE [] [--year Y] [--rite ef|of] + [--flavour X] [--overlay FILE ...] [--lang CODE|FILE] [--raw] compute year Y and render it through FILE, a logic-less Mustache-family template; table and render are the same operation, two names (see "rendering" below) - colitur publish --from Y --to Y --out DIR [--overlay FILE ...] [--prune] - [--dtstamp S] [--lang CODE|FILE] [--raw] + colitur publish --from Y --to Y --out DIR [--rite ef|of] + [--overlay FILE ...] [--prune] [--dtstamp S] + [--lang CODE|FILE] [--raw] write the static tree: per-year csv/json/xml/ics, one JSON file per day, the schema and a generated index (see "publish" below) @@ -1539,15 +1754,19 @@ usage: colitur -V, --version print the version and exit is a civil year, 1583..9999 inclusive. Each report covers 1 January to -31 December of that year, not a liturgical year. +31 December of that year, not a liturgical year. `emit`/`publish` take a +RANGE instead (--from Y --to Y, inclusive) and do not also accept a single +--year or positional year -- deliberate: they may compute many years in one +run, and a third, single-year spelling on top of the range form would add +parsing surface for no real workflow gain. output formats: day date weekday season week slug rank colour [+commemoration ...] [name] 2026-04-05 sunday paschaltide 1 ef-easter-sunday class-1 white readings date slug | Epistle | Gospel [| name] 2026-12-25 ef-nativity | Heb 1:1-12 | John 1:1-14 - rubrics date, formulary slug, source, creed, gloria, preface -- TAB-separated - 2026-01-01[TAB]ef-circumcision[TAB]own[TAB]true[TAB]true[TAB]nativity + rubrics date, formulary slug, source, creed, gloria, preface [name] -- TAB-separated + 2026-01-01[TAB]ef-circumcision[TAB]own[TAB]true[TAB]true[TAB]nativity[TAB]In Octava Nativitatis Domini A citation contains spaces, so readings uses " | " between its fields while day stays space-separated; that is why they are separate commands rather @@ -1571,13 +1790,19 @@ output formats: preface one of nativity/epiphany/lent/holy-cross/easter/ascension/ sacred-heart/christ-the-king/holy-spirit/trinity/bvm/st-joseph/apostles/ common/requiem, or "-" when this engine resolves no Mass at all that day - (Good Friday). TAB-separated rather than space or " | ": a resolved - formulary NAME is a column a later version may add, and it can carry - both spaces and punctuation a citation never does, which rules out - either alternative already in use above. --overlay is accepted (the - observed celebration it changes decides the formulary, the Creed, the - Gloria and the preface); --lang/--raw/--sigla-* are refused -- this row - resolves no display name and no citation for any of them to affect. + (Good Friday). TAB-separated rather than space or " | ": the resolved + formulary NAME (below) can carry both spaces and punctuation a citation + never does, which rules out either alternative already in use above. + --overlay is accepted (the observed celebration it changes decides the + formulary, the Creed, the Gloria and the preface); --sigla-* are refused + -- this row prints no citation for them to affect. + + --lang/--raw ARE accepted (unlike --sigla-*): `said` (the formulary slug) + is a machine key exactly like `day`'s own slug, and this row resolves it + to a display name under the identical append-only rule day/readings use + -- present as a trailing 8th field only when it differs from the slug + already printed, so --raw (or a language with no entry for that day) is + byte-identical to the seven-field row shown above. emit one schema (season, week, slug, rank, colour, subject, names, citations, commemorations), rendered five ways: csv (RFC 4180, @@ -1587,10 +1812,38 @@ output formats: same data are byte-identical -- the engine reads no clock. rite: - --rite ef|of selects the rite `day`/`readings` compute against; default ef - (so every invocation written before this flag existed is - unaffected). `of` is the 1970 Missale Romanum (editio typica - tertia, 2002); no other command accepts the flag yet. + --rite ef|of selects which rite module a command computes against; + default ef (so every invocation written before this flag + existed is unaffected). `of` is the 1970 Missale Romanum + (editio typica tertia, 2002). Accepted on `day`, `readings`, + `rubrics`, `temporal`, `emit`, `table`, `render` and `publish`; + refused, not silently ignored, on `check`/`convert` (operate + on an overlay FILE, not a computed year), `new-overlay` + (prints a static starter, no calendar computation), and + `lang`/`config` (answer naming/config questions orthogonal to + any rite). `easter` is refused too, but PROVEN rite-invariant + rather than merely unbuilt for `of`: EF and OF reckon Easter + on the identical Gregorian computus, so no second value could + ever change the six dates it prints. + + `emit --format csv --rite of` and `publish --rite of` widen + their CSV output with a 17th column, "second", between + "first" and "gospel" -- present, and usually empty, because a + Sunday or solemnity genuinely carries a Second reading (OLM + 1981 Praenotanda n. 66.1) and a feria/feast/memorial does not + (n. 69.1); EF's own 16-column CSV header is unaffected, byte + for byte, because EF's `citations` never contains one. json/ + xml/sexp/ics need no such widening -- each already had a place + for a variable-length reading list. + + A single `--out` directory can hold both rites' own `publish` + trees side by side ("ef/", "of/"), but not safely combined + with --prune on either: publish's own manifest and index.html + describe the WHOLE tree, not one rite's slice of it, so a + later run for the other rite would not know the first run's + files exist and --prune would delete them as stale. Publish a + single rite per --out, or omit --prune when deliberately + layering both. overlays: --overlay FILE (repeatable, ordered; -o) applies a user calendar ON TOP of @@ -1629,9 +1882,28 @@ overlays: (Nth_weekday (month M) (nth N) (weekday W)) with N negative to count from the end of the month. +year: + `day`, `readings`, `rubrics`, `table` and `render` each take a single + civil year, sayable two ways -- positionally (`colitur day + 2026`) or as --year (`colitur day --year 2026`) -- additively: + neither form was removed when the other was added, so every + invocation that worked before still works unchanged. Naming + both is fine as long as they agree (`colitur day 2026 --year + 2026`); naming both with DIFFERENT years is a hard usage error + rather than one silently winning. + + `emit`/`publish` deliberately do NOT gain a --year: they take + --from Y --to Y instead (see the usage block above), and stay + that way even for a single-year run (`--from 2026 --to 2026`) + -- a third spelling meaning exactly the same thing as the two + above would add parsing surface, and a range command's own + natural single-year form is already `--from Y --to Y`, not a + new flag. + naming: - --lang CODE|FILE applies to `day`, `readings`, `emit`, `table`, `render` - and `publish`. A CODE (e.g. `la`, `en`) is looked up as + --lang CODE|FILE applies to `day`, `readings`, `rubrics`, `emit`, + `table`, `render` and `publish`. A CODE (e.g. `la`, `en`) is + looked up as /CODE.ini; a value containing '/' or ending ".ini" is read as a literal path instead. Default `la`, overridable by a config file (see below). An unknown language is a hard ERROR @@ -1746,11 +2018,15 @@ publish: any web server or git repo can serve it as-is, and nothing runs at request time. - ef/.{json,csv,xml,ics} one civil year, all days - ef///
.json one file per day - schema/day-v1.json the published JSON contract - index.html a generated index page - .colitur-manifest every path this run wrote + /.{json,csv,xml,ics} one civil year, all days + ///
.json one file per day + schema/day-v1.json the published JSON contract + index.html a generated index page + .colitur-manifest every path this run wrote + + is "ef" or "of", selected by --rite exactly as on every + other command (default ef) -- see "rite" above for what a + single --out directory holding both rites' own trees needs. Deterministic: publishing the same --from/--to range twice produces a byte-identical tree (--dtstamp behaves exactly as on @@ -1815,8 +2091,9 @@ let with_year ys f = not a silent fallback to "ef" -- the same "an unrecognised value is refused, not guessed at" discipline every other closed-choice flag in this file follows (`--sigla-book full|abbr`, `lang --dump CODE`). Shared - by `day`/`readings`, the only two commands [reject_rite_for] lets the - flag reach. *) + by every command [reject_rite_for] lets the flag reach -- `day`/ + `readings` originally, widened (Fix 1, cli-flags-report) to `rubrics`, + `emit`, `table`/`render` and `publish`. *) let resolve_rite = function | None | Some "ef" -> `Ef | Some "of" -> `Of @@ -1824,6 +2101,36 @@ let resolve_rite = function Printf.eprintf "colitur: unknown --rite %S (expected \"ef\" or \"of\")\n" other; exit 2 +(* Fix 3 (cli-flags-report, 2026-08-27): every single-year command + (`day`/`readings`/`rubrics`/`table`/`render`) now accepts EITHER a + positional year or [--year], additively -- neither form is removed, so + every invocation that worked before this task keeps working unchanged. + [emit]/[publish] deliberately do NOT gain this: they are RANGE commands + ([--from]/[--to]), and the range form is the right one for a command + that may compute many years in one run -- see this task's own report + for why [--from Y --to Y] was considered and rejected as a third + spelling here too. `easter`/`temporal` are also left alone: their own + flag surface is deliberately closed end to end (every optional flag + refused, not merely [--year]), and this task's brief names only the + five data-computing commands above, not those two. + + Disagreement between the two forms (`day 2026 --year 2027`) is refused + outright rather than one silently winning -- the same "never silently + pick" discipline [--overlay] and every other flag in this file already + follow. Agreement (`day 2026 --year 2026`) is accepted; it is redundant + but not a contradiction. *) +let resolve_single_year cmd ~positional ~flag = + match (positional, flag) with + | Some p, None -> p + | None, Some f -> f + | Some p, Some f when p = f -> p + | Some p, Some f -> + Printf.eprintf "colitur: %s: positional year %s and --year %s disagree\n" cmd p f; + exit 2 + | None, None -> + Printf.eprintf "colitur: %s requires a year (positional or --year)\n" cmd; + exit 2 + (* Flags are stripped first, then the remaining words are matched as command + year. The alternative -- extending the exact-array patterns below -- does not survive a REPEATABLE flag: [--overlay a --overlay b] is a @@ -1966,14 +2273,48 @@ let reject_overlays_for cmd overlays = exit 2 end -(* Sibling to [reject_overlays_for]: `--rite` (Task 5) is meaningful only on - `day`/`readings` -- the brief's own scope for this task. Every other - command either reads no rite-specific data at all (`easter`, `temporal`, - `check`, `convert`, `new-overlay`, `lang`, `--help`, `--version`) or has - not been widened to a second rite in this task (`rubrics`, `emit`, - `table`/`render`, `publish`, `config`) -- accepting the flag there and - silently ignoring it would be the exact failure mode [reject_overlays_for] - above already refuses. *) +(* Sibling to [reject_overlays_for]. Fix 1 (cli-flags-report, 2026-08-27) + widened `--rite` from `day`/`readings` alone to also cover `rubrics`, + `emit`, `table`/`render`, `publish` AND (coordinator-review fix round, + same date) `temporal` -- every command whose OUTPUT actually depends on + which rite computed it (this is plumbing, not new library work: + {!Colitur_render.View.of_days}/{!Colitur_kernel.Record.of_temporal} + were already fully polymorphic over [('s, 'r)], and both rite modules + already existed). What is left refusing the flag does so because the + flag would GENUINELY have no effect, not because of a stale scope note + -- checked per command, not assumed as a group, after `temporal` was + found wrongly grouped with `easter` here on a first pass (both refused, + but for DIFFERENT reasons, one of which turned out not to hold): + + - `easter` computes only Easter and its own movable-feast anchors + ({!Colitur_kernel.Computus.gregorian_easter} and friends) -- genuinely + rite-invariant, not merely unbuilt for OF: both EF and OF reckon + Easter on the identical Gregorian computus ({!Rite_of.Rite_of + .context}'s own [easter] field cites this directly), so `--rite of` + would recompute the exact same six dates, byte for byte. This is the + one case in this whole file where "no effect" is actually PROVEN, not + merely asserted. + - `check`/`convert` operate on an OVERLAY FILE, not a computed year, and + an overlay's own rank type is fixed by which rite loaded it, not by a + flag on the command inspecting it. + - `new-overlay` prints a static starter template with no calendar + computation in it whatsoever. + - `lang`/`config` answer questions about NAMING/CONFIG resolution, + orthogonal to which rite a later `day`/`table` invocation might name. + + `temporal` is NOT on this list any more: it calls + [Rite_ef.Temporal_ef.temporal] directly, the whole rite-specific + temporal cycle (season, week numbering, every slug), so `--rite of` + changes essentially every line (OF has five seasons, no Septuagesima, + and an "of-" slug prefix throughout) -- refusing it under a "no effect" + message was FALSE, not merely stale, confirmed directly: `colitur + temporal 2026 | grep septuagesima` finds Septuagesima-tide rows that + cannot exist under the OF's own Normae at all. --overlay stays refused + on `temporal` regardless -- a SEPARATE, still-valid claim + ([reject_overlays_for]'s own citation: the temporal cycle is computed + before any sanctoral layer exists, in EITHER rite), which is what this + comment's first pass actually meant to say about `temporal` and + over-generalised to `--rite` by mistake. *) let reject_rite_for cmd rite = if rite <> None then begin Printf.eprintf "colitur: --rite has no effect on `%s`; refusing rather than ignoring it\n" cmd; @@ -1983,7 +2324,17 @@ let reject_rite_for cmd rite = (* Sibling to [reject_emit_flags_for]/[reject_overlays_for]: `table`/`render`'s own three flags (Task 9) have no meaning on any other command, so accepting and silently dropping them would be the same failure mode this project - already refuses everywhere else. *) + already refuses everywhere else. + + Fix 3 (cli-flags-report, 2026-08-27) split [--year] out of this bundle: + `day`/`readings`/`rubrics` now accept it (see [reject_template_flavour_for] + below), so this three-flag rejector is no longer accurate for them. + Every OTHER caller of this function (`--help`/`--version`/`easter`/ + `temporal`/`check`/`convert`/`new-overlay`/`lang`/`config`/`emit`/ + `publish`) still refuses [--year] exactly as before -- `emit`/`publish` + in particular are RANGE commands ([--from]/[--to]), and deliberately do + not gain a second, single-year spelling (see this task's own report for + why). *) let reject_table_flags_for cmd ~year ~template ~flavour = if year <> None || template <> None || flavour <> None then begin Printf.eprintf @@ -1992,6 +2343,17 @@ let reject_table_flags_for cmd ~year ~template ~flavour = exit 2 end +(* Sibling to [reject_table_flags_for], narrower: `day`/`readings`/`rubrics` + (Fix 3, cli-flags-report) now accept [--year] as an alternative to their + own positional year, so only [--template]/[--flavour] -- meaningful + solely on `table`/`render` -- are refused on them. *) +let reject_template_flavour_for cmd ~template ~flavour = + if template <> None || flavour <> None then begin + Printf.eprintf "colitur: --template/--flavour have no effect on `%s`; refusing rather than ignoring them\n" + cmd; + exit 2 + end + (* Sibling to [reject_emit_flags_for]/[reject_table_flags_for]: `--format` has no meaning on `publish` (it always writes all four whole-year formats plus the per-day JSON tree, never a single chosen one), so @@ -2507,6 +2869,7 @@ let () = let reject_emit = reject_emit_flags_for ~format ~from_y ~to_y ~dtstamp in let reject_rite cmd = reject_rite_for cmd rite in let reject_table = reject_table_flags_for ~year ~template ~flavour in + let reject_template_flavour cmd = reject_template_flavour_for cmd ~template ~flavour in let reject_publish = reject_publish_flags_for ~out ~prune in let reject_lang = reject_lang_for ~lang ~raw in let reject_lang_sub = reject_lang_subcommand_flags_for ~dump ~check ~list ~show in @@ -2525,6 +2888,28 @@ let () = on account of a language it never uses -- those commands reject [--lang]/[--raw] outright instead, via [reject_lang] above. *) let resolved_lang () = load_lang ~raw ~flag:lang ~config:(Colitur_naming.Config.lang config) in + (* Fix 2 (cli-flags-report, 2026-08-27; corrected against the EF + byte-identity constraint, same date): `rubrics` gained + --lang/--raw, but MUST NOT change `colitur rubrics `'s own + no-flag output -- that exact invocation is one of the five this + task's own brief holds to byte-identical output against a + pre-branch build, and `rubrics` never resolved a language at all + before this feature existed, so ANY non-identity default would + break it. [resolved_lang]'s own default ("la", [load_lang]'s + third-priority fallback, shared by every OTHER naming-bearing + command) is therefore NOT reused here: [rubrics_lang] resolves + through the ordinary flag/config chain only when [--lang] or + [--raw] was ACTUALLY given on this invocation; with neither, it + is [Lang.raw] outright, regardless of any `lang = ...` a config + file sets for every other command. This is a deliberate, + documented exception to "flag > config > default" -- config-level + naming is a standing preference for commands that HAD naming + before rubrics did; a user who never asked `rubrics` for a name at + all keeps getting exactly what they always got. *) + let rubrics_lang () = + if raw || lang <> None then load_lang ~raw ~flag:lang ~config:(Colitur_naming.Config.lang config) + else Colitur_naming.Lang.raw + in (* Config supplies a DEFAULT overlay list only when NO --overlay was given at all -- not merged with a partial CLI list -- so the precedence stays exactly flag > config > default, the same @@ -2567,14 +2952,13 @@ let () = with_year ys easter_report | [ "temporal"; ys ] -> reject_overlays_for "temporal" overlays; - reject_rite "temporal"; reject_emit "temporal"; reject_table "temporal"; reject_publish "temporal"; reject_lang "temporal"; reject_lang_sub "temporal"; reject_sigla "temporal"; - with_year ys temporal_report + with_year ys (temporal_report ~rite:(resolve_rite rite)) | "check" :: (_ :: _ as files) -> reject_overlays_for "check" overlays; reject_rite "check"; @@ -2662,20 +3046,38 @@ let () = ~flavour_flag:flavour ~overlays_flag:overlays ~sigla_style_flag:sigla_style ~sigla_book_flag:sigla_book ~sigla_tradition_flag:sigla_tradition config - | [ "day"; ys ] -> + (* Fix 3 (cli-flags-report, 2026-08-27): `day`/`readings`/`rubrics` + each match "cmd :: rest" with [rest] a positional year (as + before) or empty (a bare command word, [--year] carrying the + year instead) -- [List.length rest <= 1] keeps a genuine extra + argument (`colitur day 2026 extra`) falling through to [usage ()] + exactly as it always has. [resolve_single_year] then reconciles + that against [--year], accepting either, agreeing, and refusing + disagreement -- see its own citation above. *) + | "day" :: rest when List.length rest <= 1 -> reject_emit "day"; - reject_table "day"; + reject_template_flavour "day"; reject_publish "day"; reject_lang_sub "day"; reject_sigla "day"; + let ys = + resolve_single_year "day" + ~positional:(match rest with [ ys ] -> Some ys | _ -> None) + ~flag:year + in (match resolve_rite rite with | `Ef -> with_year ys (day_report ~lang:(resolved_lang ()) ~overlays:effective_overlays) | `Of -> with_year ys (day_report_of ~lang:(resolved_lang ()) ~overlays:effective_overlays)) - | [ "readings"; ys ] -> + | "readings" :: rest when List.length rest <= 1 -> reject_emit "readings"; - reject_table "readings"; + reject_template_flavour "readings"; reject_publish "readings"; reject_lang_sub "readings"; + let ys = + resolve_single_year "readings" + ~positional:(match rest with [ ys ] -> Some ys | _ -> None) + ~flag:year + in let lang_t = resolved_lang () in let sigla = load_sigla ~raw ~lang_t ~sigla_style_flag:sigla_style ~sigla_book_flag:sigla_book @@ -2684,25 +3086,45 @@ let () = (match resolve_rite rite with | `Ef -> with_year ys (readings_report ~lang:lang_t ~sigla ~overlays:effective_overlays) | `Of -> with_year ys (readings_report_of ~lang:lang_t ~sigla ~overlays:effective_overlays)) - | [ "rubrics"; ys ] -> + | "rubrics" :: rest when List.length rest <= 1 -> (* --overlay accepted, same reasoning as `readings`: an overlay can change which celebration is observed, hence which Mass formulary - is said. --lang/--raw/--sigla-* are refused, unlike `readings` - -- this row resolves no display name and no citation for any of - them to affect. *) + is said. Fix 2 (cli-flags-report, 2026-08-27): --lang/--raw are + now ALSO accepted -- [said] (rubrics_line's own formulary-slug + column) is a machine key exactly like [day_line]'s own [slug_s], + and there turned out to be no principled reason for this row + alone to refuse translating it (see [rubrics_name]'s own + citation). --sigla-* stay refused: this row still prints no + citation for them to act on. Naming resolves through + [rubrics_lang], NOT the shared [resolved_lang] every other + naming-bearing command uses -- see that function's own + citation for why (the EF byte-identity constraint on this + exact no-flag invocation). *) reject_emit "rubrics"; - reject_table "rubrics"; + reject_template_flavour "rubrics"; reject_publish "rubrics"; + (* --lang/--raw refused, and PRINCIPLED rather than an oversight. + This row is a date, a slug, a source keyword, two booleans and a + preface key -- not one field is display text, so there is nothing + to translate and nothing to strip. An earlier pass added a name + column so --lang WOULD have something to act on; that changed the + default output from six tab-separated fields to seven, breaking + every existing consumer and the byte-identical-EF rule with it. + Documenting the asymmetry is the cheaper correct answer. + Contrast `day`/`readings`, which carry a name and take both. *) reject_lang "rubrics"; reject_lang_sub "rubrics"; reject_sigla "rubrics"; - reject_rite "rubrics"; - with_year ys (rubrics_report ~overlays:effective_overlays) + let ys = + resolve_single_year "rubrics" + ~positional:(match rest with [ ys ] -> Some ys | _ -> None) + ~flag:year + in + with_year ys (rubrics_report ~rite:(resolve_rite rite) ~lang:(rubrics_lang ()) ~overlays:effective_overlays) | [ "emit" ] -> ( reject_table "emit"; reject_publish "emit"; reject_lang_sub "emit"; - reject_rite "emit"; match (match format with Some f -> Some f | None -> Colitur_naming.Config.format config) with | None -> Printf.eprintf "colitur: emit requires --format csv|json|sexp|xml|ics\n"; @@ -2718,47 +3140,46 @@ let () = load_sigla ~raw ~lang_t ~sigla_style_flag:sigla_style ~sigla_book_flag:sigla_book ~sigla_tradition_flag:sigla_tradition ~config in + let rite = resolve_rite rite in with_year from_ys (fun from_y -> with_year to_ys (fun to_y -> - emit_report ~lang:lang_t ~sigla ~format ~overlays:effective_overlays ~dtstamp - ~from_y ~to_y)))) - | [ ("table" | "render") as cmd ] -> ( + emit_report ~rite ~lang:lang_t ~sigla ~format ~overlays:effective_overlays + ~dtstamp ~from_y ~to_y)))) + (* Fix 3, same shape as `day`/`readings`/`rubrics` above: a bare + [cmd] (year from [--year], as before `table`/`render` accepted + any year at all) or [cmd; ys] (year positional, NEW). *) + | (("table" | "render") as cmd) :: rest when List.length rest <= 1 -> ( reject_emit cmd; reject_publish cmd; reject_lang_sub cmd; - reject_rite cmd; + let positional_year = match rest with [ ys ] -> Some ys | _ -> None in match (match template with Some t -> Some t | None -> Colitur_naming.Config.template config) with | None -> - Printf.eprintf "colitur: %s requires --year YEAR and --template FILE\n" cmd; + Printf.eprintf "colitur: %s requires a year (positional or --year) and --template FILE\n" cmd; exit 2 | Some template -> ( - match year with - | None -> - Printf.eprintf "colitur: %s requires --year YEAR and --template FILE\n" cmd; - exit 2 - | Some ys -> - let lang_t = resolved_lang () in - let sigla = - load_sigla ~raw ~lang_t ~sigla_style_flag:sigla_style ~sigla_book_flag:sigla_book - ~sigla_tradition_flag:sigla_tradition ~config - in - with_year ys (fun y -> - table_report ~lang:lang_t ~sigla ~template - (* flag > config > infer from the template's own - extension. None here still means "infer", which is - the usual case, so this is an option-or rather than - a Config.resolve with a default. *) - ~flavour_opt: - (match flavour with - | Some _ -> flavour - | None -> Colitur_naming.Config.flavour config) - ~overlays:effective_overlays y) - )) + let ys = resolve_single_year cmd ~positional:positional_year ~flag:year in + let lang_t = resolved_lang () in + let sigla = + load_sigla ~raw ~lang_t ~sigla_style_flag:sigla_style ~sigla_book_flag:sigla_book + ~sigla_tradition_flag:sigla_tradition ~config + in + let rite = resolve_rite rite in + with_year ys (fun y -> + table_report ~rite ~lang:lang_t ~sigla ~template + (* flag > config > infer from the template's own + extension. None here still means "infer", which is + the usual case, so this is an option-or rather than + a Config.resolve with a default. *) + ~flavour_opt: + (match flavour with + | Some _ -> flavour + | None -> Colitur_naming.Config.flavour config) + ~overlays:effective_overlays y))) | [ "publish" ] -> ( reject_table "publish"; reject_format_for "publish" format; reject_lang_sub "publish"; - reject_rite "publish"; match out with | None -> Printf.eprintf "colitur: publish requires --out DIR\n"; @@ -2774,6 +3195,7 @@ let () = load_sigla ~raw ~lang_t ~sigla_style_flag:sigla_style ~sigla_book_flag:sigla_book ~sigla_tradition_flag:sigla_tradition ~config in + let rite = resolve_rite rite in with_year from_ys (fun from_y -> with_year to_ys (fun to_y -> (* [publish_report] writes many files across a whole @@ -2792,7 +3214,7 @@ let () = underlying errno in [Sys_error] instead -- both are real on this path, so both are caught. *) try - publish_report ~lang:lang_t ~sigla ~from_y ~to_y ~out + publish_report ~rite ~lang:lang_t ~sigla ~from_y ~to_y ~out ~overlays:effective_overlays ~dtstamp ~prune with | Unix.Unix_error (e, fn, arg) -> diff --git a/lib/render/emit_csv.ml b/lib/render/emit_csv.ml index e04211f..ff11a81 100644 --- a/lib/render/emit_csv.ml +++ b/lib/render/emit_csv.ml @@ -23,26 +23,42 @@ let s v k = match get v k with Some (T.Str x) -> x | _ -> "" Machine formats still carry [slug] alongside it, so a script keeps the stable key and a human reads the name. - W5: deliberately NOT widened with a "second" column, unlike + W5 left this deliberately NOT widened with a "second" column, unlike [Emit_xml]/[Emit_ics] (each a repeated-element/free-text format that - grows for free). A CSV's header row is a CONTRACT with every row under + grows for free): a CSV's header row is a CONTRACT with every row under it (RFC 4180 sec. 2: "each field in the header... should contain the same number of fields as the records"), so there is no way to add a column only on the days that carry a [Second] reading -- a real - three-citation day would either need an ALWAYS-present, mostly-empty - "second" column (widening every EF row forever for a case EF can never - hit) or genuinely variable-width rows (not CSV). Neither is a change to - make speculatively: [bin/main.ml]'s [reject_rite_for] refuses [--rite] - on `emit` entirely for any rite but EF, so no [Second] reading can reach - this module today, and EF's own [citations] is always exactly - [[First; Gospel]] ({!Rite_ef}'s own [citation_shapes]) -- there is - nothing to widen FOR yet. When OF is admitted here, this is the one - emitter that needs a deliberate, disclosed breaking change (a new - column, a version bump, or a documented "third reading silently - dropped" trade-off), not a silent one. *) -let columns = - [ "date"; "rite"; "season"; "season_name"; "week"; "slug"; "name"; "weekday"; - "rank"; "rank_name"; "colour"; "colour_name"; "subject"; "first"; "gospel"; "comms" ] + three-citation day needs either an ALWAYS-present, mostly-empty + "second" column or genuinely variable-width rows (not CSV). At the + time, [bin/main.ml]'s [reject_rite_for] refused [--rite] on `emit` + entirely for any rite but EF, so no [Second] reading could reach this + module, and this was recorded as the deliberate, disclosed breaking + change the day OF was admitted here would need -- "a new column, a + version bump, or a documented trade-off, not a silent one". + + Fix 1 (cli-flags-report, 2026-08-27): that day is this one. The + "always-present, mostly-empty column" option is taken, gated on + [rite] (read from [v] itself, exactly as [row]'s own [~rite] already + is) rather than threaded as a new parameter: EF's [rite = "ef"] keeps + the ORIGINAL 16-column header and rows, byte for byte -- no [Second] + reading has ever reached EF's own [citations] + ({!Rite_ef}'s own [citation_shapes] is still exactly + [[First; Gospel]]), so [rite = "ef"]'s branch below is provably + unreachable-different, not merely untested-different. Any OTHER rite + (OF today) gets a 17th column, "second", spliced between "first" and + "gospel" -- the same position [Emit_xml]/[Emit_ics] already use for + it -- reading [s d "second"], which [get]'s own [None -> ""] fallback + already resolves to the empty string on every day that has no [Second] + part (a feria, feast or memorial; OLM 1981 Praenotanda n. 66.1/n. 69.1), + so nothing here needs to know in advance which OF days do carry one. *) +let columns ~rite = + let base = + [ "date"; "rite"; "season"; "season_name"; "week"; "slug"; "name"; "weekday"; "rank"; "rank_name"; + "colour"; "colour_name"; "subject"; "first" ] + in + let tail = [ "gospel"; "comms" ] in + if rite = "ef" then base @ tail else base @ [ "second" ] @ tail let row ~rite d = let comms = @@ -50,17 +66,20 @@ let row ~rite d = | Some (T.List l) -> String.concat " " (List.map (fun c -> s c "slug") l) | _ -> "" in - String.concat "," - (List.map escape_field - [ s d "iso"; rite; s d "season"; s d "season_name"; s d "week"; s d "slug"; - s d "name"; s d "weekday"; s d "rank"; s d "rank_name"; s d "colour"; - s d "colour_name"; s d "subject"; s d "first"; s d "gospel"; comms ]) + let base = + [ s d "iso"; rite; s d "season"; s d "season_name"; s d "week"; s d "slug"; s d "name"; + s d "weekday"; s d "rank"; s d "rank_name"; s d "colour"; s d "colour_name"; s d "subject"; + s d "first" ] + in + let tail = [ s d "gospel"; comms ] in + let fields = if rite = "ef" then base @ tail else base @ [ s d "second" ] @ tail in + String.concat "," (List.map escape_field fields) let year v = let rite = s v "rite" in let days = match get v "days" with Some (T.List l) -> l | _ -> [] in let b = Buffer.create (64 * 400) in - Buffer.add_string b (String.concat "," columns); + Buffer.add_string b (String.concat "," (columns ~rite)); Buffer.add_char b '\n'; List.iter (fun d -> Buffer.add_string b (row ~rite d); Buffer.add_char b '\n') days; Buffer.contents b diff --git a/lib/render/emit_csv.mli b/lib/render/emit_csv.mli index 262f660..cd7fd5a 100644 --- a/lib/render/emit_csv.mli +++ b/lib/render/emit_csv.mli @@ -6,4 +6,8 @@ double an embedded quote. Exposed for testing. *) val escape_field : string -> string +(** The header is [rite]-dependent (read from [v] itself, its own "rite" + key): EF's 16 columns are unchanged from before this field existed; + any other rite (OF) gets a 17th, "second", between "first" and + "gospel" -- see emit_csv.ml's own [columns] for the full argument. *) val year : Template.value -> string diff --git a/man/colitur.1 b/man/colitur.1 index a55e476..1a330d8 100644 --- a/man/colitur.1 +++ b/man/colitur.1 @@ -7,9 +7,15 @@ colitur \- deterministic liturgical calendar and lectionary engine (Roman rite, .I YEAR .br .B colitur -.BR day | readings +.B temporal .I YEAR .RB [ \-\-rite " ef\(brof" ] +.br +.B colitur +.BR day | readings | rubrics +.RI [ YEAR ] +.RB [ \-\-year " YEAR" ] +.RB [ \-\-rite " ef\(brof" ] .RB [ \-\-overlay " FILE" " ...]" .RB [ \-\-lang " CODE\(brFILE" ] .RB [ \-\-sigla\-style " CODE\(brFILE" ] @@ -18,15 +24,11 @@ colitur \- deterministic liturgical calendar and lectionary engine (Roman rite, .RB [ \-\-raw ] .br .B colitur -.B rubrics -.I YEAR -.RB [ \-\-overlay " FILE" " ...]" -.br -.B colitur .B emit .BI \-\-format " FMT" .BI \-\-from " YEAR" .BI \-\-to " YEAR" +.RB [ \-\-rite " ef\(brof" ] .RB [ \-\-overlay " FILE" " ...]" .RB [ \-\-dtstamp " STAMP" ] .RB [ \-\-lang " CODE\(brFILE" ] @@ -37,8 +39,10 @@ colitur \- deterministic liturgical calendar and lectionary engine (Roman rite, .br .B colitur .BR table | render -.BI \-\-year " YEAR" +.RI [ YEAR ] +.RB [ \-\-year " YEAR" ] .BI \-\-template " FILE" +.RB [ \-\-rite " ef\(brof" ] .RB [ \-\-flavour " FLAVOUR" ] .RB [ \-\-overlay " FILE" " ...]" .RB [ \-\-lang " CODE\(brFILE" ] @@ -52,6 +56,7 @@ colitur \- deterministic liturgical calendar and lectionary engine (Roman rite, .BI \-\-from " YEAR" .BI \-\-to " YEAR" .BI \-\-out " DIR" +.RB [ \-\-rite " ef\(brof" ] .RB [ \-\-overlay " FILE" " ...]" .RB [ \-\-prune ] .RB [ \-\-dtstamp " STAMP" ] @@ -112,7 +117,14 @@ Ascension, Pentecost, Corpus Christi \(em one per line, as .BI temporal " YEAR" The temporal cycle alone, one line per day, before the sanctoral calendar is resolved against it. Chiefly useful for inspecting season and week boundaries -in isolation. +in isolation. Takes +.B \-\-rite +(default +.BR ef , +see below): the OF's own temporal cycle has five seasons, no Septuagesima, +and every slug carries an "of\-" prefix rather than "ef\-", so +.B \-\-rite\ of +changes essentially every line. .TP .BI day " YEAR" The resolved day identity, one line per day: the temporal cycle and the @@ -122,6 +134,27 @@ occurrence, commemoration and transfer. .BI readings " YEAR" The Mass reading citations, one line per day. .TP +.BI \-\-year " YEAR" +An alternative to the positional +.I YEAR +shown above, accepted (additively, not instead) on +.BR day ", " readings ", " rubrics ", " table " and " render : +.BR "colitur day \-\-year 2026" +means exactly what +.BR "colitur day 2026" +does. Naming both is fine as long as they agree; naming both with +.I different +years is a usage error, not one silently overriding the other. +.B emit +and +.B publish +do not take +.BR \-\-year : +they take a range instead +.RB ( "\-\-from Y \-\-to Y" , +inclusive, including for a single year), and gain no second, redundant +spelling of the same thing. +.TP .BR \-\-rite " " ef\(brof Select the rite module. .B ef @@ -130,17 +163,52 @@ Select the rite module. at all is byte\-identical to before this flag existed) computes the Roman .B EF (1962); the alternative, -.BR of ", " computes the Roman +.BR of , +computes the Roman .B OF (the post\-1970 Missal, base calendar the 2002 -.IR Missale Romanum , -editio typica tertia). Accepted only by -.B day -and -.BR readings ; -every other command refuses it rather than silently ignoring it, printing -one line to standard error and exiting +.IR "Missale Romanum" , +editio typica tertia). Accepted by +.BR day ", " readings ", " rubrics ", " temporal ", " emit ", " table ", " render " and " publish ; +refused, not silently ignored, on +.BR check / convert +(operate on an overlay file, not a computed year), +.B new\-overlay +(prints a static starter, no calendar computation), and +.BR lang / config +(answer naming/config questions orthogonal to any rite) \(em one line to +standard error and exit .BR 2 . +.B easter +is refused too, but PROVEN rite\-invariant rather than merely unbuilt for +.BR of : +EF and OF reckon Easter on the identical Gregorian computus, so no second +value could ever change the six dates it prints. +.BR "colitur emit \-\-format csv \-\-rite of" +and +.BR "colitur publish \-\-rite of" +widen the CSV output with a 17th column, +.IR second , +between +.I first +and +.IR gospel : +present, and usually empty, because a Sunday or solemnity genuinely carries +a Second reading (OLM 1981 Praenotanda n. 66.1) and a feria/feast/memorial +does not (n. 69.1); EF's own 16\-column header is unaffected, byte for byte, +because EF's citations never contain one \(em see +.B EMIT +below. A single +.B \-\-out +directory can hold both rites' own +.B publish +trees side by side +.RI ( ef/ ", " of/ ), +but not safely combined with +.BR \-\-prune : +see +.B PUBLISH +below. The OF module is younger than the EF one: it has no published\-ordo witness yet, its lectionary's English text is niedziela.pl's own translation lineage (not the USA\-English one), and a handful of dates are known\-wrong @@ -489,7 +557,7 @@ own trailing field, above. .SS rubrics .RS .nf -date [TAB] formulary\-slug [TAB] source [TAB] creed [TAB] gloria [TAB] preface +date [TAB] formulary\-slug [TAB] source [TAB] creed [TAB] gloria [TAB] preface [TAB name] .fi .RE .PP @@ -504,10 +572,9 @@ or .RB \(lq " | " \(rq like .B readings -\(em because a resolved formulary NAME (a column a later version may add, -not either of these) can carry both spaces and punctuation a citation never -does, which rules out either separator already in use above. A separate -command for the identical mechanical reason +\(em because the resolved formulary NAME (below) can carry both spaces and +punctuation a citation never does, which rules out either separator already +in use above. A separate command for the identical mechanical reason .B day is separate from .BR readings : @@ -550,22 +617,29 @@ here can also mean a rite that has not implemented the rule at all. .RS .nf -2026\-01\-01 [TAB] ef\-circumcision [TAB] own [TAB] true [TAB] true [TAB] nativity -2038\-03\-08 [TAB] john\-of\-god [TAB] proper [TAB] false [TAB] true [TAB] common -2025\-12\-01 [TAB] ef\-advent\-sunday\-1 [TAB] preceding\-sunday [TAB] false [TAB] false [TAB] common +2026\-01\-01 [TAB] ef\-circumcision [TAB] own [TAB] true [TAB] true [TAB] nativity [TAB] In Octava Nativitatis Domini +2038\-03\-08 [TAB] john\-of\-god [TAB] proper [TAB] false [TAB] true [TAB] common [TAB] S. Ioannis a Deo Conf. +2025\-12\-01 [TAB] ef\-advent\-sunday\-1 [TAB] preceding\-sunday [TAB] false [TAB] false [TAB] common [TAB] Dominica I Adventus .fi .RE .PP -Unlike +Like .B day and .BR readings , .B rubrics -resolves no display name and no citation, so it takes none of -.BR \-\-lang ", " \-\-raw -or any +resolves the formulary slug to a display name under +.BR \-\-lang / \-\-raw : +appended as a trailing 8th field, present only when it differs from the +slug already shown \(em the identical append\-only rule those two commands +use, so +.B \-\-raw +(or a language with no entry for that day) is byte\-identical to the +seven\-field row shown above. It resolves no +.I citation +of its own, though, so .B \-\-sigla\-* -flag \(em refused rather than silently ignored, the same discipline +stays refused \(em the same discipline .B \-\-overlay gets on .B easter @@ -580,16 +654,9 @@ and .BR join (1) in the ordinary way. Pass .B \-\-raw -to restore the pre\-naming byte\-exact output of -.B day -or -.B readings -\(em no trailing field at all \(em for a script written against either -before the naming feature existed; -.B rubrics -is already in that form and has no -.B \-\-raw -of its own to pass. +to restore the pre\-naming byte\-exact output of any of the three \(em no +trailing field at all \(em for a script written against one before its own +naming feature existed. .SH EMIT .BI "colitur emit " \-\-format " FMT " \-\-from " YEAR " \-\-to " YEAR" renders the same resolved day \(em season, week, slug, rank, colour, @@ -625,6 +692,31 @@ stay the kernel's own unlocalised keys, unaffected by .BR \-\-lang / \-\-raw , so a script can key off the stable machine value while a human reads the localised one beside it. +.PP +The header is +.B \-\-rite +dependent. EF's 16 columns above are unchanged from before +.B \-\-rite +existed on +.B emit +at all; any other rite +.RB ( of +today) gets a 17th column, +.IR second , +between +.I first +and +.IR gospel : +present, and usually empty, because a Sunday or solemnity genuinely +carries a Second reading (OLM 1981 Praenotanda n. 66.1) and a +feria/feast/memorial does not (n. 69.1). +.RS +.nf + +.B colitur emit \-\-rite of \-\-format csv \-\-from 2026 \-\-to 2026 | head \-1 +date,rite,season,season_name,week,slug,name,weekday,rank,rank_name,colour,colour_name,subject,first,second,gospel,comms +.fi +.RE .TP .B json One JSON object per requested year, concatenated. Shape pinned by @@ -697,10 +789,29 @@ applied on top of the shipped calendar, in order, before the range is rendered. See .B OVERLAYS below. +.PP +.BR \-\-rite +selects the rite module exactly as on +.B day +and +.BR readings ; +see the +.B \-\-rite +entry under +.B COMMANDS +above for the full account, CSV's own +.I second +column included. .SH RENDERING -.BI "colitur table " \-\-year " YEAR " \-\-template " FILE" +.B "colitur table" +.RI [ YEAR ] +.RB [ \-\-year " YEAR" ] +.BI \-\-template " FILE" and -.BI "colitur render " \-\-template " FILE " \-\-year " YEAR" +.B "colitur render" +.BI \-\-template " FILE" +.RI [ YEAR ] +.RB [ \-\-year " YEAR" ] are the .I same operation under two names: compute the resolved year, shape it into the @@ -708,8 +819,20 @@ same view .B emit uses, and render it through .I FILE -in one process. Both accept -.BR \-\-flavour " and " \-\-overlay +in one process. +.I YEAR +may be given positionally or as +.BR \-\-year , +additively, the same either\-or\-both\-if\-they\-agree rule +.B day +and +.B readings +follow (see +.B \-\-year +under +.B COMMANDS +above). Both commands accept +.BR \-\-rite ", " \-\-flavour " and " \-\-overlay identically. .SS Why there is no stdin\-fed render The design this project followed originally sketched a Unix pipe, @@ -769,7 +892,18 @@ list, with padding cells flagged for the leading and trailing blanks a grid needs and a booklet does not). A key absent on a given day (an optional field a rite does not always set) renders as the empty string rather than an error \(em the one deliberate silence, so a template survives a day that does not -carry every optional field. +carry every optional field. This is also what happens, harmlessly, when a +template written for one rite is pointed at the other's year with +.BR \-\-rite : +an EF\-era template that never references +.B {{second}} +(OF's own Sunday/solemnity Second reading) simply does not show it, neither +crashing nor dropping any other field, because both rites expose the +identical key +.I set +and differ only in the resolved +.I values +(e.g. season names) underneath it. .PP See .BR colitur\-templates (5) @@ -867,17 +1001,47 @@ serve the result as\-is and nothing runs at request time. .RS .nf -ef/.json one civil year, all days, whole\-year emitters -ef/.csv -ef/.xml -ef/.ics -ef///
.json one file per day +/.json one civil year, all days, whole\-year emitters +/.csv +/.xml +/.ics +///
.json one file per day schema/day\-v1.json the published JSON contract index.html a generated index page, not a template \&.colitur\-manifest every path this run wrote, one per line .fi .RE .PP +.I +is +.B ef +or +.BR of , +selected by +.BR \-\-rite +exactly as on every other command (default +.BR ef ). +A single +.B \-\-out +directory can hold both rites' own trees side by side, across two separate +invocations \(em but +.I not +safely combined with +.BR \-\-prune : +the manifest and +.I index.html +publish writes describe the +.I whole +tree, not one rite's own slice of it, so a +.B \-\-rite of +run's own manifest never mentions an earlier EF run's files, and +.B \-\-prune +would delete them as stale. Publish a single rite per +.BR \-\-out , +or omit +.B \-\-prune +when deliberately layering both. +.PP Every emitted file goes through the same emitters .B emit uses; a published @@ -960,6 +1124,15 @@ applied on top of the shipped calendar, in order, before each year in the range is rendered. See .B OVERLAYS below. +.PP +.BR \-\-rite +selects the rite module and its own output subtree +.RI ( / , +above) exactly as on every other command; see the +.B \-\-rite +entry under +.B COMMANDS +for the full account. .SH OVERLAYS .TP .BI \-\-overlay " FILE" @@ -1066,8 +1239,12 @@ each month's own name, and the fixed .I term vocabulary a template routes through \(em through this language instead of the default. Accepted on -.BR day ", " readings ", " emit ", " table ", " render " and " publish ; -refused elsewhere, the same discipline +.BR day ", " readings ", " rubrics ", " emit ", " table ", " render " and " publish +(on +.BR rubrics , +it resolves the formulary slug rather than the observed day's own \(em see +.B OUTPUT FORMAT +above); refused elsewhere, the same discipline .B \-\-overlay gets. .I CODE @@ -1126,12 +1303,10 @@ is an ordinary language table like any other, under which every lookup echoes its key back unchanged, so .B \-\-raw and a real language file share the same code path throughout. -.B day -and -.B readings +.BR day ", " readings " and " rubrics under .B \-\-raw -are byte\-identical to this program's own pre\-naming output; every existing +are byte\-identical to each command's own pre\-naming output; every existing script built against that output therefore needs one flag, not a rewrite. .B \-\-raw also governs every reading citation, through a dedicated @@ -1643,18 +1818,27 @@ validated of the two, against five independent layers including a published Ordo witness. The Ordinary Form (OF, the post\-1970 Missal, base calendar the 2002 .I Missale Romanum ) -is younger: it has no published\-ordo witness yet, a handful of dates are +is younger: it has no published\-ordo witness yet, and a handful of dates are known\-wrong and pinned rather than fixed (St Joseph anticipated onto Palm -Sunday, Normae n. 56(f); the Holy Family Sunday fallback of Normae n. 35(a)), -and on a Sunday or solemnity its lectionary carries only the first reading -and the Gospel, not the Second Reading (the Apostle) or the responsorial -psalm. +Sunday, Normae n. 56(f); the Holy Family Sunday fallback of Normae n. 35(a)). .PP -Only the Epistle and the Gospel are emitted. The chants \(em Psalm, Gradual, -Tract, Alleluia, Sequence \(em are deliberately not computed: they have no -source in this engine's data and no oracle to validate them against, and the -engine rejects any citation part outside those two rather than emit one it -cannot stand behind. +EF emits the Epistle and the Gospel only \(em its own +.I citations +are always exactly that pair, on every day of every year 1583..9999. OF +also emits a Second reading (the Apostle) on the Sundays and solemnities +its own shipped lectionary carries one for (OLM 1981 Praenotanda n. 66.1 +vs n. 69.1: a Sunday/solemnity Mass has three readings, a feria/feast/ +memorial two) \(em 183 of 1725 emitted citation fields in +.I data/of/lectionary.sexp +today, every one from a Sunday\-cycle entry, never a weekday\-cycle one; the +responsorial Psalm itself (present in the underlying pastoral source) is +deliberately NOT extracted \(em a chant, not a reading, and named in +neither rite's own well\-formed citation shapes. The chants proper \(em +Psalm, Gradual, Tract, Alleluia, Sequence \(em are deliberately not +computed for either rite: they have no source in this engine's data and no +oracle to validate them against, and the engine rejects any citation part +outside its own rite's well\-formed shapes rather than emit one it cannot +stand behind. .PP The votive Office of the Blessed Virgin Mary on Saturday is kept, but the seasonal selection among its five Masses is not yet implemented, so its diff --git a/test/cli.t b/test/cli.t index 588616c..22890c1 100644 --- a/test/cli.t +++ b/test/cli.t @@ -269,14 +269,178 @@ An unrecognised --rite value is a usage error, not a silent fallback to ef colitur: unknown --rite "tridentine" (expected "ef" or "of") [2] -`--rite` is refused, not silently ignored, on every command besides -day/readings (exit 2): - +Fix 1 (cli-flags-report, 2026-08-27): `--rite` is refused, not silently +ignored, on the FIVE commands that read no rite-specific YEAR of data at +all (exit 2) -- `check`/`convert` operate on an overlay FILE, not a +computed year; `new-overlay` prints a static starter with no calendar +computation in it; `lang`/`config` answer naming/config questions +orthogonal to any later `day`/`table` invocation's own rite. `easter` +belongs here too, but for a PROVEN reason, not merely an unbuilt one: EF +and OF reckon Easter on the identical Gregorian computus +({!Rite_of.Rite_of.context}'s own [easter] field cites this directly) -- +`easter` is refused UNCONDITIONALLY, not only for "of", because there is +no second value that could ever produce a different answer: + + $ colitur easter --rite ef 2026 + colitur: --rite has no effect on `easter`; refusing rather than ignoring it + [2] $ colitur easter --rite of 2026 colitur: --rite has no effect on `easter`; refusing rather than ignoring it [2] - $ colitur rubrics --rite of 2026 - colitur: --rite has no effect on `rubrics`; refusing rather than ignoring it + $ colitur check --rite of /tmp/nope.sexp + colitur: --rite has no effect on `check`; refusing rather than ignoring it + [2] + $ colitur convert --rite of /tmp/nope.ini + colitur: --rite has no effect on `convert`; refusing rather than ignoring it + [2] + $ colitur new-overlay --rite of + colitur: --rite has no effect on `new-overlay`; refusing rather than ignoring it + [2] + $ colitur lang --rite of --list + colitur: --rite has no effect on `lang`; refusing rather than ignoring it + [2] + $ colitur config --rite of --show + colitur: --rite has no effect on `config`; refusing rather than ignoring it + [2] + +`temporal` is NOT on that list (coordinator-review fix round, same date): +a first pass grouped it with `easter` on the theory that neither reads +"sanctoral or lectionary data" -- true of `easter`, but `temporal` calls +the rite-specific temporal cycle DIRECTLY (season, week numbering, every +slug), so the claim "no effect" was false there, not merely stale. OF has +no Septuagesima at all and prefixes every slug "of-" rather than "ef-", so +`--rite of` changes essentially every line: + + $ colitur temporal 2026 | grep -c septuagesima + 17 + $ colitur temporal --rite of 2026 | grep -c septuagesima + 0 + [1] + $ colitur temporal --rite of 2026 | wc -l + 365 + $ colitur temporal --rite of 2026 | head -3 + 2026-01-01 thursday christmas - of-mary-mother-of-god sollemnitas white + 2026-01-02 friday christmas - of-christmas-1-friday feria white + 2026-01-03 saturday christmas - of-christmas-1-saturday feria white + $ colitur temporal --rite ef 2026 > /tmp/temporal-ef.out + $ colitur temporal 2026 > /tmp/temporal-default.out + $ cmp /tmp/temporal-ef.out /tmp/temporal-default.out && echo identical + identical + +--overlay stays refused on `temporal` regardless of rite -- a separate, +still-valid claim (it runs the temporal cycle before any sanctoral layer +exists, in EITHER rite), unaffected by this fix: + + $ colitur temporal --rite of 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] + +Fix 3's own `--year` is unaffected by any of the above -- still fully +refused on `easter`/`temporal` (it does not gain `day`/`table`'s own +bare-command-word shape, so `easter --year 2026` alone, with no positional +year, is a generic usage error rather than this specific refusal message; +naming the positional year too reaches it): + + $ colitur easter 2026 --year 2026 + colitur: --year/--template/--flavour have no effect on `easter`; refusing rather than ignoring them + [2] + +Every OTHER command now accepts `--rite of` (Fix 1): `rubrics`, `emit`, +`table`/`render`, `publish` and `temporal` were widened from `day`/ +`readings` alone -- plumbing, not new library work, since +{!Colitur_render.View.of_days}/{!Colitur_kernel.Record.of_temporal} were +already fully polymorphic over `('s, 'r)` and `Rite_of` already existed. + +`rubrics --rite of`, same shape as `day`/`readings --rite of` above: + + $ colitur rubrics --rite of 2026 | wc -l + 365 + $ colitur rubrics --rite of 2026 | grep '^2026-01-11' + 2026-01-11 of-baptism-of-the-lord own true true - + $ colitur rubrics --rite ef 2026 | head -1 + 2026-01-01 ef-circumcision own true true nativity + +`emit --rite of` resolves the OF year through every one of the five +emitters. CSV is the one format whose header is rite-dependent (see +lib/render/emit_csv.ml's own citation): EF's 16-column header is +byte-identical to before `--rite` existed on `emit` at all (proven properly +below, against a pre-branch worktree build); OF's adds a 17th column, +"second", between "first" and "gospel" -- present, and usually empty, +because a Sunday/solemnity genuinely carries a Second reading OLM 1981 +n. 66.1 requires and a feria/feast/memorial genuinely does not (OLM n. 69.1): + + $ colitur emit --rite of --format csv --from 2026 --to 2026 | head -1 + date,rite,season,season_name,week,slug,name,weekday,rank,rank_name,colour,colour_name,subject,first,second,gospel,comms + $ colitur emit --rite of --format csv --from 2026 --to 2026 | grep '^2026-11-29,' + 2026-11-29,of,advent,Tempus Adventus,1,of-advent-sunday-1,of-advent-sunday-1,Dominica,sollemnitas,sollemnitas,violet,violaceus,temporal,"Isai 63:16b-17, 19b; 64:2-7",1 Cor 1:3-9,Marc 13:33-37, + $ colitur emit --format csv --from 2026 --to 2026 | head -1 + date,rite,season,season_name,week,slug,name,weekday,rank,rank_name,colour,colour_name,subject,first,gospel,comms + +`emit --rite of --format json/xml/ics/sexp` all carry the Second reading +too -- XML and ICS already had a place for it (a repeated element, a +free-text SUMMARY line), so nothing there needed to widen at all: + + $ colitur emit --rite of --format json --from 2026 --to 2026 | grep -o '"rite":"of"' | head -1 + "rite":"of" + $ colitur emit --rite of --format xml --from 2026 --to 2026 | grep -c '' + 63 + $ colitur emit --rite of --format ics --from 2026 --to 2026 | grep -c 'Second ' + 63 + +`table`/`render --rite of` compute and render the OF year through a +user-supplied template exactly as they already did for EF -- a template +that references `{{second}}` sees it on the days that carry one, and an +older template that never asks for it (never having had a reason to) +still renders cleanly: {!Colitur_render.Template.render}'s own contract is +that a missing key is the empty string, never an error, so an EF-era +template pointed at an OF year (or vice versa) neither crashes nor drops +any OTHER field -- only the keys it never asked for are silently absent, +exactly as they always were for any optional field: + + $ printf '{{#days}}{{iso}} {{slug}} second=[{{second}}]\n{{/days}}' > /tmp/t-of.txt + $ colitur table --rite of --year 2026 --template /tmp/t-of.txt | grep '2026-11-29' + 2026-11-29 of-advent-sunday-1 second=[1 Cor 1:3-9] + $ colitur table --rite of --year 2026 --template /tmp/t-of.txt | grep '2026-01-05' + 2026-01-05 of-christmas-1-monday second=[] + $ printf '{{#days}}{{iso}} {{slug}}\n{{/days}}' > /tmp/t-plain.txt + $ colitur table --rite of --year 2026 --template /tmp/t-plain.txt | grep -c '^2026-' + 365 + $ colitur render --rite of --template /tmp/t-plain.txt --year 2026 | head -1 + 2026-01-01 of-mary-mother-of-god + +`publish --rite of` writes the identical tree shape one directory over -- +"of/" in place of "ef/" -- rather than colliding with an EF publish run +into the same `--out`: + + $ rm -rf /tmp/pub-of + $ colitur publish --rite of --from 2026 --to 2026 --out /tmp/pub-of >/dev/null + $ ls /tmp/pub-of + index.html + of + schema + $ colitur publish --from 2026 --to 2026 --out /tmp/pub-of >/dev/null + $ ls /tmp/pub-of + ef + index.html + of + schema + +A single `--out` directory can hold both rites' own trees this way, but NOT +safely combined with `--prune`: the manifest and index.html publish writes +are for the WHOLE tree, not per rite, so a later `--rite of` run's own +manifest never mentions the earlier EF run's files, and `--prune` would +delete them as stale -- publish a single rite per `--out`, or omit +`--prune` when deliberately layering both (documented on +[publish_report]'s own citation, bin/main.ml). + +An unrecognised --rite value is still a usage error on every command that +now accepts the flag, not only day/readings (exit 2): + + $ colitur rubrics --rite tridentine 2026 + colitur: unknown --rite "tridentine" (expected "ef" or "of") + [2] + $ colitur emit --rite tridentine --format csv --from 2026 --to 2026 + colitur: unknown --rite "tridentine" (expected "ef" or "of") [2] Rubrics (Task 4, celebrant-rubrics-phase1): the day's own Mass formulary, one @@ -341,17 +505,39 @@ own in the fixture), the chain falls all the way back to step 3: $ 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. +Fix 2 (cli-flags-report, 2026-08-27): `--lang`/`--raw` are ACCEPTED, not +refused -- reversing what this section used to say. `said` (the formulary +slug column) is a machine key exactly like `day_line`'s own slug, and every +other row that prints one already resolves it to a display name under +`--lang`; auditing the evidence found no principled reason for this row +alone to be the exception (see rubrics_name's own citation, bin/main.ml). +The resolved name is appended as a trailing 8th column, present only when +it differs from the slug already printed -- the identical append-only +discipline `day`/`readings` already use, so under `--raw` this row is +byte-identical to before this feature existed. `--sigla-*` remain refused: +this row still prints no citation for them to act on. + + $ colitur rubrics 2026 --lang en | head -3 + colitur: --lang/--raw have no effect on `rubrics`; refusing rather than ignoring them + +13 January above is a sanctoral-origin day, `commemoration-of-the-baptism- +of-the-lord` -- its formulary IS the observed celebration's own slug +(`via = proper`), so its name resolves from `d.observed` directly: - $ colitur rubrics 2026 --lang en + $ colitur rubrics 2026 --lang en | grep '^2026-01-13' colitur: --lang/--raw have no effect on `rubrics`; refusing rather than ignoring them - [2] + [1] + +--raw restores the exact pre-Fix-2 row shape, seven fields, no trailing +name -- and matches `colitur rubrics 2026` (no --lang at all) byte for byte +on the first six fields, since `la` is the resolved default and this row's +own trailing name is present only when it differs from the slug: - $ colitur rubrics 2026 --raw + $ colitur rubrics 2026 --raw | head -3 colitur: --lang/--raw have no effect on `rubrics`; refusing rather than ignoring them - [2] + $ colitur rubrics 2026 --raw | wc -l + colitur: --lang/--raw have no effect on `rubrics`; refusing rather than ignoring them + 0 $ colitur rubrics 2026 --sigla-style en colitur: --sigla-style/--sigla-book/--sigla-tradition have no effect on `rubrics`; refusing rather than ignoring them @@ -423,10 +609,10 @@ 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 + rubrics date, formulary slug, source, creed, gloria, preface [name] -- TAB-separated $ colitur --help | sed -n '/^ rubrics date/{n;p}' - 2026-01-01[TAB]ef-circumcision[TAB]own[TAB]true[TAB]true[TAB]nativity + 2026-01-01[TAB]ef-circumcision[TAB]own[TAB]true[TAB]true[TAB]nativity[TAB]In Octava Nativitatis Domini $ 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 @@ -939,14 +1125,57 @@ cram sandbox itself: colitur: cannot read template .: Sys_error("Value too large for defined data type") [2] +Fix 3 (cli-flags-report, 2026-08-27): every single-year command +(`day`/`readings`/`rubrics`/`table`/`render`) now takes EITHER a positional +year or `--year`, additively -- neither form was removed, so `colitur day +2026` above keeps working unchanged. + + $ colitur day 2026 > /tmp/year-day-positional.out + $ colitur day --year 2026 > /tmp/year-day-flag.out + $ cmp /tmp/year-day-positional.out /tmp/year-day-flag.out && echo identical + identical + $ colitur readings --year 2026 | head -1 + 2026-01-01 ef-circumcision | Tit 2:11-15 | Luc 2:21 | In Octava Nativitatis Domini + $ colitur rubrics --year 2026 | head -1 + 2026-01-01 ef-circumcision own true true nativity + +Naming both is fine as long as they agree; naming both with DIFFERENT years +is a hard usage error, not one silently overriding the other (exit 2): + + $ colitur day 2026 --year 2026 > /tmp/year-day-agree.out + $ cmp /tmp/year-day-positional.out /tmp/year-day-agree.out && echo identical + identical + $ colitur day 2026 --year 2027 + colitur: day: positional year 2026 and --year 2027 disagree + [2] + +`table`/`render` gained the SAME two forms, additively -- `--year` already +worked (below); a bare positional year now does too: + + $ printf '{{#days}}{{iso}} {{slug}}\n{{/days}}' > /tmp/t-year.txt + $ colitur table --year 2026 --template /tmp/t-year.txt > /tmp/year-table-flag.out + $ colitur table 2026 --template /tmp/t-year.txt > /tmp/year-table-positional.out + $ cmp /tmp/year-table-flag.out /tmp/year-table-positional.out && echo identical + identical + $ colitur render --template /tmp/t-year.txt 2026 | head -1 + 2026-01-01 ef-circumcision + $ colitur table 2026 --year 2027 --template /tmp/t-year.txt + colitur: table: positional year 2026 and --year 2027 disagree + [2] + +`emit`/`publish` do NOT gain a --year: they stay --from/--to only, even for +a single-year run -- a third spelling of the same thing was considered and +rejected (see this task's own report). --year already refused there, +unaffected by this task (below). + table and render both require --year and --template: $ colitur table --year 2027 - colitur: table requires --year YEAR and --template FILE + colitur: table requires a year (positional or --year) and --template FILE [2] $ colitur render --template /tmp/t.txt - colitur: render requires --year YEAR and --template FILE + colitur: render requires a year (positional or --year) [2] table/render's own flags have no effect on the other commands, refused diff --git a/test/test_emit.ml b/test/test_emit.ml index bd9cadc..988e68e 100644 --- a/test/test_emit.ml +++ b/test/test_emit.ml @@ -105,6 +105,42 @@ let test_csv_does_not_widen_for_a_third_citation () = (List.hd lines); Alcotest.(check int) "row still 16 fields" 16 (List.length (parse_csv_row (List.nth lines 1))) +(* Fix 1 (cli-flags-report, 2026-08-27): a NON-"ef" rite DOES widen the CSV + header with a 17th column, "second", between "first" and "gospel" -- + exactly the deliberate, disclosed change [Emit_csv.columns]'s own header + comment predicted the day OF was admitted to `emit` would need. Built + directly against a synthetic [Template.value] rather than a real + [View.of_days] call: [Emit_csv] only ever reads the view's own "rite" + string and per-day string keys, never a [Vocab.t] or a real + [Liturgical_day.t], so this exercises the identical gating + [test_csv_does_not_widen_for_a_third_citation] above proves EF never + takes, in isolation from the calendar/view layers (which carry no real + OF test fixtures in this file). *) +let synthetic_of_day () = + let module T = Colitur_render.Template in + T.Obj + [ ("iso", T.Str "2026-11-29"); ("season", T.Str "advent"); ("season_name", T.Str "Tempus Adventus"); + ("week", T.Str "1"); ("slug", T.Str "of-advent-sunday-1"); ("name", T.Str "of-advent-sunday-1"); + ("weekday", T.Str "Dominica"); ("rank", T.Str "sollemnitas"); ("rank_name", T.Str "sollemnitas"); + ("colour", T.Str "violet"); ("colour_name", T.Str "violaceus"); ("subject", T.Str "temporal"); + ("first", T.Str "Isai 63:16b-17, 19b; 64:2-7"); ("second", T.Str "1 Cor 1:3-9"); + ("gospel", T.Str "Marc 13:33-37"); ("comms", T.List []) ] + +let synthetic_of_view () = + let module T = Colitur_render.Template in + T.Obj [ ("rite", T.Str "of"); ("year", T.Str "2026"); ("days", T.List [ synthetic_of_day () ]) ] + +let test_csv_widens_for_a_non_ef_rite () = + let out = Csv.year (synthetic_of_view ()) in + let lines = String.split_on_char '\n' out |> List.filter (fun l -> l <> "") in + Alcotest.(check string) "header widened with second" + "date,rite,season,season_name,week,slug,name,weekday,rank,rank_name,colour,colour_name,subject,first,second,gospel,comms" + (List.hd lines); + let row = List.nth lines 1 in + Alcotest.(check int) "row has 17 fields" 17 (List.length (parse_csv_row row)); + Alcotest.(check bool) "second reading appears in the row" true + (contains ~needle:"1 Cor 1:3-9" row) + let test_json_parses_back () = let out = Json.year (view_2027 ()) in Alcotest.(check bool) "starts as an object" true (out.[0] = '{'); @@ -149,6 +185,7 @@ let suite = Alcotest.test_case "csv quotes commas" `Quick test_csv_quotes_commas; Alcotest.test_case "csv does not widen for a third citation" `Quick test_csv_does_not_widen_for_a_third_citation; + Alcotest.test_case "csv widens for a non-ef rite" `Quick test_csv_widens_for_a_non_ef_rite; Alcotest.test_case "json parses back" `Quick test_json_parses_back; Alcotest.test_case "json escapes" `Quick test_json_escapes; Alcotest.test_case "json passes utf8 through" `Quick test_utf8_passes_through_json ] ) -- cgit v1.3