Enforcing Glossary Terms in CI

A glossary gate in CI parses your termbase (TBX or CSV), then fails any pull request where a translated string uses a forbidden term or drops a required one for that locale — so “log in” never silently becomes “sign in” in German and a trademark is never inflected away. This page walks through parsing the termbase, matching terms across inflection and case, mapping violations back to source lines, and posting them as PR annotations rather than burying them in raw log output.

The failure this prevents is quiet and expensive: a translator renders account.delete.cta as “Konto entfernen” when the approved German term for delete is “löschen”, the string passes every format and placeholder check, ships, and three weeks later support is fielding tickets about inconsistent terminology across the product. Format linters never catch it because the string is structurally valid — only a term-aware gate that reads the termbase can.

Glossary enforcement gate in CI The gate loads the TBX or CSV termbase, builds per-locale required and forbidden maps, normalizes each translated string for case and inflection, matches against the maps, and emits violations as PR annotations that fail the check. Parse termbase TBX / CSV Build maps required / forbidden per locale Normalize & match case / inflection Annotate PR fail the check Glossary gate on every PR that touches a translation file A forbidden hit OR a missing required term is a violation; exit non-zero blocks merge. Normalization folds case and (optionally) inflected surface forms before matching — so "Löschen", "löscht", and "gelöscht" all resolve to the required lemma "löschen". Run only on changed keys to keep the gate fast on large catalogs.
The gate parses the termbase once, builds per-locale maps, then matches each changed string with case and inflection folding before annotating the PR.

Root cause: structurally valid strings can still violate terminology

Standard i18n checks operate on structure: matching ICU placeholders, balanced braces, and no missing keys. None of them read the termbase, so a translation that is perfectly well-formed but uses the wrong approved term sails through. Terminology is a semantic constraint layered on top of structure, and it has two distinct shapes that a gate must handle separately:

  • Required mappings — for source term X in locale L, the translation must contain approved term Y. (English delete → German löschen.) A missing required term is a violation.
  • Forbidden mappings — for locale L, term Z must never appear, usually because it is a deprecated synonym, a competitor’s wording, or a trademark that must stay untranslated. (German must not use entfernen for delete; iOS must never be lowercased.)

The termbase itself is typically authored in TBX (TermBase eXchange, the ISO 30042 / LISA standard XML that tools like Crowdin and memoQ export) or a flat CSV for smaller teams. Both encode the same source term → per-locale approved term(s) relationship plus optional forbidden flags. The gate’s job is to compile that into fast lookup maps, then run them against only the strings a pull request changed — because re-scanning a 40,000-key catalog on every commit is too slow to gate merges on. This is the enforcement counterpart to the broader translation memory and glossary management discipline: the termbase defines policy, the CI gate makes it non-negotiable.

The hard part is matching. Natural-language terms inflect. German löschen surfaces as löscht, gelöscht, Löschung; a naive substring check against the exact lemma misses every inflected form and produces false “missing required term” failures. Conversely, case-folding a trademark check incorrectly flags the legitimately-capitalized brand. The gate needs per-rule normalization, not one global setting.

Minimal reproducible example

Here is the smallest case that demonstrates both a false negative and a false positive. The termbase requires German deletelöschen and forbids entfernen:

source,locale,required,forbidden
delete,de,löschen,entfernen
delete,de,löschen,
iOS,de,iOS,ios

A naive checker that does an exact, case-sensitive includes() produces wrong results on real translations:

// naive — DO NOT SHIP
function check(translation: string, required: string, forbidden: string) {
  const missingRequired = !translation.includes(required);   // case-sensitive
  const hasForbidden = forbidden && translation.includes(forbidden);
  return { missingRequired, hasForbidden };
}

check('Konto wird gelöscht', 'löschen', 'entfernen');
// → { missingRequired: true, hasForbidden: false }
// WRONG: "gelöscht" is an inflected form of the required term "löschen",
// so this should PASS, not fail on a missing required term.

