<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Artem Hetman</title>
    <description>The latest articles on DEV Community by Artem Hetman (@thebedcoder).</description>
    <link>https://dev.to/thebedcoder</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4049733%2Fff5e33c5-11c3-4ac8-b62e-c133b1d4a7f9.jpg</url>
      <title>DEV Community: Artem Hetman</title>
      <link>https://dev.to/thebedcoder</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/thebedcoder"/>
    <language>en</language>
    <item>
      <title>Two translation bugs your build will pass, and one command that catches them</title>
      <dc:creator>Artem Hetman</dc:creator>
      <pubDate>Tue, 06 Oct 2026 12:38:13 +0000</pubDate>
      <link>https://dev.to/thebedcoder/two-translation-bugs-your-build-will-pass-and-one-command-that-catches-them-76j</link>
      <guid>https://dev.to/thebedcoder/two-translation-bugs-your-build-will-pass-and-one-command-that-catches-them-76j</guid>
      <description>&lt;p&gt;You ask an AI to translate your app. It could be ChatGPT, or the coding agent in your editor. It writes the Ukrainian file, the Flutter build passes, and you ship.&lt;/p&gt;

&lt;p&gt;Two of those strings are broken. Neither one stops the build, and you can't see either one unless you read Ukrainian.&lt;/p&gt;

&lt;p&gt;Here are both, in English terms.&lt;/p&gt;

&lt;h3&gt;
  
  
  Bug 1: the total with no number
&lt;/h3&gt;

&lt;p&gt;Your English string has a placeholder for the amount:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"checkoutTotal"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Total: {amount}"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Ukrainian translation came back like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"checkoutTotal"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Разом до сплати"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It means "Total to pay". The &lt;code&gt;{amount}&lt;/code&gt; is gone. &lt;code&gt;flutter gen-l10n&lt;/code&gt; exits 0 and says nothing about it. The generated method still takes the amount as a parameter and never puts it in the text, so your Ukrainian users see a checkout total with no number.&lt;/p&gt;

&lt;h3&gt;
  
  
  Bug 2: "2 items" in the wrong grammar
&lt;/h3&gt;

&lt;p&gt;English has two plural forms: 1 item, 2 items. Ukrainian has four, and the number decides which one you need. 2 to 4 take one form, 5 to 20 take another.&lt;/p&gt;

&lt;p&gt;The AI translated the two forms English has, and stopped:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"itemsInCart"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"{count, plural, one{{count} товар} other{{count} товарів}}"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So for 2 items the badge falls back to the only other form it has, the one for 5 to 20. It reads "2 товарів" where a Ukrainian speaker says "2 товари". In English terms, it's a badge that says "2 of items". The build passes.&lt;/p&gt;

&lt;h3&gt;
  
  
  You don't need Ukrainian to catch either one
&lt;/h3&gt;

&lt;p&gt;Both bugs are structural. The English string has a placeholder and the translation doesn't. The language needs four plural forms and the file has two. Neither needs anyone to understand the words.&lt;/p&gt;

&lt;p&gt;I build a free CLI that checks exactly this, called StringLane. Run it in the project folder:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ npx @stringlane/cli check
flutter · arb · single-origin · base en
3 locales · 3 keys · 86% complete

  de      4/4        complete
  en      4/4        complete
  uk      4/6        2 missing

3 errors · 0 warnings · 0 info
  error    placeholder_mismatch  uk/checkoutTotal
  error    missing_key           uk/itemsInCart[few]
  error    missing_key           uk/itemsInCart[many]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That output is trimmed: each error also says in a sentence what is wrong. It exits 1, so the same line in CI stops the merge:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npx @stringlane/cli check&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It reads Flutter ARB, iOS String Catalogs and &lt;code&gt;.strings&lt;/code&gt;, Android XML and i18next JSON. It needs no account and no key, and CI runs send no usage data.&lt;/p&gt;

&lt;h3&gt;
  
  
  If the AI is your coding agent
&lt;/h3&gt;

&lt;p&gt;The CLI also runs as an MCP server, so an agent can translate through it and have each translation checked before it is written. Agents still write locale files directly sometimes, with their own edit tool or a quick script, and the server never sees those writes. So keep the check in CI either way. How that works per agent, and what each one can and can't guard, is in the &lt;a href="https://stringlane.app/blog/your-agent-will-hand-edit-your-locale-files?utm_source=devto&amp;amp;utm_medium=social&amp;amp;utm_campaign=post2-agent-locale-files" rel="noopener noreferrer"&gt;full write-up&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  What it won't tell you
&lt;/h3&gt;

