Compare commits

...

5 Commits

Author SHA1 Message Date
will 8bcecd108b Record Matins reading/hymn/invitatory fixes and hour dedup in TODO.md
Deploy / deploy (push) Successful in 1m24s
Documents the full set of changes from this session: the
nocturnReadingIds precedence bug fix, the new Matins hymn Common-tier
and invitatory-antiphon mechanism (Common-of-an-Apostle content
authored, other Commons/seasons logged as backlog), the antiphon-
doubling dedup, and the Terce/Sext/None hymn dedup — including why the
Lauds/Vespers office-bundle resolvers were deliberately left alone
(they turned out not to be byte-identical: different override-
eligibility semantics) and why Matins's own octave hymn tier isn't
redundant with resolveOfficeWinner's octave-folding.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F25189JqjXddUhU9hM9nUS
2026-08-24 07:14:27 -04:00
will 72033221c3 Collapse Terce/Sext/None's identical hymn resolution into one helper
Terce, Sext, and None each had a byte-identical 'hymn' case
(resolveCommon(part.textRef.id), no doxology mechanism, no override/
season tiering). Replace all three with a single shared
resolveSelfContainedHymn helper in resolve-common.ts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F25189JqjXddUhU9hM9nUS
2026-08-24 07:14:19 -04:00
will 449472d5aa Add Matins hymn Common-of-Saints tier and a real invitatory-antiphon mechanism
resolveMatinsHymn gained a Common-category tier (override -> octave ->
Common category -> season -> ferial), reusing matins-psalmody-
overrides.ts's own getSaintRecord(id)?.common lookup pattern. The
invitatory antiphon (invitatoryParts), previously one hardcoded fixed
text for every day of the year with only full-vs-incipit doubling
varying by rank, now goes through a real resolveMatinsInvitatoryText
(override -> Common category -> season -> ferial, no octave tier) before
the doubling step.

