<?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: Ackah Kelvin</title>
    <description>The latest articles on DEV Community by Ackah Kelvin (@ackah_kelvin_45).</description>
    <link>https://dev.to/ackah_kelvin_45</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3964474%2F0c49f863-7cf1-479b-9372-278f52c7ac50.jpg</url>
      <title>DEV Community: Ackah Kelvin</title>
      <link>https://dev.to/ackah_kelvin_45</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ackah_kelvin_45"/>
    <language>en</language>
    <item>
      <title>What a string extractor gets wrong: six lessons from three real codebases</title>
      <dc:creator>Ackah Kelvin</dc:creator>
      <pubDate>Sat, 06 Jun 2026 22:32:40 +0000</pubDate>
      <link>https://dev.to/ackah_kelvin_45/what-a-string-extractor-gets-wrong-six-lessons-from-three-real-codebases-maf</link>
      <guid>https://dev.to/ackah_kelvin_45/what-a-string-extractor-gets-wrong-six-lessons-from-three-real-codebases-maf</guid>
      <description>&lt;p&gt;TransLift is a CLI that statically finds user-facing strings in a React/TypeScript&lt;br&gt;
codebase and wraps them in &lt;code&gt;t("…")&lt;/code&gt; translation calls. The premise sounds&lt;br&gt;
mechanical (grep for quoted text, wrap it), and the demos are easy. The&lt;br&gt;
interesting part is everything the demo hides.&lt;/p&gt;

&lt;p&gt;This is a write-up of what surfaced when I stopped trusting my own fixtures and&lt;br&gt;
ran the tool against three real codebases (Excalidraw, Mattermost, and Bluesky)&lt;br&gt;
alongside two other extractors. Each repo embodies a different i18n &lt;em&gt;convention&lt;/em&gt;,&lt;br&gt;
and the convention turned out to be the whole game. The headline finding is not&lt;br&gt;
"my tool is better." It's narrower and more useful: &lt;strong&gt;the hard problem in string&lt;br&gt;
extraction isn't finding strings, it's knowing which strings are already&lt;br&gt;
handled.&lt;/strong&gt; I hadn't built that capability either, until each repo forced it.&lt;/p&gt;

&lt;p&gt;What follows is section zero (the benchmark), then six problems the benchmark&lt;br&gt;
exposed, each one a mistake I made, found, and fixed, and finally an honest&lt;br&gt;
accounting of what I still don't trust.&lt;/p&gt;


&lt;h2&gt;
  
  
  §0 — The benchmark
&lt;/h2&gt;

