Proyecto independiente No afiliado, patrocinado ni avalado por la Watch Tower Bible and Tract Society o Jehovah's Witnesses.
jw-agent-toolkit
EN

Specs y planes

Fase 82.0 — Catálogo Territory ISO + JW Branch — Plan de Implementación

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: Crear jw_core.territories con Territory dataclass que compone el existente jw_core.data.locale_context.LocaleContext para añadir la dimensión legal (jw_branch_region, legal_status_summary, ban_history). Poblar ~30 países con historial legal JW relevante, garantizando que toda entrada Territory.iso_3166 tiene LocaleContext correspondiente (extendiendo LOCALE_CONTEXTS cuando falte).

Architecture: Composición sobre LocaleContext, no duplicación. Una entrada Territory se enfoca en lo legal (rama JW, status, ban_history). LocaleContext aporta lo cultural/idiomático. La función get_territory_full(iso) combina ambos para los agentes legales (F82.3+). pycountry se añade como dep para validación, sin peso runtime obligatorio.

Tech Stack: Python 3.13 · @dataclass(frozen=True) (consistente con locale_context.py) · pycountry>=24 (validación ISO) · stdlib only para runtime · pytest sin red.

Global Constraints

  • Python >=3.13 uniforme con monorepo.
  • GPL-3.0-only header en territories.py.
  • Cero duplicación: ningún campo cubierto por LocaleContext (name, languages, dominant_religions, sensitive_topics, cultural_anchors, holidays_to_acknowledge, notes) puede vivir también en Territory. Test enforced.
  • 100% de los Territory.iso_3166 deben tener LocaleContext — test enforce.
  • Hand-curación verificable: cada entrada ban_history lleva comentario con URL o referencia a publicación JW.
  • ISO 3166-1 alpha-2 válido: cada iso_3166 se valida con pycountry.countries.get(alpha_2=iso).
  • DRY · YAGNI · TDD · commits frecuentes.
  • No tocar el plugin jw-legal en esta fase. F82.0 solo entrega infra compartida en jw-core.
  • No tocar tests existentes de locale_context. Sí extender LOCALE_CONTEXTS con los países nuevos requeridos por TERRITORIES.

Lista de países objetivo (F82.0 v1)

Total: 30 países con Territory entry; al cerrar la fase todos tienen entrada también en LocaleContext.

Ya en LocaleContext (16): MX, BR, US, ES, AR, CO, PE, DE, FR, IT, JP, KR, CN, PH, RU.

Nuevos a añadir a LocaleContext mínimamente (14): KP (Corea del Norte), ER (Eritrea), SG (Singapur), TJ (Tayikistán), CU (Cuba), VN (Vietnam), MM (Myanmar), GR (Grecia), AM (Armenia), AZ (Azerbaiyán), TR (Turquía), GE (Georgia), MD (Moldavia), BY (Bielorrusia).


Task 1: Añadir pycountry como dep de jw-core

Files:

  • Modify: packages/jw-core/pyproject.toml

Interfaces:

  • Consumes: nothing.

  • Produces: pycountry importable desde el workspace.

  • Step 1: Leer dep actual de jw-core

Run: grep -n "dependencies" packages/jw-core/pyproject.toml | head -5

  • Step 2: Añadir pycountry>=24 a [project].dependencies

Edit packages/jw-core/pyproject.toml insertando en la sección dependencies = [...] (mantener orden alfabético si existe; ejemplo del cambio):

dependencies = [
    # ... existentes ...
    "pycountry>=24",
    # ... existentes ...
]
  • Step 3: Sincronizar workspace

Run: uv sync --all-packages

Expected: instala pycountry; no rompe nada.

  • Step 4: Sanity import

Run: .venv/bin/python -c "import pycountry; assert pycountry.countries.get(alpha_2='RU').name == 'Russian Federation'"

Expected: sin error.

  • Step 5: Commit
git add packages/jw-core/pyproject.toml uv.lock
git commit -m "feat(core): add pycountry>=24 dep for ISO 3166-1 validation (F82.0 task 1)"

Task 2: Extender LOCALE_CONTEXTS con los 14 países que faltan

Files:

  • Modify: packages/jw-core/src/jw_core/data/locale_context.py
  • Create: packages/jw-core/tests/test_locale_context_extensions.py

Interfaces:

  • Consumes: estructura LocaleContext existente.

  • Produces: LOCALE_CONTEXTS extendido con: KP, ER, SG, TJ, CU, VN, MM, GR, AM, AZ, TR, GE, MD, BY.

  • Step 1: Write the failing test

# packages/jw-core/tests/test_locale_context_extensions.py
"""Ensure LOCALE_CONTEXTS covers all countries needed by jw_core.territories."""

from __future__ import annotations

import pytest

from jw_core.data.locale_context import LOCALE_CONTEXTS, get_locale

REQUIRED_NEW = ["KP", "ER", "SG", "TJ", "CU", "VN", "MM", "GR", "AM", "AZ", "TR", "GE", "MD", "BY"]


@pytest.mark.parametrize("iso", REQUIRED_NEW)
def test_locale_context_present_for_required(iso: str) -> None:
    ctx = get_locale(iso)
    assert ctx is not None, f"LocaleContext missing for {iso}"
    assert ctx.iso_3166 == iso
    assert "en" in ctx.name, f"{iso} must have an English name"