&lt;p&gt;Whether a translation is good. It checks structure: missing keys and plural forms, placeholders, ICU syntax. Whether "2 товари" is the right word for your badge still needs someone who understands Ukrainian.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>localization</category>
      <category>flutter</category>
      <category>programming</category>
    </item>
    <item>
      <title>One Flutter string, five ARB files, no check</title>
      <dc:creator>Artem Hetman</dc:creator>
      <pubDate>Fri, 07 Aug 2026 11:27:00 +0000</pubDate>
      <link>https://dev.to/thebedcoder/one-flutter-string-five-arb-files-no-check-52da</link>
      <guid>https://dev.to/thebedcoder/one-flutter-string-five-arb-files-no-check-52da</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://stringlane.app/blog/one-flutter-string-five-arb-files" rel="noopener noreferrer"&gt;stringlane.app&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I am a Flutter Staff Engineer and I have been building mobile apps for ten years. Over that time I have worked on a dozen or so of them, mostly commercial, with a few of my own. One of mine supports five languages and I only understand two of them.&lt;/p&gt;

&lt;p&gt;That was fine until I shipped a release with strings missing from some of those locales, and translations that were wrong where they existed at all. Every missing string rendered in English, and the build that produced them was green. No warning, no red. A user told me, after it shipped.&lt;/p&gt;

&lt;p&gt;Everything else in that release ran itself. Codemagic builds the ipa and the aab, ships them to TestFlight and the Play Store internal track, and then emails me that it worked. Localization was the hole. That hole is fairly specific, and if you ship a Flutter app in more than one language you probably have the same one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Adding one string means opening five files
&lt;/h2&gt;

&lt;p&gt;Here is the whole problem, and it is not clever.&lt;/p&gt;

&lt;p&gt;ARB is the translation file format Flutter reads: JSON, with per-key metadata attributes. gen_l10n wants one file per locale. I add a key to the English one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"checkoutButtonLabel"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Continue to checkout"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then I open &lt;code&gt;app_uk.arb&lt;/code&gt; and add it. Then &lt;code&gt;app_pl.arb&lt;/code&gt;. Then &lt;code&gt;app_de.arb&lt;/code&gt;. Then &lt;code&gt;app_es.arb&lt;/code&gt;. One string, five files, and nothing in that process checks whether I got all five.&lt;/p&gt;

&lt;p&gt;This is the gen_l10n workflow specifically. If the chore is all you want gone, &lt;code&gt;slang&lt;/code&gt; replaces it: one file per locale still, but &lt;code&gt;dart run slang analyze&lt;/code&gt; reports missing and unused translations, and its &lt;code&gt;migrate&lt;/code&gt; command converts ARB files into slang's own JSON, one file at a time. It is the answer this complaint usually gets, and it is a fair one.&lt;/p&gt;

&lt;p&gt;After that, the translations. I paste the new strings into an AI, get them back, and paste each one into the right file. Three keys is three passes. Now I have model output in four languages, three of which I do not understand, and nothing has checked any of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The build will not save you here
&lt;/h2&gt;

&lt;p&gt;You would expect a missing translation to stop the build. It does not.&lt;/p&gt;

&lt;p&gt;Run &lt;code&gt;flutter gen-l10n&lt;/code&gt; with a locale that is short a few keys and you get this, in among the rest of the build output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;"de": 12 untranslated message(s).
To see a detailed report, use the untranslated-messages-file
option in the l10n.yaml file:
untranslated-messages-file: desiredFileName.txt
&amp;lt;other option&amp;gt;: &amp;lt;other selection&amp;gt;

This will generate a JSON format file containing all messages that
need to be translated.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It exits successfully and the build continues.&lt;/p&gt;

&lt;p&gt;The usual version of this complaint is wrong, so it is worth being precise. Flutter reports the gap. It prints a count, it tells you how to get the detailed version, and if you take that advice it writes a JSON report listing every missing key per locale. The information is there.&lt;/p&gt;

&lt;p&gt;Two details about how it reaches you. The count goes out through &lt;code&gt;logger.printStatus&lt;/code&gt;, not &lt;code&gt;printWarning&lt;/code&gt; and not &lt;code&gt;printError&lt;/code&gt;, so it arrives at the same level as every other line in the build. And that call sits in an &lt;code&gt;else&lt;/code&gt; branch: set &lt;code&gt;untranslated-messages-file&lt;/code&gt; and the console count stops printing entirely, replaced by the file.&lt;/p&gt;