&lt;p&gt;I compared three tools, each in its intended aggressive auto-wrap mode:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;TransLift&lt;/strong&gt; (&lt;code&gt;extract&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;i18next-cli&lt;/strong&gt; (&lt;code&gt;instrument&lt;/code&gt;): the closest comparable, an i18next codemod&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;a18n&lt;/strong&gt; (&lt;code&gt;wrap&lt;/code&gt;): a CJK-first (Chinese/Japanese/Korean) extractor&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Before the numbers, two terms, because everything below is scored on them. When a&lt;br&gt;
tool rewrites your code, it can fail in two opposite directions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Recall&lt;/strong&gt; — of all the user-facing strings that &lt;em&gt;should&lt;/em&gt; be translated, how
many did the tool actually find? A miss here is silent: the string stays
hardcoded and compiles fine, so nobody notices until a user sees untranslated
text in production.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;False positives&lt;/strong&gt; (the inverse of &lt;em&gt;precision&lt;/em&gt;) — of all the strings the tool
did wrap, how many were a mistake? The damaging kind is re-wrapping text that
is &lt;em&gt;already&lt;/em&gt; translated, which produces double-translated, often uncompilable
code.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These pull against each other: a tool that wraps nothing has zero false positives&lt;br&gt;
and zero recall; one that wraps everything has perfect recall and ruinous false&lt;br&gt;
positives. The bar is doing well on &lt;strong&gt;both at once&lt;/strong&gt;. Each repo below uses a&lt;br&gt;
different convention and is chosen to isolate one axis:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Repo&lt;/th&gt;
&lt;th&gt;Convention&lt;/th&gt;
&lt;th&gt;Axis it measures&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Excalidraw&lt;/strong&gt; (pre-i18n)&lt;/td&gt;
&lt;td&gt;none (raw hardcoded strings)&lt;/td&gt;
&lt;td&gt;recall&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Mattermost&lt;/strong&gt; (&lt;code&gt;admin_console&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;react-intl (&lt;code&gt;formatMessage&lt;/code&gt;/&lt;code&gt;&amp;lt;FormattedMessage&amp;gt;&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;false positives&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Bluesky&lt;/strong&gt; (&lt;code&gt;screens/Settings&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Lingui macros + React Native&lt;/td&gt;
&lt;td&gt;false positives + RN recall&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The false-positive axis is the cleaner measurement and the more dramatic result.&lt;br&gt;
A false positive here is specific and damaging: the tool takes a&lt;br&gt;
string that is &lt;em&gt;already internationalized&lt;/em&gt; and wraps it again, producing&lt;br&gt;
double-translated, often uncompilable code. On the two internationalized repos:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Mattermost re-translation FP&lt;/th&gt;
&lt;th&gt;Bluesky re-translation FP&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;TransLift&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;i18next-cli &lt;code&gt;instrument&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1,892&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;23&lt;/strong&gt; (and broke TypeScript, see §6)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;a18n (&lt;code&gt;--text=capitalized&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;3,751&lt;/td&gt;
&lt;td&gt;254&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;a18n (default &lt;code&gt;--text=cjk&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The single most vivid example is i18next-cli on Mattermost emitting literal&lt;br&gt;
double-translation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;defaultMessage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;t&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;...&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Test is unavailable in this environment&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;defaultMessage&lt;/code&gt; is &lt;em&gt;already&lt;/em&gt; a react-intl translation source. Wrapping it in&lt;br&gt;
&lt;code&gt;t(...)&lt;/code&gt; translates the translation. There are 1,892 of these.&lt;/p&gt;

&lt;p&gt;Now the recall axis, stated &lt;strong&gt;directionally&lt;/strong&gt;. The three tools measure recall&lt;br&gt;
differently (TransLift via its own verdicts, i18next-cli by diff-parsing the&lt;br&gt;
strings it replaced, a18n by grepping inserted calls), so treat these as ±1–2,&lt;br&gt;
not exact:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Excalidraw recall (÷29)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;TransLift&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~90% (26/29)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;i18next-cli &lt;code&gt;instrument&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;~76% (22/29)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;a18n (&lt;code&gt;--text=capitalized&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;100% (29/29), but at 3,751 FP&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;a18n (default &lt;code&gt;cjk&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;TransLift is the only tool that scores on &lt;em&gt;both&lt;/em&gt; axes: high recall &lt;strong&gt;and&lt;/strong&gt; zero&lt;br&gt;
re-translation. But here is the part that matters, and that I want to state before&lt;br&gt;
anything else:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TransLift was ~96% false-positive on Mattermost too, until I specifically&lt;br&gt;
built the guard that recognizes react-intl.&lt;/strong&gt; There is nothing innate. a18n's two&lt;br&gt;
columns (0/0 or 100%/3,751) show the default behavior of a string extractor with&lt;br&gt;
no convention-awareness: either it finds nothing, or it wraps everything&lt;br&gt;
including the already-translated. The entire difference is one capability,&lt;br&gt;
recognizing what's already handled, and it has to be built per convention, by&lt;br&gt;
hand, against real code. The rest of this essay is the story of building it, one&lt;br&gt;
repo at a time, getting it wrong first each time.&lt;/p&gt;

&lt;p&gt;A note on method, kept honest throughout: scoring is &lt;em&gt;positional&lt;/em&gt; (a wrap is an FP&lt;br&gt;
if its source position lands inside an existing i18n construct), computed from each&lt;br&gt;
tool's emitted output by parsing its write diff into &lt;code&gt;{file, line, col}&lt;/code&gt; and&lt;br&gt;
checking containment. Denominators differ by how aggressive each tool is, so the&lt;br&gt;
exact FP counts are directional; the &lt;em&gt;ordering&lt;/em&gt; (0 vs 23 vs 254) is solid. a18n's&lt;br&gt;
&lt;code&gt;capitalized&lt;/code&gt; mode runs it outside its CJK design, shown only for a comparable&lt;br&gt;
English data point, with that caveat attached every time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One paragraph on how TransLift decides, because everything below leans on it.&lt;/strong&gt;&lt;br&gt;
For each string, TransLift asks one question: is this human-facing copy? It answers&lt;br&gt;
in two layers. First, &lt;em&gt;decisive&lt;/em&gt; rules settle the obvious cases outright — a hard&lt;br&gt;
skip (&lt;code&gt;className&lt;/code&gt;, a URL, a &lt;code&gt;console.log&lt;/code&gt; argument) or a hard wrap. Anything left&lt;br&gt;
falls to a &lt;em&gt;weighted score&lt;/em&gt;, where signals add or subtract points and the total&lt;br&gt;
picks the verdict: &lt;strong&gt;wrap&lt;/strong&gt;, &lt;strong&gt;skip&lt;/strong&gt;, or &lt;strong&gt;escalate&lt;/strong&gt; (&lt;em&gt;escalate&lt;/em&gt; = "I'm not&lt;br&gt;
sure, show a human"). The signal that matters most is the &lt;strong&gt;sink&lt;/strong&gt; — a registered&lt;br&gt;
place where user-facing copy is known to live: a component prop&lt;br&gt;
(&lt;code&gt;&amp;lt;Toast message=…&amp;gt;&lt;/code&gt;), a function argument (&lt;code&gt;alert(…)&lt;/code&gt;), or an attribute&lt;br&gt;
(&lt;code&gt;aria-label&lt;/code&gt;). Landing in a sink is strong evidence a string is copy; looking&lt;br&gt;
&lt;em&gt;structural&lt;/em&gt; — an SVG path, a CSS &lt;code&gt;var(…)&lt;/code&gt; — pushes the other way. Three words to&lt;br&gt;
carry into the sections below: a &lt;strong&gt;sink&lt;/strong&gt; (where copy lives), a &lt;strong&gt;boost/penalty&lt;/strong&gt;&lt;br&gt;
(points for or against in the weighted score), and a &lt;strong&gt;decisive&lt;/strong&gt; rule (one that&lt;br&gt;
settles a string before scoring even runs).&lt;/p&gt;


&lt;h2&gt;
  
  
  §1 — Shape-based rules fail in both directions (Excalidraw)
&lt;/h2&gt;

&lt;p&gt;The first real run, against the live Excalidraw checkout rather than my fixtures,&lt;br&gt;
broke the precision claim immediately. My fixture suite said 100% precision.&lt;br&gt;
Real Excalidraw was 86%.&lt;/p&gt;

&lt;p&gt;The false positives were all SVG and CSS attribute values:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;path&lt;/span&gt; &lt;span class="na"&gt;d&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"M39.9 12.3..."&lt;/span&gt; &lt;span class="na"&gt;viewBox&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"0 0 40 40"&lt;/span&gt; &lt;span class="na"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"translate(2 2)"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Icon&lt;/span&gt; &lt;span class="na"&gt;size&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"var(--icon-size, 1rem)"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;d&lt;/code&gt;, &lt;code&gt;viewBox&lt;/code&gt;, &lt;code&gt;transform&lt;/code&gt;, &lt;code&gt;size&lt;/code&gt; reached the weighted-scoring tier and picked&lt;br&gt;
up a generic "attribute carrying a string" boost, because they weren't in any&lt;br&gt;
structural blocklist. i18next-cli skipped all of them correctly. The fix was two&lt;br&gt;
parts: a structural-attribute blocklist (SVG presentation attrs get a penalty),&lt;br&gt;
plus a decisive &lt;em&gt;value-shape&lt;/em&gt; skip. A string that looks like &lt;code&gt;var(...)&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;calc(...)&lt;/code&gt;, a numeric coordinate list, or SVG path data is not copy regardless of&lt;br&gt;
which attribute holds it.&lt;/p&gt;

&lt;p&gt;Then the same run revealed the opposite failure. &lt;code&gt;aria-label="Shade"&lt;/code&gt; was&lt;br&gt;
&lt;strong&gt;silently missed&lt;/strong&gt;: not wrapped, not flagged. The cause was rule &lt;em&gt;ordering&lt;/em&gt;: an&lt;br&gt;
identifier-shaped hard-skip ("&lt;code&gt;Shade&lt;/code&gt; looks like a code identifier, drop it") ran&lt;br&gt;
&lt;em&gt;before&lt;/em&gt; the check for registered attribute sinks. So a legitimately registered&lt;br&gt;
sink lost to a shape heuristic. This was the exact twin of a bug I'd already fixed&lt;br&gt;
for function sinks and scoped out for attributes at the time.&lt;/p&gt;

&lt;p&gt;The lesson generalizes into a principle I leaned on for everything after:&lt;br&gt;
&lt;strong&gt;position beats shape, and rule order is a correctness surface, not a detail.&lt;/strong&gt; A&lt;br&gt;
string's meaning comes from where it sits (a registered sink, a structural attr),&lt;br&gt;
not what its characters look like. When a shape heuristic runs before a structural&lt;br&gt;
rule, it silently overrides better information. Net effect of the fix: Excalidraw&lt;br&gt;
shed all 13 SVG/CSS false positives and, in the same pass, began wrapping the&lt;br&gt;
identifier-shaped &lt;code&gt;aria-label&lt;/code&gt; copy it had been missing, &lt;code&gt;Shade&lt;/code&gt; among them,&lt;br&gt;
with zero new false positives.&lt;/p&gt;


&lt;h2&gt;
  
  
  §2 — The fixture that lied (Excalidraw recall)
&lt;/h2&gt;

&lt;p&gt;My synthetic fixture suite reported &lt;strong&gt;100% recall&lt;/strong&gt;. I believed it. Then I ran a&lt;br&gt;
recall test against reality: check out Excalidraw at the commit &lt;em&gt;just before&lt;/em&gt; it&lt;br&gt;
adopted i18n, run the tool, and score against the strings the maintainers actually&lt;br&gt;
translated in the i18n commit. Real recall was &lt;strong&gt;55%&lt;/strong&gt; (16/29).&lt;/p&gt;

&lt;p&gt;A 45-point gap between the fixtures and the truth, and it was one hidden failure&lt;br&gt;
class: user-facing labels declared as &lt;strong&gt;object-property values&lt;/strong&gt;, not JSX.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;contextItemLabel&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Delete&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Change text alignment&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;My fixtures had JSX text, attributes, function-call arguments: the shapes I&lt;br&gt;
&lt;em&gt;thought&lt;/em&gt; of when writing tests. They had almost no object-property labels,&lt;br&gt;
because it didn't occur to me. The tool scored 100% on the cases I imagined and&lt;br&gt;
55% on the cases the real app actually used.&lt;/p&gt;

&lt;p&gt;The fix is less interesting than how it had to be tuned. My first version reused&lt;br&gt;
the JSX copy-prop allowlist for object keys and produced &lt;strong&gt;159 wraps on current&lt;br&gt;
Excalidraw, ~150 of them false&lt;/strong&gt;, because as object keys, &lt;code&gt;text:&lt;/code&gt;/&lt;code&gt;title:&lt;/code&gt;/&lt;code&gt;label:&lt;/code&gt;&lt;br&gt;
overwhelmingly hold &lt;em&gt;data&lt;/em&gt; (element content, demo data, or already-keys like&lt;br&gt;
&lt;code&gt;label: "labels.alignTop"&lt;/code&gt;), not copy. I had to tighten it empirically to a strict&lt;br&gt;
copy-key set plus a value-shape guard rejecting dotted-key-shaped and empty&lt;br&gt;
values. Recall climbed 55% → 79% → 90% across iterations, each verified in both&lt;br&gt;
directions on real code.&lt;/p&gt;

&lt;p&gt;The lesson is the one I keep relearning: &lt;strong&gt;a green synthetic suite is a liar's&lt;br&gt;
contract.&lt;/strong&gt; It tests your imagination, not the world. The only ground truth is real&lt;br&gt;
repo code, and the only safe way to extend a heuristic is to measure its blast&lt;br&gt;
radius on a real codebase before trusting it.&lt;/p&gt;


&lt;h2&gt;
  
  
  §3 — Re-translation false positives, the actual differentiator (Mattermost)
&lt;/h2&gt;

&lt;p&gt;Mattermost's admin console is 642 files, almost entirely internationalized with&lt;br&gt;
react-intl. It's the perfect false-positive test: nearly every string a naive&lt;br&gt;
extractor sees is &lt;em&gt;already&lt;/em&gt; translated. Run a convention-blind tool here and it&lt;br&gt;
re-wraps the whole codebase.&lt;/p&gt;

&lt;p&gt;That's exactly what happened. i18next-cli's &lt;code&gt;instrument&lt;/code&gt; re-wrapped &lt;strong&gt;1,892&lt;/strong&gt;&lt;br&gt;
react-intl &lt;code&gt;defaultMessage&lt;/code&gt; values into &lt;code&gt;t(...)&lt;/code&gt;, the literal double-translation&lt;br&gt;
shown in §0. a18n in capitalized mode produced 3,751. And, the honest part,&lt;br&gt;
&lt;strong&gt;TransLift's raw behavior here was ~96% false-positive too.&lt;/strong&gt; A string extractor&lt;br&gt;
with no model of react-intl sees &lt;code&gt;defaultMessage: "Save"&lt;/code&gt; and thinks "untranslated&lt;br&gt;
copy, wrap it."&lt;/p&gt;

&lt;p&gt;The fix is a structural signal I called &lt;code&gt;enclosingI18n&lt;/code&gt;: walk a string's ancestors,&lt;br&gt;
and if it sits inside a recognized translation construct (&lt;code&gt;formatMessage(...)&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;&amp;lt;FormattedMessage&amp;gt;&lt;/code&gt;, &lt;code&gt;defineMessages(...)&lt;/code&gt;, &lt;code&gt;&amp;lt;Trans&amp;gt;&lt;/code&gt;), mark it already-translated&lt;br&gt;
and skip it. With that one guard, TransLift's re-translation FP on Mattermost went&lt;br&gt;
to &lt;strong&gt;0&lt;/strong&gt;, while still wrapping the genuine hardcoded copy in the same files.&lt;/p&gt;

&lt;p&gt;This is the differentiator, and I want to frame it precisely. It is not that&lt;br&gt;
TransLift is smarter. It is that recognizing what's already translated is &lt;em&gt;a&lt;br&gt;
feature you can build&lt;/em&gt;, and the other extractors haven't built it. i18next-cli&lt;br&gt;
knows i18next; point it at react-intl and it's blind. a18n knows CJK characters;&lt;br&gt;
point it at English and it either sees nothing or everything. I can't tell from&lt;br&gt;
outside whether that absence is a deliberate scope choice (each tool serving its&lt;br&gt;
own ecosystem) or a gap no one's filled yet; I can only report that on these&lt;br&gt;
three repos, today, neither recognizes a convention it wasn't built for. The moat is&lt;br&gt;
cross-convention awareness, it's achievable, and as the next sections show, it has&lt;br&gt;
to be earned one convention at a time, including the ones you think you already&lt;br&gt;
covered.&lt;/p&gt;


&lt;h2&gt;
  
  
  §4 — Why one guard wasn't enough (react-intl descriptors)
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;enclosingI18n&lt;/code&gt; structural rule from §3 is elegant: it keys off &lt;em&gt;position&lt;/em&gt; in&lt;br&gt;
the AST (the parsed syntax tree the tool reads your code into), the most reliable&lt;br&gt;
signal there is. So my instinct was to make it the&lt;br&gt;
single source of truth and delete the grubbier name-based guard I'd written earlier&lt;br&gt;
(skip anything whose object key is &lt;code&gt;defaultMessage&lt;/code&gt; or &lt;code&gt;id&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;Replacing specific-with-general regressed Mattermost from 144 wraps to 188. The&lt;br&gt;
structural rule had a blind spot.&lt;/p&gt;

&lt;p&gt;react-intl message descriptors are routinely passed around &lt;em&gt;detached&lt;/em&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;messages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;defineMessages&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;greeting&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;app.greeting&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;defaultMessage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Hello there&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="c1"&gt;// ...elsewhere, far from any formatMessage or &amp;lt;FormattedMessage&amp;gt;:&lt;/span&gt;
&lt;span class="nf"&gt;showToast&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;greeting&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;{ id, defaultMessage }&lt;/code&gt; object has no &lt;code&gt;formatMessage&lt;/code&gt;/&lt;code&gt;&amp;lt;FormattedMessage&amp;gt;&lt;/code&gt;&lt;br&gt;
ancestor anywhere near it. The structural walk finds nothing and the descriptor's&lt;br&gt;
&lt;code&gt;defaultMessage&lt;/code&gt; value gets re-wrapped. The grubby name-based guard ("a property&lt;br&gt;
keyed &lt;code&gt;defaultMessage&lt;/code&gt; is never copy to wrap") catches exactly these, because it&lt;br&gt;
keys off the &lt;em&gt;name&lt;/em&gt;, which travels with the object wherever it goes.&lt;/p&gt;

&lt;p&gt;So I kept both. The structural rule covers message children and inline&lt;br&gt;
&lt;code&gt;formatMessage&lt;/code&gt; calls; the name-based guard covers detached descriptors the&lt;br&gt;
structural rule can't see. Neither is sufficient alone.&lt;/p&gt;

&lt;p&gt;The lesson is about epistemics, not code: &lt;strong&gt;you cannot predict from first&lt;br&gt;
principles which rule you'll need.&lt;/strong&gt; The elegant general rule and the ugly specific&lt;br&gt;
one cover different &lt;em&gt;real-world shapes&lt;/em&gt;, and only real codebases reveal which&lt;br&gt;
shapes exist. I wanted one clean rule. The codebase wanted two. The codebase was&lt;br&gt;
right.&lt;/p&gt;


&lt;h2&gt;
  
  
  §5 — Wrapper and HOC resolution (validated on fixtures; real-world is future work)
&lt;/h2&gt;

&lt;p&gt;A common real-world pattern hides a registered sink behind a wrapper — a styling&lt;br&gt;
helper or a higher-order component (HOC) that takes the real component and returns&lt;br&gt;
a new one under a new name:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;StyledToast&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;styled&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;Toast&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;        &lt;span class="c1"&gt;// or memo(Toast), withTracking(Toast)&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;StyledToast&lt;/span&gt; &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Saved"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;           &lt;span class="c1"&gt;// AST sees "StyledToast", registry has "Toast"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The AST sees &lt;code&gt;StyledToast&lt;/code&gt;; the registry knows &lt;code&gt;Toast&lt;/code&gt;; the relationship is&lt;br&gt;
invisible to name matching and the trace dead-ends. TransLift resolves this by&lt;br&gt;
asking the TypeScript type checker for the &lt;em&gt;resolved prop type&lt;/em&gt; of the wrapped&lt;br&gt;
component rather than trying to follow the wrapper call, because TypeScript&lt;br&gt;
already computes &lt;code&gt;StyledToast&lt;/code&gt;'s props through &lt;code&gt;styled()&lt;/code&gt;, &lt;code&gt;ComponentProps&amp;lt;typeof&lt;br&gt;
Toast&amp;gt;&lt;/code&gt;, intersections, and so on. It unwraps &lt;code&gt;styled(X)&lt;/code&gt; / &lt;code&gt;memo(X)&lt;/code&gt; / &lt;code&gt;withX(Y)&lt;/code&gt;&lt;br&gt;
/ &lt;code&gt;connect()()&lt;/code&gt; to the inner registered component by inspecting the declaration's&lt;br&gt;
call arguments. react-docgen-typescript resolves props through wrappers the same&lt;br&gt;
way to power Storybook's prop tables, so the approach has precedent.&lt;/p&gt;

&lt;p&gt;The bound: this is validated on clean, two-layer fixtures only. &lt;code&gt;styled(Toast)&lt;/code&gt;&lt;br&gt;
works in a synthetic test; real design-system composition (Grafana's&lt;br&gt;
&lt;code&gt;@grafana/ui&lt;/code&gt; chains, Backstage's MUI &lt;code&gt;styled&lt;/code&gt; + HOC stacks, wrappers nested&lt;br&gt;
several layers deep across typed and untyped boundaries) is exactly the shape §2&lt;br&gt;
warns about, where a green fixture suite hid an entire failure class on real code.&lt;br&gt;
So I cap the claim at the evidence: the type-driven approach resolves the common&lt;br&gt;
single-wrapper cases, and real-world design-system coverage is future work.&lt;br&gt;
Render-wrappers (&lt;code&gt;forwardRef((p, ref) =&amp;gt; …)&lt;/code&gt;, a hand-written component that&lt;br&gt;
internally renders another) and untyped JS still require a manual registry entry.&lt;/p&gt;


&lt;h2&gt;
  
  
  §6 — The crash that falsified my own convention-awareness (Bluesky)
&lt;/h2&gt;

&lt;p&gt;Bluesky is a real Lingui codebase, and React Native rather than web. I added it to&lt;br&gt;
the benchmark expecting it to &lt;em&gt;validate&lt;/em&gt; two things I'd shipped on faith: that my&lt;br&gt;
foreign-i18n guard covered Lingui, and that the adapter handled RN's &lt;code&gt;&amp;lt;Text&amp;gt;&lt;/code&gt;&lt;br&gt;
component vocabulary. I'd built the guard against react-intl and added &lt;code&gt;&amp;lt;Trans&amp;gt;&lt;/code&gt;&lt;br&gt;
(which Lingui also uses) to the recognized list, so I assumed Lingui was covered.&lt;/p&gt;

&lt;p&gt;Instead, on the very first run, &lt;code&gt;extract&lt;/code&gt; &lt;strong&gt;crashed&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;TypeError: Property quasi of TaggedTemplateExpression expected node to be of a
type ["TemplateLiteral"] but instead got "CallExpression"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Exit 1, nothing written. The guard I thought covered Lingui covered exactly one of&lt;br&gt;
its forms, the &lt;code&gt;&amp;lt;Trans&amp;gt;&lt;/code&gt; JSX element, and missed the form Bluesky actually uses&lt;br&gt;
overwhelmingly: the macro &lt;code&gt;_(msg`Require alt text`)&lt;/code&gt;. There are 1,894 &lt;code&gt;msg&lt;/code&gt;&lt;br&gt;
macros in Bluesky's source versus 1,560 &lt;code&gt;&amp;lt;Trans&amp;gt;&lt;/code&gt; elements. The dominant Lingui&lt;br&gt;
idiom was the one my guard had never seen, and the failure wasn't a quiet&lt;br&gt;
false-positive. It was a hard crash, because the mutator tried to replace a&lt;br&gt;
template literal that was the &lt;code&gt;.quasi&lt;/code&gt; of a tagged-template macro, which violates a&lt;br&gt;
babel AST invariant.&lt;/p&gt;

&lt;p&gt;This is the cleanest vindication of the whole "convention-awareness must be earned&lt;br&gt;
per convention" thesis, so I want to be exact about the arc, including the missteps:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;First reading (wrong):&lt;/strong&gt; I measured 156 "re-translation FPs", but that was a
count of dry-run &lt;em&gt;verdicts&lt;/em&gt;, not emitted output. The tool never got that far.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Second reading (also wrong):&lt;/strong&gt; a write-mode run showed 0 files changed, which
I briefly read as "the guard works." It was a &lt;em&gt;crashed&lt;/em&gt; run: 0 files changed
because &lt;code&gt;extract&lt;/code&gt; threw, not because it cleanly skipped.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Root cause:&lt;/strong&gt; pinned to one line in the mutator's &lt;code&gt;TemplateLiteral&lt;/code&gt; visitor.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fix:&lt;/strong&gt; recognize all three Lingui macro shapes (the tagged template
&lt;code&gt;_(msg`…`)&lt;/code&gt;, the descriptor call &lt;code&gt;msg({ message, context })&lt;/code&gt;, and the
&lt;code&gt;&amp;lt;Plural&amp;gt;&lt;/code&gt;/&lt;code&gt;&amp;lt;Select&amp;gt;&lt;/code&gt; components), plus a mutator fail-safe that never replaces
a tagged-template quasi, so any future mis-score degrades to a no-op instead of
a crash.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Result:&lt;/strong&gt; re-translation FP on the Settings slice went 156 → 20 → 2 → &lt;strong&gt;0&lt;/strong&gt;
across the three shapes; &lt;strong&gt;190/190 tests pass&lt;/strong&gt;; three regression tests pin the
exact Bluesky shapes, including the crash case.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I'm keeping the two wrong readings in this write-up on purpose. They're not flaws&lt;br&gt;
in the finding; they're what the finding actually looked like before it resolved,&lt;br&gt;
and a write-up that pretends the root cause was obvious on first look is lying the&lt;br&gt;
same way a green fixture suite lies.&lt;/p&gt;

&lt;p&gt;The competitor data point on the same repo is its own §0-grade example. i18next-cli&lt;br&gt;
on Bluesky didn't just re-translate (23 cases of &lt;code&gt;t()&lt;/code&gt; nested inside &lt;code&gt;_(msg(...))&lt;/code&gt;).&lt;br&gt;
It &lt;strong&gt;broke TypeScript&lt;/strong&gt;, wrapping a &lt;em&gt;type annotation&lt;/em&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Props&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;NativeStackScreenProps&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;CommonNavigatorParams&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i18next&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;t&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;aboutsettings&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;AboutSettings&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That doesn't compile. A string in type position is not runtime copy, and a&lt;br&gt;
convention-blind codemod can't tell the difference.&lt;/p&gt;

&lt;p&gt;The RN recall axis came out clean, &lt;strong&gt;~31/32&lt;/strong&gt;, measured by&lt;br&gt;
un-translating real Bluesky RN files (the stripped Lingui messages are the ground&lt;br&gt;
truth) and checking how many the tool recovers. &lt;code&gt;&amp;lt;Text&amp;gt;&lt;/code&gt; children and&lt;br&gt;
&lt;code&gt;accessibilityLabel&lt;/code&gt;/&lt;code&gt;accessibilityHint&lt;/code&gt; all wrap correctly; the single miss is a&lt;br&gt;
single-word &lt;code&gt;&amp;lt;ButtonText&amp;gt;{"Submit"}&amp;lt;/ButtonText&amp;gt;&lt;/code&gt;, the same identifier-shaped-label&lt;br&gt;
class as Excalidraw's "Code"/"Normal" misses. The RN component vocabulary didn't&lt;br&gt;
choke.&lt;/p&gt;

&lt;p&gt;The lesson is the thesis, sharpened: &lt;strong&gt;convention-awareness is real but fragile&lt;br&gt;
per-convention. A convention you haven't tested against real code is a latent bug;&lt;br&gt;
here, a latent crash.&lt;/strong&gt; The differentiator isn't a property you have; it's a debt&lt;br&gt;
you keep paying, one codebase at a time.&lt;/p&gt;




&lt;h2&gt;
  
  
  §7 — What I still don't trust
&lt;/h2&gt;

&lt;p&gt;A write-up that ends on the wins is marketing. Here's the honest ledger.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The fixture-vs-real gap is managed, not closed.&lt;/strong&gt; §2 is a permanent hazard, not&lt;br&gt;
a solved problem. Every heuristic I have could be hiding another 45-point gap&lt;br&gt;
behind a convention I haven't benchmarked. The mitigation (benchmark against&lt;br&gt;
real pre-i18n checkouts) only covers the conventions I've thought to test.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Recall numbers are directional, measured differently per tool.&lt;/strong&gt; The 90-vs-76&lt;br&gt;
Excalidraw gap is real and large enough to mean something; I would not defend the&lt;br&gt;
exact digits. Cross-tool recall comparison via three different measurement&lt;br&gt;
methods (own verdicts vs. diff-parsing vs. grep) is inherently ±1–2.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Bluesky RN recall is synthetic and small-sample.&lt;/strong&gt; ~31/32 is one slice,&lt;br&gt;
reconstructed by un-translating real files rather than a true pre-i18n checkout&lt;br&gt;
(Bluesky adopted Lingui too early for that). Read it as "the RN adapter clearly&lt;br&gt;
works," not as a precise figure.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Wrapper/HOC resolution (§5) is unproven on real design systems.&lt;/strong&gt; Fixtures&lt;br&gt;
only. I flagged this hardest because it's where I'm most likely wrong, and the&lt;br&gt;
one place a future benchmark (Grafana, Backstage) could still overturn a claim.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The residual identifier-shaped-label misses persist&lt;/strong&gt; across all three repos:&lt;br&gt;
"Code", "Normal", and "Submit", single-word labels in unregistered components that&lt;br&gt;
look like code identifiers. Consistent, understood, not yet fixed.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;a18n is run outside its design.&lt;/strong&gt; Its capitalized-mode numbers exist only to&lt;br&gt;
give an English data point; its real CJK mode is a different tool for a different&lt;br&gt;
job, and the comparison says nothing about how good it is at that job.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The thread through all six problems is one idea: in static string extraction,&lt;br&gt;
&lt;em&gt;location is tractable and meaning is not.&lt;/em&gt; The symbol graph can tell you exactly&lt;br&gt;
where a string flows: through props, wrappers, function calls, to its terminal&lt;br&gt;
position. What it cannot tell you is whether that string is &lt;em&gt;copy&lt;/em&gt;. Every fix in&lt;br&gt;
this essay is a hand-built rule layered on top of the graph to answer the meaning&lt;br&gt;
question for one more shape, one more convention. That's not a deficiency to be&lt;br&gt;
engineered away; it's the irreducible core of the problem.&lt;/p&gt;

&lt;p&gt;Convention-awareness, knowing what's already translated, is the capability that&lt;br&gt;
separates a usable extractor from one that double-translates a codebase. It is&lt;br&gt;
achievable. The other tools haven't built it. I built it three times, for three&lt;br&gt;
conventions, and got it wrong first each time. That's the finding: not that the&lt;br&gt;
problem is solved, but that it's earnable, and the earning leaves an audit trail of&lt;br&gt;
exactly the kind of mistakes above.&lt;/p&gt;

</description>
      <category>react</category>
      <category>typescript</category>
      <category>i18n</category>
      <category>staticanalysis</category>
    </item>
  </channel>
</rss>