@pytest.mark.parametrize("iso", REQUIRED_NEW)
def test_locale_context_has_at_least_one_language(iso: str) -> None:
    ctx = get_locale(iso)
    assert ctx is not None
    assert len(ctx.languages) >= 1
  • Step 2: Run test (expect 28 failures)

Run: .venv/bin/python -m pytest packages/jw-core/tests/test_locale_context_extensions.py -v

Expected: muchos assert ctx is not None failures.

  • Step 3: Extender LOCALE_CONTEXTS

Edit packages/jw-core/src/jw_core/data/locale_context.py insertando entradas dentro del dict LOCALE_CONTEXTS = {...} ordenadas por ISO (al final, antes de la llave de cierre):

    # ---- Países añadidos por F82.0 (catálogo Territory) ----
    "KP": LocaleContext(
        iso_3166="KP",
        name={"en": "North Korea", "es": "Corea del Norte", "pt": "Coreia do Norte"},
        languages=("ko",),
        dominant_religions=("None (state-enforced)",),
        notes={"en": "JW activity is completely banned; no public ministry possible."},
    ),
    "ER": LocaleContext(
        iso_3166="ER",
        name={"en": "Eritrea", "es": "Eritrea", "pt": "Eritreia"},
        languages=("ti", "ar", "en"),
        dominant_religions=("Orthodox", "Muslim", "Catholic"),
        notes={"en": "JWs detained without trial since 1994; only state-recognised religions allowed."},
    ),
    "SG": LocaleContext(
        iso_3166="SG",
        name={"en": "Singapore", "es": "Singapur", "pt": "Singapura"},
        languages=("en", "zh", "ms", "ta"),
        dominant_religions=("Buddhist", "Christian", "Muslim", "Taoist", "Hindu"),
        notes={"en": "JW activity restricted (deregistered 1972, ban under Societies Act)."},
    ),
    "TJ": LocaleContext(
        iso_3166="TJ",
        name={"en": "Tajikistan", "es": "Tayikistán", "pt": "Tajiquistão"},
        languages=("tg", "ru"),
        dominant_religions=("Muslim",),
        notes={"en": "JWs banned since 2007 as 'extremist'."},
    ),
    "CU": LocaleContext(
        iso_3166="CU",
        name={"en": "Cuba", "es": "Cuba", "pt": "Cuba"},
        languages=("es",),
        dominant_religions=("Catholic", "Santería", "None"),
        cultural_anchors=("family", "music"),
    ),
    "VN": LocaleContext(
        iso_3166="VN",
        name={"en": "Vietnam", "es": "Vietnam", "pt": "Vietnã"},
        languages=("vi",),
        dominant_religions=("Buddhist", "Catholic", "Cao Dai", "None"),
    ),
    "MM": LocaleContext(
        iso_3166="MM",
        name={"en": "Myanmar", "es": "Myanmar", "pt": "Myanmar"},
        languages=("my",),
        dominant_religions=("Buddhist", "Christian", "Muslim"),
    ),
    "GR": LocaleContext(
        iso_3166="GR",
        name={"en": "Greece", "es": "Grecia", "pt": "Grécia"},
        languages=("el",),
        dominant_religions=("Orthodox", "None"),
        cultural_anchors=("family", "philoxenia"),
    ),
    "AM": LocaleContext(
        iso_3166="AM",
        name={"en": "Armenia", "es": "Armenia", "pt": "Armênia"},
        languages=("hy", "ru"),
        dominant_religions=("Orthodox (Armenian Apostolic)",),
    ),
    "AZ": LocaleContext(
        iso_3166="AZ",
        name={"en": "Azerbaijan", "es": "Azerbaiyán", "pt": "Azerbaijão"},
        languages=("az", "ru"),
        dominant_religions=("Muslim",),
        notes={"en": "Religious activity tightly regulated; congregational registration required."},
    ),
    "TR": LocaleContext(
        iso_3166="TR",
        name={"en": "Türkiye", "es": "Turquía", "pt": "Turquia"},
        languages=("tr", "ku"),
        dominant_religions=("Muslim", "None"),
    ),
    "GE": LocaleContext(
        iso_3166="GE",
        name={"en": "Georgia", "es": "Georgia", "pt": "Geórgia"},
        languages=("ka", "ru"),
        dominant_religions=("Orthodox",),
    ),
    "MD": LocaleContext(
        iso_3166="MD",
        name={"en": "Moldova", "es": "Moldavia", "pt": "Moldávia"},
        languages=("ro", "ru"),
        dominant_religions=("Orthodox",),
    ),
    "BY": LocaleContext(
        iso_3166="BY",
        name={"en": "Belarus", "es": "Bielorrusia", "pt": "Bielorrússia"},
        languages=("be", "ru"),
        dominant_religions=("Orthodox", "Catholic", "None"),
    ),
  • Step 4: Run tests (expect PASS)

Run: .venv/bin/python -m pytest packages/jw-core/tests/test_locale_context_extensions.py -v

Expected: 28 passed.

  • Step 5: Re-run pre-existing locale_context tests

Run: .venv/bin/python -m pytest packages/jw-core/tests/test_locale_context.py -v

Expected: todos los tests previos siguen pasando.

  • Step 6: Commit
