Skip to content

fix(term-propagation): two corruption bugs uncovered by ConfessIt backfill#204

Merged
JohnRDOrazio merged 1 commit into
mainfrom
fix/term-propagation-slug-collision-and-pll-fallback
Jun 11, 2026
Merged

fix(term-propagation): two corruption bugs uncovered by ConfessIt backfill#204
JohnRDOrazio merged 1 commit into
mainfrom
fix/term-propagation-slug-collision-and-pll-fallback

Conversation

@JohnRDOrazio

@JohnRDOrazio JohnRDOrazio commented Jun 8, 2026

Copy link
Copy Markdown
Member

Summary

Fixes two distinct bugs in cdcf_get_or_create_translated_term (from #201) that surfaced when backfilling tags on the ConfessIt community_project translations on production 2026-06-08. Both bugs corrupt Polylang term state silently — they were masked on the Interior Castle App test (contemplation/prayer/wisdom-of-the-saints — no translation collision, no corruption) and only became visible when EN tags examen + examination both translated to the same Romance-language word.

Observed corruption on ConfessIt backfill

Post Tags Note
DE (1501) Beichte, Examen, Prüfung, Versöhnung ✅ clean — German distinguishes "examen" vs "examination"
IT (1497) confessione, esame, riconciliazione ⚠️ 3 tags (lost "examination" via OpenAI collision)
ES (1498) confesión, examen, examen (raw EN term 171 leaked), reconciliación ⚠️ EN term cross-leaked into ES post
FR (1499) confession, examen, examen (raw EN term 171 leaked), réconciliation ⚠️ Same
PT (1500) confissão, exame, reconciliação ⚠️ 3 tags (same OpenAI collision)
EN source (1485) confession, examen (now language=fr), examination, reconciliation ⚠️ EN term 171's language got flipped to fr — EN post serving an fr-classified tag

Two bugs, two fixes

Bug 1 — pll_get_term self-fallback treated as "found a sibling"

Some Polylang versions return the input term_id from pll_get_term when no target-language sibling exists (vs. returning 0/false). The old code:

$existing = pll_get_term((int) $en_term->term_id, $target_lang);
if ($existing) {
    return (int) $existing;
}

treats the fallback as a real sibling, returns the EN id, and wp_set_object_terms($post, [..., EN_ID, ...]) assigns an EN term to a non-EN post. Subsequent runs then call pll_set_term_language(EN_ID, $target_lang) on the EN term itself, flipping its language out from under the EN post that still references it.

Fix: guard if ($existing && (int) $existing !== (int) $en_term->term_id).

Bug 2 — slug-collision adoption rewrites the colliding term's Polylang group

When two distinct EN terms translate to the same target word (examen + examinationexamen in IT/ES/FR/PT), wp_insert_term returns term_exists with the existing term's id in data. The old code adopted that id and called:

pll_set_term_language($existing_id, $target_lang);
pll_save_term_translations(['en' => $en_term_id, $target_lang => $existing_id]);

The save call REASSIGNS the adopted term from its original EN sibling to ours — orphaning the original link. The set-language call re-asserts language on a term that already had one and (interacting with bug 1) can flip the language entirely.

Fix: on term_exists, look up the EN term's current Polylang group:

  • If the colliding term IS already our sibling in $target_lang, return its id (idempotent re-runs stay safe).
  • Otherwise, log a warning and return null. The post ends up with fewer tags than the source had — that's information loss but not corruption. The alternative (rewriting the group) was a silent data-integrity violation.

Test churn

  • Removed test_get_or_create_adopts_existing_term_on_slug_collision — its assertion encoded the buggy adoption+rewrite behavior.
  • Added test_get_or_create_treats_pll_get_term_self_fallback_as_no_sibling — regression guard for bug 1; stubs pll_get_term to return the input id and asserts the handler creates a new term instead of reusing the EN id.
  • Added test_get_or_create_reuses_collision_term_when_already_my_polylang_sibling — idempotent re-run path on collision.
  • Added test_get_or_create_returns_null_when_collision_term_belongs_to_different_sibling — the corruption case is now refused with a logged warning; verifies pll_set_term_language and pll_save_term_translations are never called.

Net: -1 + 3 = +2 new tests. The other 9 existing tests for the function continue to pass unchanged. 546/546 theme suite green.

Production data cleanup — deferred

Restoring ConfessIt's term state (term 171's language back to en, un-leaking it from ES/FR posts, optionally adding proper ES/FR/IT/PT siblings for "examination") is deferred to a follow-up manual step once this fix deploys. Re-toggling status while the buggy code is still live would just re-corrupt.