&lt;p&gt;None of it stops the build. That was the part I had not expected, so I read what the generator does with a message that has no translation for a locale. It is in &lt;a href="https://github.com/flutter/flutter/blob/3cce25a9ad538e8f4b755f84d49ba08dd9bb4d7f/packages/flutter_tools/lib/src/localizations/gen_l10n.dart" rel="noopener noreferrer"&gt;&lt;code&gt;gen_l10n.dart&lt;/code&gt;&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="n"&gt;localeWithFallback&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;_addUnimplementedMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;resourceId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="n"&gt;localeWithFallback&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_templateArbLocale&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The missing German string falls back to the template locale. It renders in English. The app compiles, runs, and looks fine to me, because I read English and I wrote the template.&lt;/p&gt;

&lt;p&gt;That is the release I shipped.&lt;/p&gt;

&lt;h2&gt;
  
  
  A count is not a check
&lt;/h2&gt;

&lt;p&gt;Say you do read the count, and you fix all twelve. You still know nothing about:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A string that exists and is wrong.&lt;/strong&gt; Present in the file, so it is not untranslated.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A dropped placeholder.&lt;/strong&gt; A translation comes back without &lt;code&gt;{count}&lt;/code&gt; in it at all. The method still takes the parameter, the string never uses it, and gen_l10n does not report it. Rename it instead, &lt;code&gt;{count}&lt;/code&gt; to &lt;code&gt;{Anzahl}&lt;/code&gt;, and you get this: gen_l10n infers an undeclared placeholder, generates a method taking both names, and your call sites stop compiling.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Missing plural forms.&lt;/strong&gt; English has two categories, &lt;code&gt;one&lt;/code&gt; and &lt;code&gt;other&lt;/code&gt;. Ukrainian has four, and &lt;code&gt;one&lt;/code&gt; is not the one you would guess: it takes 21 and 31, but not 11. Hand a model an English plural and you get back two forms. The key is present, the count is satisfied, and every number that needed &lt;code&gt;few&lt;/code&gt; or &lt;code&gt;many&lt;/code&gt; falls through to the single &lt;code&gt;other&lt;/code&gt; string.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the count answers "did someone type something here." It does not answer "is this correct."&lt;/p&gt;

&lt;p&gt;Some strings are not in ARB at all. Your app's display name and every iOS permission prompt come from &lt;code&gt;ios/Runner/Info.plist&lt;/code&gt;, and localizing them means a file you create by hand: &lt;code&gt;ios/Runner/&amp;lt;lang&amp;gt;.lproj/InfoPlist.strings&lt;/code&gt;, which is &lt;a href="https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/AboutInformationPropertyListFiles.html" rel="noopener noreferrer"&gt;Apple's long-documented mechanism&lt;/a&gt; for localizing Info.plist keys. &lt;code&gt;gen_l10n&lt;/code&gt; never opens it. So you can have a perfect ARB report and still ship a camera permission dialog in English.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhf45pamk6tkdxcs0pszg.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhf45pamk6tkdxcs0pszg.webp" alt="The plural key cartItemCount in a five-locale Flutter project. Ukrainian came back with only one and other, so few and many are flagged as CLDR-required. Spanish carries one, many and other, and Polish all four." width="800" height="619"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Three things you can do today without any new tool
&lt;/h2&gt;

&lt;p&gt;These all work. I used the first two for a while.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Turn on the report.&lt;/strong&gt; Flutter told you how, in that block above. In &lt;code&gt;l10n.yaml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;untranslated-messages-file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;l10n_missing.json&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You get the file instead of the count. Catches absent keys. Catches nothing else. And it takes the console line away, so either you read the file, or you see nothing in the console.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Diff the keys yourself.&lt;/strong&gt; ARB is JSON, so this is short:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pathlib&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;