git add packages/jw-core/src/jw_core/data/locale_context.py packages/jw-core/tests/test_locale_context_extensions.py
git commit -m "feat(core): extend LOCALE_CONTEXTS with 14 countries required by territories catalog (F82.0 task 2)"

Task 3: Crear Territory dataclass + LegalStatus type

Files:

  • Create: packages/jw-core/src/jw_core/territories.py
  • Create: packages/jw-core/tests/test_territories_dataclass.py

Interfaces:

  • Consumes: LocaleContext, get_locale.

  • Produces: Territory dataclass, LegalStatus Literal, helper get_territory(iso).

  • Step 1: Write the failing test

# packages/jw-core/tests/test_territories_dataclass.py
"""Territory dataclass + retrieval helper."""

from __future__ import annotations

from jw_core.territories import Territory, get_territory


def test_territory_composes_locale_context() -> None:
    t = Territory(
        iso_3166="ES",
        jw_branch_region="España",
        legal_status_summary="free",
        ban_history=(),
    )
    locale = t.locale
    assert locale is not None
    assert locale.localized_name("es") == "España"


def test_get_territory_returns_none_for_unknown() -> None:
    assert get_territory("ZZ") is None


def test_territory_no_field_duplicates_locale_context() -> None:
    # Compile-time / structural: Territory must NOT have name/languages/etc.
    territory_fields = set(Territory.__dataclass_fields__.keys())
    forbidden_overlap = {
        "name",
        "languages",
        "dominant_religions",
        "sensitive_topics",
        "cultural_anchors",
        "holidays_to_acknowledge",
        "notes",
    }
    overlap = territory_fields & forbidden_overlap
    assert not overlap, (
        f"Territory must not duplicate LocaleContext fields, overlap found: {overlap}"
    )
  • Step 2: Run test (expect FAIL)

Run: .venv/bin/python -m pytest packages/jw-core/tests/test_territories_dataclass.py -v

Expected: ModuleNotFoundError.

  • Step 3: Implement territories.py shell
# packages/jw-core/src/jw_core/territories.py
"""Country-level legal dimension catalog, composing LocaleContext.

`Territory` adds the legal slice (jw_branch_region, legal_status_summary,
ban_history) that the F82 legal-cases plugin and the news_monitor need;
everything cultural (name, languages, religions, anchors, holidays, notes)
lives in `jw_core.data.locale_context` and is referenced by `iso_3166`.

Why not duplicate? Two catalogues drift. Composition keeps LocaleContext
as the single source of truth for cultural data and Territory as the
single source of truth for legal data — orthogonal dimensions.
"""

from __future__ import annotations

from dataclasses import dataclass
from typing import Literal

from jw_core.data.locale_context import LocaleContext, get_locale

LegalStatus = Literal["free", "restricted", "banned", "unknown"]


@dataclass(frozen=True)
class Territory:
    """Legal dimension of a country. Cultural data via `self.locale`."""

    iso_3166: str
    jw_branch_region: str
    legal_status_summary: LegalStatus
    ban_history: tuple[str, ...] = ()

    @property
    def locale(self) -> LocaleContext | None:
        return get_locale(self.iso_3166)


TERRITORIES: dict[str, Territory] = {
    # Populated in Tasks 4–6.
}


def get_territory(iso: str) -> Territory | None:
    """Look up a Territory by ISO 3166-1 alpha-2 code (case-insensitive)."""
    return TERRITORIES.get(iso.upper())
  • Step 4: Run tests

Run: .venv/bin/python -m pytest packages/jw-core/tests/test_territories_dataclass.py -v

Expected: 3 passed.

  • Step 5: Commit
git add packages/jw-core/src/jw_core/territories.py packages/jw-core/tests/test_territories_dataclass.py
git commit -m "feat(core): Territory dataclass composes LocaleContext (no field duplication) (F82.0 task 3)"

Task 4: Poblar bloque 1 — Países con ban_history activo (8 territorios)

Files:

  • Modify: packages/jw-core/src/jw_core/territories.py (poblar TERRITORIES)
  • Create: packages/jw-core/tests/test_territories_block1_banned.py

Interfaces:

  • Consumes: TERRITORIES dict, get_territory.

  • Produces: 8 entradas para países con ban activo o restringido: RU, KP, ER, SG, TJ, CN, AZ, BY.

  • Step 1: Write the failing test

# packages/jw-core/tests/test_territories_block1_banned.py
"""Block 1: countries with active bans or severe restrictions on JWs."""

from __future__ import annotations

import pytest

from jw_core.territories import get_territory

BANNED_ISOS = ["RU", "KP", "ER", "SG", "TJ", "CN", "AZ", "BY"]


@pytest.mark.parametrize("iso", BANNED_ISOS)
def test_territory_present(iso: str) -> None:
    t = get_territory(iso)
    assert t is not None, f"Territory {iso} missing"
    assert t.iso_3166 == iso


@pytest.mark.parametrize("iso", BANNED_ISOS)
def test_territory_has_locale_context(iso: str) -> None:
    t = get_territory(iso)
    assert t is not None
    assert t.locale is not None, f"Territory {iso} has no LocaleContext"


@pytest.mark.parametrize("iso", BANNED_ISOS)
def test_territory_has_ban_history(iso: str) -> None:
    t = get_territory(iso)
    assert t is not None
    assert len(t.ban_history) >= 1, f"Territory {iso} should have ban_history populated"