Deploy

Backend (theme) change — ship via gh workflow run deploy.yml -f environment=production after merge. A bare deploy.yml run defaults to staging and skips the WP theme steps.

Related

🤖 Generated with Claude Code

Summary by CodeRabbit

Release Notes

  • Bug Fixes
    • Fixed taxonomy term propagation to properly validate language sibling detection, preventing accidental metadata corruption in multilingual content
    • Enhanced slug collision handling with stricter validation to ensure language group assignments remain correct across different content variations
    • Improved edge case handling in multilingual term synchronization to prevent unintended term reassignment

…kfill

Both observed on production 2026-06-08 when backfilling project_tag terms
on the ConfessIt community_project translations (referral predated PR #201,
so its 5 non-EN siblings had zero tags). Toggling status to fire the
propagation hook on each non-EN sibling produced:

  - DE post 1501: 4 correctly-localized tags (Beichte/Examen/Prüfung/
    Versöhnung) — German distinguishes "examen" from "examination", no
    collision, all clean.
  - IT/PT posts (1497/1500): only 3 tags each — lost "examination" because
    OpenAI translates both EN "examen" and EN "examination" to the same
    target word (esame/exame).
  - ES/FR posts (1498/1499): 4 tags listed but one entry is the raw EN
    term 171 ("examen"), and querying term 171 directly now shows
    language=fr — the EN source term itself got language-flipped.
  - EN source 1485 still references term 171 in its term_relationships,
    so its `projectTags.nodes[1].language.slug` is now "fr". Cross-class
    corruption: an EN post serving a French-classified tag.

Two distinct bugs in cdcf_get_or_create_translated_term, both fixed here:

BUG 1 — pll_get_term self-fallback treated as "found a sibling".

  Some Polylang versions return the INPUT term_id from pll_get_term when
  no target-language sibling exists (vs returning 0/false). The handler's
  `if ($existing) return (int) $existing;` then returns the EN term_id,
  which propagates up to wp_set_object_terms($post, [..., EN_TERM_ID, ...])
  — assigning an English term to a non-English post. Subsequent runs
  with the same EN term then call pll_set_term_language($new_id,
  $target_lang) on the EN term id (because `pll_get_term` would return
  the just-promoted-with-incorrect-language sibling), flipping its
  language out from under the EN post that still references it.

  Fix: guard with `if ($existing && (int) $existing !== (int) $en_term->term_id)`.

BUG 2 — slug-collision adoption rewrites the colliding term's polylang group.

  When wp_insert_term hits term_exists (two distinct EN terms translating
  to the same target word), the old code adopted the existing term_id
  AND called both pll_set_term_language AND pll_save_term_translations
  on it. The save_term_translations call REASSIGNS the adopted term from
  its original EN sibling to ours — orphaning the original link. The
  set_term_language re-asserts language on a term that already had one,
  which in pathological cases (interacting with bug 1) flipped the
  language outright.

  Fix: on term_exists, look up the EN term's current polylang group:
    - If the colliding term IS already our sibling in this language,
      return its id (idempotent re-runs are safe).
    - Otherwise, log a warning and return null. Don't adopt; don't
      rewrite. The post ends up with fewer tags than the source had,
      which is information loss but NOT corruption. The alternative
      (rewriting the group) was a silent data-integrity violation.