&lt;span class="n"&gt;l10n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pathlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lib/l10n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;template&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;l10n&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;app_en.arb&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;read_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;encoding&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="n"&gt;keys&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;template&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;@&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;
&lt;span class="n"&gt;incomplete&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;l10n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;glob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;app_*.arb&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;app_en.arb&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;continue&lt;/span&gt;
    &lt;span class="n"&gt;other&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;encoding&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;missing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;keys&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;other&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;@&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;missing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;incomplete&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;missing&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;incomplete&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it before you tag. That &lt;code&gt;encoding="utf-8"&lt;/code&gt; is load-bearing on Python 3.14 and earlier. Without it the script dies on the first Ukrainian file on a Windows runner, which took me longer to work out than it should have.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Fail the build in CI.&lt;/strong&gt; Wire the report from step 1 into a check that fails when the file contains anything but &lt;code&gt;{}&lt;/code&gt;. Watch that shape. When nothing is missing gen_l10n writes an empty JSON object, not an empty file, so &lt;code&gt;[ -s l10n_missing.json ]&lt;/code&gt; is true on every green build and the check fires forever until you give up and delete it. &lt;code&gt;jq -e 'length == 0' l10n_missing.json&lt;/code&gt; is the whole check.&lt;/p&gt;

&lt;p&gt;And if a new tool is not the problem, &lt;code&gt;dart pub global activate rebellion&lt;/code&gt; then &lt;code&gt;rebellion analyze lib/l10n/&lt;/code&gt;. It catches missing keys, missing plural values per locale, and a translation that dropped a placeholder the template declares or added one it does not. That covers the placeholder and plural gaps above, and you write none of it. It is ARB only, it is CLI only by its author's own README, and the last release was March 2025.&lt;/p&gt;

&lt;p&gt;If you would rather have this in the editor, two extensions cover part of it. Google's ARB Editor validates a file against the ARB schema, understands ICU syntax and offers quick fixes: 112,000 installs, last updated September 2026. i18n-ally shows every locale for a key at once and has passed a million installs, though its repository has had no commits since November 2024. Both work on the file you have open rather than sweeping a project on a tagged build.&lt;/p&gt;

&lt;p&gt;The three do not have the same limits. Option 2 only compares key sets. Options 1 and 3 read gen_l10n's own report. And whichever you pick, gen_l10n runs on every build anyway, so malformed ICU fails the build for free.&lt;/p&gt;

&lt;p&gt;If you ship ARB only and you understand every language in your app, that is enough. It stops being enough when the values themselves need checking, or when strings start living outside ARB: the &lt;code&gt;InfoPlist.strings&lt;/code&gt; above, a marketing site, a web dashboard sharing the same copy.&lt;/p&gt;

&lt;h2&gt;
  
  
  One check over all five ARB files
&lt;/h2&gt;

&lt;p&gt;I wrote StringLane because localization was the one part of my release I still did by hand. It is a desktop app, which is the part I sell, and a command-line tool, which is free. This section is the free part, run on a project small enough to read.&lt;/p&gt;

&lt;p&gt;Five locales, four keys. The English template:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"@@locale"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"en"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"appTitle"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Groceries"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"checkoutButtonLabel"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Continue to checkout"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"welcomeBack"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Welcome back, {name}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"@welcomeBack"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"placeholders"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"String"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"cartItemCount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"{count, plural, one {{count} item in your cart} other {{count} items in your cart}}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"@cartItemCount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"placeholders"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"count"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"int"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Polish is complete. Each of the other three carries one of the gaps from earlier:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;app_de.arb&lt;/code&gt; has no &lt;code&gt;checkoutButtonLabel&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;app_es.arb&lt;/code&gt; has &lt;code&gt;"welcomeBack": "Hola de nuevo"&lt;/code&gt;. The &lt;code&gt;{name}&lt;/code&gt; is gone. Its plural has all three Spanish forms, including &lt;code&gt;many&lt;/code&gt;, which CLDR keeps for round millions.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;app_uk.arb&lt;/code&gt; has &lt;code&gt;cartItemCount&lt;/code&gt; with &lt;code&gt;one&lt;/code&gt; and &lt;code&gt;other&lt;/code&gt;, and nothing else.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On Flutter 3.47.0, &lt;code&gt;flutter gen-l10n&lt;/code&gt; prints &lt;code&gt;"de": 1 untranslated message(s).&lt;/code&gt; and exits 0. &lt;code&gt;flutter analyze&lt;/code&gt; finds no issues. Neither one mentions Spanish or Ukrainian.&lt;/p&gt;

&lt;p&gt;The check needs Node 22 or newer, which a Flutter pipeline may not have installed yet. npx fetches the CLI, and &lt;code&gt;@latest&lt;/code&gt; makes it ask npm for the current release instead of running an older copy you may already have installed. The output below is from 0.6.1.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx @stringlane/cli@latest check .&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$?&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;flutter · arb · single-origin · base en (detected)
5 locales · 4 keys · 90% complete

  de      4/5        1 missing
  en      5/5        complete
  es      6/6        complete
  pl      7/7        complete
  uk      5/7        2 missing