def test_russia_2017_ruling_present() -> None:
    t = get_territory("RU")
    assert t is not None
    assert any("2017" in entry for entry in t.ban_history)
    assert t.legal_status_summary == "banned"
  • Step 2: Run tests (expect FAIL)

Run: .venv/bin/python -m pytest packages/jw-core/tests/test_territories_block1_banned.py -v

Expected: muchos assert t is not None failures.

  • Step 3: Poblar TERRITORIES con bloque 1

Edit packages/jw-core/src/jw_core/territories.py, sustituir TERRITORIES: dict[str, Territory] = {} por:

TERRITORIES: dict[str, Territory] = {
    # --------- Block 1: countries with active bans (F82.0 task 4) ---------
    "RU": Territory(
        iso_3166="RU",
        jw_branch_region="Russia (closed since 2017)",
        legal_status_summary="banned",
        ban_history=(
            # Source: jw.org/en/news/legal/by-region/russia/
            "2017-04-20: Supreme Court ruling designates JWs as 'extremist'",
            "2017-07-17: appeal denied; property liquidation begins",
            "2022-01: ECHR Jehovah's Witnesses of Moscow v. Russia (302/02 follow-up)",
        ),
    ),
    "KP": Territory(
        iso_3166="KP",
        jw_branch_region="(no branch)",
        legal_status_summary="banned",
        ban_history=(
            # No published rulings; persecution documented in Anuarios + HRW.
            "Continuous ban; no legal framework for non-juche religion",
            "2014: UN Commission of Inquiry references JW imprisonment cases",
        ),
    ),
    "ER": Territory(
        iso_3166="ER",
        jw_branch_region="(no branch)",
        legal_status_summary="banned",
        ban_history=(
            # Source: jw.org/en/news/legal/by-region/eritrea/
            "1994-05-25: Presidential decree strips JWs of civil rights",
            "Continuous detention without trial since 1994",
        ),
    ),
    "SG": Territory(
        iso_3166="SG",
        jw_branch_region="(restricted)",
        legal_status_summary="banned",
        ban_history=(
            # Source: Societies Act + Undesirable Publications Act invocations
            "1972-01-14: Deregistered under Societies Act",
            "1996: Publications gazetted as 'undesirable'",
        ),
    ),
    "TJ": Territory(
        iso_3166="TJ",
        jw_branch_region="(no branch)",
        legal_status_summary="banned",
        ban_history=(
            "2007-10-11: Banned by Ministry of Culture as 'extremist'",
        ),
    ),
    "CN": Territory(
        iso_3166="CN",
        jw_branch_region="(no branch)",
        legal_status_summary="restricted",
        ban_history=(
            "Continuous restriction; only state-sanctioned religions allowed",
            "JW activity criminalised under 'cult' framework",
        ),
    ),
    "AZ": Territory(
        iso_3166="AZ",
        jw_branch_region="Azerbaijan (restricted)",
        legal_status_summary="restricted",
        ban_history=(
            # Source: Forum 18 + ECHR cases Mammadov v. Azerbaijan
            "Registration required; activity outside registered locations restricted",
        ),
    ),
    "BY": Territory(
        iso_3166="BY",
        jw_branch_region="Belarus (restricted)",
        legal_status_summary="restricted",
        ban_history=(
            "Religious activity tightly regulated under 2002 Religion Law",
        ),
    ),
}
  • Step 4: Run tests (expect PASS)

Run: .venv/bin/python -m pytest packages/jw-core/tests/test_territories_block1_banned.py -v

Expected: todos pass.

  • Step 5: Commit
git add packages/jw-core/src/jw_core/territories.py packages/jw-core/tests/test_territories_block1_banned.py
git commit -m "feat(core): territories block 1 — 8 countries with active bans or restrictions (F82.0 task 4)"

Files:

  • Modify: packages/jw-core/src/jw_core/territories.py
  • Create: packages/jw-core/tests/test_territories_block2_history.py

Interfaces:

  • Consumes: dict TERRITORIES.

  • Produces: 12 entradas más: ES, MX, US, AR, BR, KR, JP, DE, FR, IT, GR, AM.

  • Step 1: Write the failing test

# packages/jw-core/tests/test_territories_block2_history.py
"""Block 2: countries with resolved legal history (now 'free' status)."""

from __future__ import annotations

import pytest

from jw_core.territories import get_territory

RESOLVED_ISOS = ["ES", "MX", "US", "AR", "BR", "KR", "JP", "DE", "FR", "IT", "GR", "AM"]


@pytest.mark.parametrize("iso", RESOLVED_ISOS)
def test_territory_present(iso: str) -> None:
    assert get_territory(iso) is not None, f"Territory {iso} missing"


@pytest.mark.parametrize("iso", RESOLVED_ISOS)
def test_status_is_free(iso: str) -> None:
    t = get_territory(iso)
    assert t is not None
    assert t.legal_status_summary == "free", f"{iso} should be 'free' now"


def test_armenia_bayatyan_ban_history() -> None:
    t = get_territory("AM")
    assert t is not None
    assert any("Bayatyan" in entry or "2011" in entry for entry in t.ban_history)


def test_germany_bvg_2000() -> None:
    t = get_territory("DE")
    assert t is not None
    assert any("2000" in entry for entry in t.ban_history)
  • Step 2: Run tests (expect FAIL)