The string “Konto wird gelöscht” is correct German and uses the approved term — yet the naive gate fails it. Multiply that across a catalog and translators learn to ignore the gate entirely, which is worse than having no gate.

The fix: normalized, inflection-aware matching

The corrected gate normalizes both the termbase entries and each translated string through the same pipeline before comparing, and it treats inflection per-locale rather than per-string. For required terms, it matches against a stem (or an explicit list of allowed surface forms from the termbase); for forbidden terms and trademarks, it can disable case-folding so capitalization is enforced exactly.

// glossary-gate.ts — inflection- and case-aware term enforcement
import { parse } from 'csv-parse/sync';
import { readFileSync } from 'node:fs';

interface Rule {
  source: string;
  locale: string;
  required?: string;          // approved lemma, e.g. "löschen"
  forbidden?: string;         // banned surface form, e.g. "entfernen"
  caseSensitive?: boolean;    // trademarks set this true
}

// Locale-aware fold: lowercase via the locale's casing rules, strip combining marks
// only where safe. For German/Slavic we keep diacritics (ö ≠ o changes meaning).
function fold(s: string, locale: string, caseSensitive = false): string {
  const cased = caseSensitive ? s : s.toLocaleLowerCase(locale);
  return cased.normalize('NFC').replace(/\s+/g, ' ').trim();
}

// Match a required lemma allowing common inflectional suffixes. The termbase can
// override `stem` per entry; here we derive a conservative prefix stem so that
// "löschen" also matches "löscht", "gelöscht", "löschung".
function requiredMatch(text: string, lemma: string, locale: string): boolean {
  const stem = lemma.replace(/(en|st|ung|t)$/u, ''); // drop a trailing inflection
  // Word-boundary-aware regex; \p{L} keeps Unicode letters (umlauts) intact.
  const re = new RegExp(`(?:ge)?${stem}\\p{L}*`, 'iu');
  return re.test(fold(text, locale));
}

export function lint(translation: string, rules: Rule[], locale: string): string[] {
  const violations: string[] = [];
  for (const r of rules.filter((x) => x.locale === locale)) {
    if (r.required && !requiredMatch(translation, r.required, locale)) {
      violations.push(`missing required term "${r.required}" for "${r.source}"`);
    }
    if (r.forbidden) {
      const text = fold(translation, locale, r.caseSensitive);
      const term = fold(r.forbidden, locale, r.caseSensitive);
      // Word boundary so "entfernen" doesn't match inside an unrelated word.
      if (new RegExp(`\\b${term}\\b`, 'u').test(text)) {
        violations.push(`forbidden term "${r.forbidden}" used for "${r.source}"`);
      }
    }
  }
  return violations;
}

export function loadRules(path: string): Rule[] {
  // CSV path; for TBX, parse the XML <termEntry> nodes into the same Rule shape.
  return parse(readFileSync(path, 'utf8'), { columns: true, skip_empty_lines: true });
}

The two non-obvious lines carry the whole fix. The stem derivation in requiredMatch drops a trailing inflectional suffix so gelöscht satisfies the löschen requirement, and the caseSensitive branch in fold lets a trademark rule (iOS) enforce exact capitalization while ordinary terms fold case. For production German or Slavic locales, replace the suffix-stripping heuristic with the explicit surface-form list your termbase exports, or a stemmer such as Snowball — the heuristic is a starting point, not a morphology engine. The deeper morphology problem is the same one that makes pluralization across languages hard: surface forms vary far more than the lemma count suggests.

Wiring it into the pull request

Run the gate only against changed translation files so it stays fast, collect violations, and emit them in GitHub’s annotation format so they appear inline on the diff rather than scrolling past in raw logs:

#!/usr/bin/env bash
# ci/glossary-check.sh — run inside the i18n CI gate job
set -euo pipefail

# Only the translation files this PR touched (keeps the gate fast on big catalogs):
mapfile -t changed < <(git diff --name-only "origin/${GITHUB_BASE_REF}"...HEAD \
  -- 'locales/**/*.json')