Content authored this pass: Common-of-an-Apostle only, both hymn
(matins-hymn-common-of-an-apostle.yml, "Aeterna Christi munera") and
invitatory antiphon (matins-invitatory-common-of-an-apostle.yml, "Regem
Apostolorum Dominum"), both read directly from the reference engine's
Commune/C1.txt. Live-verified: St. Bartholomew (2026-08-24) and St.
Andrew (2026-11-30) both now resolve to this Common's text.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F25189JqjXddUhU9hM9nUS
2026-08-24 07:14:06 -04:00
will 8f55ec6931 Centralize antiphon opening-doubling logic across all hours
The same idiom (splitNamedAntiphon + isDoubleOrHigher(...) ? full :
incipit) was copy-pasted at 11 call sites across 8 hour files. Add one
shared openingAntiphon(antiphon, winner) helper in resolve-common.ts
and use it everywhere a psalm/canticle antiphon's opening form is
decided. Pure dedup, no behavior change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F25189JqjXddUhU9hM9nUS
2026-08-24 07:13:38 -04:00
will f8de318b7f Fix Matins nocturn readings leaking suppressed Sunday content
nocturnReadingIds unconditionally pooled the plain temporal-cycle and
month-week reading ids into every day's nocturn-reading pool, even when
a real feast wins the day outright with zero commemorations
(decideOccurrence's `ordinary-feria` branch, which correctly suppresses
the temporal identity entirely). St. Bartholomew (duplex-2-classis,
2026-08-24, a Monday) has no Nocturn 3 content of his own, so the
leftover 13th-Sunday-after-Pentecost and month-week readings wrongly
filled his Nocturn 3.

Gate the pooling on whether the day's own occurrence decision actually
retained the temporal identity (day.winner.kind === 'temporal', or a
`temporal`-kind entry in day.commemorations) — mirrors getDayCollects's
existing pattern for the same question.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F25189JqjXddUhU9hM9nUS
2026-08-24 07:11:09 -04:00
13 changed files with 459 additions and 53 deletions
+76
View File
@@ -2621,6 +2621,82 @@ saint-wins-Ember-commemorated cases) and content resolution (Advent, wins-outrig
verified Matins content on all 6 days) end-to-end. `npm test` (435 tests) and `tsc --noEmit` verified Matins content on all 6 days) end-to-end. `npm test` (435 tests) and `tsc --noEmit`
both pass. both pass.
### Matins nocturn-reading temporal/month-week suppression bug, hymn Common-tier + real invitatory mechanism, antiphon-doubling dedup (2026-08-24)
**Bug fixed**: `nocturnReadingIds` (`hours/matins.ts`) unconditionally pooled the plain
temporal-cycle reading id and the calendar-month/week id into every day's nocturn reading
pool, even on a day where a real feast wins outright with zero commemorations
(`calendar/commemorations.ts`'s `decideOccurrence`, `ordinary-feria` branch — correctly
suppressing the temporal identity entirely). St. Bartholomew (duplex-2-classis, 2026-08-24, a
Monday) has no Nocturn 3 content of his own, so the leftover 13th-Sunday-after-Pentecost
(`post-pentecost-13.yml`) and month-week (`month-week-084.yml`) readings wrongly filled his
Nocturn 3. Fixed by gating that pooling on whether the day's own occurrence decision actually
retained the temporal identity (`day.winner.kind === 'temporal'`, or a `kind: 'temporal'` entry
in `day.commemorations`) — mirrors `getDayCollects`'s own long-standing pattern for the same
question. New tests in `tests/hours/matins.test.ts` cover both the regression (Bartholomew) and
the non-regression case (a plain ferial weekday with no sanctoral winner, 2026-09-04).
**Matins hymn + invitatory antiphon — new Common-of-Saints tier, real invitatory mechanism**:
also fixed while investigating the same report. `resolveMatinsHymn` gained a Common-category
tier (override → octave → **Common category** → season → ferial), reusing
`matins-psalmody-overrides.ts`'s own `getSaintRecord(id)?.common` lookup pattern. The invitatory
antiphon (`invitatoryParts`), previously one hardcoded fixed text for every day of the year with
only full-vs-incipit doubling varying by rank, now goes through a real
`resolveMatinsInvitatoryText` (override → Common category → season → ferial, no octave tier —
no per-octave invitatory override exists anywhere in the reference source) before the
pre-existing doubling step is applied. Content authored this pass: Common-of-an-Apostle only,
both hymn (`matins-hymn-common-of-an-apostle.yml`, "Ætérna Christi múnera") and invitatory
antiphon (`matins-invitatory-common-of-an-apostle.yml`, "Regem Apostolórum Dóminum"), both read
directly from the reference engine's `Commune/C1.txt` (Latin + English both present there).
Live-verified: St. Bartholomew (2026-08-24) and St. Andrew (2026-11-30) both now resolve to this
Common's text for hymn + invitatory.
**Not authored, logged as explicit backlog**, same incremental-content pattern as everywhere
else in this project:
- Hymn + invitatory Common tiers for: Martyrs (C2), Confessors (C4), Virgins (C6), Holy Women
(C7), Dedication (C8), General/All Saints (C9) — all confirmed to have their own distinct
`[Invit]`/`[Hymnus Matutinum]` text in the reference source, not yet transcribed.
- Seasonal invitatory-antiphon tier content (Advent/Lent/Passiontide/Paschaltide/Christmastide)
— mechanism now exists (same `seasonalOfficeSuffix`-keyed lookup the hymn's own long-standing
seasonal tier already uses), but no seasonal invitatory file is authored yet; every season
currently still falls through to the plain ferial invitatory antiphon. Confirmed varying
content exists in the reference source for at least Paschaltide (`Tempora/Pasc0-0.txt`,
"Surréxit Dóminus vere") and Christmastide (`Tempora/Nat2-0.txt`, "Admirábile nomen Jesu").
- Seasonal Matins-hymn content itself (Advent/Lent/Passiontide/Paschaltide) — this gap predates
this pass (already noted in `resolveMatinsHymn`'s own doc comment) and is unchanged here.
**Cross-hour antiphon-doubling dedup**: the same idiom (`splitNamedAntiphon` +
`isDoubleOrHigher(...) ? full : incipit`) was copy-pasted at 11 call sites across 8 hour files
(matins.ts x4, lauds.ts x2, vespers.ts x2, compline.ts x1, prime/terce/sext/none.ts x1 each).
Centralized into one new `openingAntiphon(antiphon, winner)` helper in `hours/resolve-common.ts`
(alongside `splitNamedAntiphon`, which it wraps); every call site now uses it. Pure dedup, no
behavior change — confirmed by the full existing test suite passing unchanged.
**Cross-hour hymn-resolution dedup — partial**: investigated whether hymn resolution across all
8 hours could collapse into one shared resolver the same way antiphon-doubling did. Found it
does *not* fit a single universal helper — hymn resolution falls into 4 genuinely different tier
shapes (Matins's standalone 4-tier hymn-only resolver; Lauds/Vespers's bundled
hymn+chapter+responsory+versicle resolver; Prime/Compline's fixed-id-plus-`appendDoxology`;
Terce/Sext/None's flat one-liner with the doxology baked into the static hymn text). Of these,
only Terce/Sext/None's case was genuinely byte-identical and safe to collapse — done, via a new
`resolveSelfContainedHymn` helper in `resolve-common.ts`. **Lauds and Vespers's own bundle
resolvers turned out NOT to be byte-identical either**, despite looking that way at a glance:
Lauds gates its whole bundle on whether a `LaudsPsalmodyOverride` exists for the winning saint
(`getPsalmodyOverrideFor(day)?.id`), while Vespers gates purely on duplex-majus+ rank via
`getOfficeOverrideId` directly (it has no separate per-feast psalmody-override table to check
against). Collapsing them into one parameterized helper risks silently changing eligibility
behavior for one of the two hours, so this was deliberately left alone rather than risked — a
real follow-on refactor, but one that needs an explicit "eligibility" plugin point designed
first, not a same-day mechanical extraction. Also checked: Matins's own explicit
`resolveActiveOctave` hymn tier looked at first like it might be redundant with
`resolveOfficeWinner`'s own octave-folding (which Lauds/Vespers rely on instead of a separate
octave check) — confirmed it is *not* redundant: `resolveOfficeWinner`'s fold-in only fires for
`ordinary-feria` days or via `octaveGoverningPrivilegedDay` (which explicitly excludes
Christmastide's own stacked octaves), so Matins's separate check still covers real cases (e.g.
Christmas/Stephen/John/Innocents octave days) the other path doesn't reach. Left unchanged.
`npm test` (470 tests) and `tsc --noEmit` both pass.
## Known, deliberate simplifications (not bugs — working as designed) ## Known, deliberate simplifications (not bugs — working as designed)
- `getDayCollects`: each collect in a multi-collect day renders as its own - `getDayCollects`: each collect in a multi-collect day renders as its own
@@ -0,0 +1,101 @@
# The Common-of-an-Apostle's own Matins hymn ("Ætérna Christi múnera" /
# "Th' eternal gifts of Christ the King"), read from the reference engine's
# own Commune/C1.txt [Hymnus Matutinum] block (both Latin and English
# present and complete there). Any winning apostle with no proper Matins
# hymn of his own authored falls to this Common tier (hours/matins.ts's
# resolveMatinsHymn) — e.g. St. Bartholomew (2026-08-24, live-verified):
# Monastic 1617 gives him no [Hymnus Matutinum] override of his own, so
# this Common's own text genuinely renders.
id: matins-hymn-common-of-an-apostle
text:
la: >-
Ætérna Christi múnera,
Apostolórum glóriam,
Palmas et hymnos débitos
Lætis canámus méntibus.
Ecclesiárum Príncipes,
Belli triumpháles duces,
Cæléstis aulæ mílites,
Et vera mundi lúmina.
Devóta Sanctórum fides,
Invícta spes credéntium,
Perfécta Christi cáritas
Mundi tyránnum cónterit.
In his Patérna glória,
In his triúmphat Fílius,
In his volúntas Spíritus,
Cælum replétur gáudio.
Patri, simúlque Fílio,
Tibíque Sancte Spíritus,
Sicut fuit, sit júgiter
Sæclum per omne glória. Amen.
en: >-
Th' eternal gifts of Christ the King,
The Apostles' glory, let us sing;
And all with hearts of gladness raise
Due hymns of thankful love and praise.
For they the Church's princes are,
Triumphant leaders in the war,
The heavenly King's own warrior band,
True lights to lighten every land.
Theirs was the steadfast faith of saints,
The hope that never yields nor faints,
The love of Christ in perfect glow,
That lay the prince of this world low.
In them the Father's glory shone,
In them the Spirit's will was done,
The Son himself exults in them;
Joy fills the new Jerusalem.
Praise to the Father, with the Son,
And Holy Spirit, Three in One;
As ever was in ages past,
And so shall be while ages last. Amen.
status:
la: verified
en: verified
@@ -0,0 +1,13 @@
# The Common-of-an-Apostle's own invitatory antiphon ("Regem Apostolórum
# Dóminum" / "The Lord, the King of Apostles"), read from the reference
# engine's own Commune/C1.txt [Invit] line (both languages). Any winning
# apostle with no proper invitatory antiphon of his own authored falls to
# this Common tier (hours/matins.ts's resolveMatinsInvitatoryText) — e.g.
# St. Bartholomew (2026-08-24, live-verified).
id: matins-invitatory-common-of-an-apostle
text:
la: "Regem Apostolórum Dóminum, * Veníte, adorémus."
en: "The Lord, the King of Apostles, * O come, let us adore."
status:
la: verified
en: verified
+4 -3
View File
@@ -7,7 +7,7 @@ import { getHymnDoxologyId } from './hymn-doxology';
import { getOpeningVersicleId } from './opening-versicle'; import { getOpeningVersicleId } from './opening-versicle';
import { getMarianAntiphonId, getMarianAntiphonLabel } from './marian-antiphon'; import { getMarianAntiphonId, getMarianAntiphonLabel } from './marian-antiphon';
import { isDoubleOrHigher } from './antiphon'; import { isDoubleOrHigher } from './antiphon';
import { resolveCommon, appendDoxology, splitNamedAntiphon, resolveResponsory } from './resolve-common'; import { resolveCommon, appendDoxology, splitNamedAntiphon, openingAntiphon, resolveResponsory } from './resolve-common';
import complineDefinitionData from '../data/hours/compline.yml'; import complineDefinitionData from '../data/hours/compline.yml';
const complineDefinition = complineDefinitionData as HourDefinition; const complineDefinition = complineDefinitionData as HourDefinition;
@@ -63,8 +63,9 @@ function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] {
}, },
]; ];
case 'nunc-dimittis': { case 'nunc-dimittis': {
const { incipit, full } = splitNamedAntiphon(resolveCommon('nunc-dimittis-antiphon')); const antiphonText = resolveCommon('nunc-dimittis-antiphon');
const opening = isDoubleOrHigher(day.winner) ? full : incipit; const opening = openingAntiphon(antiphonText, day.winner);
const { full } = splitNamedAntiphon(antiphonText);
return [ return [
{ kind: 'canticle', canticleId: 'nunc-dimittis', text: resolveCommon('nunc-dimittis'), antiphon: opening }, { kind: 'canticle', canticleId: 'nunc-dimittis', text: resolveCommon('nunc-dimittis'), antiphon: opening },
{ kind: 'antiphon', text: full }, { kind: 'antiphon', text: full },
+6 -6
View File
@@ -14,6 +14,7 @@ import {
getDayCollects, getDayCollects,
getBenedictusAntiphon, getBenedictusAntiphon,
splitNamedAntiphon, splitNamedAntiphon,
openingAntiphon,
resolveOfficeWinner, resolveOfficeWinner,
verifiedText, verifiedText,
seasonalOfficeSuffix, seasonalOfficeSuffix,
@@ -134,10 +135,8 @@ function canticleText(canticleId: string, slice?: [number, number]): ResolvedTex
* plain weekday default below gets substituted. */ * plain weekday default below gets substituted. */
function resolvePsalmody(day: LiturgicalDay): ResolvedPart[] { function resolvePsalmody(day: LiturgicalDay): ResolvedPart[] {
const wd = getPsalmodyOverrideFor(day) ?? laudsAntiphons[day.weekday]; const wd = getPsalmodyOverrideFor(day) ?? laudsAntiphons[day.weekday];
const opening = (antiphon: BilingualText) => { const winner = resolveOfficeWinner(day);
const { incipit, full } = splitNamedAntiphon(verifiedText(antiphon)); const opening = (antiphon: BilingualText) => openingAntiphon(verifiedText(antiphon), winner);
return isDoubleOrHigher(resolveOfficeWinner(day)) ? full : incipit;
};
const parts: ResolvedPart[] = [ const parts: ResolvedPart[] = [
{ {
kind: 'psalm', kind: 'psalm',
@@ -237,8 +236,9 @@ function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] {
case 'lauds-office': case 'lauds-office':
return resolveOffice(day); return resolveOffice(day);
case 'benedictus': { case 'benedictus': {
const { incipit, full } = splitNamedAntiphon(getBenedictusAntiphon(day)); const antiphonText = getBenedictusAntiphon(day);
const opening = isDoubleOrHigher(resolveOfficeWinner(day)) ? full : incipit; const opening = openingAntiphon(antiphonText, resolveOfficeWinner(day));
const { full } = splitNamedAntiphon(antiphonText);
return [ return [
{ kind: 'canticle', canticleId: 'benedictus', text: resolveCommon('benedictus'), antiphon: opening }, { kind: 'canticle', canticleId: 'benedictus', text: resolveCommon('benedictus'), antiphon: opening },
{ kind: 'antiphon', text: full }, { kind: 'antiphon', text: full },
+98 -14
View File
@@ -78,7 +78,9 @@ import {
seasonalOfficeSuffix, seasonalOfficeSuffix,
verifiedText, verifiedText,
splitNamedAntiphon, splitNamedAntiphon,
openingAntiphon,
} from './resolve-common'; } from './resolve-common';
import { getSaintRecord } from '../calendar/feasts';
import { getBiblePlanReadings } from '../propers/bible-plan'; import { getBiblePlanReadings } from '../propers/bible-plan';
import { getNocturnReadings, type NocturnReading } from '../propers/nocturn-readings'; import { getNocturnReadings, type NocturnReading } from '../propers/nocturn-readings';
import { getOctaveReading } from '../propers/octave-readings'; import { getOctaveReading } from '../propers/octave-readings';
@@ -167,14 +169,56 @@ function psalmRefParts(refs: PsalmRef[]): ResolvedPart[] {
})); }));
} }
/** The invitatory antiphon's own text — same override > Common-category >
* seasonal > ferial precedence as `resolveMatinsHymn`, minus the octave
* tier: no per-octave invitatory-antiphon override exists anywhere in the
* reference source (unlike the hymn, which genuinely persists all week on
* some octaves) — an octave day already falls through correctly to its
* governing saint's own Common/season tier here, so adding an octave tier
* would have nothing to fire on. Keeps the existing ferial fallback id
* (`matins-invitatory-antiphon`) as-is rather than renaming it to match
* the hymn's `-ferial` convention — it's already `verified` and its own
* header already documents itself as the fallback-of-record; renaming
* would be pure churn.
*
* Doubling (`isDoubleOrHigher`, via `openingAntiphon`) is a separate, final
* step applied to whichever tier's text wins here — never part of content
* selection itself. */
function resolveMatinsInvitatoryText(day: LiturgicalDay): ResolvedText {
const overrideId = getOfficeOverrideId(day);
if (overrideId) {
const proper = resolveCommon(`matins-invitatory-${overrideId}`);
if (proper.status.la !== 'missing' || proper.status.en !== 'missing') {
return proper;
}
}
const winner = resolveOfficeWinner(day);
const commonId = winner.kind === 'sanctoral' ? getSaintRecord(winner.id)?.common : undefined;
if (commonId) {
const common = resolveCommon(`matins-invitatory-${commonId}`);
if (common.status.la !== 'missing' || common.status.en !== 'missing') {
return common;
}
}
const seasonSuffix = seasonalOfficeSuffix(day.season);
if (seasonSuffix) {
const seasonal = resolveCommon(`matins-invitatory-${seasonSuffix}`);
if (seasonal.status.la !== 'missing' || seasonal.status.en !== 'missing') {
return seasonal;
}
}
return resolveCommon('matins-invitatory-antiphon');
}
/** The Invitatory (Ps 94) — real practice interleaves its antiphon as a /** The Invitatory (Ps 94) — real practice interleaves its antiphon as a
* repeating refrain between verse groups; per direct instruction this app * repeating refrain between verse groups; per direct instruction this app
* frames it like any other psalm antiphon instead (one opening, one full * frames it like any other psalm antiphon instead (one opening, one full
* repeat after) — see data/propers/common/matins-invitatory-antiphon.yml's * repeat after) — see data/propers/common/matins-invitatory-antiphon.yml's
* own header. */ * own header. */
function invitatoryParts(day: LiturgicalDay): ResolvedPart[] { function invitatoryParts(day: LiturgicalDay): ResolvedPart[] {
const { incipit, full } = splitNamedAntiphon(resolveCommon('matins-invitatory-antiphon')); const text = resolveMatinsInvitatoryText(day);
const opening = isDoubleOrHigher(resolveOfficeWinner(day)) ? full : incipit; const opening = openingAntiphon(text, resolveOfficeWinner(day));
const { full } = splitNamedAntiphon(text);
return [ return [
psalmPartWithAntiphon(94, opening), psalmPartWithAntiphon(94, opening),
{ kind: 'antiphon', text: full }, { kind: 'antiphon', text: full },
@@ -187,8 +231,9 @@ function invitatoryParts(day: LiturgicalDay): ResolvedPart[] {
function sundayPsalmNocturn(group: SundayNocturn, day: LiturgicalDay): ResolvedPart[] { function sundayPsalmNocturn(group: SundayNocturn, day: LiturgicalDay): ResolvedPart[] {
const parts: ResolvedPart[] = []; const parts: ResolvedPart[] = [];
for (const g of group.groups ?? []) { for (const g of group.groups ?? []) {
const { incipit, full } = splitNamedAntiphon(verifiedText(g.antiphon)); const antiphonText = verifiedText(g.antiphon);
const opening = isDoubleOrHigher(resolveOfficeWinner(day)) ? full : incipit; const opening = openingAntiphon(antiphonText, resolveOfficeWinner(day));
const { full } = splitNamedAntiphon(antiphonText);
g.psalms.forEach((n, i) => { g.psalms.forEach((n, i) => {
parts.push(psalmPartWithAntiphon(n, i === 0 ? opening : undefined)); parts.push(psalmPartWithAntiphon(n, i === 0 ? opening : undefined));
}); });
@@ -201,8 +246,9 @@ function sundayPsalmNocturn(group: SundayNocturn, day: LiturgicalDay): ResolvedP
/** Nocturn 3's 3 fixed OT canticles under one shared antiphon — see /** Nocturn 3's 3 fixed OT canticles under one shared antiphon — see
* data/hours/matins-sunday-antiphons.yml's own header. */ * data/hours/matins-sunday-antiphons.yml's own header. */
function sundayCanticleNocturn(group: SundayNocturn, day: LiturgicalDay): ResolvedPart[] { function sundayCanticleNocturn(group: SundayNocturn, day: LiturgicalDay): ResolvedPart[] {
const { incipit, full } = splitNamedAntiphon(verifiedText(group.antiphon ?? {})); const antiphonText = verifiedText(group.antiphon ?? {});
const opening = isDoubleOrHigher(resolveOfficeWinner(day)) ? full : incipit; const opening = openingAntiphon(antiphonText, resolveOfficeWinner(day));
const { full } = splitNamedAntiphon(antiphonText);
const parts: ResolvedPart[] = (group.canticles ?? []).map((c, i) => { const parts: ResolvedPart[] = (group.canticles ?? []).map((c, i) => {
const verses = c.refs.flatMap((ref) => getScriptureVerses(ref.book, ref.chapter, ref.verses)); const verses = c.refs.flatMap((ref) => getScriptureVerses(ref.book, ref.chapter, ref.verses));
const text: BilingualText = { const text: BilingualText = {
@@ -226,11 +272,16 @@ function sundayCanticleNocturn(group: SundayNocturn, day: LiturgicalDay): Resolv
* getOfficeOverrideId eligibility Lauds/Vespers' own resolveOffice uses), * getOfficeOverrideId eligibility Lauds/Vespers' own resolveOffice uses),
* when authored; else, on a day within an active octave whose own feast * when authored; else, on a day within an active octave whose own feast
* has a hymn authored, that octave's hymn (`matins-hymn-${octave.id}`) — * has a hymn authored, that octave's hymn (`matins-hymn-${octave.id}`) —
* see below; else falls to a *seasonal* default (Advent/Lent/ * see below; else the winner's Common-of-Saints hymn (`matins-hymn-
* Passiontide/Paschaltide, none authored yet), else the plain year-round * ${commonId}`, via `SaintRecord.common`, e.g. "Aeterna Christi munera"
* ferial hymn — same override > octave > season > ferial precedence every * for any Apostle with no proper hymn of his own — same category-lookup
* other hour's own office bundle uses (see lauds.ts's resolveOffice), just * pattern as matins-psalmody-overrides.ts's getMatinsPsalmodyOverride);
* never fully wired up here before. * else falls to a *seasonal* default (Advent/Lent/Passiontide/
* Paschaltide, none authored yet), else the plain year-round ferial hymn —
* override > octave > Common > season > ferial. Common slots between
* octave and season: an octave's own proper hymn is more specific than any
* Common and must keep winning; a Common-of-Saints hymn is more specific
* than a bare season and must win over it.
* *
* Concrete motivating case (user, 2026-08-21/22): the Assumption's octave * Concrete motivating case (user, 2026-08-21/22): the Assumption's octave
* (`Sancti/08-21bmv.txt`'s own `[Rule] ex Sancti/08-15`) genuinely keeps * (`Sancti/08-21bmv.txt`'s own `[Rule] ex Sancti/08-15`) genuinely keeps
@@ -261,6 +312,23 @@ function resolveMatinsHymn(day: LiturgicalDay): ResolvedText {
return octaveHymn; return octaveHymn;
} }
} }
// Common-of-Saints tier (2026-08): a winning saint with no proper hymn of
// his own (most of them) still often shares a real, generic hymn with
// every other saint of his Common (e.g. "Aeterna Christi munera" for any
// Apostle) — same getSaintRecord(id)?.common lookup already proven by
// matins-psalmody-overrides.ts's getMatinsPsalmodyOverride. Keyed off the
// actual winner, not `overrideId` above: `getOfficeOverrideId` only
// returns an id for duplex-majus+ winners (an eligibility gate for the
// per-saint-proper tier), so reusing it here would silently skip this
// tier for any lower-ranked sanctoral winner.
const winner = resolveOfficeWinner(day);
const commonId = winner.kind === 'sanctoral' ? getSaintRecord(winner.id)?.common : undefined;
if (commonId) {
const commonHymn = resolveCommon(`matins-hymn-${commonId}`);
if (commonHymn.status.la !== 'missing' || commonHymn.status.en !== 'missing') {
return commonHymn;
}
}
const seasonSuffix = seasonalOfficeSuffix(day.season); const seasonSuffix = seasonalOfficeSuffix(day.season);
if (seasonSuffix) { if (seasonSuffix) {
const seasonal = resolveCommon(`matins-hymn-${seasonSuffix}`); const seasonal = resolveCommon(`matins-hymn-${seasonSuffix}`);
@@ -285,11 +353,12 @@ function ferialPsalmody(day: LiturgicalDay): ResolvedPart[] {
* as one combined V./R. block via `versicleText`. */ * as one combined V./R. block via `versicleText`. */
function ferialAntiphonedNocturn(day: LiturgicalDay): ResolvedPart[] { function ferialAntiphonedNocturn(day: LiturgicalDay): ResolvedPart[] {
const nocturn = ferialAntiphons[day.weekday as Exclude<Weekday, 'sunday'>]; const nocturn = ferialAntiphons[day.weekday as Exclude<Weekday, 'sunday'>];
const isDouble = isDoubleOrHigher(resolveOfficeWinner(day)); const winner = resolveOfficeWinner(day);
const parts: ResolvedPart[] = []; const parts: ResolvedPart[] = [];
for (const group of nocturn.groups) { for (const group of nocturn.groups) {
const { incipit, full } = splitNamedAntiphon(verifiedText(group.antiphon)); const antiphonText = verifiedText(group.antiphon);
const opening = isDouble ? full : incipit; const opening = openingAntiphon(antiphonText, winner);
const { full } = splitNamedAntiphon(antiphonText);
group.psalms.forEach((ref, i) => { group.psalms.forEach((ref, i) => {
const rawVerses = getPsalmVerses(ref.number, ref.verses).map((v): ResolvedVerse => ({ n: v.n, text: v.text, status: v.status })); const rawVerses = getPsalmVerses(ref.number, ref.verses).map((v): ResolvedVerse => ({ n: v.n, text: v.text, status: v.status }));
const { verses, antiphon } = applyFlexaMark(rawVerses, i === 0 ? opening : undefined); const { verses, antiphon } = applyFlexaMark(rawVerses, i === 0 ? opening : undefined);
@@ -353,9 +422,24 @@ function nocturnReadingIds(day: LiturgicalDay, temporalId: string, date: string)
// nocturn-readings content too, not just a commemorated sanctoral one. // nocturn-readings content too, not just a commemorated sanctoral one.
if (c.kind === 'sanctoral' || c.kind === 'temporal') ids.add(c.id); if (c.kind === 'sanctoral' || c.kind === 'temporal') ids.add(c.id);
} }
// The plain temporalId/month-week content is only pooled when the day's
// own occurrence decision (calendar/commemorations.ts's decideOccurrence)
// actually retained the temporal identity in some form: the temporal
// cycle won outright (day.winner.kind === 'temporal' -- a plain ferial/
// Sunday, or a named temporal override like Christ the King), or it
// survives as a commemoration alongside a sanctoral winner. Excluded:
// decideOccurrence's `ordinary-feria` branch, where a real feast --
// however low-ranked -- wins with zero commemorations, correctly
// suppressing the temporal identity entirely (e.g. St. Bartholomew,
// duplex-2-classis, 2026-08-24 -- his own proper reading has no Nocturn
// 3 content, and without this gate the leftover 13th-Sunday-after-
// Pentecost/month-week content wrongly filled Nocturn 3 instead).
const temporalKept = day.winner.kind === 'temporal' || day.commemorations.some((c) => c.kind === 'temporal');
if (temporalKept) {
ids.add(temporalId); ids.add(temporalId);
const monthWeek = monthWeekId(date); const monthWeek = monthWeekId(date);
if (monthWeek) ids.add(`month-week-${monthWeek}`); if (monthWeek) ids.add(`month-week-${monthWeek}`);
}
return [...ids]; return [...ids];
} }
+5 -6
View File
@@ -5,14 +5,15 @@ import { getDayLabel } from '../calendar/day-label';
import { getPsalmsFor } from '../psalter/distribution'; import { getPsalmsFor } from '../psalter/distribution';
import { getPsalmVerses } from '../psalter'; import { getPsalmVerses } from '../psalter';
import { getOpeningVersicleId } from './opening-versicle'; import { getOpeningVersicleId } from './opening-versicle';
import { isDoubleOrHigher, applyFlexaMark } from './antiphon'; import { applyFlexaMark } from './antiphon';
import { import {
resolveCommon, resolveCommon,
getDayCollect, getDayCollect,
splitNamedAntiphon, openingAntiphon,
resolveOfficeWinner, resolveOfficeWinner,
resolveMinorHourAntiphon, resolveMinorHourAntiphon,
resolveMinorHourChapter, resolveMinorHourChapter,
resolveSelfContainedHymn,
} from './resolve-common'; } from './resolve-common';
import noneDefinitionData from '../data/hours/none.yml'; import noneDefinitionData from '../data/hours/none.yml';
import antiphonsData from '../data/hours/none-antiphons.yml'; import antiphonsData from '../data/hours/none-antiphons.yml';
@@ -25,8 +26,7 @@ function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] {
case 'opening-versicle': case 'opening-versicle':
return [{ kind: 'versicle', text: resolveCommon(getOpeningVersicleId(day.season, day.winner)) }]; return [{ kind: 'versicle', text: resolveCommon(getOpeningVersicleId(day.season, day.winner)) }];
case 'hymn': case 'hymn':
// Self-contained, no appendDoxology — see none-hymn.yml. return resolveSelfContainedHymn(part);
return [{ kind: 'hymn', text: resolveCommon(part.textRef.id) }];
case 'chapter': case 'chapter':
return [{ kind: 'chapter', text: resolveMinorHourChapter('none', day, part.textRef.id) }]; return [{ kind: 'chapter', text: resolveMinorHourChapter('none', day, part.textRef.id) }];
case 'day-collect': case 'day-collect':
@@ -38,8 +38,7 @@ function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] {
// psalms, no closing repeat (unlike Prime): confirmed directly // psalms, no closing repeat (unlike Prime): confirmed directly
// against the engine, Capitulum follows the third psalm immediately. // against the engine, Capitulum follows the third psalm immediately.
const psalmRefs = getPsalmsFor('none', day.weekday); const psalmRefs = getPsalmsFor('none', day.weekday);
const { incipit, full } = splitNamedAntiphon(resolveMinorHourAntiphon('none', day, antiphons[day.weekday])); const opening = openingAntiphon(resolveMinorHourAntiphon('none', day, antiphons[day.weekday]), resolveOfficeWinner(day));
const opening = isDoubleOrHigher(resolveOfficeWinner(day)) ? full : incipit;
return psalmRefs.map((ref, i) => { return psalmRefs.map((ref, i) => {
const rawVerses = getPsalmVerses(ref.number, ref.verses).map((v) => ({ n: v.n, text: v.text, status: v.status })); const rawVerses = getPsalmVerses(ref.number, ref.verses).map((v) => ({ n: v.n, text: v.text, status: v.status }));
const { verses, antiphon } = applyFlexaMark(rawVerses, i === 0 ? opening : undefined); const { verses, antiphon } = applyFlexaMark(rawVerses, i === 0 ? opening : undefined);
+3 -2
View File
@@ -14,6 +14,7 @@ import {
resolveCommon, resolveCommon,
appendDoxology, appendDoxology,
splitNamedAntiphon, splitNamedAntiphon,
openingAntiphon,
resolveOfficeWinner, resolveOfficeWinner,
resolveMinorHourAntiphon, resolveMinorHourAntiphon,
isSundayOrFeastOffice, isSundayOrFeastOffice,
@@ -65,11 +66,11 @@ function resolvePart(part: HourPart, date: string, day: LiturgicalDay): Resolved
} }
{ {
const psalmRefs = getPsalmsFor('prime', day.weekday); const psalmRefs = getPsalmsFor('prime', day.weekday);
const { incipit, full } = splitNamedAntiphon(resolveMinorHourAntiphon('prime', day, antiphons[day.weekday])); const antiphonText = resolveMinorHourAntiphon('prime', day, antiphons[day.weekday]);
// Full text on a Double-or-higher feast, otherwise just the incipit // Full text on a Double-or-higher feast, otherwise just the incipit
// — see hours/antiphon.ts. The full repeat comes later, after the // — see hours/antiphon.ts. The full repeat comes later, after the
// Creed (see 'closing-antiphon'), not tacked onto the last psalm. // Creed (see 'closing-antiphon'), not tacked onto the last psalm.
const opening = isDoubleOrHigher(resolveOfficeWinner(day)) ? full : incipit; const opening = openingAntiphon(antiphonText, resolveOfficeWinner(day));
return psalmRefs.map((ref, i) => { return psalmRefs.map((ref, i) => {
const rawVerses = getPsalmVerses(ref.number, ref.verses).map((v) => ({ n: v.n, text: v.text, status: v.status })); const rawVerses = getPsalmVerses(ref.number, ref.verses).map((v) => ({ n: v.n, text: v.text, status: v.status }));
const { verses, antiphon } = applyFlexaMark(rawVerses, i === 0 ? opening : undefined); const { verses, antiphon } = applyFlexaMark(rawVerses, i === 0 ? opening : undefined);
+24 -2
View File
@@ -1,4 +1,4 @@
import type { ResolvedPart, ResolvedText } from './types'; import type { PropersRef, ResolvedPart, ResolvedText } from './types';
import type { Commemoration, DayWinner, LiturgicalDay, Weekday } from '../calendar/types'; import type { Commemoration, DayWinner, LiturgicalDay, Weekday } from '../calendar/types';
import type { ProperText } from '../propers'; import type { ProperText } from '../propers';
import { getCommonProper, getTemporalProper } from '../propers'; import { getCommonProper, getTemporalProper } from '../propers';
@@ -6,7 +6,7 @@ import { getSaintRecord } from '../calendar/feasts';
import { resolveActiveOctave, activeOctavesFor, octaveGoverningPrivilegedDay, isAtLeast } from '../calendar'; import { resolveActiveOctave, activeOctavesFor, octaveGoverningPrivilegedDay, isAtLeast } from '../calendar';
import { getTemporalFeastRecord } from '../calendar/temporal-feasts'; import { getTemporalFeastRecord } from '../calendar/temporal-feasts';
import { isInTriduum } from '../calendar/temporal'; import { isInTriduum } from '../calendar/temporal';
import { splitAntiphon } from './antiphon'; import { splitAntiphon, isDoubleOrHigher } from './antiphon';
import vespersMagnificatAntiphonsData from '../data/hours/vespers-magnificat-antiphons.yml'; import vespersMagnificatAntiphonsData from '../data/hours/vespers-magnificat-antiphons.yml';
type BilingualText = Partial<Record<string, string>>; type BilingualText = Partial<Record<string, string>>;
@@ -31,6 +31,15 @@ export function resolveCommon(id: string): ResolvedText {
return toResolvedText(getCommonProper(id)); return toResolvedText(getCommonProper(id));
} }
/** Terce/Sext/None's own Matins-less hymn 'hymn' case — a plain, fixed,
* self-contained text with no doxology mechanism at all (the closing
* doxology is baked directly into each hour's own hymn text file; see
* {terce,sext,none}-hymn.yml) and no override/season tiering. Byte-
* identical across all three files before being centralized here. */
export function resolveSelfContainedHymn(part: { textRef: PropersRef }): ResolvedPart[] {
return [{ kind: 'hymn', text: resolveCommon(part.textRef.id) }];
}
// Named temporal winners with real standing of their own, unconditionally // Named temporal winners with real standing of their own, unconditionally
// override-eligible — see hours/lauds.ts's getPsalmodyOverrideFor, which // override-eligible — see hours/lauds.ts's getPsalmodyOverrideFor, which
// shares this set (imported from here, not duplicated) since it's the // shares this set (imported from here, not duplicated) since it's the
@@ -692,6 +701,19 @@ export function splitNamedAntiphon(antiphon: ResolvedText): {
return { incipit: { text: incipitText, status: incipitStatus }, full: { text: fullText, status: fullStatus } }; return { incipit: { text: incipitText, status: incipitStatus }, full: { text: fullText, status: fullStatus } };
} }
/** The antiphon actually shown *before* a psalm/canticle: full text on a
* Double-rank winner or higher, incipit only below that (see
* `isDoubleOrHigher`'s own doc comment for the underlying rule) — the one
* idiom every hour's psalmody independently repeated (`splitNamedAntiphon`
* + `isDoubleOrHigher(...) ? full : incipit`) before being centralized
* here. The closing repeat after the psalm/canticle is always `full`,
* unconditionally, at each call site — this helper only decides the
* opening. */
export function openingAntiphon(antiphon: ResolvedText, winner: DayWinner): ResolvedText {
const { incipit, full } = splitNamedAntiphon(antiphon);
return isDoubleOrHigher(winner) ? full : incipit;
}
/** Fixed wording, same as lauds.ts's weekday-canticle Gloria Patri — /** Fixed wording, same as lauds.ts's weekday-canticle Gloria Patri —
* appended after every psalm (hours/index.ts) except during the Sacred * appended after every psalm (hours/index.ts) except during the Sacred
* Triduum. Not "verified" via verifiedText() because it's boilerplate * Triduum. Not "verified" via verifiedText() because it's boilerplate
+5 -6
View File
@@ -5,14 +5,15 @@ import { getDayLabel } from '../calendar/day-label';
import { getPsalmsFor } from '../psalter/distribution'; import { getPsalmsFor } from '../psalter/distribution';
import { getPsalmVerses } from '../psalter'; import { getPsalmVerses } from '../psalter';
import { getOpeningVersicleId } from './opening-versicle'; import { getOpeningVersicleId } from './opening-versicle';
import { isDoubleOrHigher, applyFlexaMark } from './antiphon'; import { applyFlexaMark } from './antiphon';
import { import {
resolveCommon, resolveCommon,
getDayCollect, getDayCollect,
splitNamedAntiphon, openingAntiphon,
resolveOfficeWinner, resolveOfficeWinner,
resolveMinorHourAntiphon, resolveMinorHourAntiphon,
resolveMinorHourChapter, resolveMinorHourChapter,
resolveSelfContainedHymn,
} from './resolve-common'; } from './resolve-common';
import sextDefinitionData from '../data/hours/sext.yml'; import sextDefinitionData from '../data/hours/sext.yml';
import antiphonsData from '../data/hours/sext-antiphons.yml'; import antiphonsData from '../data/hours/sext-antiphons.yml';
@@ -25,8 +26,7 @@ function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] {
case 'opening-versicle': case 'opening-versicle':
return [{ kind: 'versicle', text: resolveCommon(getOpeningVersicleId(day.season, day.winner)) }]; return [{ kind: 'versicle', text: resolveCommon(getOpeningVersicleId(day.season, day.winner)) }];
case 'hymn': case 'hymn':
// Self-contained, no appendDoxology — see sext-hymn.yml. return resolveSelfContainedHymn(part);
return [{ kind: 'hymn', text: resolveCommon(part.textRef.id) }];
case 'chapter': case 'chapter':
return [{ kind: 'chapter', text: resolveMinorHourChapter('sext', day, part.textRef.id) }]; return [{ kind: 'chapter', text: resolveMinorHourChapter('sext', day, part.textRef.id) }];
case 'day-collect': case 'day-collect':
@@ -38,8 +38,7 @@ function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] {
// psalms, no closing repeat (unlike Prime): confirmed directly // psalms, no closing repeat (unlike Prime): confirmed directly
// against the engine, Capitulum follows the third psalm immediately. // against the engine, Capitulum follows the third psalm immediately.
const psalmRefs = getPsalmsFor('sext', day.weekday); const psalmRefs = getPsalmsFor('sext', day.weekday);
const { incipit, full } = splitNamedAntiphon(resolveMinorHourAntiphon('sext', day, antiphons[day.weekday])); const opening = openingAntiphon(resolveMinorHourAntiphon('sext', day, antiphons[day.weekday]), resolveOfficeWinner(day));
const opening = isDoubleOrHigher(resolveOfficeWinner(day)) ? full : incipit;
return psalmRefs.map((ref, i) => { return psalmRefs.map((ref, i) => {
const rawVerses = getPsalmVerses(ref.number, ref.verses).map((v) => ({ n: v.n, text: v.text, status: v.status })); const rawVerses = getPsalmVerses(ref.number, ref.verses).map((v) => ({ n: v.n, text: v.text, status: v.status }));
const { verses, antiphon } = applyFlexaMark(rawVerses, i === 0 ? opening : undefined); const { verses, antiphon } = applyFlexaMark(rawVerses, i === 0 ? opening : undefined);
+5 -6
View File
@@ -5,14 +5,15 @@ import { getDayLabel } from '../calendar/day-label';
import { getPsalmsFor } from '../psalter/distribution'; import { getPsalmsFor } from '../psalter/distribution';
import { getPsalmVerses } from '../psalter'; import { getPsalmVerses } from '../psalter';
import { getOpeningVersicleId } from './opening-versicle'; import { getOpeningVersicleId } from './opening-versicle';
import { isDoubleOrHigher, applyFlexaMark } from './antiphon'; import { applyFlexaMark } from './antiphon';
import { import {
resolveCommon, resolveCommon,
getDayCollect, getDayCollect,
splitNamedAntiphon, openingAntiphon,
resolveOfficeWinner, resolveOfficeWinner,
resolveMinorHourAntiphon, resolveMinorHourAntiphon,
resolveMinorHourChapter, resolveMinorHourChapter,
resolveSelfContainedHymn,
} from './resolve-common'; } from './resolve-common';
import terceDefinitionData from '../data/hours/terce.yml'; import terceDefinitionData from '../data/hours/terce.yml';
import antiphonsData from '../data/hours/terce-antiphons.yml'; import antiphonsData from '../data/hours/terce-antiphons.yml';
@@ -25,8 +26,7 @@ function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] {
case 'opening-versicle': case 'opening-versicle':
return [{ kind: 'versicle', text: resolveCommon(getOpeningVersicleId(day.season, day.winner)) }]; return [{ kind: 'versicle', text: resolveCommon(getOpeningVersicleId(day.season, day.winner)) }];
case 'hymn': case 'hymn':
// Self-contained, no appendDoxology — see terce-hymn.yml. return resolveSelfContainedHymn(part);
return [{ kind: 'hymn', text: resolveCommon(part.textRef.id) }];
case 'chapter': case 'chapter':
return [{ kind: 'chapter', text: resolveMinorHourChapter('terce', day, part.textRef.id) }]; return [{ kind: 'chapter', text: resolveMinorHourChapter('terce', day, part.textRef.id) }];
case 'day-collect': case 'day-collect':
@@ -38,8 +38,7 @@ function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] {
// psalms, no closing repeat (unlike Prime): confirmed directly // psalms, no closing repeat (unlike Prime): confirmed directly
// against the engine, Capitulum follows the third psalm immediately. // against the engine, Capitulum follows the third psalm immediately.
const psalmRefs = getPsalmsFor('terce', day.weekday); const psalmRefs = getPsalmsFor('terce', day.weekday);
const { incipit, full } = splitNamedAntiphon(resolveMinorHourAntiphon('terce', day, antiphons[day.weekday])); const opening = openingAntiphon(resolveMinorHourAntiphon('terce', day, antiphons[day.weekday]), resolveOfficeWinner(day));
const opening = isDoubleOrHigher(resolveOfficeWinner(day)) ? full : incipit;
return psalmRefs.map((ref, i) => { return psalmRefs.map((ref, i) => {
const rawVerses = getPsalmVerses(ref.number, ref.verses).map((v) => ({ n: v.n, text: v.text, status: v.status })); const rawVerses = getPsalmVerses(ref.number, ref.verses).map((v) => ({ n: v.n, text: v.text, status: v.status }));
const { verses, antiphon } = applyFlexaMark(rawVerses, i === 0 ? opening : undefined); const { verses, antiphon } = applyFlexaMark(rawVerses, i === 0 ? opening : undefined);
+8 -5
View File
@@ -4,12 +4,13 @@ import { resolveEveningDay } from '../calendar/vespers';
import { getDayLabel } from '../calendar/day-label'; import { getDayLabel } from '../calendar/day-label';
import { getPsalmVerses } from '../psalter'; import { getPsalmVerses } from '../psalter';
import { getOpeningVersicleId } from './opening-versicle'; import { getOpeningVersicleId } from './opening-versicle';
import { isDoubleOrHigher, applyFlexaMark } from './antiphon'; import { applyFlexaMark } from './antiphon';
import { import {
resolveCommon, resolveCommon,
getDayCollects, getDayCollects,
getMagnificatAntiphon, getMagnificatAntiphon,
splitNamedAntiphon, splitNamedAntiphon,
openingAntiphon,
resolveOfficeWinner, resolveOfficeWinner,
verifiedText, verifiedText,
seasonalOfficeSuffix, seasonalOfficeSuffix,
@@ -47,8 +48,9 @@ function normalizePsalmRef(ref: PsalmRefLike): { number: number; verses?: string
* in full once, after the last. Mirrors lauds.ts's psalmParts, extended * in full once, after the last. Mirrors lauds.ts's psalmParts, extended
* for verse-range entries (see PsalmRefLike). */ * for verse-range entries (see PsalmRefLike). */
function psalmParts(group: VespersGroup, day: LiturgicalDay): ResolvedPart[] { function psalmParts(group: VespersGroup, day: LiturgicalDay): ResolvedPart[] {
const { incipit, full } = splitNamedAntiphon(verifiedText(group.antiphon)); const antiphonText = verifiedText(group.antiphon);
const opening = isDoubleOrHigher(resolveOfficeWinner(day)) ? full : incipit; const opening = openingAntiphon(antiphonText, resolveOfficeWinner(day));
const { full } = splitNamedAntiphon(antiphonText);
const refs = group.psalms.map(normalizePsalmRef); const refs = group.psalms.map(normalizePsalmRef);
const parts: ResolvedPart[] = refs.map((ref, i) => { const parts: ResolvedPart[] = refs.map((ref, i) => {
const rawVerses = getPsalmVerses(ref.number, ref.verses).map((v) => ({ n: v.n, text: v.text, status: v.status })); const rawVerses = getPsalmVerses(ref.number, ref.verses).map((v) => ({ n: v.n, text: v.text, status: v.status }));
@@ -161,8 +163,9 @@ function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] {
case 'vespers-office': case 'vespers-office':
return resolveOffice(day); return resolveOffice(day);
case 'magnificat': { case 'magnificat': {
const { incipit, full } = splitNamedAntiphon(getMagnificatAntiphon(day)); const antiphonText = getMagnificatAntiphon(day);
const opening = isDoubleOrHigher(resolveOfficeWinner(day)) ? full : incipit; const opening = openingAntiphon(antiphonText, resolveOfficeWinner(day));
const { full } = splitNamedAntiphon(antiphonText);
return [ return [
{ kind: 'canticle', canticleId: 'magnificat', text: resolveCommon('magnificat'), antiphon: opening }, { kind: 'canticle', canticleId: 'magnificat', text: resolveCommon('magnificat'), antiphon: opening },
{ kind: 'antiphon', text: full }, { kind: 'antiphon', text: full },
+108
View File
@@ -490,3 +490,111 @@ describe('resolveOrdo("matins", ...) calendar-month/week nocturn-readings import
} }
}); });
}); });
// Real bug (2026-08-24 fix): nocturnReadingIds unconditionally pooled the
// plain temporalId/month-week ids into every day's nocturn-reading pool,
// even on a day where a real feast wins outright with zero commemorations
// (decideOccurrence's `ordinary-feria` branch) -- correctly suppressing the
// temporal identity entirely. St. Bartholomew (duplex-2-classis, 2026-08-24,
// a Monday) has no Nocturn 3 content of his own, so the leftover 13th-
// Sunday-after-Pentecost (post-pentecost-13.yml) and month-week (month-
// week-084.yml) readings wrongly filled his Nocturn 3. Fixed by gating that
// pooling on whether the day's own occurrence decision actually retained
// the temporal identity (day.winner.kind === 'temporal', or a `kind:
// 'temporal'` entry in day.commemorations).
describe('resolveOrdo("matins", ...) nocturn-reading temporal/month-week suppression (2026-08-24 fix)', () => {
function lessonLabels(date: string) {
const ordo = resolveOrdo('matins', date);
return ordo.parts.filter((p) => p.kind === 'lesson').map((p) => (p as { label?: string }).label);
}
it("St. Bartholomew (2026-08-24, duplex-2-classis, zero commemorations) does not pool the suppressed Sunday/month-week readings", () => {
const labels = lessonLabels('2026-08-24');
expect(labels).not.toContain('St. Augustine, Bishop of Hippo, Book 2, Questions on the Gospels, ch. 40');
expect(labels).not.toContain('St. Gregory the Great, Moralia in Job, Book 1, ch. 10');
});
it("St. Bartholomew's own Nocturn 2 still carries his own proper vita reading", () => {
const ordo = resolveOrdo('matins', '2026-08-24');
const lessons = ordo.parts.filter((p) => p.kind === 'lesson') as { text: { text: Record<string, string> } }[];
expect(lessons.some((l) => l.text.text.la?.includes('Bartholomǽus Apóstolus'))).toBe(true);
});
it('a plain ferial weekday with no sanctoral winner (2026-09-04, a gap day in sanctoral-calendar.yml) still pools the plain temporal/month-week readings, unaffected by the gate', () => {
const ordo = resolveOrdo('matins', '2026-09-04');
const lessons = ordo.parts.filter((p) => p.kind === 'lesson') as { text: { status: Record<string, string> } }[];
expect(lessons.length).toBeGreaterThan(0);
expect(lessons.some((l) => l.text.status.la !== 'missing' && l.text.status.en !== 'missing')).toBe(true);
});
});
// Coverage gap fix (no bug found): the pre-existing ferial-branch test
// above only asserted the opening invitatory antiphon *contains* "Veníte,"
// true for both the incipit ("Ant. Veníte.") and full ("Ant. Veníte, *
// Exsultémus Dómino.") forms -- it never actually distinguished doubled vs.
// undoubled. isDoubleOrHigher/openingAntiphon traced out correct in source
// for every rank tested here; these tests just make the distinction real.
describe('resolveOrdo("matins", ...) invitatory antiphon doubling by rank', () => {
function openingAntiphonLatin(date: string): string | undefined {
const ordo = resolveOrdo('matins', date);
const invitatoryPsalm = ordo.parts.find((p) => p.kind === 'psalm' && (p as { psalmNumber: number }).psalmNumber === 94) as
| { antiphon?: { text: Record<string, string> } }
| undefined;
return invitatoryPsalm?.antiphon?.text.la;
}
it('a Duplex+ weekday winner opens with the FULL (not incipit-only) invitatory antiphon (St. Lawrence, 2026-08-17: ferial text, no proper/Common of his own; St. Bartholomew, duplex-2-classis, 2026-08-24: Common-of-an-Apostle text)', () => {
expect(openingAntiphonLatin('2026-08-17')).toContain('Exsultémus');
expect(openingAntiphonLatin('2026-08-24')).toContain('Regem Apostolórum Dóminum, * Veníte');
});
it('a plain ferial day opens with the incipit only', () => {
expect(openingAntiphonLatin(FERIAL_DATE)).not.toContain('Exsultémus');
});
it("a Simplex saint merely commemorated (not winning) under an Advent feria still opens with the incipit only — the winner's own rank governs, not any commemorated saint's (St. Bibiana, 2025-12-02)", () => {
expect(openingAntiphonLatin('2025-12-02')).not.toContain('Exsultémus');
});
});
// New mechanism (2026-08): resolveMatinsHymn gained a Common-of-Saints
// tier (override -> octave -> Common category -> season -> ferial), and
// the invitatory antiphon -- previously one hardcoded fixed text for every
// day of the year -- now goes through the same shape of tiering
// (resolveMatinsInvitatoryText: override -> Common category -> season ->
// ferial). Both confirmed against the reference engine's own Commune/
// C1.txt (Common of an Apostle): hymn "Ætérna Christi múnera," invitatory
// "Regem Apostolórum Dóminum." St. Bartholomew has no proper Matins hymn
// or invitatory of his own authored, so this Common tier is what actually
// renders for him.
describe('resolveOrdo("matins", ...) Common-of-an-Apostle hymn + invitatory (2026-08-24 fix)', () => {
function hymnLatin(date: string): string | undefined {
const ordo = resolveOrdo('matins', date);
const hymn = ordo.parts.find((p) => p.kind === 'hymn') as { text: { text: { la: string } } } | undefined;
return hymn?.text.text.la;
}
function invitatoryFullLatin(date: string): string | undefined {
const ordo = resolveOrdo('matins', date);
const antiphonParts = ordo.parts.filter((p) => p.kind === 'antiphon') as { text: { text: Record<string, string> } }[];
return antiphonParts[0]?.text.text.la;
}
it("St. Bartholomew's own day (2026-08-24, no proper Matins hymn of his own) picks up the Common-of-an-Apostle hymn", () => {
expect(hymnLatin('2026-08-24')).toContain('Ætérna Christi múnera');
});
it("St. Bartholomew's invitatory antiphon resolves to the Common-of-an-Apostle text", () => {
expect(invitatoryFullLatin('2026-08-24')).toContain('Regem Apostolórum Dóminum');
});
it('St. Andrew (2026-11-30, also common-of-an-apostle, no proper hymn/invitatory of his own) resolves to the same Common text -- confirms the tier generalizes beyond Bartholomew', () => {
expect(hymnLatin('2026-11-30')).toContain('Ætérna Christi múnera');
expect(invitatoryFullLatin('2026-11-30')).toContain('Regem Apostolórum Dóminum');
});
it('a plain ferial day is unaffected -- still falls through to the plain ferial hymn and invitatory antiphon', () => {
expect(hymnLatin(FERIAL_DATE)).toContain('Somno');
expect(invitatoryFullLatin(FERIAL_DATE)).toContain('Veníte');
expect(invitatoryFullLatin(FERIAL_DATE)).not.toContain('Regem Apostolórum');
});
});