Run: .venv/bin/python -m pytest packages/jw-core/tests/test_territories_block2_history.py -v

Expected: failures por missing territories.

  • Step 3: Añadir bloque 2 al dict TERRITORIES

Edit packages/jw-core/src/jw_core/territories.py, añadir dentro de TERRITORIES = {...} (después del bloque 1, antes de la llave de cierre):

    # --------- Block 2: resolved legal history (F82.0 task 5) -----------
    "ES": Territory(
        iso_3166="ES",
        jw_branch_region="España",
        legal_status_summary="free",
        ban_history=(
            "1956-1970: not recognised as religious entity",
            "1970-10-10: legal recognition under Religious Liberty Law",
        ),
    ),
    "MX": Territory(
        iso_3166="MX",
        jw_branch_region="México",
        legal_status_summary="free",
        ban_history=(
            "1992: SCJN ruling protects conscientious objection in schools",
        ),
    ),
    "US": Territory(
        iso_3166="US",
        jw_branch_region="United States",
        legal_status_summary="free",
        ban_history=(
            "1940-05-20: Minersville v. Gobitis allows flag-salute compulsion",
            "1943-06-14: WV State Board v. Barnette overturns Gobitis",
            "1940-05-20: Cantwell v. Connecticut incorporates Free Exercise to states",
            "2002-06-17: Watchtower v. Stratton invalidates door-to-door permit law",
        ),
    ),
    "AR": Territory(
        iso_3166="AR",
        jw_branch_region="Argentina",
        legal_status_summary="free",
        ban_history=(
            "1976-08-31: Decreto 1867 prohíbe a los TJ",
            "1984-04-04: Decreto 1029 levanta la prohibición",
        ),
    ),
    "BR": Territory(
        iso_3166="BR",
        jw_branch_region="Brazil",
        legal_status_summary="free",
        ban_history=(),
    ),
    "KR": Territory(
        iso_3166="KR",
        jw_branch_region="South Korea",
        legal_status_summary="free",
        ban_history=(
            "2018-06-28: Supreme Court legalises conscientious objection",
            "2018-11-01: Constitutional Court rules alternative service required",
        ),
    ),
    "JP": Territory(
        iso_3166="JP",
        jw_branch_region="Japan",
        legal_status_summary="free",
        ban_history=(
            "1939-1945: persecution under Peace Preservation Law",
        ),
    ),
    "DE": Territory(
        iso_3166="DE",
        jw_branch_region="Germany Central Europe",
        legal_status_summary="free",
        ban_history=(
            "1933-1945: outlawed under National Socialism",
            "2000-12-19: Bundesverwaltungsgericht orders recognition as Körperschaft d.ö.R.",
            "2006-03-24: Berlin grants public-law corporation status",
        ),
    ),
    "FR": Territory(
        iso_3166="FR",
        jw_branch_region="France",
        legal_status_summary="free",
        ban_history=(
            "2011-06-30: ECHR Association Les Témoins de Jéhovah v. France rules tax assessment violates art. 9",
        ),
    ),
    "IT": Territory(
        iso_3166="IT",
        jw_branch_region="Italy",
        legal_status_summary="free",
        ban_history=(
            "Awaiting Intesa with the Italian Republic (ongoing process)",
        ),
    ),
    "GR": Territory(
        iso_3166="GR",
        jw_branch_region="Greece",
        legal_status_summary="free",
        ban_history=(
            "1993-05-25: ECHR Kokkinakis v. Greece protects proselytism under art. 9",
            "2008-02-05: ECHR Religionsgemeinschaft framework cited in JW context",
        ),
    ),
    "AM": Territory(
        iso_3166="AM",
        jw_branch_region="Armenia",
        legal_status_summary="free",
        ban_history=(
            "2011-07-07: ECHR Bayatyan v. Armenia (23459/03) recognises conscientious objection under art. 9",
            "2013: Armenia introduces alternative civilian service",
        ),
    ),
  • Step 4: Run tests

Run: .venv/bin/python -m pytest packages/jw-core/tests/test_territories_block2_history.py -v

Expected: todos pass.

  • Step 5: Commit
git add packages/jw-core/src/jw_core/territories.py packages/jw-core/tests/test_territories_block2_history.py
git commit -m "feat(core): territories block 2 — 12 countries with resolved legal history (F82.0 task 5)"

Files:

  • Modify: packages/jw-core/src/jw_core/territories.py
  • Create: packages/jw-core/tests/test_territories_block3_misc.py

Interfaces:

  • Produces: 10 entradas más: VN, MM, TR, GE, MD, CO, PE, PH, CU, KZ (Kazakhstan).

  • Step 1: Write the failing test

# packages/jw-core/tests/test_territories_block3_misc.py
"""Block 3: additional countries with known legal context."""

from __future__ import annotations

import pytest

from jw_core.territories import get_territory

ADDITIONAL_ISOS = ["VN", "MM", "TR", "GE", "MD", "CO", "PE", "PH", "CU", "KZ"]


@pytest.mark.parametrize("iso", ADDITIONAL_ISOS)
def test_territory_present(iso: str) -> None:
    assert get_territory(iso) is not None, f"Territory {iso} missing"