fail=0
for file in "${changed[@]}"; do
  locale="$(basename "$(dirname "$file")")"   # locales/de/common.json → de
  # The node script prints "file:line: message" for each violation:
  while IFS= read -r line; do
    [ -z "$line" ] && continue
    echo "::error file=${file}::${line}"      # GitHub PR annotation
    fail=1
  done < <(node ci/run-gate.mjs "$file" "$locale" glossary.csv)
done

exit "$fail"

The ::error file=...:: syntax is what turns a log line into an annotation pinned to the file in the PR’s Files-changed view. Drop this script into the broader GitHub Actions i18n CI gates job alongside the placeholder and untranslated-key checks so terminology is enforced in the same pass, and the same job that blocks a build on an empty key also blocks it on the wrong approved term.

Verification

Lock the behavior with assertions that pin both the false-negative (inflection) and the false-positive (case) the naive version got wrong:

import { describe, it, expect } from 'vitest';
import { lint } from './glossary-gate';

const rules = [
  { source: 'delete', locale: 'de', required: 'löschen', forbidden: 'entfernen' },
  { source: 'iOS', locale: 'de', forbidden: 'ios', caseSensitive: true },
];

describe('glossary gate', () => {
  it('accepts an inflected form of the required term', () => {
    expect(lint('Konto wird gelöscht', rules, 'de')).toEqual([]); // no missing-required
  });

  it('flags a forbidden synonym', () => {
    const v = lint('Konto entfernen', rules, 'de');
    expect(v.some((m) => m.includes('forbidden term "entfernen"'))).toBe(true);
  });

  it('enforces trademark casing only when caseSensitive', () => {
    expect(lint('Lädt unter ios', rules, 'de')).toHaveLength(1); // wrong case → fail
    expect(lint('Lädt unter iOS', rules, 'de')).toHaveLength(0); // correct → pass
  });
});

Run it locally and in CI with the same command so a green check on the PR means the exact rules ran:

npx vitest run glossary-gate.test.ts
# expect: 3 passed — inflection accepted, forbidden caught, casing enforced

When to escalate

This script-level gate handles a flat termbase with regular inflection. It stops being enough when the morphology is genuinely irregular — Arabic broken plurals, Finnish consonant gradation, or Russian case stems that share no common prefix with the lemma — where a suffix heuristic produces too many false positives to be trusted. At that point the matcher needs a real per-locale stemmer or a lemmatizer fed the surface-form lists your TM tool exports, and the rules belong in the localization platform’s own QA checks rather than a shell script. Likewise, if reviewers need to approve term additions (not just enforce existing ones), the workflow moves upstream into the translation memory and glossary management process, where termbase changes go through their own review before the CI gate ever sees them.

FAQ

Should the glossary gate run on the whole catalog or only changed strings?

Only the changed strings on a PR. A full-catalog scan on every commit is too slow to gate merges and floods the PR with annotations for pre-existing strings nobody touched. Diff against the base branch, lint the changed keys, and run a full scan on a nightly schedule instead so historical drift is still caught without blocking day-to-day merges.

How do I match a required term when the language inflects it heavily?

Do not match the lemma as an exact substring — derive a stem (drop the inflectional suffix) and match with a Unicode-aware word regex, or, better, match against the explicit list of allowed surface forms that TBX exports per term. For irregular morphology (Arabic, Finnish, Russian), use a real stemmer or lemmatizer; a prefix heuristic will produce false “missing required term” failures.

Can the same gate enforce that trademarks stay untranslated and correctly cased?

Yes — model a trademark as a forbidden rule with caseSensitive: true (ban the lowercased or translated form) plus a required rule for the exact brand string. Keeping case-folding off for those specific rules lets the gate flag ios while accepting iOS, without affecting ordinary case-insensitive term checks.

Part of Translation Memory & Glossary Management.