aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukasz@arcofasiagroup.com>2026-08-27 12:33:43 +0200
committerLukasz Kasprzak <lukasz@arcofasiagroup.com>2026-08-27 12:33:43 +0200
commit32e792a0741f62fc2c0c7dcf9255408b3256fad5 (patch)
tree92f0b7b0a289de5251bb18bcc9239becdaee223c
parent85903385f8a32be3de3964f43b3d7831474d0a72 (diff)
downloadcolitur-32e792a0741f62fc2c0c7dcf9255408b3256fad5.tar.gz
colitur-32e792a0741f62fc2c0c7dcf9255408b3256fad5.zip
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.
-rw-r--r--bin/main.ml710
-rw-r--r--lib/render/emit_csv.ml63
-rw-r--r--lib/render/emit_csv.mli4
-rw-r--r--man/colitur.1314
-rw-r--r--test/cli.t259
-rw-r--r--test/test_emit.ml37
6 files changed, 1141 insertions, 246 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.
+ 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.
- [d.formulary] is documented as [Some] on every day of every year for EF,
+ 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
- "<!doctype html>\n<html lang=\"en\"><head><meta charset=\"utf-8\">\n\
- <title>colitur</title>\n\
- <style>body{font-family:sans-serif;max-width:40em;margin:2em auto;line-height:1.5}\n\
- code{background:#f4f4f4;padding:.1em .3em}</style></head><body>\n\
- <h1>colitur</h1>\n\
- <p>Liturgical calendar of the 1962 Missale Romanum. Citations only \xe2\x80\x94 never scripture text.</p>\n";
+ (Printf.sprintf
+ "<!doctype html>\n<html lang=\"en\"><head><meta charset=\"utf-8\">\n\
+ <title>colitur</title>\n\
+ <style>body{font-family:sans-serif;max-width:40em;margin:2em auto;line-height:1.5}\n\
+ code{background:#f4f4f4;padding:.1em .3em}</style></head><body>\n\
+ <h1>colitur</h1>\n\
+ <p>%s Citations only \xe2\x80\x94 never scripture text.</p>\n"
+ missal_sentence);
Buffer.add_string b "<h2>Subscribe</h2>\n<ul>\n";
for y = from_y to to_y do
- Buffer.add_string b (Printf.sprintf "<li><a href=\"ef/%d.ics\">ef/%d.ics</a></li>\n" y y)
+ Buffer.add_string b
+ (Printf.sprintf "<li><a href=\"%s/%d.ics\">%s/%d.ics</a></li>\n" rite_id y rite_id y)
done;
Buffer.add_string b "</ul>\n<h2>Data</h2>\n<ul>\n";
for y = from_y to to_y do
Buffer.add_string b
(Printf.sprintf
- "<li>%d: <a href=\"ef/%d.json\">json</a> <a href=\"ef/%d.csv\">csv</a> \
- <a href=\"ef/%d.xml\">xml</a> \xe2\x80\x94 per-day at <code>ef/%d/MM/DD.json</code></li>\n"
- y y y y y)
+ "<li>%d: <a href=\"%s/%d.json\">json</a> <a href=\"%s/%d.csv\">csv</a> \
+ <a href=\"%s/%d.xml\">xml</a> \xe2\x80\x94 per-day at <code>%s/%d/MM/DD.json</code></li>\n"
+ y rite_id y rite_id y rite_id y rite_id y)
done;
Buffer.add_string b
"</ul>\n<p>Contract: <a href=\"schema/day-v1.json\">schema/day-v1.json</a></p>\n\
</body></html>\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 <year> Easter, and the movable feasts anchored to it
- colitur temporal <year> the temporal cycle, one line per day
+ colitur temporal <year> [--rite ef|of]
+ the temporal cycle, one line per day
colitur day <year> the resolved day identity, one line per day
colitur readings <year> the Mass reading citations, one line per day
colitur rubrics <year> the Mass formulary said, one line per day
- colitur day|readings <year> [--rite ef|of] [--overlay FILE ...] [--lang CODE|FILE] [--raw]
- colitur rubrics <year> [--overlay FILE ...]
- colitur emit --format csv|json|sexp|xml|ics --from Y --to Y
+ colitur day|readings|rubrics [<year>] [--year Y] [--rite ef|of]
+ [--overlay FILE ...] [--lang CODE|FILE] [--raw]
+ <year> 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>] [--year Y] --template FILE [--rite ef|of]
+ [--flavour X] [--overlay FILE ...] [--lang CODE|FILE] [--raw]
+ colitur render --template FILE [<year>] [--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
<year> 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
<lang-dir>/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/<year>.{json,csv,xml,ics} one civil year, all days
- ef/<year>/<mm>/<dd>.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
+ <rite>/<year>.{json,csv,xml,ics} one civil year, all days
+ <rite>/<year>/<mm>/<dd>.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
+
+ <rite> 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 <year>`'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/<year>.json one civil year, all days, whole\-year emitters
-ef/<year>.csv
-ef/<year>.xml
-ef/<year>.ics
-ef/<year>/<mm>/<dd>.json one file per day
+<rite>/<year>.json one civil year, all days, whole\-year emitters
+<rite>/<year>.csv
+<rite>/<year>.xml
+<rite>/<year>.ics
+<rite>/<year>/<mm>/<dd>.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 <rite>
+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 ( <rite>/ ,
+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 '<citation part="second">'
+ 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
+ $ colitur rubrics 2026 --lang en | head -3
colitur: --lang/--raw have no effect on `rubrics`; refusing rather than ignoring them
- [2]
- $ colitur rubrics 2026 --raw
+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 | 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 | head -3
+ colitur: --lang/--raw have no effect on `rubrics`; refusing rather than ignoring them
+ $ 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 ] )