@pytest.mark.parametrize("iso", ADDITIONAL_ISOS)
def test_locale_context_present(iso: str) -> None:
    t = get_territory(iso)
    assert t is not None
    assert t.locale is not None, f"LocaleContext missing for {iso}"
  • Step 2: Add KZ to LOCALE_CONTEXTS (only one missing in block 3)

Edit packages/jw-core/src/jw_core/data/locale_context.py, añadir junto al bloque añadido en Task 2:

    "KZ": LocaleContext(
        iso_3166="KZ",
        name={"en": "Kazakhstan", "es": "Kazajistán", "pt": "Cazaquistão"},
        languages=("kk", "ru"),
        dominant_religions=("Muslim", "Orthodox", "None"),
    ),
  • Step 3: Run test (expect FAIL — territories missing)

Run: .venv/bin/python -m pytest packages/jw-core/tests/test_territories_block3_misc.py -v

Expected: 20 failures (10 ISOs × 2 tests).

  • Step 4: Añadir bloque 3 a TERRITORIES

Edit packages/jw-core/src/jw_core/territories.py, añadir:

    # --------- Block 3: additional context (F82.0 task 6) -----------
    "VN": Territory(
        iso_3166="VN",
        jw_branch_region="(restricted)",
        legal_status_summary="restricted",
        ban_history=(
            "Religious activity requires state registration; JW status varies",
        ),
    ),
    "MM": Territory(
        iso_3166="MM",
        jw_branch_region="(restricted)",
        legal_status_summary="restricted",
        ban_history=(),
    ),
    "TR": Territory(
        iso_3166="TR",
        jw_branch_region="Türkiye",
        legal_status_summary="restricted",
        ban_history=(
            "2007-01-23: ECHR Tarhan v. Türkiye relates to conscientious objection",
            "Religious foundations regulated under Foundations Law",
        ),
    ),
    "GE": Territory(
        iso_3166="GE",
        jw_branch_region="Georgia",
        legal_status_summary="free",
        ban_history=(
            "2007-05-03: ECHR 97 Members of Gldani Congregation v. Georgia (71156/01) — violence against JW meeting",
        ),
    ),
    "MD": Territory(
        iso_3166="MD",
        jw_branch_region="Moldova",
        legal_status_summary="free",
        ban_history=(
            "1995-1997: registration disputes resolved",
        ),
    ),
    "CO": Territory(
        iso_3166="CO",
        jw_branch_region="Colombia",
        legal_status_summary="free",
        ban_history=(),
    ),
    "PE": Territory(
        iso_3166="PE",
        jw_branch_region="Peru",
        legal_status_summary="free",
        ban_history=(),
    ),
    "PH": Territory(
        iso_3166="PH",
        jw_branch_region="Philippines",
        legal_status_summary="free",
        ban_history=(
            "1993-03-03: Ebralinag v. Division Superintendent — flag salute protection (paralelo a Barnette)",
        ),
    ),
    "CU": Territory(
        iso_3166="CU",
        jw_branch_region="Cuba (restricted)",
        legal_status_summary="restricted",
        ban_history=(
            "1974: prohibition under socialist government",
            "1990s onward: gradual relaxation; activity remains restricted",
        ),
    ),
    "KZ": Territory(
        iso_3166="KZ",
        jw_branch_region="Kazakhstan",
        legal_status_summary="restricted",
        ban_history=(
            "2011 Religion Law tightens registration and proselytism",
        ),
    ),
  • Step 5: Run tests (expect PASS)

Run: .venv/bin/python -m pytest packages/jw-core/tests/test_territories_block3_misc.py packages/jw-core/tests/test_locale_context_extensions.py -v

Expected: todos pass.

  • Step 6: Commit
git add packages/jw-core/src/jw_core/territories.py packages/jw-core/src/jw_core/data/locale_context.py packages/jw-core/tests/test_territories_block3_misc.py
git commit -m "feat(core): territories block 3 — 10 additional countries with legal context (F82.0 task 6)"

Task 7: Helpers get_territory_full, territories_by_status, territories_by_branch

Files:

  • Modify: packages/jw-core/src/jw_core/territories.py
  • Create: packages/jw-core/tests/test_territories_helpers.py

Interfaces:

  • Consumes: TERRITORIES.

  • Produces: get_territory_full(iso) -> dict | None, territories_by_status(status) -> list[Territory], territories_by_branch(branch_substring) -> list[Territory].

  • Step 1: Write the failing test

# packages/jw-core/tests/test_territories_helpers.py
"""Helpers that compose Territory + LocaleContext or filter the catalog."""

from __future__ import annotations

from jw_core.territories import (
    get_territory_full,
    territories_by_branch,
    territories_by_status,
)


def test_get_territory_full_composes_legal_and_cultural() -> None:
    full = get_territory_full("RU")
    assert full is not None
    # Legal data
    assert full["jw_branch_region"].startswith("Russia")
    assert full["legal_status_summary"] == "banned"
    assert any("2017" in entry for entry in full["ban_history"])
    # Cultural data from LocaleContext
    assert full["name"]["en"] == "Russia"
    assert "ru" in full["languages"]


def test_get_territory_full_returns_none_for_unknown() -> None:
    assert get_territory_full("ZZ") is None


def test_territories_by_status_banned() -> None:
    banned = territories_by_status("banned")
    isos = {t.iso_3166 for t in banned}
    assert {"RU", "KP", "ER", "SG", "TJ"}.issubset(isos)


