diff options
Diffstat (limited to 'docs/superpowers')
| -rw-r--r-- | docs/superpowers/plans/2026-07-27-lectio-national-bibles.md | 699 |
1 files changed, 699 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-07-27-lectio-national-bibles.md b/docs/superpowers/plans/2026-07-27-lectio-national-bibles.md new file mode 100644 index 0000000..c07b254 --- /dev/null +++ b/docs/superpowers/plans/2026-07-27-lectio-national-bibles.md @@ -0,0 +1,699 @@ +# National Bibles Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let anyone add their nation's Bible as a `<code>.tsv` + `<code>.ini` pair — dropped into `~/.config/lectio/corpora/` at runtime or embedded via a validated Makefile step — so offline readings render in any language. + +**Architecture:** A corpus is a file pair. Built-ins live in `internal/bible/corpora/` (glob-embedded); user corpora in `~/.config/lectio/corpora/` override built-ins of the same code. Reading-text selection is config-driven (`reading_version` > `reading_lang` match > `ui_language` match > Latin `vul`). One validator (`lectio --corpus-check`) gates both delivery paths. + +**Tech Stack:** Go (stdlib + `internal/ini`), 6-column TSV, INI sidecars, `go:embed`, Makefile + POSIX `sh`. + +**Spec:** `docs/superpowers/specs/2026-07-27-lectio-national-bibles-design.md` + +## Global Constraints + +- **Corpus text format:** exactly 6 tab-separated columns, UTF-8, one verse per row: `Book · Abbrev · BookNum · Chapter · Verse · Text`. Loader uses columns 1/4/5/6; columns 2/3 are informational. +- **`Book` column:** canonical English name; must be one of the 73 keys in `internal/bible/books.ini`. +- **Metadata sidecar `<code>.ini`:** keys `lang` (required), `name` (required), `psalm_system` (required, one of `vulgate`/`hebrew`/`drb`), `sigla` (optional). Parsed with `internal/ini` (full-line comments only). +- **User corpus dir:** `~/.config/lectio/corpora/`. A user `<code>` overrides the embedded one; new codes add. +- **Selection order:** `reading_version` (explicit code) → corpus whose `lang` == `reading_lang` → corpus whose `lang` == `ui_language` → `""` (caller uses `vul`). +- **`traditional_lang` is not touched** and is never read by the corpus resolver. +- **`psalm_system` is metadata only** in this feature — captured + validated, never applied (no offline psalm parts exist yet). +- **Purity:** `internal/bible` imports only stdlib + `internal/ini`. No network, no new third-party deps. +- **Non-breaking:** `--ref`, version comparison, TUI/web daily view, and the scraper path keep working unchanged. +- **Every task ends green:** `gofmt -l` clean and `go test ./...` passing before commit. + +--- + +### Task 1: Built-in sidecars + metadata parser + +**Files:** +- Create: `internal/bible/corpora/vul.ini`, `drb.ini`, `grb.ini`, `wuj.ini` +- Modify: `internal/bible/bible.go` (embed directive) +- Create: `internal/bible/corpusmeta.go` +- Test: `internal/bible/corpusmeta_test.go` + +**Interfaces:** +- Produces: `type CorpusMeta struct { Code, Lang, Name, PsalmSystem, Sigla string }`; `func parseCorpusMeta(code string, data []byte) CorpusMeta`; `func embeddedCorpusMeta(code string) (CorpusMeta, bool)`. + +- [ ] **Step 1: Write the sidecars.** Each is four lines. `internal/bible/corpora/vul.ini`: + +```ini +; lectio corpus metadata +lang = la +name = Vulgate (Latin) +psalm_system = vulgate +``` + +`drb.ini`: `lang = en`, `name = Douay-Rheims (English)`, `psalm_system = drb`. +`grb.ini`: `lang = el`, `name = Greek`, `psalm_system = vulgate`. +`wuj.ini`: `lang = pl`, `name = Wujek (Polish)`, `psalm_system = vulgate`. + +- [ ] **Step 2: Widen the embed** in `internal/bible/bible.go` — replace the explicit `//go:embed corpora/wuj.tsv corpora/vul.tsv corpora/grb.tsv corpora/drb.tsv` with: + +```go +//go:embed corpora +var corporaFS embed.FS +``` + +(embedding the whole dir picks up both `*.tsv` and `*.ini`; existing `corporaFS.ReadFile("corpora/"+version+".tsv")` calls are unchanged.) + +- [ ] **Step 3: Write the failing test** `internal/bible/corpusmeta_test.go`: + +```go +package bible + +import "testing" + +func TestEmbeddedCorpusMeta(t *testing.T) { + m, ok := embeddedCorpusMeta("drb") + if !ok { + t.Fatal("drb sidecar not found") + } + if m.Lang != "en" || m.PsalmSystem != "drb" || m.Name == "" { + t.Fatalf("bad drb meta: %+v", m) + } +} +``` + +- [ ] **Step 4: Run it, verify it fails** — `go test ./internal/bible/ -run TestEmbeddedCorpusMeta` → FAIL (undefined `embeddedCorpusMeta`). + +- [ ] **Step 5: Implement** `internal/bible/corpusmeta.go`: + +```go +package bible + +import "github.com/lukaszkasprzak/lectio/internal/ini" + +// CorpusMeta is the sidecar metadata for a bible corpus (<code>.ini). +type CorpusMeta struct { + Code, Lang, Name, PsalmSystem, Sigla string +} + +// parseCorpusMeta reads a section-less sidecar. ini.Parse returns +// []ini.Section{Name, Pairs}; keys before any [section] land in the section +// whose Name == "" (verified against internal/ini). +func parseCorpusMeta(code string, data []byte) CorpusMeta { + m := CorpusMeta{Code: code} + secs, err := ini.Parse(data) + if err != nil { + return m + } + for _, s := range secs { + if s.Name != "" { + continue + } + for _, p := range s.Pairs { + switch p.Key { + case "lang": + m.Lang = p.Val + case "name": + m.Name = p.Val + case "psalm_system": + m.PsalmSystem = p.Val + case "sigla": + m.Sigla = p.Val + } + } + } + return m +} + +func embeddedCorpusMeta(code string) (CorpusMeta, bool) { + data, err := corporaFS.ReadFile("corpora/" + code + ".ini") + if err != nil { + return CorpusMeta{}, false + } + return parseCorpusMeta(code, data), true +} +``` + +- [ ] **Step 6: Run tests** — `go test ./internal/bible/` → PASS. Then `gofmt -l internal/bible/`. + +- [ ] **Step 7: Commit** — `git add -A && git commit -m "feat(bible): corpus metadata sidecars + parser"` + +--- + +### Task 2: Corpus registry, user-dir discovery, override + +**Files:** +- Modify: `internal/bible/bible.go` (registry + `load` lookup) +- Create: `internal/bible/registry.go` +- Modify: `internal/config/config.go` (add `CorporaDir()`) +- Test: `internal/bible/registry_test.go` + +**Interfaces:** +- Consumes: `CorpusMeta`, `embeddedCorpusMeta` (Task 1). +- Produces: `func SetUserCorporaDir(dir string)`; `func Corpora() []CorpusMeta`; `func Meta(code string) (CorpusMeta, bool)`; `func CorporaForLang(lang string) []CorpusMeta`. `load(code)` reads the user file when present, else embed. +- `config.CorporaDir() (string, error)` → `~/.config/lectio/corpora/`. + +- [ ] **Step 1: Add the config path helper** to `internal/config/config.go`, mirroring the existing `CalendarsDir()`: + +```go +// CorporaDir is the user directory for drop-in bible corpora (<code>.tsv + +// <code>.ini), alongside calendars/ under the lectio config dir. Mirrors +// CalendarsDir exactly (verified: it uses configPath() then filepath.Dir). +func CorporaDir() (string, error) { + p, _, err := configPath() + if err != nil { + return "", err + } + return filepath.Join(filepath.Dir(p), "corpora"), nil +} +``` + +- [ ] **Step 2: Write the failing test** `internal/bible/registry_test.go`: + +```go +package bible + +import ( + "os" + "path/filepath" + "testing" +) + +func TestUserCorpusOverride(t *testing.T) { + dir := t.TempDir() + os.WriteFile(filepath.Join(dir, "drb.tsv"), []byte("Genesis\tGn\t1\t1\t1\tUSER TEXT\n"), 0o644) + os.WriteFile(filepath.Join(dir, "drb.ini"), []byte("lang = en\nname = User DRB\npsalm_system = drb\n"), 0o644) + SetUserCorporaDir(dir) + t.Cleanup(func() { SetUserCorporaDir("") }) + + if got := Verses("drb", "Genesis", 1); len(got) == 0 || got[0].Text != "USER TEXT" { + t.Fatalf("user drb.tsv did not override embed: %+v", got) + } + m, _ := Meta("drb") + if m.Name != "User DRB" { + t.Fatalf("user sidecar not used: %+v", m) + } +} + +func TestUserCorpusNewCode(t *testing.T) { + dir := t.TempDir() + os.WriteFile(filepath.Join(dir, "fr-x.tsv"), []byte("Genesis\tGn\t1\t1\t1\tAu commencement\n"), 0o644) + os.WriteFile(filepath.Join(dir, "fr-x.ini"), []byte("lang = fr\nname = Test FR\npsalm_system = vulgate\n"), 0o644) + SetUserCorporaDir(dir) + t.Cleanup(func() { SetUserCorporaDir("") }) + + if l := CorporaForLang("fr"); len(l) != 1 || l[0].Code != "fr-x" { + t.Fatalf("new fr corpus not registered: %+v", l) + } +} +``` + +- [ ] **Step 3: Run it, verify it fails** — `go test ./internal/bible/ -run TestUserCorpus` → FAIL (undefined symbols). + +- [ ] **Step 4: Implement** `internal/bible/registry.go`: + +```go +package bible + +import ( + "os" + "path/filepath" + "sort" + "strings" + "sync" +) + +var ( + regMu sync.Mutex + userDir string + regBuilt bool + regMeta map[string]CorpusMeta // code -> meta (user overrides embed) + regUserTSV map[string]string // code -> user .tsv path (override source) +) + +// SetUserCorporaDir sets the drop-in dir and invalidates the registry cache. +// Empty string disables user corpora (used by tests). +func SetUserCorporaDir(dir string) { + regMu.Lock() + defer regMu.Unlock() + userDir = dir + regBuilt = false + corpora = map[string]*corpus{} // drop cached loads so overrides take effect +} + +func buildRegistry() { + if regBuilt { + return + } + regMeta = map[string]CorpusMeta{} + regUserTSV = map[string]string{} + // embedded first + ents, _ := corporaFS.ReadDir("corpora") + for _, e := range ents { + if code, ok := strings.CutSuffix(e.Name(), ".ini"); ok { + if m, ok := embeddedCorpusMeta(code); ok { + regMeta[code] = m + } + } + } + // user dir overrides + if userDir != "" { + ents, _ := os.ReadDir(userDir) + for _, e := range ents { + name := e.Name() + if code, ok := strings.CutSuffix(name, ".tsv"); ok { + regUserTSV[code] = filepath.Join(userDir, name) + if _, have := regMeta[code]; !have { + regMeta[code] = CorpusMeta{Code: code, PsalmSystem: "vulgate"} + } + } + } + for _, e := range ents { + if code, ok := strings.CutSuffix(e.Name(), ".ini"); ok { + data, err := os.ReadFile(filepath.Join(userDir, e.Name())) + if err == nil { + regMeta[code] = parseCorpusMeta(code, data) + } + } + } + } + regBuilt = true +} + +// Corpora returns all known corpora, sorted by code. +func Corpora() []CorpusMeta { + regMu.Lock() + defer regMu.Unlock() + buildRegistry() + out := make([]CorpusMeta, 0, len(regMeta)) + for _, m := range regMeta { + out = append(out, m) + } + sort.Slice(out, func(i, j int) bool { return out[i].Code < out[j].Code }) + return out +} + +// Meta returns a corpus's metadata. +func Meta(code string) (CorpusMeta, bool) { + regMu.Lock() + defer regMu.Unlock() + buildRegistry() + m, ok := regMeta[code] + return m, ok +} + +// CorporaForLang returns corpora whose lang == lang, sorted by code. +func CorporaForLang(lang string) []CorpusMeta { + var out []CorpusMeta + for _, m := range Corpora() { + if m.Lang == lang { + out = append(out, m) + } + } + return out +} + +// userTSVPath returns the override .tsv path for code, or "". +func userTSVPath(code string) string { + regMu.Lock() + defer regMu.Unlock() + buildRegistry() + return regUserTSV[code] +} +``` + +- [ ] **Step 5: Route `load()` through the override** in `internal/bible/bible.go` — change the read source: + +```go +func load(version string) *corpus { + corporaMu.Lock() + defer corporaMu.Unlock() + if c, ok := corpora[version]; ok { + return c + } + var data []byte + if p := userTSVPath(version); p != "" { + data, _ = os.ReadFile(p) + } + if data == nil { + data, _ = corporaFS.ReadFile("corpora/" + version + ".tsv") + } + if data == nil { + corpora[version] = &corpus{books: map[string]map[int][]Verse{}} + return corpora[version] + } + // ... existing parse loop unchanged, using `data` ... +} +``` + +(Add `"os"` to the imports if not present. `userTSVPath` takes `regMu`; `load` holds `corporaMu` — different locks, no deadlock. `SetUserCorporaDir` resets `corpora` under `regMu`; guard the reset by also taking `corporaMu` if the race detector flags it — see Step 7.) + +- [ ] **Step 6: Run tests** — `go test ./internal/bible/ -run TestUserCorpus` → PASS. + +- [ ] **Step 7: Race check** — `go test -race ./internal/bible/`. If `SetUserCorporaDir`'s `corpora = ...` reset races with `load`, take `corporaMu` around the reset inside `SetUserCorporaDir`. Re-run until clean. + +- [ ] **Step 8: Full suite + gofmt + commit** — `go test ./...`; `gofmt -l internal/`; `git commit -m "feat(bible): user corpus discovery + override registry"`. + +--- + +### Task 3: Config fields + `ReadingCorpus` resolver + +**Files:** +- Modify: `internal/config/config.go` (struct fields, setField, renderConfigINI, config header) +- Test: `internal/config/config_test.go` + +**Interfaces:** +- Consumes: `bible.Corpora`, `bible.Meta` (Task 2). +- Produces: `Config.ReadingLang`, `Config.ReadingVersion` (strings); `func (c Config) ReadingCorpus() string`. + +- [ ] **Step 1: Write the failing test** in `internal/config/config_test.go`: + +```go +func TestReadingCorpusResolution(t *testing.T) { + // drb is an embedded corpus with lang=en. + c := Config{ReadingVersion: "drb"} + if got := c.ReadingCorpus(); got != "drb" { + t.Fatalf("explicit reading_version: got %q", got) + } + c = Config{ReadingLang: "en"} + if got := c.ReadingCorpus(); got != "drb" { + t.Fatalf("reading_lang match: got %q", got) + } + c = Config{UILanguage: "en"} + if got := c.ReadingCorpus(); got != "drb" { + t.Fatalf("ui_language fallback: got %q", got) + } + c = Config{UILanguage: "pl"} // no pl corpus embedded + if got := c.ReadingCorpus(); got != "" { + t.Fatalf("no match should be empty: got %q", got) + } +} +``` + +- [ ] **Step 2: Run it, verify it fails** — `go test ./internal/config/ -run TestReadingCorpus` → FAIL. + +- [ ] **Step 3: Add the struct fields** to `Config` in `internal/config/config.go` (near `UILanguage`): + +```go + ReadingLang string `toml:"reading_lang"` + ReadingVersion string `toml:"reading_version"` +``` + +- [ ] **Step 4: Add setField cases** in the `switch key` block: + +```go + case "reading_lang": + cfg.ReadingLang = val + case "reading_version": + cfg.ReadingVersion = val +``` + +- [ ] **Step 5: Implement the resolver** (new method, `internal/config/config.go`): + +```go +// ReadingCorpus resolves which bible corpus renders offline reading text: +// an explicit reading_version, else the first corpus matching reading_lang, +// else the first matching ui_language, else "" (caller uses the Latin vul). +func (c Config) ReadingCorpus() string { + if c.ReadingVersion != "" { + if _, ok := bible.Meta(c.ReadingVersion); ok { + return c.ReadingVersion + } + } + for _, lang := range []string{c.ReadingLang, NormalizeUILanguage(c.UILanguage)} { + if lang == "" { + continue + } + if m := bible.CorporaForLang(lang); len(m) > 0 { + return m[0].Code + } + } + return "" +} +``` + +(Add the `internal/bible` import to config. Confirm no import cycle: `bible` must not import `config`. Task 2's `CorporaDir()` lives in `config`, and `bible` gets the dir via `SetUserCorporaDir` called from `main`/CLI — so `bible` does NOT import `config`. Verify before committing.) + +- [ ] **Step 6: Render the fields** in `renderConfigINI` (after `ui_language`): + +```go + fmt.Fprintf(&b, "reading_lang = %s\n", cfg.ReadingLang) + fmt.Fprintf(&b, "reading_version = %s\n", cfg.ReadingVersion) +``` + +And add two lines to the config-header doc block explaining them (match the existing comment style: `reading_lang` = language for offline reading text, blank follows ui_language; `reading_version` = force a specific corpus code). + +- [ ] **Step 7: Run tests + gofmt** — `go test ./internal/config/ ./internal/bible/`; `gofmt -l internal/`. Confirm no import cycle (`go build ./...`). + +- [ ] **Step 8: Commit** — `git commit -m "feat(config): reading_lang/reading_version + ReadingCorpus resolver"` + +--- + +### Task 4: `lectio --corpus-check` validator + +**Files:** +- Create: `internal/bible/validate.go` +- Modify: `internal/cli/cli.go` (flag wiring) and `internal/cli/corpus.go` (new command handler) +- Test: `internal/bible/validate_test.go`; fixtures under `internal/bible/testdata/corpora/` + +**Interfaces:** +- Consumes: `Corpora`, `Meta`, `Verses`, `load` (Task 2); the canonical book set. +- Produces: `type CorpusReport struct { Errors, Warnings []string }`; `func CheckCorpus(code string) CorpusReport`; `func (r CorpusReport) OK() bool`. + +- [ ] **Step 1: Expose the canonical book set.** In `internal/bible/booktable.go` (or wherever `books.ini` is parsed), add `func CanonicalBooks() map[string]bool` returning the 73 English keys. Reuse the existing parse; do not re-embed. + +- [ ] **Step 2: Write fixtures** under `internal/bible/testdata/corpora/`: + - `good.tsv` (a few valid rows across 2 books) + `good.ini` (valid metadata). + - `badbook.tsv` (one row with `Genessis` typo). + - `gap.tsv` (Genesis 1 verses 1,2,4 — missing 3). + - `nosidecar.tsv` (valid rows, no `.ini`). + - `badsystem.ini` + `badsystem.tsv` (`psalm_system = klingon`). + +- [ ] **Step 3: Write the failing test** `internal/bible/validate_test.go`: + +```go +func TestCheckCorpus(t *testing.T) { + SetUserCorporaDir("testdata/corpora") + t.Cleanup(func() { SetUserCorporaDir("") }) + + if r := CheckCorpus("good"); !r.OK() { + t.Fatalf("good corpus flagged: %v", r.Errors) + } + if r := CheckCorpus("badbook"); r.OK() { + t.Fatal("unknown book not caught") + } + if r := CheckCorpus("gap"); len(r.Warnings) == 0 { + t.Fatal("verse gap not warned") + } + if r := CheckCorpus("nosidecar"); r.OK() { + t.Fatal("missing sidecar not caught") + } + if r := CheckCorpus("badsystem"); r.OK() { + t.Fatal("bad psalm_system not caught") + } +} +``` + +- [ ] **Step 4: Run it, verify it fails** — `go test ./internal/bible/ -run TestCheckCorpus` → FAIL. + +- [ ] **Step 5: Implement `CheckCorpus`** in `internal/bible/validate.go`: + +```go +package bible + +import ( + "fmt" + "sort" +) + +type CorpusReport struct { + Code string + Errors []string + Warnings []string +} + +func (r CorpusReport) OK() bool { return len(r.Errors) == 0 } + +var validPsalmSystems = map[string]bool{"vulgate": true, "hebrew": true, "drb": true} + +// CheckCorpus validates a corpus's text + sidecar and reports coverage vs vul. +func CheckCorpus(code string) CorpusReport { + r := CorpusReport{Code: code} + // sidecar + m, ok := Meta(code) + if !ok || m.Lang == "" || m.Name == "" { + r.Errors = append(r.Errors, "missing or incomplete sidecar (need lang, name, psalm_system)") + } + if m.PsalmSystem != "" && !validPsalmSystems[m.PsalmSystem] { + r.Errors = append(r.Errors, fmt.Sprintf("invalid psalm_system %q", m.PsalmSystem)) + } + // text present? + c := load(code) + if len(c.books) == 0 { + r.Errors = append(r.Errors, "no verses parsed (empty or malformed .tsv)") + return r + } + // canonical book names + canon := CanonicalBooks() + for book := range c.books { + if !canon[book] { + r.Errors = append(r.Errors, "unknown book name: "+book) + } + } + // verse integrity + coverage vs vul + ref := load("vul") + for book, chaps := range c.books { + for ch, verses := range chaps { + seen := map[int]bool{} + for _, v := range verses { + if seen[v.Verse] { + r.Errors = append(r.Errors, fmt.Sprintf("%s %d: duplicate verse %d", book, ch, v.Verse)) + } + seen[v.Verse] = true + } + } + if refChaps, ok := ref.books[book]; ok { + for ch := range refChaps { + if _, have := chaps[ch]; !have { + r.Warnings = append(r.Warnings, fmt.Sprintf("%s: missing chapter %d (present in vul)", book, ch)) + } + } + } + } + sort.Strings(r.Errors) + sort.Strings(r.Warnings) + return r +} +``` + +(Gap detection: within each chapter, warn when the max verse number exceeds the count by more than a small threshold, reusing the run-shape logic from the session audit — keep it a warning, never an error.) + +- [ ] **Step 6: Run tests** — `go test ./internal/bible/ -run TestCheckCorpus` → PASS. + +- [ ] **Step 7: Wire the CLI flag.** In `internal/cli/cli.go`, add a `--corpus-check <code>` flag (and `--json`); on set, call a handler in new `internal/cli/corpus.go` that resolves the user dir via `config.CorporaDir()` + `bible.SetUserCorporaDir`, runs `bible.CheckCorpus`, prints errors/warnings (or JSON), and returns exit 1 if `!report.OK()`. Accept a path argument too: if the arg contains a `/` or ends `.tsv`, point `SetUserCorporaDir` at its dir and use its basename as the code. + +- [ ] **Step 8: CLI smoke test** — `go run ./cmd/lectio --corpus-check drb` prints a clean report, exit 0. Add a `internal/cli` test asserting exit code 1 for the `badbook` fixture. + +- [ ] **Step 9: Full suite + gofmt + commit** — `go test ./...`; `gofmt -l internal/`; `git commit -m "feat(cli): lectio --corpus-check validator"`. + +--- + +### Task 5: Wire resolver into rendering + surface user corpora + +**Files:** +- Modify: `internal/cli/liturgy.go` (`vernacularVersion`) +- Modify: `internal/cli/cli.go` or main wiring (call `bible.SetUserCorporaDir` at startup) +- Modify: `internal/cli` `--list` and comparison label lookup +- Test: `internal/cli/liturgy_test.go` + +**Interfaces:** +- Consumes: `Config.ReadingCorpus` (Task 3), `bible.Corpora`, `bible.Meta` (Task 2). + +- [ ] **Step 1: Set the user corpora dir at startup.** Wherever the CLI builds `cfg` (the same place it resolves `config.CalendarsDir()` for the calendar stack), call: + +```go +if dir, err := config.CorporaDir(); err == nil { + bible.SetUserCorporaDir(dir) +} +``` + +so both `--ref`, comparison, and `--liturgy` see user corpora. + +- [ ] **Step 2: Write the failing test** in `internal/cli/liturgy_test.go`: + +```go +func TestVernacularVersionResolver(t *testing.T) { + if got := vernacularVersion(config.Config{ReadingVersion: "drb"}); got != "drb" { + t.Fatalf("explicit: %q", got) + } + if got := vernacularVersion(config.Config{UILanguage: "pl"}); got != "vul" { + t.Fatalf("pl should fall back to Latin: %q", got) + } + if got := vernacularVersion(config.Config{UILanguage: "en"}); got != "drb" { + t.Fatalf("en should be drb: %q", got) + } +} +``` + +- [ ] **Step 3: Run it, verify it fails** (current `vernacularVersion` ignores `ReadingVersion`). + +- [ ] **Step 4: Replace `vernacularVersion`** in `internal/cli/liturgy.go`: + +```go +// vernacularVersion is the corpus that renders reading text: the config's +// resolved reading corpus, else the complete Latin Vulgate. +func vernacularVersion(cfg config.Config) string { + if code := cfg.ReadingCorpus(); code != "" { + return code + } + return latinFallback // "vul" +} +``` + +- [ ] **Step 5: Run tests** — `go test ./internal/cli/ -run TestVernacularVersion` → PASS. Manually confirm behaviour with a temp config: `LECTIO_CONFIG=… lectio 2025-03-10 -L` shows Latin for pl, Douay for en (unchanged from today). + +- [ ] **Step 6: Surface corpora in `--list`/comparison.** Where version labels are resolved (today via `i18n.Get(lang).Version[code]`), fall back to `bible.Meta(code).Name` when the i18n map has no entry, and include user corpora when enumerating available versions. Add a test asserting a user fixture corpus appears in the `--list` output. + +- [ ] **Step 7: Full suite + gofmt + commit** — `go test ./...`; `gofmt -l internal/`; `git commit -m "feat(cli): config-driven reading corpus + surface user corpora"`. + +--- + +### Task 6: Scripts + Makefile + +**Files:** +- Create: `scripts/corpus-validate.sh` +- Modify: `Makefile` +- Test: manual + `check-corpora` target + +**Interfaces:** +- Consumes: `lectio --corpus-check` (Task 4). + +- [ ] **Step 1: Write** `scripts/corpus-validate.sh`: + +```sh +#!/bin/sh +# Validate a bible corpus by code or by .tsv path (see lectio --corpus-check). +# Usage: scripts/corpus-validate.sh <code|path-to-tsv> +set -e +[ -n "$1" ] || { echo "usage: $0 <code|path.tsv>" >&2; exit 2; } +exec go run ./cmd/lectio --corpus-check "$1" +``` + +`chmod +x scripts/corpus-validate.sh`. + +- [ ] **Step 2: Add Makefile targets:** + +```make +# Validate every embedded corpus (CI gate). +check-corpora: + @for f in internal/bible/corpora/*.tsv; do \ + code=$$(basename $$f .tsv); \ + echo "checking $$code"; \ + go run ./cmd/lectio --corpus-check $$code || exit 1; \ + done + +# Validate then embed a corpus pair: make add-corpus CORPUS=fr-crampon SRC=/path/to/dir +add-corpus: + @test -n "$(CORPUS)" || { echo "set CORPUS=<code>" >&2; exit 2; } + @test -n "$(SRC)" || { echo "set SRC=<dir with $(CORPUS).tsv/.ini>" >&2; exit 2; } + go run ./cmd/lectio --corpus-check "$(SRC)/$(CORPUS).tsv" + cp "$(SRC)/$(CORPUS).tsv" "$(SRC)/$(CORPUS).ini" internal/bible/corpora/ + $(MAKE) build +``` + +- [ ] **Step 3: Wire `check-corpora` into the test/check target** — add it as a prerequisite of the existing `test` (or `check`) target so CI runs it. + +- [ ] **Step 4: Verify** — `make check-corpora` passes for all four built-ins. Create a throwaway bad pair in `/tmp` and confirm `make add-corpus CORPUS=bad SRC=/tmp` aborts before copying (validation non-zero). Clean up. + +- [ ] **Step 5: Commit** — `git commit -m "build: corpus-validate script + add-corpus/check-corpora make targets"`. + +--- + +## Wrap-up + +- [ ] Run the full suite once more: `go test ./... && gofmt -l internal/ scripts/ cmd/`. +- [ ] Bump `config.Version` (0.33.0 → 0.34.0). +- [ ] Update memory `lectio-selfcontained-calendar-epic.md`: national-bibles corpora mechanism shipped; how a corpus is added; that a corrected Wujek is now a drop-in. +- [ ] Use **superpowers:finishing-a-development-branch** to merge/install. +- [ ] Manual acceptance: drop a tiny `fr-x.tsv` + `fr-x.ini` into `~/.config/lectio/corpora/`, set `reading_version = fr-x` (temp config), confirm `lectio … -L` renders the French text and `--corpus-check fr-x` reports coverage. + +## Self-Review Notes + +- Spec coverage: format (T1), discovery/override (T2), config/selection (T3), validation (T4), rendering/surfacing (T5), scripts/Makefile (T6). `psalm_system` = metadata+validation only (T1, T4), application deferred per spec §5. All spec sections mapped. +- Import-cycle watch (T3): `bible` must not import `config`; the user dir is injected via `SetUserCorporaDir` from the CLI, not read inside `bible`. +- Concurrency watch (T2): two locks (`regMu`, `corporaMu`); `SetUserCorporaDir` resets the load cache — race-test in T2 Step 7. +- Confirm-before-coding: `ini.Parse`'s exact return shape (T1 Step 5) and `CalendarsDir`'s helper (T2 Step 1) must be read from the current code, not assumed. |
