> For the complete documentation index, see [llms.txt](https://wong-coupon.gitbook.io/flutter/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://wong-coupon.gitbook.io/flutter/my-flutter/quality-delivery/slang-i18n-guard-missing-translations-ci.md).

# Blocking Missing Translations with a Slang i18n Guard in CI

How I separate the Slang missing report, translator-MR policy, and semantic tests so CI blocks missing keys even when the app falls back to its base locale

## Result

In my project, Vietnamese is the base locale and English is the secondary locale. I enable `fallback_strategy: base_locale` so the app can still render when an English key does not exist.

Fallback protects runtime behavior, but it also makes missing translations difficult to spot during manual testing. The English screen still contains text, but that text comes from the Vietnamese base locale.

I do not treat one command as proof that i18n has been fully checked. I split the quality gate into three layers:

| Check                 | Contract it protects                                   | When it fails                                                                                      |
| --------------------- | ------------------------------------------------------ | -------------------------------------------------------------------------------------------------- |
| Slang missing report  | Every base-locale key exists in the secondary locale.  | The report still contains at least one missing leaf.                                               |
| Translator-MR guard   | Translators change values without changing the schema. | The diff contains a file outside the allowlist or the YAML tree, tokens, or tags diverge.          |
| Semantic/widget tests | Copy and behavior are correct in important flows.      | Text violates an expectation, a mapper misses a case, or a widget does not update with the locale. |

My final flow looks like this:

```
Translation change
      │
      ├── diff policy ──► target-locale files only?
      │                         │
      │                         └── compare YAML tree · tokens · rich-text tags
      │
      ├── slang analyze ──► _missing_translations.yaml
      │                               │
      │                               └── leaf remains ──► exit 1
      │
      └── semantic/widget tests ──► correct copy and locale behavior?
```

These three branches complement one another. A structural guard does not understand natural language, while semantic tests for selected flows cannot replace a completeness check across the entire translation tree.

## Problem

### Fallback can hide missing translations

Suppose I add a key to the Vietnamese file but forget the English file:

```yaml
# checkout_vi.i18n.yaml
summary:
  pageTitle: "Xác nhận đơn hàng"
```

With base-locale fallback enabled, the translation API can still return the Vietnamese `pageTitle` on the English screen. The app does not crash, and the layout still contains text. If I only open the screen and confirm that “something is displayed,” the defect can pass review.

I want to keep fallback at runtime and still fail early in CI. These goals do not conflict:

```
Runtime       missing key ──► use the base locale so the app keeps working
CI            missing key ──► fail the pipeline so that state is not released
```

### A report does not become a quality gate on its own

Slang provides a command that analyzes missing and unused translations:

```bash
dart run slang analyze
```

The command generates two files in the input directory:

```
assets/i18n/_missing_translations.yaml
assets/i18n/_unused_translations.yaml
```

If the pipeline only creates these reports and finishes with exit code `0`, a reviewer must open the files manually to find defects. The report exists, but it blocks nothing.

I also do not rely on `--exit-if-changed` alone in this workflow. In Slang `4.19.0`, that flag compares new output with an old file when the old file already exists. My reports are ignored generated files, so a clean checkout has no baseline during the first run.

### Translator MRs and developer MRs have different permissions

When a developer adds a translation key, the change usually touches several parts at once:

* The base locale.
* Secondary locales.
* Call sites that use the generated API.
* Related semantic or widget tests.

A translator, on the other hand, only needs to change values in the target locale. If that MR renames a key, removes a placeholder, or touches Dart source, its review scope has changed.

I therefore use a separate guard for translator MRs. It does not replace the global completeness check; it protects the allowed changes and schema for a narrower type of MR.

### Matching YAML trees do not guarantee matching meaning

Two files can contain the same keys, tokens, and tags while the English sentence is still wrong for its context. A translation can also be linguistically correct while the widget fails to rebuild after a locale change.

I keep these boundaries explicit:

* The structural guard answers, “Are both locale schemas still compatible?”
* Semantic tests answer, “Does important copy meet its expectations?”
* Widget tests answer, “Does the UI react correctly when the locale changes?”
* Language review answers, “Is this sentence natural and appropriate for the product tone?”

## Solution

### Configure the base locale and namespaces

The version I verified uses `slang`, `slang_flutter`, and `slang_build_runner` `4.19.0`. The dependencies can be declared as follows:

```yaml
dependencies:
  slang: ^4.18.0
  slang_flutter: ^4.18.0

dev_dependencies:
  slang_build_runner: ^4.18.0
  yaml: ^3.1.3
```

The `^4.18.0` constraint can resolve to a newer minor version. I therefore inspect `pubspec.lock` before drawing conclusions about CLI behavior.

The relevant `build.yaml` configuration is:

```yaml
slang_build_runner:
  options:
    base_locale: vi
    fallback_strategy: base_locale
    input_directory: assets/i18n
    input_file_pattern: .i18n.yaml
    output_directory: lib/i18n
    output_file_name: strings.g.dart
    namespaces: true
    flutter_integration: true
    translate_var: t
    enum_name: AppLocale
    class_name: Translations
```

I chose Vietnamese because it is the source copy for this product. The `base_locale` does not have to be English. What matters is that the team chooses one canonical tree and applies that decision consistently in naming, review, and CI.

`fallback_strategy: base_locale` makes runtime behavior safer when a secondary locale is missing a key. According to the Slang documentation, missing translations are analyzed when this strategy is used.

`namespaces: true` lets me split translations by feature:

```
assets/i18n/
├── checkout_vi.i18n.yaml
├── checkout_en.i18n.yaml
├── profile_vi.i18n.yaml
└── profile_en.i18n.yaml
```

Each namespace has one file for the base locale and one for the secondary locale. Generated `strings.g.dart` is output; YAML remains the source of truth for copy.

If you need the basics of organizing translation assets and switching locales, the following article owns that foundation. This article focuses only on Slang quality gates.

{% content-ref url="/pages/4HPxogOT3HRVQ8Hwj6mG" %}
[Multi-Language](/flutter/my-flutter/ui-media/multi-language.md)
{% endcontent-ref %}

### Generate the missing report before validation

I always run the analyzer before the guard:

```bash
dart run slang analyze
dart run tool/check_missing_translations.dart
```

A clean report can represent the secondary locale as an empty map:

```yaml
"@@info":
  - Missing translations report
en: {}
```

If English is missing `checkout.summary.pageTitle`, the report contains the corresponding leaf:

```yaml
"@@info":
  - Missing translations report
en:
  checkout:
    summary:
      pageTitle: "Xác nhận đơn hàng"
```

The value in the missing report comes from the base locale. The guard does not need to evaluate that value; the presence of the leaf is enough to prove that the secondary locale is incomplete.

I do not edit `_missing_translations.yaml` by hand. It will be generated again during the next analysis. The correct fix is to add the key to the secondary-locale file and rerun both commands.

### Turn the missing report into an exit code

The following minimal public tool reads the report, skips metadata, and returns exit code `1` when missing paths remain:

```dart
import 'dart:io';

import 'package:yaml/yaml.dart';

void main() {
  final report = File('assets/i18n/_missing_translations.yaml');

  if (!report.existsSync()) {
    stderr.writeln('Missing-translations report does not exist.');
    exitCode = 1;
    return;
  }

  try {
    final document = loadYaml(report.readAsStringSync());
    if (document is! YamlMap) {
      stderr.writeln('Missing-translations report must be a YAML map.');
      exitCode = 1;
      return;
    }

    final paths = _leafPaths(document).toList();
    if (paths.isEmpty) {
      stdout.writeln('Missing-translations guard passed.');
      return;
    }

    stderr.writeln('Missing-translations guard failed:');
    for (final path in paths) {
      stderr.writeln('- Missing translation `$path`.');
    }
    exitCode = 1;
  } on YamlException catch (error) {
    stderr.writeln('Invalid missing-translations report: ${error.message}');
    exitCode = 1;
  }
}

Iterable<String> _leafPaths(
  Object? value, [
  String path = '',
]) sync* {
  if (value == null) return;

  if (value is YamlMap) {
    for (final entry in value.entries) {
      if (entry.key is! String || entry.key == '@@info') continue;
      final key = entry.key as String;
      final nextPath = path.isEmpty ? key : '$path.$key';
      yield* _leafPaths(entry.value, nextPath);
    }
    return;
  }

  if (value is YamlList) {
    for (var index = 0; index < value.length; index++) {
      yield* _leafPaths(value[index], '$path[$index]');
    }
    return;
  }

  if (path.isNotEmpty) yield path;
}
```

The walker has four rules:

* `@@info` is metadata, not a translation path.
* `null` and empty maps do not create errors.
* Maps and lists are traversed recursively; list indexes remain in the path.
* Every remaining scalar leaf is a missing translation.

The guard returns all paths in one run so the translator does not have to fix one key and rerun the pipeline repeatedly.

### Keep translator MRs within an allowlist

The translator-MR guard starts from the merge request diff:

```dart
final result = await Process.run('git', [
  'diff',
  '--name-only',
  baseSha,
  headSha,
]);

if (result.exitCode != 0) {
  stderr.writeln('Could not read merge request diff.');
  exitCode = result.exitCode;
  return;
}

final changedPaths = (result.stdout as String)
    .split('\n')
    .where((path) => path.isNotEmpty)
    .toSet();
```

I normalize path separators and allow only this pattern:

```dart
final allowedPath = RegExp(
  r'^assets/i18n/[^/]+_en\.i18n\.yaml$',
);
```

This policy has three early failures:

1. An empty diff is not a translator MR.
2. The MR is rejected if any file falls outside `*_en.i18n.yaml`.
3. The MR is rejected if the English file is deleted or has no Vietnamese file in the same namespace.

I stop at policy errors before validating content. The reviewer first fixes the MR scope, then the pipeline can proceed to detailed YAML errors.

### Compare YAML trees instead of raw text

Indentation and quote styles can differ while producing the same YAML tree. I parse both files and compare their node types:

| Source node | Target-locale contract                                             |
| ----------- | ------------------------------------------------------------------ |
| Map         | It remains a map, with no missing or unexpected keys.              |
| List        | It remains a list with the same number of items.                   |
| Leaf        | Both values are strings, and the target-locale value is not empty. |

A valid example:

```yaml
# checkout_vi.i18n.yaml
summary:
  greeting(rich): "Xin chào $name, bạn có <b>${count}</b> mục"
```

```yaml
# checkout_en.i18n.yaml
summary:
  greeting(rich): "Hello $name, you have <b>${count}</b> items"
```

The translator can reorder `$name` and `${count}` to fit the target language, but cannot remove or rename either token.

I compare token multisets instead of calling `contains`:

```dart
final tokenPattern = RegExp(
  r'\$\{[A-Za-z_][A-Za-z0-9_]*\}'
  r'|\$[A-Za-z_][A-Za-z0-9_]*',
);

Map<String, int> countTokens(String value) {
  final counts = <String, int>{};
  for (final match in tokenPattern.allMatches(value)) {
    final token = match.group(0)!;
    counts[token] = (counts[token] ?? 0) + 1;
  }
  return counts;
}
```

Keeping counts means a source with `$name` twice fails against a translation with `$name` once. Both count maps must match exactly.

I apply the same idea to rich-text tags:

```dart
final tagPattern = RegExp(
  r'</?[A-Za-z][A-Za-z0-9:-]*(?:\s+[^>]*)?>',
);
```

This pattern catches changing `<b>` to `<strong>`, dropping a closing tag, or changing an attribute. It is not an HTML parser. Two strings with the same set of tags but invalid nesting can still pass the count check, so complex rich text needs a parser or a dedicated widget test.

The guard treats every leaf as text. Numeric, boolean, and `null` leaves are rejected. That is a policy for my translation tree, not a requirement for every YAML project.

### Wire both guards into GitLab CI

The source I verified currently uses `only`. GitLab has deprecated `only/except`, so the public example below uses `rules`. This is a recommended configuration, not a verbatim copy of the current pipeline.

```yaml
stages:
  - quality

i18n_completeness:
  stage: quality
  needs: []
  script:
    - fvm dart run slang analyze
    - fvm dart run tool/check_missing_translations.dart
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
    - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
    - if: '$CI_COMMIT_TAG'
  allow_failure: false
  interruptible: true

i18n_translation_policy:
  stage: quality
  needs: []
  script:
    - >-
      fvm dart run tool/check_translation_mr.dart
      --base "$CI_MERGE_REQUEST_DIFF_BASE_SHA"
      --head "$CI_COMMIT_SHA"
  rules:
    - if: >-
        $CI_PIPELINE_SOURCE == "merge_request_event" &&
        $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == $CI_DEFAULT_BRANCH &&
        $CI_MERGE_REQUEST_SOURCE_BRANCH_NAME =~ /^i18n\//
  allow_failure: false
  interruptible: true
```

`needs: []` lets both jobs start as soon as the pipeline is created and bypass stage ordering. I do not create a dependency between them because they validate independent contracts:

* The completeness job reads the entire translation tree.
* The translator-policy job reads changed paths and compares the modified pairs.

If I want to keep the report for debugging, I can add an artifact:

```yaml
artifacts:
  when: always
  expire_in: 1 day
  paths:
    - assets/i18n/_missing_translations.yaml
```

The current source does not upload this report. The artifact is a proposal that lets reviewers inspect the complete output when logs become long.

GitLab decides which jobs run, while FVM and Make keep commands consistent between local development and CI. The following article owns the overall pipeline architecture, so I do not repeat bootstrap, cache, coverage, and reporting here.

{% content-ref url="/pages/iXaQgqQ1eqF71OtJywau" %}
[GitLab CI for a Flutter Monorepo](/flutter/my-flutter/quality-delivery/gitlab-ci-flutter-monorepo.md)
{% endcontent-ref %}

### Keep semantic and widget tests in a separate layer

The guard cannot know whether `Continue` is correct in context or whether a widget rebuilds after the locale changes. I add focused assertions for important copy and flows:

```dart
import 'package:example_app/i18n/strings.g.dart';
import 'package:flutter_test/flutter_test.dart';

test('critical checkout copy exists in both locales', () async {
  final vi = AppLocale.vi.buildSync();
  final en = await AppLocale.en.build();

  expect(vi.checkout.summary.pageTitle, isNotEmpty);
  expect(en.checkout.summary.pageTitle, isNotEmpty);
  expect(
    en.checkout.summary.pageTitle,
    isNot(vi.checkout.summary.pageTitle),
  );
});
```

I do not snapshot every English sentence. Doing so would turn a valid copy change into a large batch of test updates. Semantic assertions should focus on:

* Legally significant copy or critical actions.
* Interpolation, plurals, and rich text.
* Enum/value mappers that must cover every case.
* Locale changes that must update mounted widgets.
* Fallback and persistence behavior in the locale controller.

Widget tests can validate behavior, while goldens are useful when longer text changes layout or causes overflow. The following article owns the setup and visual-regression review workflow.

{% content-ref url="/pages/OXzEEJ7gphdUcYxkfusW" %}
[Widget Tests and Golden Regression](/flutter/my-flutter/quality-delivery/widget-test-golden-regression.md)
{% endcontent-ref %}

### Run locally in a repeatable order

After changing translations, I run:

```bash
dart run slang
dart run slang analyze
dart run tool/check_missing_translations.dart
flutter test test/tool/check_translation_guard_test.dart
flutter test test/i18n
```

If the project wraps commands with Make, the equivalent workflow can be:

```bash
make gen-i18n
make slang-analyze
make test
```

The verification order is:

```
Edit locale YAML
→ generate the type-safe API
→ generate the missing report
→ fail if the report contains a leaf
→ run focused guard tests
→ run related semantic/widget tests
→ verify that the diff contains only intended files
```

The reports and generated Dart can be ignored. A clean `git status` therefore does not prove that the missing report is clean; the guard command is the signal that matters.

### Read failures by layer

| Signal                                        | Likely cause                                                                                 | Fix                                                       |
| --------------------------------------------- | -------------------------------------------------------------------------------------------- | --------------------------------------------------------- |
| `Missing-translations report does not exist.` | `slang analyze` was skipped, or the command ran from the wrong working directory.            | Run the analyzer from the package root before the guard.  |
| `Missing translation en...`                   | The base locale contains a key that the secondary locale lacks.                              | Add it to the secondary-locale file and analyze again.    |
| `Translator MR cannot change ...`             | The diff contains a file outside the allowlist.                                              | Separate developer changes from the translator MR.        |
| `Missing key ...` / `Unexpected key ...`      | The translator changed the YAML schema.                                                      | Restore the key and change only the value.                |
| `must keep N list items`                      | The target locale changed the list shape.                                                    | Synchronize schema changes in a developer MR.             |
| `Interpolation tokens do not match ...`       | A token was removed, renamed, or used a different number of times.                           | Restore the exact token multiset.                         |
| `Rich-text tags do not match ...`             | A tag or attribute was removed or changed.                                                   | Preserve the tag contract and translate only text.        |
| Semantic/widget test failure                  | Copy violates an expectation, a mapper misses a case, or the locale does not rebuild the UI. | Fix behavior or copy; do not weaken the structural guard. |

One diagnostic limitation exists in the current implementation: both YAML files are parsed inside the same `try/catch`, but a parse error always names the target-locale file. If the base-locale YAML is invalid, the message can point to the wrong file. The fix is to parse each file in a separate `try/catch` and attach the correct path to each error.

### Do not call the unused report a blocking gate

`slang analyze` also generates `_unused_translations.yaml`, but my missing guard does not read that file. The current CI configuration does not use `--full`.

In the default mode, the unused report finds keys that exist in a secondary locale but not in the base locale. Only `--full` scans Dart source for base keys that appear unused. That full scan searches for translation-variable text patterns rather than using a complete Dart analyzer, so wrappers and dynamic access can create false positives or negatives.

If I need to block unused keys, I introduce a separate policy:

1. Run `slang analyze --full` without blocking to establish a baseline.
2. Review dynamic access and false positives.
3. Give every exception an owner and expiration date.
4. Make the check blocking only after the baseline is clean.

I do not expand the missing guard and silently change the meaning of the pipeline.

### Trade-offs and limitations

* Fallback keeps the app working but can hide missing copy during manual testing.
* The global completeness guard catches missing keys but does not understand grammar, tone, or context.
* A translator allowlist narrows review scope but does not fit developer MRs that change schema and call sites.
* Token and tag counts permit sentence reordering but do not prove valid rich-text nesting.
* A YAML parser discards comments, so the guard does not protect context comments or indentation style.
* Semantic tests protect important copy, but snapshotting too much text makes the translation workflow rigid.
* Widget and golden tests catch behavior and layout defects but need more setup than tree validation.
* `needs: []` reduces waiting time, but the two jobs cannot depend on each other's artifacts.
* Ignored generated reports avoid merge conflicts, but CI must regenerate them in every clean checkout.
* The missing guard does not prove that every base-locale key is used in source code.

### Checks I performed

In the source snapshot I verified:

* There are 20 VI/EN namespace pairs with no unmatched stems.
* The pair guard returned `0` errors when run directly against all 20 English files.
* The translator CLI returned exit `1` for an empty diff and exit `64` when `--base` was missing.
* The missing-report CLI returned exit `1` when the report had not been generated.
* The source contains 12 unit tests for the two guards.
* It also contains 41 semantic/widget i18n tests across 12 files; the standard root test target includes them.

The focused Flutter guard test did not run any cases in my research environment because the local pub cache was missing a separate test dependency. I do not claim `12/12 passed` from source code or from the number of test cases. The pass/fail behavior above was verified by reading the source and running the listed CLI paths directly.

### Verified versions

* Flutter workspace: `3.41.2`.
* Dart SDK constraint: `>=3.11.0 <4.0.0`.
* `slang`: `4.19.0` in the lockfile.
* `slang_flutter`: `4.19.0` in the lockfile.
* `slang_build_runner`: `4.19.0` in the lockfile.
* `yaml`: `3.1.3` in the lockfile.
* Platform scope: Shared.

### References

* [Slang `4.19.0`](https://pub.dev/packages/slang/versions/4.19.0)
* [Slang repository](https://codeberg.org/Tienisto/slang)
* [GitLab CI/CD — `needs`](https://docs.gitlab.com/ci/yaml/#needs)
* [GitLab CI/CD — deprecated `only`/`except`](https://docs.gitlab.com/ci/yaml/deprecated_keywords/#only--except)
* [GitLab CI/CD — job `rules`](https://docs.gitlab.com/ci/jobs/job_rules/)

## Conclusion

Fallback and CI guards solve different problems. Fallback keeps the app running when a secondary locale is missing a key; the CI guard stops that state from reaching a release branch.

I separate global completeness, translator-MR policy, and semantic/widget tests so every failure identifies the contract that broke. `slang analyze` produces the data, the missing-report guard turns it into an exit code, and the pair guard preserves schema, tokens, and tags within the translator's allowed scope.

This approach fits translation assets with a clear base locale and a team that controls changes through merge requests. It does not replace language review, visual tests, or an unused-key policy. Those layers need their own tests and rollout instead of being forced into one validator.

[Buy Me a Coffee](https://buymeacoffee.com/ducmng12g) | [Support Me on Ko-fi](https://ko-fi.com/I2I81AEJG8)
