From 1efb66328dfb05c308e196e1ddb1b3212910882e Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 22:02:54 -0400 Subject: [PATCH] Add curated New Zealand bundle suite Add --suite nz with all 48 NZ source packages, including the 36 Census 2023 place-summary members. Guard the roster against alias drift and refuse explicit sources with either curated suite while preserving UK error text. Route the NZ Treasury's raw and derived artifact keys through the NZ country prefix, matching its existing manifests. Document year-independent NZ declarations and preserved publisher reference periods. Co-Authored-By: Claude Opus 5.5 Co-Authored-By: GPT-6.1 Sol --- README.md | 18 ++ changelog.d/nz-bundle-suite.added.md | 8 + changelog.d/nz-treasury-r2-prefix.fixed.md | 4 + chronicle/artifacts.py | 2 +- chronicle/bundle.py | 135 +++++++++++++-- chronicle/harness.py | 15 +- docs/agent-source-package-harness.md | 6 + tests/test_chronicle_artifacts.py | 46 +++++ tests/test_chronicle_bundle.py | 130 ++++++++++++++ tests/test_source_package_alias_drift.py | 189 +++++++++++++++++++++ 10 files changed, 534 insertions(+), 19 deletions(-) create mode 100644 changelog.d/nz-bundle-suite.added.md create mode 100644 changelog.d/nz-treasury-r2-prefix.fixed.md diff --git a/README.md b/README.md index 6673753c..d62c5f28 100644 --- a/README.md +++ b/README.md @@ -309,6 +309,24 @@ adopt the facts-only surface explicitly. `--year` is inert for `--suite uk` because the UK packages are year-pinned. The US off-year bundle behavior is unchanged and out of scope here. +The New Zealand feed builds the same way from the curated NZ suite: + +```bash +uv run chronicle build-bundle --suite nz --out /tmp/chronicle-nz --replace +uv run chronicle build-consumer-artifact --facts /tmp/chronicle-nz --out /tmp/chronicle-nz-artifact --replace +``` + +`NZ_BUNDLE_SOURCES` in `chronicle/bundle.py` lists every package under +`packages/ird`, `packages/mbie`, `packages/msd`, `packages/stats_nz` and +`packages/treasury`, including the national, regional-council and +area-outside-region (`rc_99`) Census 2023 place-summary packages; a test +fails if an alias under those directories is added or dropped without +updating it. `--year` is inert for +`--suite nz` too: every NZ package pins its `artifact_year` and uses +year-independent declarations, so one build at any `--year` emits its facts with +the publisher's own calendar, tax, fiscal-year or monthly period. `--suite` +cannot be combined with `--source`. + Builds without an Axiom CLI still pass when the source package is otherwise valid, but `agent_acceptance.json` warns with `concept_alignment_validation_skipped`. For strict agent review, require every diff --git a/changelog.d/nz-bundle-suite.added.md b/changelog.d/nz-bundle-suite.added.md new file mode 100644 index 00000000..d38fbd6c --- /dev/null +++ b/changelog.d/nz-bundle-suite.added.md @@ -0,0 +1,8 @@ +Add `chronicle build-bundle --suite nz`, a curated New Zealand bundle suite for +Microcosm's NZ feed. It builds all 48 packages under `packages/ird`, +`packages/mbie`, `packages/msd`, `packages/stats_nz` and `packages/treasury`, +including the 36 national, regional-council and area-outside-region Census +2023 place-summary packages. A drift guard fails when an alias under those +directories is added or dropped without updating the +suite. Like `--suite uk`, it refuses `--source`, and `--year` does not change +what it builds because every NZ package pins its artifact year. diff --git a/changelog.d/nz-treasury-r2-prefix.fixed.md b/changelog.d/nz-treasury-r2-prefix.fixed.md new file mode 100644 index 00000000..10260f7d --- /dev/null +++ b/changelog.d/nz-treasury-r2-prefix.fixed.md @@ -0,0 +1,4 @@ +Route `packages/treasury` and `db/data/treasury` to the `raw/nz` and +`derived/nz` R2 prefixes, so `verify-raw` and `publish-raw` compute the +`raw/nz/treasury/...` keys the two NZ Treasury manifests declare instead of +`raw/treasury/...`. diff --git a/chronicle/artifacts.py b/chronicle/artifacts.py index 9b5b5444..691eb2d7 100644 --- a/chronicle/artifacts.py +++ b/chronicle/artifacts.py @@ -35,7 +35,7 @@ # and ``derived/{source_id}`` shapes. Publisher directories are the stable # routing input because they are shared by packages/ and db/data/. R2_COUNTRY_PUBLISHERS = { - "nz": frozenset({"ird", "mbie", "msd", "stats_nz"}), + "nz": frozenset({"ird", "mbie", "msd", "stats_nz", "treasury"}), "uk": frozenset( { "desnz", diff --git a/chronicle/bundle.py b/chronicle/bundle.py index b79b5914..782c1775 100644 --- a/chronicle/bundle.py +++ b/chronicle/bundle.py @@ -291,6 +291,72 @@ "welshgov-household-projections-2022-based", "welshgov-transport-revenue-outturn-2024-25", ) +# Every package under these publisher directories is a New Zealand package: +# Inland Revenue, MBIE, MSD, Stats NZ and the New Zealand Treasury. "treasury" +# names no country, so a non-NZ treasury package must not be added under +# packages/treasury/; the drift guard would then pull it into the NZ suite. +NZ_BUNDLE_SOURCE_PREFIXES = ( + "ird", + "mbie", + "msd", + "stats_nz", + "treasury", +) +# The curated NZ suite (`build-bundle --suite nz`). Each member pins one +# artifact_year and declares nothing by year, so it reads the same file and +# emits the same facts at any --year; the suite's periods (calendar, tax, +# fiscal and monthly) are the publishers' own, whatever --year is passed. +NZ_BUNDLE_SOURCES = ( + "ird-taxable-income-distribution-2025", + "ird-wage-salary-distribution-2026", + "ird-working-for-families-statistics-sept-2025", + "mbie-tenancy-bond-rents-tla-2026", + "msd-annual-report-benefit-expenses-2025", + "msd-benefit-fact-sheets-national-june-2026", + "msd-benefit-fact-sheets-supplementary-june-2026", + "msd-nzs-vp-fact-sheet-june-2026", + "stats-nz-census-2023-family-type", + "stats-nz-census-2023-family-type-rc-01", + "stats-nz-census-2023-family-type-rc-02", + "stats-nz-census-2023-family-type-rc-03", + "stats-nz-census-2023-family-type-rc-04", + "stats-nz-census-2023-family-type-rc-05", + "stats-nz-census-2023-family-type-rc-06", + "stats-nz-census-2023-family-type-rc-07", + "stats-nz-census-2023-family-type-rc-08", + "stats-nz-census-2023-family-type-rc-09", + "stats-nz-census-2023-family-type-rc-12", + "stats-nz-census-2023-family-type-rc-13", + "stats-nz-census-2023-family-type-rc-14", + "stats-nz-census-2023-family-type-rc-15", + "stats-nz-census-2023-family-type-rc-16", + "stats-nz-census-2023-family-type-rc-17", + "stats-nz-census-2023-family-type-rc-18", + "stats-nz-census-2023-family-type-rc-99", + "stats-nz-census-2023-households-by-region", + "stats-nz-census-2023-households-by-region-rc-01", + "stats-nz-census-2023-households-by-region-rc-02", + "stats-nz-census-2023-households-by-region-rc-03", + "stats-nz-census-2023-households-by-region-rc-04", + "stats-nz-census-2023-households-by-region-rc-05", + "stats-nz-census-2023-households-by-region-rc-06", + "stats-nz-census-2023-households-by-region-rc-07", + "stats-nz-census-2023-households-by-region-rc-08", + "stats-nz-census-2023-households-by-region-rc-09", + "stats-nz-census-2023-households-by-region-rc-12", + "stats-nz-census-2023-households-by-region-rc-13", + "stats-nz-census-2023-households-by-region-rc-14", + "stats-nz-census-2023-households-by-region-rc-15", + "stats-nz-census-2023-households-by-region-rc-16", + "stats-nz-census-2023-households-by-region-rc-17", + "stats-nz-census-2023-households-by-region-rc-18", + "stats-nz-census-2023-households-by-region-rc-99", + "stats-nz-household-net-worth-2024", + "stats-nz-subnational-population-estimates-2025", + "treasury-an24-01-fiscal-totals", + "treasury-estimates-of-appropriations-2026-27", +) +BUNDLE_SUITES = ("nz", "uk") _KEY_DOMAINS = { "aggregate_fact_key": "aggregate_fact", @@ -304,29 +370,76 @@ } -def uk_bundle_sources_from_aliases() -> tuple[str, ...]: - """Return UK-package aliases implied by the source-package directory prefixes.""" +def _prefixed_aliases(prefixes: Sequence[str]) -> tuple[str, ...]: + """Return the sorted aliases whose package directory sits under ``prefixes``.""" return tuple( sorted( alias for alias, path in SOURCE_PACKAGE_ALIASES.items() - if path.parts and path.parts[0] in UK_BUNDLE_SOURCE_PREFIXES + if path.parts and path.parts[0] in prefixes ) ) -def assert_uk_bundle_sources_match_aliases() -> None: - """Fail loudly if the curated UK suite omits a UK-prefixed alias.""" - expected = uk_bundle_sources_from_aliases() - if UK_BUNDLE_SOURCES != expected: - missing = sorted(set(expected) - set(UK_BUNDLE_SOURCES)) - extra = sorted(set(UK_BUNDLE_SOURCES) - set(expected)) +def _assert_suite_matches_aliases( + country: str, + sources: tuple[str, ...], + expected: tuple[str, ...], +) -> None: + if sources != expected: + missing = sorted(set(expected) - set(sources)) + extra = sorted(set(sources) - set(expected)) raise ValueError( - "UK_BUNDLE_SOURCES drifted from UK-prefixed SOURCE_PACKAGE_ALIASES: " - f"missing={missing}, extra={extra}" + f"{country}_BUNDLE_SOURCES drifted from {country}-prefixed " + f"SOURCE_PACKAGE_ALIASES: missing={missing}, extra={extra}" ) +def uk_bundle_sources_from_aliases() -> tuple[str, ...]: + """Return UK-package aliases implied by the source-package directory prefixes.""" + return _prefixed_aliases(UK_BUNDLE_SOURCE_PREFIXES) + + +def assert_uk_bundle_sources_match_aliases() -> None: + """Fail loudly if the curated UK suite omits a UK-prefixed alias.""" + _assert_suite_matches_aliases( + "UK", UK_BUNDLE_SOURCES, uk_bundle_sources_from_aliases() + ) + + +def nz_bundle_sources_from_aliases() -> tuple[str, ...]: + """Return NZ-package aliases implied by the source-package directory prefixes. + + Nested packages count: each Census 2023 place-summary family has a national + root, 16 regional-council ``rc_XX`` children and an area-outside-region + ``rc_99`` child. + + ``--year`` does not select or relabel NZ facts: every member pins its + ``artifact_year`` and uses year-independent declarations. A single build + at any year preserves all members' published calendar, tax, fiscal and + monthly reference periods. + """ + return _prefixed_aliases(NZ_BUNDLE_SOURCE_PREFIXES) + + +def assert_nz_bundle_sources_match_aliases() -> None: + """Fail loudly if the curated NZ suite omits or adds an NZ-prefixed alias.""" + _assert_suite_matches_aliases( + "NZ", NZ_BUNDLE_SOURCES, nz_bundle_sources_from_aliases() + ) + + +def bundle_suite_sources(suite: str) -> tuple[str, ...]: + """Return a curated suite's sources once they match its prefixed aliases.""" + if suite == "uk": + assert_uk_bundle_sources_match_aliases() + return UK_BUNDLE_SOURCES + if suite == "nz": + assert_nz_bundle_sources_match_aliases() + return NZ_BUNDLE_SOURCES + raise ValueError(f"Unknown bundle suite: {suite!r}") + + @dataclass(frozen=True) class BuildBundleIssue: """One bundle-level build issue.""" diff --git a/chronicle/harness.py b/chronicle/harness.py index 3ca4ee74..22832851 100644 --- a/chronicle/harness.py +++ b/chronicle/harness.py @@ -22,10 +22,10 @@ verify_raw_artifacts, ) from chronicle.bundle import ( - UK_BUNDLE_SOURCES, + BUNDLE_SUITES, BuildBundleReport, - assert_uk_bundle_sources_match_aliases, build_bundle, + bundle_suite_sources, ) from chronicle.concepts import ConceptAlignmentReport, validate_concept_alignments from chronicle.consumer_contract import ( @@ -276,11 +276,12 @@ def build_bundle_dir( replace: bool = False, ) -> BuildBundleReport: """Build a merged Chronicle consumer bundle from source-package suites.""" - if suite == "uk": + if suite is not None: + if suite not in BUNDLE_SUITES: + raise ValueError(f"Unknown bundle suite: {suite!r}") if sources: - raise ValueError("--suite uk cannot be combined with --source.") - assert_uk_bundle_sources_match_aliases() - sources = list(UK_BUNDLE_SOURCES) + raise ValueError(f"--suite {suite} cannot be combined with --source.") + sources = list(bundle_suite_sources(suite)) return build_bundle( output_dir, year=year, @@ -744,7 +745,7 @@ def main(argv: list[str] | None = None) -> int: ) bundle_parser.add_argument( "--suite", - choices=["uk"], + choices=list(BUNDLE_SUITES), help="Curated source-package suite to build.", ) bundle_parser.add_argument( diff --git a/docs/agent-source-package-harness.md b/docs/agent-source-package-harness.md index c8cec774..41f540cb 100644 --- a/docs/agent-source-package-harness.md +++ b/docs/agent-source-package-harness.md @@ -672,6 +672,12 @@ uv run chronicle build-consumer-artifact --facts /tmp/chronicle-uk --out /tmp/ch `--year` is inert for `--suite uk` because the UK packages are year-pinned. The US off-year bundle behavior is unchanged and out of scope here. +`--suite nz` builds the curated New Zealand suite (every package under +`packages/ird`, `packages/mbie`, `packages/msd`, `packages/stats_nz` and +`packages/treasury`) the same way, and +`--year` is inert for it as well: each NZ package pins its `artifact_year` and +uses year-independent declarations. + The first agent-facing gate is now `/tmp/chronicle-suite/reports/agent_acceptance.json`; it summarizes whether raw artifacts have R2 pointers, the full source document was parsed, facts have diff --git a/tests/test_chronicle_artifacts.py b/tests/test_chronicle_artifacts.py index 409f0be2..6d26bb98 100644 --- a/tests/test_chronicle_artifacts.py +++ b/tests/test_chronicle_artifacts.py @@ -12,6 +12,7 @@ from chronicle.cli import main as cli_main from chronicle.artifacts import ( + R2_COUNTRY_PUBLISHERS, build_artifact_key, build_artifact_rows, build_derived_r2_key, @@ -24,8 +25,10 @@ publish_derived_artifacts, publish_source_artifacts, ) +from chronicle.bundle import NZ_BUNDLE_SOURCE_PREFIXES, NZ_BUNDLE_SOURCES from chronicle.epoch import Epoch from chronicle.harness import main as harness_main +from chronicle.source_package import load_source_package REPOSITORY_ROOT = Path(__file__).resolve().parents[1] @@ -97,6 +100,12 @@ def test_build_artifact_key_rejects_unknown_build_epoch(): ("source_id", "package_path", "expected_country"), [ ("ird", "db/data/ird/wff", "nz"), + ("treasury", "db/data/treasury/an24_01_fiscal_totals", "nz"), + ( + "treasury", + "/repo/packages/treasury/estimates_of_appropriations_2026_27", + "nz", + ), ("desnz", "db/data/desnz/qep", "uk"), ("ons", "db/data/ons/mye", "uk"), ("irs_soi", "db/data/irs_soi/table_1_1", None), @@ -147,6 +156,43 @@ def test_country_aware_r2_keys_preserve_legacy_us_layout(): assert desnz_key == "raw/uk/desnz/desnz-qep/2026/def456/qep.xlsx" +def test_every_nz_suite_manifest_declares_its_default_canonical_raw_key(): + """Every NZ suite artifact sits under raw/nz, Treasury's included. + + verify-raw and publish-raw route a package to raw/nz by its publisher + directory, so every NZ suite publisher must be an NZ R2 publisher. + """ + assert R2_COUNTRY_PUBLISHERS["nz"] == frozenset(NZ_BUNDLE_SOURCE_PREFIXES) + manifests = set() + for alias in NZ_BUNDLE_SOURCES: + artifact = load_source_package(alias).artifact + manifests.add( + REPOSITORY_ROOT + / artifact.resource_package + / artifact.resource_directory + / artifact.manifest + ) + + checked = [] + for manifest_path in sorted(manifests): + manifest = yaml.safe_load(manifest_path.read_text()) + for year, artifact in manifest["files"].items(): + expected_key = build_r2_key( + source_id=manifest["source_id"], + package_id=manifest["package_id"], + year=year, + sha256=artifact["sha256"], + filename=artifact["filename"], + package_path=manifest_path, + ) + assert expected_key.startswith("raw/nz/") + assert artifact["storage"]["r2"]["key"] == expected_key, manifest_path + checked.append(manifest["source_id"]) + + assert set(checked) == set(NZ_BUNDLE_SOURCE_PREFIXES) + assert checked.count("treasury") == 4 + + def test_all_desnz_manifests_use_canonical_uk_r2_keys_and_pinned_bytes(): manifests = sorted(DESNZ_DATA_ROOT.glob("*/manifest.yaml")) diff --git a/tests/test_chronicle_bundle.py b/tests/test_chronicle_bundle.py index d12cde82..29dee597 100644 --- a/tests/test_chronicle_bundle.py +++ b/tests/test_chronicle_bundle.py @@ -14,6 +14,7 @@ BUNDLE_COVERAGE_SCHEMA_VERSION, BUNDLE_SCHEMA_VERSION, BUNDLE_SOURCES_SCHEMA_VERSION, + NZ_BUNDLE_SOURCES, UK_BUNDLE_SOURCES, build_bundle, build_bundle_coverage, @@ -164,6 +165,135 @@ def fake_build_bundle_dir(output_dir, **kwargs): assert captured["suite"] == "uk" +class _FakeBundleReport: + valid = True + + def to_dict(self): + return {"valid": True} + + +@pytest.fixture +def fake_build_bundle(monkeypatch): + """Record what build_bundle_dir asks build_bundle to build, without building.""" + calls = [] + + def record(output_dir, **kwargs): + calls.append({"output_dir": output_dir, **kwargs}) + return _FakeBundleReport() + + monkeypatch.setattr("chronicle.harness.build_bundle", record) + return calls + + +def test_build_bundle_dir_nz_suite_uses_curated_sources(tmp_path, fake_build_bundle): + report = build_bundle_dir(tmp_path / "bundle", year=2023, suite="nz") + + assert report.valid + [call] = fake_build_bundle + assert tuple(call["sources"]) == NZ_BUNDLE_SOURCES + assert call["output_dir"] == tmp_path / "bundle" + assert call["year"] == 2023 + assert "treasury-an24-01-fiscal-totals" in call["sources"] + assert "treasury-estimates-of-appropriations-2026-27" in call["sources"] + assert "stats-nz-census-2023-households-by-region-rc-99" in call["sources"] + assert not set(call["sources"]) & set(UK_BUNDLE_SOURCES) + + +@pytest.mark.parametrize( + "suite,roster", + [ + pytest.param("nz", NZ_BUNDLE_SOURCES, id="nz"), + pytest.param("uk", UK_BUNDLE_SOURCES, id="uk"), + ], +) +def test_build_bundle_cli_builds_each_curated_suite( + tmp_path, fake_build_bundle, suite, roster +): + status = harness_main( + ["build-bundle", "--suite", suite, "--out", str(tmp_path / "bundle")] + ) + + assert status == 0 + [call] = fake_build_bundle + assert tuple(call["sources"]) == roster + assert call["year"] == 2023 + + +@pytest.mark.parametrize("suite", ["nz", "uk"]) +def test_build_bundle_dir_refuses_a_suite_with_explicit_sources( + tmp_path, fake_build_bundle, suite +): + with pytest.raises(ValueError) as excinfo: + build_bundle_dir( + tmp_path / "bundle", + year=2023, + suite=suite, + sources=["ird-working-for-families-statistics-sept-2025"], + ) + + assert str(excinfo.value) == f"--suite {suite} cannot be combined with --source." + assert fake_build_bundle == [] + + +@pytest.mark.parametrize("suite", ["nz", "uk"]) +def test_build_bundle_cli_refuses_a_suite_with_explicit_sources( + tmp_path, fake_build_bundle, suite +): + argv = [ + "build-bundle", + "--suite", + suite, + "--source", + "treasury-an24-01-fiscal-totals", + "--out", + str(tmp_path / "bundle"), + ] + + with pytest.raises(ValueError, match=f"^--suite {suite} cannot be combined"): + harness_main(argv) + + assert fake_build_bundle == [] + + +def test_build_bundle_dir_nz_suite_refuses_a_drifted_roster( + tmp_path, fake_build_bundle, monkeypatch +): + monkeypatch.setattr( + chronicle.bundle, + "NZ_BUNDLE_SOURCES", + tuple( + alias + for alias in NZ_BUNDLE_SOURCES + if alias != "stats-nz-census-2023-family-type-rc-12" + ), + ) + + with pytest.raises( + ValueError, + match=r"^NZ_BUNDLE_SOURCES drifted .*" + r"missing=\['stats-nz-census-2023-family-type-rc-12'\], extra=\[\]$", + ): + build_bundle_dir(tmp_path / "bundle", year=2023, suite="nz") + + assert fake_build_bundle == [] + + +def test_build_bundle_dir_rejects_an_unknown_suite(tmp_path, fake_build_bundle): + with pytest.raises(ValueError, match="Unknown bundle suite: 'au'"): + build_bundle_dir(tmp_path / "bundle", year=2023, suite="au") + + assert fake_build_bundle == [] + + +def test_build_bundle_cli_rejects_an_unknown_suite(tmp_path, fake_build_bundle, capsys): + with pytest.raises(SystemExit) as excinfo: + harness_main(["build-bundle", "--suite", "au", "--out", str(tmp_path)]) + + assert excinfo.value.code == 2 + assert "invalid choice: 'au'" in capsys.readouterr().err + assert fake_build_bundle == [] + + @pytest.fixture def prune_suite_intermediates(monkeypatch): """Delete each source suite's intermediates once the suite is built. diff --git a/tests/test_source_package_alias_drift.py b/tests/test_source_package_alias_drift.py index bd26c21c..d0720a20 100644 --- a/tests/test_source_package_alias_drift.py +++ b/tests/test_source_package_alias_drift.py @@ -12,18 +12,57 @@ import pytest from pathlib import Path +import chronicle.bundle from chronicle.bundle import ( + NZ_BUNDLE_SOURCE_PREFIXES, + NZ_BUNDLE_SOURCES, UK_BUNDLE_SOURCE_PREFIXES, UK_BUNDLE_SOURCES, + assert_nz_bundle_sources_match_aliases, assert_uk_bundle_sources_match_aliases, build_bundle, + nz_bundle_sources_from_aliases, ) from chronicle.source_package import ( SOURCE_PACKAGE_ALIASES, SourcePackageAliasDriftError, + _declarations_depend_on_year, assert_alias_map_covers_packages, discover_source_package_dirs, find_alias_map_drift, + load_source_package, + source_package_root, +) + +# Stats NZ regional council codes (there is no 10 or 11) and 99, Area Outside +# Region: one child package per code under each Census 2023 place-summary root. +NZ_CENSUS_REGION_CODES = ( + "01", + "02", + "03", + "04", + "05", + "06", + "07", + "08", + "09", + "12", + "13", + "14", + "15", + "16", + "17", + "18", + "99", +) +NZ_CENSUS_ROOTS = ( + "stats-nz-census-2023-family-type", + "stats-nz-census-2023-households-by-region", +) +NZ_CENSUS_MEMBERS = tuple( + alias + for root in NZ_CENSUS_ROOTS + for alias in (root, *(f"{root}-rc-{code}" for code in NZ_CENSUS_REGION_CODES)) ) @@ -94,6 +133,156 @@ def test_uk_bundle_sources_match_uk_prefixed_aliases(): assert_uk_bundle_sources_match_aliases() +def test_uk_bundle_drift_message_is_unchanged(monkeypatch): + """Factoring the suites through one check keeps the UK error text.""" + monkeypatch.setattr( + chronicle.bundle, + "UK_BUNDLE_SOURCES", + (*UK_BUNDLE_SOURCES[1:], "zz-planted-uk-package"), + ) + + with pytest.raises(ValueError) as excinfo: + assert_uk_bundle_sources_match_aliases() + + assert str(excinfo.value) == ( + "UK_BUNDLE_SOURCES drifted from UK-prefixed SOURCE_PACKAGE_ALIASES: " + "missing=['desnz-energy-trends-domestic-electricity-2026'], " + "extra=['zz-planted-uk-package']" + ) + + +def test_nz_bundle_sources_match_nz_prefixed_aliases(): + """The NZ bundle suite must include every alias under an NZ source prefix.""" + expected = tuple( + sorted( + alias + for alias, path in SOURCE_PACKAGE_ALIASES.items() + if path.parts and path.parts[0] in NZ_BUNDLE_SOURCE_PREFIXES + ) + ) + + assert NZ_BUNDLE_SOURCE_PREFIXES == ("ird", "mbie", "msd", "stats_nz", "treasury") + assert len(NZ_BUNDLE_SOURCES) == 48 + assert NZ_BUNDLE_SOURCES == expected == nz_bundle_sources_from_aliases() + assert_nz_bundle_sources_match_aliases() + + +def test_nz_bundle_sources_include_every_census_place_summary_package(): + """All 36 Census 2023 place-summary packages, roots and rc_XX children.""" + assert len(NZ_CENSUS_MEMBERS) == len(set(NZ_CENSUS_MEMBERS)) == 36 + assert set(NZ_CENSUS_MEMBERS) <= set(NZ_BUNDLE_SOURCES) + census_in_suite = { + alias for alias in NZ_BUNDLE_SOURCES if alias.startswith("stats-nz-census-") + } + assert census_in_suite == set(NZ_CENSUS_MEMBERS) + for alias in NZ_CENSUS_MEMBERS: + root = next(root for root in NZ_CENSUS_ROOTS if alias.startswith(root)) + directory = Path("stats_nz") / root.removeprefix("stats-nz-").replace("-", "_") + suffix = alias.removeprefix(root) + expected = directory / f"rc_{suffix[-2:]}" if suffix else directory + assert SOURCE_PACKAGE_ALIASES[alias] == expected + + +def test_nz_bundle_sources_cover_every_nz_package_on_disk(): + """Every NZ source_package.yaml, at any depth, is a suite member.""" + root = source_package_root() + on_disk = { + manifest.parent.relative_to(root) + for prefix in NZ_BUNDLE_SOURCE_PREFIXES + for manifest in (root / prefix).rglob("source_package.yaml") + } + + assert len(on_disk) == 48 + assert on_disk == {SOURCE_PACKAGE_ALIASES[alias] for alias in NZ_BUNDLE_SOURCES} + + +def test_nz_and_uk_bundle_suites_are_disjoint(): + assert not set(NZ_BUNDLE_SOURCE_PREFIXES) & set(UK_BUNDLE_SOURCE_PREFIXES) + assert not set(NZ_BUNDLE_SOURCES) & set(UK_BUNDLE_SOURCES) + assert all( + SOURCE_PACKAGE_ALIASES[alias].parts[0] in NZ_BUNDLE_SOURCE_PREFIXES + for alias in NZ_BUNDLE_SOURCES + ) + + +@pytest.mark.parametrize( + "roster,missing,extra", + [ + pytest.param( + tuple( + alias + for alias in NZ_BUNDLE_SOURCES + if alias != "stats-nz-census-2023-households-by-region-rc-07" + ), + ["stats-nz-census-2023-households-by-region-rc-07"], + [], + id="drop-census-rc-member", + ), + pytest.param( + tuple( + alias + for alias in NZ_BUNDLE_SOURCES + if alias != "treasury-an24-01-fiscal-totals" + ), + ["treasury-an24-01-fiscal-totals"], + [], + id="drop-treasury-package", + ), + pytest.param( + tuple(sorted((*NZ_BUNDLE_SOURCES, "ons-mye-2025-uk"))), + [], + ["ons-mye-2025-uk"], + id="add-uk-package", + ), + ], +) +def test_nz_bundle_drift_guard_rejects_a_changed_roster( + monkeypatch, roster, missing, extra +): + monkeypatch.setattr(chronicle.bundle, "NZ_BUNDLE_SOURCES", roster) + + with pytest.raises(ValueError) as excinfo: + assert_nz_bundle_sources_match_aliases() + + assert str(excinfo.value) == ( + "NZ_BUNDLE_SOURCES drifted from NZ-prefixed SOURCE_PACKAGE_ALIASES: " + f"missing={missing}, extra={extra}" + ) + + +def test_nz_bundle_drift_guard_rejects_a_new_nz_alias(monkeypatch): + monkeypatch.setitem( + SOURCE_PACKAGE_ALIASES, "treasury-planted-package", Path("treasury/planted") + ) + + with pytest.raises(ValueError, match=r"missing=\['treasury-planted-package'\]"): + assert_nz_bundle_sources_match_aliases() + + +def test_nz_bundle_drift_guard_rejects_a_dropped_nz_alias(monkeypatch): + monkeypatch.delitem( + SOURCE_PACKAGE_ALIASES, "stats-nz-census-2023-family-type-rc-99" + ) + + with pytest.raises( + ValueError, match=r"extra=\['stats-nz-census-2023-family-type-rc-99'\]" + ): + assert_nz_bundle_sources_match_aliases() + + +def test_nz_bundle_members_use_year_independent_declarations(): + """``--year`` is inert for the NZ suite. + + A package that pins ``artifact_year`` reads that year's manifest file at + every build year, and one whose declarations render no ``{year}`` and use + no ``*_by_year`` mapping selects and labels the same cells at every year. + """ + for alias in NZ_BUNDLE_SOURCES: + package = load_source_package(alias) + assert package.artifact.artifact_year is not None, alias + assert not _declarations_depend_on_year(package), alias + + def test_discover_source_package_dirs_finds_real_packages(): """Discovery must return real package directories (prove it can find some).""" discovered = discover_source_package_dirs()