4 errors · 0 warnings · 0 info
  error    missing_key           de/checkoutButtonLabel: Key "checkoutButtonLabel" exists in base locale but is missing in "de"
  error    placeholder_mismatch  es/welcomeBack: Placeholder mismatch in "welcomeBack" for "es" — missing: name
  error    missing_key           uk/cartItemCount[few]: Plural quantity "few" for "cartItemCount" is missing in "uk"
  error    missing_key           uk/cartItemCount[many]: Plural quantity "many" for "cartItemCount" is missing in "uk"
1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The table in that output counts cells, not keys. A plural key is one cell per category the locale needs, which is why Polish has seven and German five. The Ukrainian plural is two errors, one for each category it is missing.&lt;/p&gt;

&lt;p&gt;Spanish reads as complete and still has an error: complete means every cell has a value, and says nothing about what is in it.&lt;/p&gt;

&lt;p&gt;Now the fix. Add the German key, put &lt;code&gt;{name}&lt;/code&gt; back into the Spanish string, and give Ukrainian its two missing forms:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"@@locale"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"uk"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"appTitle"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Покупки"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"checkoutButtonLabel"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Перейти до оформлення"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"welcomeBack"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"З поверненням, {name}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"cartItemCount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"{count, plural, one {{count} товар у кошику} few {{count} товари у кошику} many {{count} товарів у кошику} other {{count} товару у кошику}}"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;flutter · arb · single-origin · base en (detected)
5 locales · 4 keys · 100% complete

  de      5/5        complete
  en      5/5        complete
  es      6/6        complete
  pl      7/7        complete
  uk      7/7        complete

0 errors · 0 warnings · 0 info
0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In CI, the exit code is what fails the job: &lt;code&gt;0&lt;/code&gt; clean, &lt;code&gt;1&lt;/code&gt; issues found, &lt;code&gt;2&lt;/code&gt; the check could not run. A project that is not clean today can record a baseline and fail only on new problems, and &lt;code&gt;--json&lt;/code&gt; writes the findings as a report. &lt;a href="https://stringlane.app/docs/cli/ci?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=post1-arb-files" rel="noopener noreferrer"&gt;The CI guide&lt;/a&gt; has a job to copy.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;check&lt;/code&gt; writes nothing into your repository. What it keeps between runs lives in StringLane's own state directory: a cache of the source files it has scanned, a random install ID, and when it last checked npm for a newer version. The first run prints a notice on stderr saying the CLI collects usage data under that ID and never sends your file paths, translation keys, translated text or API keys. &lt;code&gt;STRINGLANE_TELEMETRY=0&lt;/code&gt; or &lt;code&gt;DO_NOT_TRACK=1&lt;/code&gt; turns it off, and a CI runner sends nothing. Telemetry was off for the runs above, so the notice is not in the pasted output.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;rebellion&lt;/code&gt;, named above, finds the same four problems in these five files, and for ARB alone it is a complete answer. Where they part is the rest of a Flutter app's strings. One &lt;code&gt;check&lt;/code&gt; run reads one format, and pointed at the project root it reads ARB, so the &lt;code&gt;InfoPlist.strings&lt;/code&gt; from earlier needs a run of its own:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx @stringlane/cli@latest check ios/Runner
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;NSCameraUsageDescription&lt;/code&gt; in &lt;code&gt;en.lproj&lt;/code&gt; and not in &lt;code&gt;de.lproj&lt;/code&gt;, that run reports a &lt;code&gt;missing_key&lt;/code&gt; error for German and exits 1. The same command reads &lt;code&gt;.xcstrings&lt;/code&gt;, Android XML and i18next JSON projects, if some of your copy lives there.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;check&lt;/code&gt; does not translate anything, and it does not know whether a translation is &lt;em&gt;good&lt;/em&gt;, which is the other half of what got me. What it removes is the other class of failure, the one where you could have known and did not.&lt;/p&gt;

&lt;p&gt;If you run the check on your own project, I would genuinely like to hear where it breaks. And feature requests, obviously.&lt;/p&gt;

</description>
      <category>flutter</category>
      <category>dart</category>
      <category>mobile</category>
      <category>cicd</category>
    </item>
  </channel>
</rss>