def test_territories_by_status_free() -> None:
    free = territories_by_status("free")
    isos = {t.iso_3166 for t in free}
    assert {"ES", "MX", "US"}.issubset(isos)


def test_territories_by_branch_substring_match() -> None:
    russia_branches = territories_by_branch("Russia")
    assert any(t.iso_3166 == "RU" for t in russia_branches)


def test_get_territory_full_no_field_collision() -> None:
    """Cultural and legal keys must not collide; legal wins on overlap.

    Currently they don't overlap because we enforce Territory has no
    duplicate fields with LocaleContext. This test guards future regressions.
    """
    full = get_territory_full("ES")
    assert full is not None
    # Both 'iso_3166' is in LocaleContext AND Territory; should be the same string.
    assert full["iso_3166"] == "ES"
  • Step 2: Run tests (expect FAIL)

Run: .venv/bin/python -m pytest packages/jw-core/tests/test_territories_helpers.py -v

Expected: ImportError on get_territory_full/etc.

  • Step 3: Implement helpers

Append to packages/jw-core/src/jw_core/territories.py:

from dataclasses import asdict


def get_territory_full(iso: str) -> dict | None:
    """Compose Territory + LocaleContext into a flat dict.

    LocaleContext fields are copied first; Territory fields override on
    keys that collide (only `iso_3166` collides, identical value). The
    cultural `notes: dict` is preserved as-is so callers can pick a language.
    """
    territory = get_territory(iso)
    if territory is None:
        return None
    locale = territory.locale
    out: dict = asdict(locale) if locale else {}
    out.update(asdict(territory))
    return out


def territories_by_status(status: LegalStatus) -> list[Territory]:
    """Return all territories whose `legal_status_summary` matches."""
    return [t for t in TERRITORIES.values() if t.legal_status_summary == status]


def territories_by_branch(branch_substring: str) -> list[Territory]:
    """Return all territories whose `jw_branch_region` contains the substring."""
    needle = branch_substring.lower()
    return [t for t in TERRITORIES.values() if needle in t.jw_branch_region.lower()]
  • Step 4: Run tests

Run: .venv/bin/python -m pytest packages/jw-core/tests/test_territories_helpers.py -v

Expected: 6 passed.

  • Step 5: Commit
git add packages/jw-core/src/jw_core/territories.py packages/jw-core/tests/test_territories_helpers.py
git commit -m "feat(core): territory helpers — get_territory_full, by_status, by_branch (F82.0 task 7)"

Task 8: Validador CI — todo ISO existe + tiene LocaleContext

Files:

  • Create: packages/jw-core/tests/test_territories_iso_validation.py

Interfaces:

  • Consumes: TERRITORIES, pycountry, get_locale.

  • Produces: 3 invariant tests.

  • Step 1: Write the test (no implementation needed, just enforcement)

# packages/jw-core/tests/test_territories_iso_validation.py
"""Invariants enforced by CI on every Territory entry."""

from __future__ import annotations

import pycountry
import pytest

from jw_core.data.locale_context import get_locale
from jw_core.territories import TERRITORIES


@pytest.mark.parametrize("iso,territory", list(TERRITORIES.items()))
def test_iso_is_valid_alpha2(iso: str, territory) -> None:
    assert pycountry.countries.get(alpha_2=iso) is not None, (
        f"Territory key {iso!r} is not a valid ISO 3166-1 alpha-2 code"
    )


@pytest.mark.parametrize("iso,territory", list(TERRITORIES.items()))
def test_every_territory_has_locale_context(iso: str, territory) -> None:
    locale = get_locale(iso)
    assert locale is not None, (
        f"Territory {iso!r} has no LocaleContext entry — extend LOCALE_CONTEXTS"
    )


@pytest.mark.parametrize("iso,territory", list(TERRITORIES.items()))
def test_jw_branch_region_non_empty(iso: str, territory) -> None:
    assert territory.jw_branch_region, f"Territory {iso!r} has empty jw_branch_region"
  • Step 2: Run tests

Run: .venv/bin/python -m pytest packages/jw-core/tests/test_territories_iso_validation.py -v

Expected: 90 tests pass (30 territories × 3 invariants).

  • Step 3: Commit
git add packages/jw-core/tests/test_territories_iso_validation.py
git commit -m "test(core): enforce ISO + LocaleContext + branch invariants on all territories (F82.0 task 8)"

Task 9: CLI smoke + documentación

Files:

  • Create: docs/guias/territories.md
  • Modify: docs/ROADMAP.md (marcar F82.0 ✅)

Interfaces:

  • Consumes: nothing.

  • Produces: guía de cómo añadir un país nuevo.

  • Step 1: Write the guide

<!-- docs/guias/territories.md -->
# Catálogo `Territory` (jw-core)

`jw_core.territories.Territory` aporta la dimensión **legal** de un país
(`jw_branch_region`, `legal_status_summary`, `ban_history`). Lo
**cultural/idiomático** vive en `jw_core.data.locale_context.LocaleContext`
y se referencia por `iso_3166`. **No duplicamos campos** entre los dos.

## Lookup