Test churn:
  - REMOVED test_get_or_create_adopts_existing_term_on_slug_collision —
    its assertion encoded the buggy adoption + rewrite behavior.
  - ADDED test_get_or_create_treats_pll_get_term_self_fallback_as_no_sibling
    (regression guard for bug 1; stubs pll_get_term to return the input
    term_id and asserts the handler creates a NEW term instead of reusing
    the EN id).
  - ADDED test_get_or_create_reuses_collision_term_when_already_my_polylang_sibling
    (idempotent re-run path on collision).
  - ADDED test_get_or_create_returns_null_when_collision_term_belongs_to_different_sibling
    (the corruption case — returns null, never calls pll_set_term_language
    or pll_save_term_translations).

Production cleanup of the ConfessIt term state (restoring term 171's
language to 'en', un-leaking it from ES/FR posts) is deferred to a
follow-up manual step once this fix deploys — re-toggling status while
the buggy code is live would just re-corrupt.

All 9 existing tests for the function (the ones unrelated to the buggy
adoption path) continue to pass unchanged. 546/546 theme suite green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 8, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 7663f256-3a95-417c-b608-4f1131309b92

📥 Commits

Reviewing files that changed from the base of the PR and between cf7e2bb and 1a0ea02.

📒 Files selected for processing (2)
  • wordpress/themes/cdcf-headless/includes/admin/term-propagation.php
  • wordpress/themes/cdcf-headless/tests/TermPropagationTest.php

📝 Walkthrough

Walkthrough

The PR hardens cdcf_get_or_create_translated_term() by adding a guard against Polylang self-fallbacks and refactoring slug-collision handling to distinguish between collisions that are already correct Polylang siblings (reuse) and those belonging to different siblings (reject propagation). Tests cover all three scenarios: fallback detection, valid collision reuse, and invalid collision rejection.

Changes

Term Propagation Polylang Robustness

Layer / File(s) Summary
Polylang self-fallback guard
wordpress/themes/cdcf-headless/includes/admin/term-propagation.php, wordpress/themes/cdcf-headless/tests/TermPropagationTest.php
Adds validation after pll_get_term() to detect when Polylang returns the input EN term ID as a fallback; if fallback is detected, propagation aborts. Test verifies fallback is not treated as existing sibling and that new term insertion and language linking proceed instead.
Term insertion and collision handling with sibling awareness
wordpress/themes/cdcf-headless/includes/admin/term-propagation.php, wordpress/themes/cdcf-headless/tests/TermPropagationTest.php
Refactors wp_insert_term() error handling on slug collision to check whether the colliding term is already the correct Polylang sibling: if yes, reuses the ID; if no, logs a warning and returns null instead of adopting the term. Two tests verify collision-is-sibling (reuse without relinking) and collision-is-different-sibling (reject propagation) paths.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • CatholicOS/cdcf-website#201: Both PRs modify the shared Polylang/wp_insert_term term-creation logic inside includes/admin/term-propagation.php—specifically cdcf_get_or_create_translated_term() and its collision/self-fallback handling—so this PR's fixes are directly related to the retrieved PR's term propagation feature.

Poem

🐰 A rabbit hops through Polylang's garden,
Guarding terms from self-fallback pardon,
Smart collisions now wear a test-covered vest,
No sibling confusion—just the very best! ✨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly addresses the main purpose of the PR: fixing two specific corruption bugs in term-propagation uncovered during ConfessIt backfill.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/term-propagation-slug-collision-and-pll-fallback

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@codacy-production

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 0 complexity

Metric Results
Complexity 0

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

@codecov-commenter

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@JohnRDOrazio

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 11, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@JohnRDOrazio JohnRDOrazio merged commit fef66d3 into main Jun 11, 2026
13 checks passed
@JohnRDOrazio JohnRDOrazio deleted the fix/term-propagation-slug-collision-and-pll-fallback branch June 11, 2026 00:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants