aboutsummaryrefslogtreecommitdiff
path: root/bin
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 /bin
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.
Diffstat (limited to 'bin')
-rw-r--r--bin/main.ml710
1 files changed, 566 insertions, 144 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) ->