```python
from jw_core.territories import get_territory, get_territory_full

t = get_territory("RU")
print(t.legal_status_summary)         # "banned"
print(t.ban_history)                  # ("2017-04-20: Supreme Court ...", ...)
print(t.locale.localized_name("es"))  # "Rusia"

# Combinado en un dict para agentes legales (F82.3+):
full = get_territory_full("RU")
print(full["name"]["en"])             # "Russia"
print(full["jw_branch_region"])       # "Russia (closed since 2017)"

Filtros

from jw_core.territories import territories_by_status, territories_by_branch

banned = territories_by_status("banned")
# → [Territory(iso_3166='RU', ...), Territory(iso_3166='KP', ...), ...]

russia_region = territories_by_branch("Russia")
# → [Territory(iso_3166='RU', ...)]

Añadir un país nuevo

  1. Verificar que existe en LocaleContext. Si no, añadir entry mínima con iso_3166, name multilang y languages:
    "XX": LocaleContext(
        iso_3166="XX",
        name={"en": "Foo", "es": "Foo", "pt": "Foo"},
        languages=("foo",),
        dominant_religions=("...",),
    ),
  2. Añadir Territory con la dimensión legal:
    "XX": Territory(
        iso_3166="XX",
        jw_branch_region="...",
        legal_status_summary="free",
        ban_history=(
            # Source: jw.org/en/news/legal/by-region/foo/
            "YYYY-MM-DD: descripción de cada evento clave",
        ),
    ),
  3. Cada entrada de ban_history lleva comentario inline con la URL o referencia a la publicación JW. Cero entries sin fuente.
  4. uv run pytest packages/jw-core/tests/test_territories_iso_validation.py -v confirma que las invariantes ISO + LocaleContext + branch pasan.

Lo que no va en Territory

Si vas a añadir un campo nuevo, primero pregúntate: ¿es cultural (idioma, religión, festividades, sensibilidades sociales)? Ese campo va en LocaleContext. ¿Es legal (ley, ban, sentencia, tribunal)? Va en Territory. Si no encaja en ninguna categoría, probablemente no es infra compartida — pertenece al plugin que la necesita.

  • F82.1jw-legal BrainDomain usa Territory como nodo del grafo.
  • F82.2HUDOCSource mapea sentencias por Territory.iso_3166.
  • F82.3legal_case_researcher filtra por país usando ISO.
  • Futuro — news_monitor filtra noticias por legal_status_summary.

- [ ] **Step 2: Marcar F82.0 ✅ en ROADMAP**

Edit `docs/ROADMAP.md`, línea de F82.0:

```markdown
- ⬜ **F82.0 — catálogo `Territory`** (1 semana): ISO 3166-1 +
  `jw_branch_region` + `ban_history`. ≥200 territorios, ≥30 con
  `ban_history` poblado.

cambiar por + añadir (entregado YYYY-MM-DD). Actualizar contadores si listas finales (30 territorios entregados).

  • Step 3: Smoke test global

Run: .venv/bin/python -m pytest packages/jw-core/tests/test_territories_*.py packages/jw-core/tests/test_locale_context_extensions.py -v

Expected: todos pass.

  • Step 4: Suite global (sin regresiones)

Run: .venv/bin/python -m pytest

Expected: 2 716 baseline + ~100 nuevos tests = >2 800, todos pass.

  • Step 5: Ruff + mypy del package

Run: .venv/bin/python -m ruff check packages/jw-core/src/jw_core/territories.py packages/jw-core/src/jw_core/data/locale_context.py

Run: .venv/bin/python -m mypy packages/jw-core/src/jw_core/territories.py

Expected: ambos limpios.

  • Step 6: Commit
git add docs/guias/territories.md docs/ROADMAP.md
git commit -m "docs(core): territory catalog guide + mark F82.0 delivered (F82.0 task 9)"

Self-review (al cerrar el plan)

CheckResultado esperado
9 tasks, cada una con TDD red→green→commit
30 territorios poblados con ban_history verificable
100% de Territory.iso_3166 tiene LocaleContext✅ (Task 8 enforce)
0 duplicación de campos LocaleContextTerritory✅ (Task 3 + Task 7 enforce)
ISO 3166-1 alpha-2 válidos✅ (Task 8 con pycountry)
Sources documentadas inline para cada ban_history entry
Tests verdes (~100 nuevos sobre la suite)
0 regresiones en 2716 tests baseline
pycountry añadido como dep de jw-core
Guía operativa para añadir país nuevo

Cómo verificar al cerrar

# 1. Sincronizar
uv sync --all-packages

# 2. Suite de F82.0
.venv/bin/python -m pytest packages/jw-core/tests/test_territories_*.py \
                            packages/jw-core/tests/test_locale_context_extensions.py -v

# 3. Ruff + mypy
.venv/bin/python -m ruff check packages/jw-core/src/jw_core/territories.py packages/jw-core/src/jw_core/data/locale_context.py
.venv/bin/python -m mypy packages/jw-core/src/jw_core/territories.py

# 4. Suite global (sin regresiones)
.venv/bin/python -m pytest

# 5. Smoke Python
.venv/bin/python -c "
from jw_core.territories import get_territory, get_territory_full, territories_by_status
print('RU:', get_territory('RU'))
print('Banned:', [t.iso_3166 for t in territories_by_status('banned')])
print('Full RU:', get_territory_full('RU'))
"

Editar esta página en docs/superpowers/plans/2026-06-17-fase-82-0-territory-catalog-plan.md