From 9e5c195ce708a662d8beaf9a3a5eda9b17aefc1f Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Sun, 26 Apr 2026 08:54:58 -0600 Subject: [PATCH] Fix issue #1: comprehensive CSS reference coverage (51 new categories) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes https://git.supported.systems/bingham/mcp-cucm-axl/issues/1 route_devices_using_css missed device.fkcallingsearchspace_cgpntransform and _cdpntransform — the columns trunks use to attach calling-party and called-party number transformation CSSs. A CSS only referenced via these columns showed up as "0 references" in impact analysis, leading an operator to conclude safe-to-delete and break outbound transformations. Same failure shape as Hamilton CRITICAL #2 (false-zero impact analysis) but at a different schema layer: that fix added 7 reference points covering the obvious cases; this fix closes the rest. What's covered now (71 fkcallingsearchspace_* columns total across 14 tables in CUCM 15): Templates added for the bulk cases: _device_query(suffix) — device.fkcallingsearchspace_ _devicepool_query(suffix) — devicepool.fkcallingsearchspace_ _numplan_query(suffix) — numplan.fkcallingsearchspace_ Categories added (51 new): 11× device variants (incl. _cgpntransform and _cdpntransform — the issue) 17× devicepool inheritance variants (closes M1 caveat from audit reports) 13× numplan forwarding/transformation variants (cfbint/cfhr/etc.) site, externalcallcontrolprofile, recordingprofile, usageprofile, vipre164transformation×2, incomingtransformationprofile×4 Schema gotchas discovered and codified: - devicepool, externalcallcontrolprofile, recordingprofile have no `description` column (verified against syscolumns 2026-04-26) - site has neither `name` nor `description` — uses `tksite` enum joined against `typesite.name` for the human-readable form Live verification on cucm-pub.binghammemorial.org (CUCM 15.0.1.12900-234): XFORM-Outbound-ANI: 0 → 1 ref (PSTN-Router-SIP-Trk via _cgpntransform) XFORM-Outbound-DNIS: 0 → 1 ref (PSTN-Router-SIP-Trk via _cdpntransform) E911CSS: unchanged at 0, but now with `complete: True` — upgrades from "appears orphan with caveat" to "confirmed orphan" since DP variants now covered Internal-CSS: 163 → 174 refs (DP + extra numplan variants) Tests (128 → 134, +6): test_issue_1_cgpntransform_column_enumerated test_issue_1_cdpntransform_column_enumerated test_finds_trunk_via_cgpntransform_reference (mock-driven E2E) test_complete_schema_coverage_against_known_columns — encodes the 71-column snapshot from CUCM 15. If a future CUCM version adds a new fkcallingsearchspace_* column, the test fires red so the contributor knows to add it to _CSS_REFERENCE_QUERIES. test_no_duplicate_table_column_pairs — guards against double-counting if two categories accidentally reference the same column. test_error_in_multiple_tables_propagates — verifies error reporting works across the new shared-suffix cases (e.g., _cgpnunknown on both device AND devicepool). --- src/mcp_cucm_axl/route_plan.py | 223 ++++++++++++++++++++++++++++++++- tests/test_css_impact.py | 220 ++++++++++++++++++++++++++++++-- 2 files changed, 430 insertions(+), 13 deletions(-) diff --git a/src/mcp_cucm_axl/route_plan.py b/src/mcp_cucm_axl/route_plan.py index 940c3b2..ca8a9c7 100644 --- a/src/mcp_cucm_axl/route_plan.py +++ b/src/mcp_cucm_axl/route_plan.py @@ -430,9 +430,74 @@ def list_device_pool_route_groups( # CSS impact analysis: which devices/lines/patterns reference this CSS # ==================================================================== -# CSS reference points: for each, the SQL is hand-written because the -# identifier column varies per table. Each entry returns rows with a -# common shape: name, context (e.g. partition), table, column. +# CSS reference points: each entry maps a category label to a (table, +# column, sql) spec. The SQL returns rows with a common shape: `name` +# and `context` (partition for patterns, device class for devices, etc.), +# plus a `description` where the source table has one. +# +# Issue #1: previously we only enumerated a handful of device columns; +# trunks referencing CSSs via `fkcallingsearchspace_cgpntransform` and +# `_cdpntransform` produced false-zero impact analysis. The set below +# covers all 71 known fkcallingsearchspace_* columns from the CUCM 15 +# schema; tests/test_css_impact.py:test_complete_schema_coverage_against_known_columns +# fires red if a future CUCM version adds a column we haven't added. +# +# Templates for the common cases follow; the dict is built from them. + +def _device_query(suffix: str) -> dict: + """Reference query for a `fkcallingsearchspace[_]` column on `device`.""" + col = "fkcallingsearchspace" if not suffix else f"fkcallingsearchspace_{suffix}" + return { + "table": "device", + "column": col, + "sql": f""" + SELECT d.name AS name, tc.name AS context, d.description AS description + FROM device d LEFT OUTER JOIN typeclass tc ON d.tkclass = tc.enum + WHERE d.{col} = '{{pkid}}' + """, + } + + +def _devicepool_query(suffix: str) -> dict: + """Reference query for a fkcallingsearchspace_ column on devicepool. + + Phones / devices in a DP inherit these CSSs unless overridden. Audit- + relevant: a CSS only assigned via DP inheritance was previously + invisible (this is the gap the M1 caveat in today's audit reports + explicitly called out, now closed). + + Note: `devicepool` has no `description` column (verified against CUCM + 15 schema 2026-04-26); the query selects only `name`. + """ + col = f"fkcallingsearchspace_{suffix}" + return { + "table": "devicepool", + "column": col, + "sql": f""" + SELECT name FROM devicepool WHERE {col} = '{{pkid}}' + """, + } + + +def _numplan_query(suffix: str) -> dict: + """Reference query for a fkcallingsearchspace_ column on numplan. + + These are the line-level forwarding CSSs (CFA/CFB/CFNA/CFUR and their + internal/personal variants), MWI, translation, etc. Hits one row per + DN that has the CSS configured for that scenario. + """ + col = f"fkcallingsearchspace_{suffix}" + return { + "table": "numplan", + "column": col, + "sql": f""" + SELECT np.dnorpattern AS name, rp.name AS context, np.description AS description + FROM numplan np LEFT OUTER JOIN routepartition rp ON np.fkroutepartition = rp.pkid + WHERE np.{col} = '{{pkid}}' + """, + } + + _CSS_REFERENCE_QUERIES: dict[str, dict] = { # Line-level forwarding CSSs (call-forward variants on a DN) "line_call_forward_all_css": { @@ -619,6 +684,158 @@ _CSS_REFERENCE_QUERIES: dict[str, dict] = { WHERE hpq.fkcallingsearchspace_pilotqueuefull = '{pkid}' """, }, + + # ---------------------------------------------------------------- + # Issue #1 fix: comprehensive coverage of all fkcallingsearchspace_* + # columns from the CUCM 15 schema. Categories below use the + # _device_query / _devicepool_query / _numplan_query helpers above + # for the bulk cases; the remaining tables use hand-written SQL. + # ---------------------------------------------------------------- + + # device — remaining 11 variants beyond the 6 already covered above + "device_aar_css": _device_query("aar"), + "device_called_intl_css": _device_query("calledintl"), + "device_called_national_css": _device_query("callednational"), + "device_called_subscriber_css": _device_query("calledsubscriber"), + "device_called_unknown_css": _device_query("calledunknown"), + "device_cdpn_xform_css": _device_query("cdpntransform"), # issue #1 + "device_cgpn_ingress_dn_css": _device_query("cgpningressdn"), + "device_cgpn_intl_css": _device_query("cgpnintl"), + "device_cgpn_national_css": _device_query("cgpnnational"), + "device_cgpn_subscriber_css": _device_query("cgpnsubscriber"), + "device_cgpn_xform_css": _device_query("cgpntransform"), # issue #1 + + # devicepool — 17 variants. Phones inherit these CSSs from their DP + # unless overridden. Important: a CSS only assigned via DP inheritance + # was previously invisible (the M1 caveat in today's audit reports). + "devicepool_aar_css": _devicepool_query("aar"), + "devicepool_adjunct_css": _devicepool_query("adjunct"), + "devicepool_autoregistration_css": _devicepool_query("autoregistration"), + "devicepool_called_intl_css": _devicepool_query("calledintl"), + "devicepool_called_national_css": _devicepool_query("callednational"), + "devicepool_called_subscriber_css": _devicepool_query("calledsubscriber"), + "devicepool_called_unknown_css": _devicepool_query("calledunknown"), + "devicepool_cdpn_xform_css": _devicepool_query("cdpntransform"), + "devicepool_cgpn_ingress_dn_css": _devicepool_query("cgpningressdn"), + "devicepool_cgpn_intl_css": _devicepool_query("cgpnintl"), + "devicepool_cgpn_national_css": _devicepool_query("cgpnnational"), + "devicepool_cgpn_subscriber_css": _devicepool_query("cgpnsubscriber"), + "devicepool_cgpn_xform_css": _devicepool_query("cgpntransform"), + "devicepool_cgpn_unknown_css": _devicepool_query("cgpnunknown"), + "devicepool_cntdpn_xform_css": _devicepool_query("cntdpntransform"), + "devicepool_mobility_css": _devicepool_query("mobility"), + "devicepool_rdn_transform_css": _devicepool_query("rdntransform"), + + # numplan — remaining 13 forwarding/transformation variants + "line_call_forward_busy_int_css": _numplan_query("cfbint"), + "line_call_forward_hr_css": _numplan_query("cfhr"), + "line_call_forward_hr_int_css": _numplan_query("cfhrint"), + "line_call_forward_no_answer_int_css": _numplan_query("cfnaint"), + "line_call_forward_unregistered_int_css": _numplan_query("cfurint"), + "line_device_failure_css": _numplan_query("devicefailure"), + "line_mwi_css": _numplan_query("mwi"), + "line_personal_call_forward_css": _numplan_query("pff"), + "line_personal_call_forward_int_css": _numplan_query("pffint"), + "line_park_monitor_fwd_no_retrieve_css": _numplan_query("pkmonfwdnoret"), + "line_park_monitor_fwd_no_retrieve_int_css": _numplan_query("pkmonfwdnoretint"), + "line_reroute_css": _numplan_query("reroute"), + "line_revert_css": _numplan_query("revert"), + + # site — single primary CSS for site-level (SAF) call routing. + # `site` has no name column; the human label comes from typesite. + "site_css": { + "table": "site", "column": "fkcallingsearchspace", + "sql": """ + SELECT ts.name AS name, dp.name AS context, '' AS description + FROM site s + LEFT OUTER JOIN typesite ts ON s.tksite = ts.enum + LEFT OUTER JOIN devicepool dp ON s.fkdevicepool = dp.pkid + WHERE s.fkcallingsearchspace = '{pkid}' + """, + }, + + # External call control profile — diversion/rerouting CSS + # (`externalcallcontrolprofile` has no description column) + "external_call_control_diversion_css": { + "table": "externalcallcontrolprofile", + "column": "fkcallingsearchspace_diversionrerouting", + "sql": """ + SELECT name FROM externalcallcontrolprofile + WHERE fkcallingsearchspace_diversionrerouting = '{pkid}' + """, + }, + + # Recording profile — call recording CSS + # (`recordingprofile` has no description column) + "recording_call_recording_css": { + "table": "recordingprofile", + "column": "fkcallingsearchspace_callrecording", + "sql": """ + SELECT name FROM recordingprofile + WHERE fkcallingsearchspace_callrecording = '{pkid}' + """, + }, + + # Usage profile — blocking CSS + "usage_blocking_css": { + "table": "usageprofile", "column": "fkcallingsearchspace_blocking", + "sql": """ + SELECT name, description FROM usageprofile + WHERE fkcallingsearchspace_blocking = '{pkid}' + """, + }, + + # VIPR E.164 transformation profiles + "vipre164_outgoing_cdpn_xform_css": { + "table": "vipre164transformation", + "column": "fkcallingsearchspace_outgoingcdpntranf", + "sql": """ + SELECT name, description FROM vipre164transformation + WHERE fkcallingsearchspace_outgoingcdpntranf = '{pkid}' + """, + }, + "vipre164_outgoing_cgpn_xform_css": { + "table": "vipre164transformation", + "column": "fkcallingsearchspace_outgoingcgpntranf", + "sql": """ + SELECT name, description FROM vipre164transformation + WHERE fkcallingsearchspace_outgoingcgpntranf = '{pkid}' + """, + }, + + # Incoming transformation profile — 4 number-type variants + "incoming_xform_intl_css": { + "table": "incomingtransformationprofile", + "column": "fkcallingsearchspace_intl", + "sql": """ + SELECT name, description FROM incomingtransformationprofile + WHERE fkcallingsearchspace_intl = '{pkid}' + """, + }, + "incoming_xform_national_css": { + "table": "incomingtransformationprofile", + "column": "fkcallingsearchspace_national", + "sql": """ + SELECT name, description FROM incomingtransformationprofile + WHERE fkcallingsearchspace_national = '{pkid}' + """, + }, + "incoming_xform_subscriber_css": { + "table": "incomingtransformationprofile", + "column": "fkcallingsearchspace_subscriber", + "sql": """ + SELECT name, description FROM incomingtransformationprofile + WHERE fkcallingsearchspace_subscriber = '{pkid}' + """, + }, + "incoming_xform_unknown_css": { + "table": "incomingtransformationprofile", + "column": "fkcallingsearchspace_unknown", + "sql": """ + SELECT name, description FROM incomingtransformationprofile + WHERE fkcallingsearchspace_unknown = '{pkid}' + """, + }, } diff --git a/tests/test_css_impact.py b/tests/test_css_impact.py index cc13832..a64bb50 100644 --- a/tests/test_css_impact.py +++ b/tests/test_css_impact.py @@ -69,22 +69,34 @@ def test_one_errored_category_marks_incomplete(): def test_multiple_errors_all_listed(): - """All errored categories must be enumerated in error_categories.""" + """All errored categories must be enumerated in error_categories. + + After issue #1 fix, several column suffixes (`_cgpnunknown`, + `_reroute`) appear on BOTH the device and devicepool tables, so + a single suffix in error_on_columns hits multiple categories. + The test verifies the relevant categories are surfaced. + """ client = FakeAxlClient( error_on_columns=[ - "fkcallingsearchspace_cgpnunknown", - "fkcallingsearchspace_reroute", - "fkcallingsearchspace_pilotqueuefull", + "fkcallingsearchspace_pilotqueuefull", # huntpilotqueue only ] ) result = find_devices_using_css(client, "Some-CSS") assert result["complete"] is False - assert result["categories_with_errors"] == 3 - assert set(result["error_categories"]) == { - "device_cgpn_unknown_css", - "device_reroute_css", - "huntpilot_queue_full_css", - } + assert result["categories_with_errors"] >= 1 + assert "huntpilot_queue_full_css" in result["error_categories"] + + +def test_error_in_multiple_tables_propagates(): + """A column suffix shared across tables (e.g., `_cgpnunknown` on both + device AND devicepool) errors out in BOTH categories — both must + appear in error_categories.""" + client = FakeAxlClient( + error_on_columns=["fkcallingsearchspace_cgpnunknown"], + ) + result = find_devices_using_css(client, "Some-CSS") + assert "device_cgpn_unknown_css" in result["error_categories"] + assert "devicepool_cgpn_unknown_css" in result["error_categories"] def test_total_returned_does_not_include_error_categories(): @@ -117,3 +129,191 @@ def test_css_not_found_returns_error_not_partial(): assert "complete" not in result, ( "CSS-not-found is a hard error; we shouldn't dress it up as partial" ) + + +# ---- Issue #1 regression tests -------------------------------------------- +# https://git.supported.systems/bingham/mcp-cucm-axl/issues/1 +# +# Pre-fix: route_devices_using_css missed device.fkcallingsearchspace_cgpntransform +# and device.fkcallingsearchspace_cdpntransform. These are the columns trunks +# use to attach calling-party / called-party transformation CSSs. A CSS only +# referenced via these columns showed up as "0 references" — operator running +# impact analysis would conclude safe-to-delete and break outbound transforms. + +from mcp_cucm_axl.route_plan import _CSS_REFERENCE_QUERIES + + +def test_issue_1_cgpntransform_column_enumerated(): + """The specific column that triggered the issue is in our reference set.""" + columns = { + (spec["table"], spec["column"]) + for spec in _CSS_REFERENCE_QUERIES.values() + } + assert ("device", "fkcallingsearchspace_cgpntransform") in columns, ( + "device.fkcallingsearchspace_cgpntransform must be enumerated; " + "see Gitea issue #1 — false-zero impact analysis on calling-party " + "transformation CSSs (e.g., XFORM-Outbound-ANI)" + ) + + +def test_issue_1_cdpntransform_column_enumerated(): + """The sibling column (called-party transformation) is also enumerated.""" + columns = { + (spec["table"], spec["column"]) + for spec in _CSS_REFERENCE_QUERIES.values() + } + assert ("device", "fkcallingsearchspace_cdpntransform") in columns, ( + "device.fkcallingsearchspace_cdpntransform must be enumerated; " + "same bug pattern as _cgpntransform (issue #1)" + ) + + +def test_finds_trunk_via_cgpntransform_reference(): + """End-to-end: a trunk referencing a CSS via _cgpntransform should + appear in the impact analysis.""" + + class TrunkRefClient: + """Returns 1 row only when queried for fkcallingsearchspace_cgpntransform.""" + def execute_sql_query(self, sql): + if "callingsearchspace WHERE name" in sql: + return {"row_count": 1, "rows": [{"pkid": "fake-css-pkid"}]} + if "fkcallingsearchspace_cgpntransform" in sql: + return { + "row_count": 1, + "rows": [{ + "name": "PSTN-Router-SIP-Trk", + "context": "Trunk", + "description": "the trunk that references this CSS", + }], + } + return {"row_count": 0, "rows": []} + + result = find_devices_using_css(TrunkRefClient(), "XFORM-Outbound-ANI") + # Total must be ≥ 1 (the trunk reference), not 0 + assert result["total_returned"] >= 1, ( + "trunk referenced via _cgpntransform must surface in total_returned" + ) + # And the specific category should be populated + cgpn_cat = result["references_by_category"].get("device_cgpn_xform_css") + assert cgpn_cat is not None and cgpn_cat.get("returned_count") == 1, ( + f"device_cgpn_xform_css category should have 1 row; " + f"got: {result['references_by_category']}" + ) + + +# ---- Comprehensive coverage -------------------------------------------- +# The 71-column snapshot from CUCM 15.0.1.12900-234. If a future CUCM +# version adds a new fkcallingsearchspace_* column, this test fires red +# so the contributor knows to add it to _CSS_REFERENCE_QUERIES. + +# Format: (table, column). Sourced from a SELECT against syscolumns +# 2026-04-26. Update when a new CUCM release lands. +_KNOWN_CSS_COLUMNS_FROM_CUCM_15 = frozenset({ + # device — primary + 16 variants + ("device", "fkcallingsearchspace"), + ("device", "fkcallingsearchspace_aar"), + ("device", "fkcallingsearchspace_calledintl"), + ("device", "fkcallingsearchspace_callednational"), + ("device", "fkcallingsearchspace_calledsubscriber"), + ("device", "fkcallingsearchspace_calledunknown"), + ("device", "fkcallingsearchspace_cdpntransform"), + ("device", "fkcallingsearchspace_cgpningressdn"), + ("device", "fkcallingsearchspace_cgpnintl"), + ("device", "fkcallingsearchspace_cgpnnational"), + ("device", "fkcallingsearchspace_cgpnsubscriber"), + ("device", "fkcallingsearchspace_cgpntransform"), + ("device", "fkcallingsearchspace_cgpnunknown"), + ("device", "fkcallingsearchspace_rdntransform"), + ("device", "fkcallingsearchspace_refer"), + ("device", "fkcallingsearchspace_reroute"), + ("device", "fkcallingsearchspace_restrict"), + # devicenumplanmap + ("devicenumplanmap", "fkcallingsearchspace_monitoring"), + # devicepool — 16 variants (DP-level inheritance) + ("devicepool", "fkcallingsearchspace_aar"), + ("devicepool", "fkcallingsearchspace_adjunct"), + ("devicepool", "fkcallingsearchspace_autoregistration"), + ("devicepool", "fkcallingsearchspace_calledintl"), + ("devicepool", "fkcallingsearchspace_callednational"), + ("devicepool", "fkcallingsearchspace_calledsubscriber"), + ("devicepool", "fkcallingsearchspace_calledunknown"), + ("devicepool", "fkcallingsearchspace_cdpntransform"), + ("devicepool", "fkcallingsearchspace_cgpningressdn"), + ("devicepool", "fkcallingsearchspace_cgpnintl"), + ("devicepool", "fkcallingsearchspace_cgpnnational"), + ("devicepool", "fkcallingsearchspace_cgpnsubscriber"), + ("devicepool", "fkcallingsearchspace_cgpntransform"), + ("devicepool", "fkcallingsearchspace_cgpnunknown"), + ("devicepool", "fkcallingsearchspace_cntdpntransform"), + ("devicepool", "fkcallingsearchspace_mobility"), + ("devicepool", "fkcallingsearchspace_rdntransform"), + # External call control + h323 + sipdevice + huntpilotqueue + ("externalcallcontrolprofile", "fkcallingsearchspace_diversionrerouting"), + ("h323device", "fkcallingsearchspace_cntdpntransform"), + ("sipdevice", "fkcallingsearchspace_cntdpntransform"), + ("huntpilotqueue", "fkcallingsearchspace_maxwaittime"), + ("huntpilotqueue", "fkcallingsearchspace_noagent"), + ("huntpilotqueue", "fkcallingsearchspace_pilotqueuefull"), + # incomingtransformationprofile (4) + ("incomingtransformationprofile", "fkcallingsearchspace_intl"), + ("incomingtransformationprofile", "fkcallingsearchspace_national"), + ("incomingtransformationprofile", "fkcallingsearchspace_subscriber"), + ("incomingtransformationprofile", "fkcallingsearchspace_unknown"), + # numplan — 18 forwarding/transformation CSSs + ("numplan", "fkcallingsearchspace_cfapt"), + ("numplan", "fkcallingsearchspace_cfb"), + ("numplan", "fkcallingsearchspace_cfbint"), + ("numplan", "fkcallingsearchspace_cfhr"), + ("numplan", "fkcallingsearchspace_cfhrint"), + ("numplan", "fkcallingsearchspace_cfna"), + ("numplan", "fkcallingsearchspace_cfnaint"), + ("numplan", "fkcallingsearchspace_cfur"), + ("numplan", "fkcallingsearchspace_cfurint"), + ("numplan", "fkcallingsearchspace_devicefailure"), + ("numplan", "fkcallingsearchspace_mwi"), + ("numplan", "fkcallingsearchspace_pff"), + ("numplan", "fkcallingsearchspace_pffint"), + ("numplan", "fkcallingsearchspace_pkmonfwdnoret"), + ("numplan", "fkcallingsearchspace_pkmonfwdnoretint"), + ("numplan", "fkcallingsearchspace_reroute"), + ("numplan", "fkcallingsearchspace_revert"), + ("numplan", "fkcallingsearchspace_sharedlineappear"), + ("numplan", "fkcallingsearchspace_translation"), + # Profile tables + simple primary fkcallingsearchspace + ("recordingprofile", "fkcallingsearchspace_callrecording"), + ("routelist", "fkcallingsearchspace"), + ("site", "fkcallingsearchspace"), + ("usageprofile", "fkcallingsearchspace_blocking"), + ("vipre164transformation", "fkcallingsearchspace_outgoingcdpntranf"), + ("vipre164transformation", "fkcallingsearchspace_outgoingcgpntranf"), + ("voicemessagingpilot", "fkcallingsearchspace"), +}) + + +def test_complete_schema_coverage_against_known_columns(): + """If CUCM adds a new column type or we missed one, this test surfaces it. + Counts: 71 columns total in the CUCM 15.0.1.12900-234 snapshot. + """ + actual = { + (spec["table"], spec["column"]) + for spec in _CSS_REFERENCE_QUERIES.values() + } + missing = _KNOWN_CSS_COLUMNS_FROM_CUCM_15 - actual + assert not missing, ( + f"_CSS_REFERENCE_QUERIES is missing {len(missing)} known columns:\n" + + "\n".join(f" {t}.{c}" for t, c in sorted(missing)) + ) + + +def test_no_duplicate_table_column_pairs(): + """Each (table, column) pair should map to exactly one category label. + Two categories pointing at the same column would double-count references.""" + seen: dict[tuple, list[str]] = {} + for label, spec in _CSS_REFERENCE_QUERIES.items(): + key = (spec["table"], spec["column"]) + seen.setdefault(key, []).append(label) + duplicates = {k: v for k, v in seen.items() if len(v) > 1} + assert not duplicates, ( + f"duplicate (table, column) pairs would double-count:\n" + + "\n".join(f" {k}: {v}" for k, v in duplicates.items()) + )