<?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: Oscar Green</title>
    <description>The latest articles on DEV Community by Oscar Green (@owgreen).</description>
    <link>https://dev.to/owgreen</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%2F3119068%2Ff516b26b-d3e2-4391-9c14-d0e122ae11bf.png</url>
      <title>DEV Community: Oscar Green</title>
      <link>https://dev.to/owgreen</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/owgreen"/>
    <language>en</language>
    <item>
      <title>Your CI checks your types. It doesn't check your translations.</title>
      <dc:creator>Oscar Green</dc:creator>
      <pubDate>Sat, 22 Aug 2026 11:26:30 +0000</pubDate>
      <link>https://dev.to/owgreen/your-ci-checks-your-types-it-doesnt-check-your-translations-lof</link>
      <guid>https://dev.to/owgreen/your-ci-checks-your-types-it-doesnt-check-your-translations-lof</guid>
      <description>&lt;p&gt;Your pipeline lints your JavaScript. It typechecks your types. It runs your tests, checks your&lt;br&gt;
formatting, audits your dependencies, and scans for secrets.&lt;/p&gt;

&lt;p&gt;Then it ships a &lt;code&gt;de.json&lt;/code&gt; that nobody has read, produced by a model that nobody audited, straight to&lt;br&gt;
production.&lt;/p&gt;

&lt;p&gt;Here is a bug I planted in a test corpus. It is representative of the real thing:&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;en&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Your&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;changes&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;were&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;saved&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;successfully."&lt;/span&gt;
&lt;span class="na"&gt;es&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;No&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;se&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;pudieron&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;guardar&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;tus&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;cambios."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Spanish says &lt;em&gt;your changes could not be saved&lt;/em&gt;. Not a near-miss — the exact inverse of the&lt;br&gt;
English. Same key. No placeholders in either string, so nothing to drop. Valid JSON. Byte-identical&lt;br&gt;
key sets.&lt;/p&gt;

&lt;p&gt;Every i18n checker I know of passes that file.&lt;/p&gt;
&lt;h2&gt;
  
  
  What key-diffing can and can't see
&lt;/h2&gt;

&lt;p&gt;The standard tooling compares the shape of your locale files. Take the source tree, take the target&lt;br&gt;
tree, flatten both, diff the key sets. You get:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;keys in the source that are missing from the target&lt;/li&gt;
&lt;li&gt;keys in the target that no longer exist in the source&lt;/li&gt;
&lt;li&gt;sometimes: empty values, or values identical to the source&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is genuinely useful and you should absolutely have it. It catches the single most common i18n&lt;br&gt;
defect — the key someone added on Friday that nobody translated. It runs in milliseconds, it is&lt;br&gt;
deterministic, and it costs nothing.&lt;/p&gt;

&lt;p&gt;But look at what it is actually examining. It reads the &lt;em&gt;keys&lt;/em&gt;. It does not read the &lt;em&gt;values&lt;/em&gt;. Every&lt;br&gt;
check in that list is a statement about structure, and the class of bug above is not a structural&lt;br&gt;
bug. The structure is perfect. The meaning is inverted.&lt;/p&gt;

&lt;p&gt;There is a second tier that structural tools can reach, and it is worth doing well: placeholder&lt;br&gt;
drift.&lt;/p&gt;
&lt;h2&gt;
  
  
  Placeholder drift, which is structural and still hard
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;en&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Hello&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;{{name}},&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;you&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;have&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;{{count}}&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;new&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;messages"&lt;/span&gt;
&lt;span class="na"&gt;es&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Hola,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;tienes&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;mensajes&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;nuevos"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Both placeholders are gone. Depending on your i18n library and your framework, this either renders&lt;br&gt;
as a sentence that has lost its subject and its number, or it throws at runtime, or — my favourite —&lt;br&gt;
it silently renders the literal string &lt;code&gt;{{name}}&lt;/code&gt; to a user in production.&lt;/p&gt;

&lt;p&gt;The reason this is harder than it looks is that "placeholder" is not one syntax. In a mixed codebase&lt;br&gt;
you will meet:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Syntax&lt;/th&gt;
&lt;th&gt;Where&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;{{name}}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;i18next, Vue I18n, Handlebars&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;{name}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;ICU MessageFormat, react-intl&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;%s&lt;/code&gt;, &lt;code&gt;%d&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;printf-style, gettext&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;%1$s&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;positional printf — Android, and reorderable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;%@&lt;/code&gt;, &lt;code&gt;%lld&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Apple &lt;code&gt;.strings&lt;/code&gt; / &lt;code&gt;.xcstrings&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;%{name}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Ruby i18n&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;$t(key)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;i18next interpolation of another key&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;&amp;lt;0&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;b&amp;gt;&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;react-i18next Trans components, inline HTML&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A checker that only knows &lt;code&gt;{{...}}&lt;/code&gt; will report a clean file for a tree full of &lt;code&gt;%1$s&lt;/code&gt;. And&lt;br&gt;
positional printf has a failure mode of its own: a translator may legitimately reorder &lt;code&gt;%1$s&lt;/code&gt; and&lt;br&gt;
&lt;code&gt;%2$s&lt;/code&gt; to fit target grammar, so "the placeholders appear in a different order" is &lt;em&gt;correct&lt;/em&gt; and must&lt;br&gt;
not be flagged, while "&lt;code&gt;%2$s&lt;/code&gt; disappeared entirely" is a bug.&lt;/p&gt;

&lt;p&gt;Direction matters too. A placeholder that exists in the source and is gone from the translation is an&lt;br&gt;
error. A placeholder &lt;em&gt;invented&lt;/em&gt; in the translation that has no counterpart in the source is a&lt;br&gt;
different, weirder problem — usually a hallucination — and it deserves a warning rather than a hard&lt;br&gt;
failure, because occasionally it is deliberate.&lt;/p&gt;

&lt;p&gt;So: do the structural pass, do it across every syntax, and get the direction and the positional case&lt;br&gt;
right. That covers most defects. It does not cover the inverted sentence.&lt;/p&gt;
&lt;h2&gt;
  
  
  Using an LLM as the judge, carefully
&lt;/h2&gt;

&lt;p&gt;The obvious move is to have a model read each source/translation pair and say whether the translation&lt;br&gt;
means what the source means. The obvious move is also where most of the danger is, so it is worth&lt;br&gt;
being precise about the failure modes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Judge scores are unstable across runs.&lt;/strong&gt; This is well documented — the GEMBA line of work found&lt;br&gt;
single-pass LLM quality scores swinging wildly on identical input. If you ask once and act on the&lt;br&gt;
answer, you have built a random number generator with good manners. The mitigation is to ask N times&lt;br&gt;
and take the majority; I default to 3 passes and discard any pass that fails to parse rather than&lt;br&gt;
counting it as a non-flag.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A noisy gate gets uninstalled.&lt;/strong&gt; This is the part I feel strongest about. If a check fails builds&lt;br&gt;
on judgment calls about tone, the first thing a team does is turn it off, and then they have neither&lt;br&gt;
the semantic check nor the structural one. So the semantic findings are &lt;strong&gt;advisory by default&lt;/strong&gt; —&lt;br&gt;
they surface as warnings and do not change your exit code unless you explicitly opt in with&lt;br&gt;
&lt;code&gt;--semantic-fail&lt;/code&gt;. The structural checks, which are deterministic, are the ones allowed to fail your&lt;br&gt;
build.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Don't pay a model to look at something you already know is broken.&lt;/strong&gt; Keys that already carry a&lt;br&gt;
structural error are excluded from judging entirely. If a string has dropped its placeholder, you do&lt;br&gt;
not need an opinion about its semantics; you need to fix the placeholder. This is also the cost&lt;br&gt;
story: on a tree where everything is broken, the judge makes zero calls.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Only judge what changed.&lt;/strong&gt; Verdicts are cached by a hash of the source/translation pair. A re-run&lt;br&gt;
where nothing changed costs nothing at all — literally zero model calls. This is what makes it&lt;br&gt;
viable in CI rather than a thing you run once and disable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Give it the categories, not a score.&lt;/strong&gt; Instead of "rate this 1-100", the judge returns an MQM-style&lt;br&gt;
error type: mistranslation, omission, or addition. These are checkable. A number is not.&lt;/p&gt;

&lt;p&gt;Here is what each of those looks like, from the corpus:&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="s"&gt;mistranslation&lt;/span&gt;
  &lt;span class="s"&gt;en&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Your&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;changes&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;were&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;saved&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;successfully."&lt;/span&gt;
  &lt;span class="na"&gt;es&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;No&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;se&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;pudieron&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;guardar&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;tus&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;cambios."&lt;/span&gt;          &lt;span class="s"&gt;← "could not be saved"&lt;/span&gt;

&lt;span class="s"&gt;omission&lt;/span&gt;
  &lt;span class="s"&gt;en&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Save&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;your&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;work&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;before&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;closing&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;the&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;window,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;or&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;unsaved&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;changes&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;will&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;be&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;lost."&lt;/span&gt;
  &lt;span class="na"&gt;es&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Guarda&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;tu&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;trabajo&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;antes&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;de&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;cerrar&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;la&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;ventana."&lt;/span&gt;  &lt;span class="s"&gt;← the consequence is gone&lt;/span&gt;

&lt;span class="s"&gt;addition&lt;/span&gt;
  &lt;span class="s"&gt;en&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Your&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;file&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;has&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;been&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;uploaded."&lt;/span&gt;
  &lt;span class="na"&gt;es&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Tu&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;archivo&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;se&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;ha&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;subido&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;y&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;se&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;compartirá&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;con&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;todo&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;tu&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;equipo."&lt;/span&gt;
                                          &lt;span class="s"&gt;← "and will be shared with your whole team"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That last one is my favourite category, because it is the one no human reviewer catches by skimming.&lt;br&gt;
The Spanish reads beautifully. It is fluent, natural, correctly conjugated — and it promises your&lt;br&gt;
users something your product does not do.&lt;/p&gt;

&lt;p&gt;There is a fourth check that needs no model at all: a glossary. You list the terms that must never be&lt;br&gt;
translated — product names, "OAuth", "webhook" — and the terms that must always be translated one&lt;br&gt;
specific way. That is a deterministic string check, and it catches this:&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;en&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Shipi18n&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;checks&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;your&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;translations&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;in&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;CI."&lt;/span&gt;
&lt;span class="na"&gt;es&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;EnvíoI18n&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;comprueba&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;tus&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;traducciones&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;en&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;CI."&lt;/span&gt;     &lt;span class="s"&gt;← it translated the product name&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Measuring it instead of claiming it
&lt;/h2&gt;

&lt;p&gt;Here is the part I would want to interrogate if I were reading someone else's post, so let me be&lt;br&gt;
explicit about the methodology before the numbers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The corpus was committed before the judge existed.&lt;/strong&gt; 228 source/translation pairs across Spanish,&lt;br&gt;
German and Japanese: 168 clean pairs taken from real production locale files, and 60 with deliberately&lt;br&gt;
planted errors — 18 mistranslations, 18 omissions, 18 additions, 6 glossary violations. It went into&lt;br&gt;
git at commit &lt;code&gt;60d699b&lt;/code&gt;, and the judge was written afterwards.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The thresholds were fixed before the implementation, too.&lt;/strong&gt; Catch rate ≥80%, per-category recall&lt;br&gt;
≥60%, false positives &amp;lt;10%, glossary recall 100%. Writing the thresholds after seeing the results is&lt;br&gt;
how you produce a number that means nothing, and it is very easy to do accidentally.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The runner is the gate.&lt;/strong&gt; It exits non-zero when a threshold is missed, so I cannot quietly ship a&lt;br&gt;
regression and keep quoting the old figures.&lt;/p&gt;

&lt;p&gt;The results, on &lt;code&gt;claude-haiku-4-5&lt;/code&gt; with 3 passes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;catch rate         54/54 planted errors flagged      (100%)
per-category       mistranslation 100% · omission 100% · addition 100%
false positives    12/168 clean pairs flagged        (7.1%)
glossary           6/6 caught, 0 false
label accuracy     100% this run — 98.1%–100% across three runs
cost               48 model calls · ~59k tokens · 138s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things about those numbers that matter more than the numbers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Label accuracy is a range, not a constant.&lt;/strong&gt; Across runs a day apart it was 100% and then 98.1%.&lt;br&gt;
The catch rate and false-positive rate reproduced exactly; the labels moved. If you see a single&lt;br&gt;
crisp percentage quoted for anything a language model produces, ask how many times they ran it. I&lt;br&gt;
published the range because publishing the better of two runs would have been a lie of selection.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The false-positive rate is the number I care about, and it is not zero.&lt;/strong&gt; Twelve clean pairs&lt;br&gt;
got flagged. Here is what they actually look like:&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="nv"&gt;es&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;i18n&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Localization&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Blog&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;|&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Shipi18n"&lt;/span&gt;
     &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Blog&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;API&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;de&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;traducción&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;de&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Shipi18n"&lt;/span&gt;

&lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;de&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Tutorials&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;and&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;guides&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;on&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;i18next,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;React&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;i18n,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Next.js&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;localization…"&lt;/span&gt;
     &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Erfahren&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Sie&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;mehr&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;über&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Internationalisierung,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Best&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Practices…"&lt;/span&gt;

&lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;ja&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;View&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;@shipi18n/mcp&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;on&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;npm"&lt;/span&gt;
     &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;npmで@shipi18n/mcpを見る"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I want to be precise about these, because "false positive" is doing different work in each one.&lt;/p&gt;

&lt;p&gt;The first is &lt;strong&gt;not a false positive at all — the judge is right and I was wrong.&lt;/strong&gt; The Spanish says&lt;br&gt;
"Shipi18n &lt;em&gt;translation API&lt;/em&gt;", which is a product that no longer exists. That is stale copy sitting in&lt;br&gt;
my locale files, and the only reason it counts against the tool in this table is that my corpus&lt;br&gt;
labelled it clean. Three of the twelve are that same category of thing.&lt;/p&gt;

&lt;p&gt;The second is a judgment call. The German is a &lt;em&gt;rewrite&lt;/em&gt; rather than a translation — it conveys the&lt;br&gt;
gist and restructures the sentence. Whether that is an error depends on whether you think marketing&lt;br&gt;
copy should be translated or transcreated, and reasonable people disagree.&lt;/p&gt;

&lt;p&gt;The third is a real false positive. The Japanese is fine. The judge flagged it anyway.&lt;/p&gt;

&lt;p&gt;So the honest reading of "7.1%" is: some of it is the tool being right about data I mislabelled, some&lt;br&gt;
is the tool having opinions about tone, and some is just wrong. I report the whole 7.1% rather than&lt;br&gt;
relitigating the labels in my favour, because a corpus you edit after seeing the results is not a&lt;br&gt;
corpus any more.&lt;/p&gt;

&lt;p&gt;These are the cost of the feature. Some are arguments about tone rather than errors. On a real&lt;br&gt;
production tree the flag rate came out around 3.6%, and when I went through those by hand, several of&lt;br&gt;
the "false positives" turned out to be true catches of stale copy I had forgotten about — the site&lt;br&gt;
still described a "translation API" that no longer existed, and the Japanese used 連携 ("integration&lt;br&gt;
/ linkage") for a nav item that meant something narrower.&lt;/p&gt;

&lt;p&gt;That is the honest shape of this feature: it finds real problems, and it also wants to talk to you&lt;br&gt;
about things that are fine. Which is exactly why it does not fail your build.&lt;/p&gt;
&lt;h2&gt;
  
  
  What it found in my own project
&lt;/h2&gt;

&lt;p&gt;The most useful thing I can tell you about this tool is what it failed to catch.&lt;/p&gt;

&lt;p&gt;I built it, dogfooded it on my own site — eleven languages — and it passed. Then, doing an unrelated&lt;br&gt;
review, I found two things that had been live for weeks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The documentation described a feature that did not work.&lt;/strong&gt; My MCP server was advertised as being&lt;br&gt;
able to translate with no API key at all, via a mechanism called MCP sampling, where the client's&lt;br&gt;
model does the inference. That mechanism was deprecated in the spec, and Claude Desktop — the client&lt;br&gt;
my own setup instructions told people to configure — never implemented it. The claim was live in&lt;br&gt;
eleven languages. It was a translation of a sentence that was false in English.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The copy-paste command on my own homepage exited 1.&lt;/strong&gt; The hero command was &lt;code&gt;npx @shipi18n/cli&lt;br&gt;
translate ...&lt;/code&gt;. If you ran it exactly as printed, it failed, because &lt;code&gt;npx&lt;/code&gt; installs the CLI but not&lt;br&gt;
the provider SDK, which is an optional peer dependency. The first command a visitor would ever run&lt;br&gt;
did not work, in the README and on the homepage, for weeks.&lt;/p&gt;

&lt;p&gt;My QA tool caught neither, and it was right not to: neither is a translation defect. The German&lt;br&gt;
translation of a false sentence is a perfectly good translation. This is the boundary of the thing —&lt;br&gt;
it checks that your translations say what your source says. It has no opinion about whether your&lt;br&gt;
source is true.&lt;/p&gt;

&lt;p&gt;Both are fixed. I am telling you because a post about a QA tool that only lists what the tool catches&lt;br&gt;
is an advertisement, and because the second one is a genuinely useful warning about optional peer&lt;br&gt;
dependencies and &lt;code&gt;npx&lt;/code&gt; that has nothing to do with i18n.&lt;/p&gt;
&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;The structural half needs no API key, no account, and no config:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx @shipi18n/cli check ./locales &lt;span class="nt"&gt;-s&lt;/span&gt; en
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;JSON, Flutter &lt;code&gt;.arb&lt;/code&gt;, Apple &lt;code&gt;.xcstrings&lt;/code&gt;. Human output, JSON, SARIF so GitHub annotates your PRs&lt;br&gt;
inline, or JUnit.&lt;/p&gt;

&lt;p&gt;The semantic half needs your own key, and only ever talks to the provider you picked:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm i &lt;span class="nt"&gt;-D&lt;/span&gt; @shipi18n/cli @anthropic-ai/sdk
npx shipi18n check ./locales &lt;span class="nt"&gt;-s&lt;/span&gt; en &lt;span class="nt"&gt;--semantic&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is also an MCP server, if you would rather have your agent do it — its validator tools make no&lt;br&gt;
model calls at all; the review tool hands the pairs and the criteria back to the agent, which judges&lt;br&gt;
with its own inference.&lt;/p&gt;

&lt;p&gt;If you want to reproduce the example this article opened with, it takes four commands:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; demo/locales &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd &lt;/span&gt;demo &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; npm init &lt;span class="nt"&gt;-y&lt;/span&gt;
npm i &lt;span class="nt"&gt;-D&lt;/span&gt; @shipi18n/cli @anthropic-ai/sdk
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'{"saved":"Your changes were saved successfully."}'&lt;/span&gt;  &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; locales/en.json
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'{"saved":"No se pudieron guardar tus cambios."}'&lt;/span&gt;    &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; locales/es.json
npx shipi18n check ./locales &lt;span class="nt"&gt;-s&lt;/span&gt; en              &lt;span class="c"&gt;# passes — nothing structural is wrong&lt;/span&gt;
npx shipi18n check ./locales &lt;span class="nt"&gt;-s&lt;/span&gt; en &lt;span class="nt"&gt;--semantic&lt;/span&gt;   &lt;span class="c"&gt;# catches it&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second command prints:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;warning  saved  semantic-mistranslation — Source states changes were saved
         successfully; translation states changes could not be saved (opposite meaning)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and still exits 0, because it is advisory.&lt;/p&gt;

&lt;p&gt;Apache-2.0, no server, no telemetry: &lt;strong&gt;&lt;a href="https://github.com/Shipi18n/shipi18n" rel="noopener noreferrer"&gt;https://github.com/Shipi18n/shipi18n&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The eval harness and the whole corpus are in the repo under &lt;code&gt;evals/semantic/&lt;/code&gt;. If you want to know&lt;br&gt;
whether these numbers hold for your language pair, your domain, or a different judge model, run it —&lt;br&gt;
&lt;code&gt;JUDGE_MODEL=... node evals/semantic/run.mjs&lt;/code&gt;. I would genuinely rather hear that you made it look&lt;br&gt;
worse than never hear from you. The false-positive rate is where I expect it to break first.&lt;/p&gt;

</description>
      <category>testing</category>
      <category>opensource</category>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>tsc-correctness != runtime-correctness</title>
      <dc:creator>Oscar Green</dc:creator>
      <pubDate>Sun, 24 May 2026 12:28:12 +0000</pubDate>
      <link>https://dev.to/owgreen/tsc-correctness-runtime-correctness-3ol5</link>
      <guid>https://dev.to/owgreen/tsc-correctness-runtime-correctness-3ol5</guid>
      <description>&lt;p&gt;Here is a TypeScript error you have probably seen.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;src/Header.tsx:3:10 - error TS2614: Module '"./logo.svg"' has no exported
  member 'ReactComponent'. Did you mean to use 'import Logo from "./logo.svg"'
  instead?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;tsc helpfully tells you the fix. Your editor offers it as a one-click Quick Fix. An LLM doing code repair will reliably take it. After the fix:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Logo&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./logo.svg&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;//                            ← tsc is now green&lt;/span&gt;
&lt;span class="c1"&gt;//                            ← the dev server now crashes&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fixed file type-checks. The app is broken. &lt;code&gt;Logo&lt;/code&gt; is now the asset URL string, not a React component, so &lt;code&gt;&amp;lt;Logo /&amp;gt;&lt;/code&gt; blows up with "Logo is not a function" the moment the page renders.&lt;/p&gt;

&lt;p&gt;This is the gap LLM-driven code repair walks into every day, and the gap &lt;a href="https://github.com/owgreen-dev/tsfix" rel="noopener noreferrer"&gt;tsfix&lt;/a&gt; is built around.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three characters
&lt;/h2&gt;

&lt;p&gt;Three things shape the LLM-repair failure in this case, and they all have to be in the room to see why it happens.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The library author.&lt;/strong&gt; &lt;code&gt;vite-plugin-svgr&lt;/code&gt; v4 changed how you import an SVG as a React component. The default import is now the asset URL, and you have to opt in to the React-component import with a &lt;code&gt;?react&lt;/code&gt; query suffix:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Logo&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./logo.svg?react&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;Reasonable choice for them: it makes the plugin's default behavior match what every other Vite asset import does, and the &lt;code&gt;?react&lt;/code&gt; suffix is explicit. Breaking change, but small. Their migration guide says exactly this.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The TypeScript team.&lt;/strong&gt; TS doesn't know about Vite plugins. All it sees is a &lt;code&gt;*.svg&lt;/code&gt; ambient module declaration. If your declaration says &lt;code&gt;export const ReactComponent: ...&lt;/code&gt; and you import &lt;code&gt;{ ReactComponent }&lt;/code&gt;, that's a named import that doesn't exist — TS2614. Its Quick Fix logic looks for plausible alternative imports from the same module, finds the &lt;code&gt;default&lt;/code&gt; export, and suggests &lt;code&gt;import Logo from "./logo.svg"&lt;/code&gt;. From TS's seat, that's a clean suggestion. It makes the program type-check.&lt;/p&gt;

&lt;p&gt;TS is right about types and wrong about runtime semantics. It has no way to be otherwise.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The LLM.&lt;/strong&gt; Trained on a corpus that is, overwhelmingly, "code that compiles." Pre-v4 vite-plugin-svgr exported &lt;code&gt;ReactComponent&lt;/code&gt; as a named member — millions of training tokens still say so. v4 docs exist but they're a thin slice of the data. When tsc says "use the default import instead," the LLM agrees. It would have suggested the same fix on its own.&lt;/p&gt;

&lt;p&gt;The LLM is not being lazy. It is doing exactly what its training distribution and the compiler's hint tell it to do. They both happen to be wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix is not the LLM. The fix is the prompt context.
&lt;/h2&gt;

&lt;p&gt;tsfix reads your &lt;code&gt;package.json&lt;/code&gt; on every Layer-2 invocation. If it sees &lt;code&gt;vite-plugin-svgr&lt;/code&gt; at v4 or higher in your dependencies, it injects this into the system prompt headline, &lt;em&gt;before&lt;/em&gt; the model sees the errored file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gu"&gt;### library-migrations&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; vite-plugin-svgr: v4 requires the &lt;span class="sb"&gt;`?react`&lt;/span&gt; query suffix to import an SVG
  as a React component. &lt;span class="sb"&gt;`import Logo from "./logo.svg"`&lt;/span&gt; returns the asset URL.

&lt;span class="gu"&gt;### task&lt;/span&gt;
Library migration: vite-plugin-svgr
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. No fine-tuning, no agent loop, no retrieval pipeline. A registry lookup against &lt;code&gt;package.json&lt;/code&gt; and four lines of prompt.&lt;/p&gt;

&lt;p&gt;On our benchmark, the &lt;code&gt;?react&lt;/code&gt;-migration case goes from &lt;strong&gt;0/3 to 3/3&lt;/strong&gt; with this change. The model already knew about &lt;code&gt;?react&lt;/code&gt;; it just needed permission to override tsc's hint.&lt;/p&gt;

&lt;p&gt;The same shape works for other libraries whose major bumps generate confidently wrong tsc fixes: &lt;code&gt;next@15&lt;/code&gt; (params and searchParams are now Promises and must be awaited), &lt;code&gt;ai@v3&lt;/code&gt;/&lt;code&gt;v6&lt;/code&gt; (&lt;code&gt;generateText&lt;/code&gt; API rewrite), &lt;code&gt;drizzle-orm&lt;/code&gt; (parameterized template literals, not string concat).&lt;/p&gt;

&lt;h2&gt;
  
  
  Why prompt headline, not "more context"
&lt;/h2&gt;

&lt;p&gt;The first version of this lived in &lt;code&gt;MendContext.featureSpecText&lt;/code&gt; — a freeform Markdown section the model would see somewhere in the middle of the prompt. It did approximately nothing. The model still followed tsc's quick-fix.&lt;/p&gt;

&lt;p&gt;Moving the same two sentences to the &lt;strong&gt;headline &lt;code&gt;taskDescription&lt;/code&gt;&lt;/strong&gt; — the first thing after the system instructions and before the file content — flipped the result. Same content, different position, opposite outcome.&lt;/p&gt;

&lt;p&gt;This is consistent with what we know about long-context attention falloff and how Claude in particular interprets the "task" framing: the model treats the headline as &lt;em&gt;what it's actually being asked to do&lt;/em&gt; and weights the rest of the prompt against it. "Library migration: vite-plugin-svgr" is read as "the user knows about this migration; whatever quick-fix tsc is suggesting, the migration is the reason." That single reframing overrides the gravity well of "tsc says X."&lt;/p&gt;

&lt;h2&gt;
  
  
  The same gap, with worse consequences
&lt;/h2&gt;

&lt;p&gt;The svgr case is the easy version of the failure mode: a crashed dev server is loud, immediate, and obviously broken. Move the same dynamic into security territory and the failure goes quiet.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Case 1 — &lt;code&gt;dangerouslySetInnerHTML&lt;/code&gt; as a children-type escape hatch.&lt;/strong&gt; An LLM is asked to render some user-controlled HTML in a React component. The component signature says &lt;code&gt;children: string&lt;/code&gt;, the input is an HTML string, tsc complains. The path of least resistance — and one I've seen models take — is to switch the rendering to &lt;code&gt;dangerouslySetInnerHTML={{ __html: input }}&lt;/code&gt;. The error vanishes. The XSS hole opens. Same three-character collision: React's type system &lt;em&gt;correctly&lt;/em&gt; warns that an HTML string isn't a React node; tsc enforces it; the LLM picks the dodge that makes the type-checker happy. The runtime-correct fix is to render the text as JSX (&lt;code&gt;{input}&lt;/code&gt; auto-escapes) or sanitize via DOMPurify before mounting. The type system has no way to know which of those you wanted.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Case 2 — substituting &lt;code&gt;crypto.subtle.digest&lt;/code&gt; for a missing &lt;code&gt;bcrypt&lt;/code&gt; import.&lt;/strong&gt; A repo's &lt;code&gt;package.json&lt;/code&gt; lists &lt;code&gt;bcrypt&lt;/code&gt;; the source imports it; an LLM-generated refactor accidentally removes the import line. tsc emits &lt;code&gt;Cannot find name 'bcrypt'. Did you mean 'crypto'?&lt;/code&gt; — and the LLM dutifully takes the suggestion, switching &lt;code&gt;bcrypt.hash(password, 10)&lt;/code&gt; to &lt;code&gt;crypto.subtle.digest("SHA-256", encoder.encode(password))&lt;/code&gt;. tsc is happy. The code compiles. An unsalted, un-adaptive SHA-256 of every user password is now shipping to production. Every detail of tsc's reasoning was correct — &lt;code&gt;crypto&lt;/code&gt; &lt;em&gt;is&lt;/em&gt; the closest in-scope identifier, and SHA-256 &lt;em&gt;does&lt;/em&gt; return a digest — but the runtime semantics are catastrophically different from a salted, adaptive-cost password hash.&lt;/p&gt;

&lt;p&gt;Both cases are the same shape as svgr, just with stakes that get someone fired instead of a broken page reload. &lt;strong&gt;tsc is a static system reasoning about types; the program is a dynamic system that has to actually work.&lt;/strong&gt; A repair that prioritizes the first over the second is the kind of fix that makes the build green and the security report red.&lt;/p&gt;

&lt;p&gt;tsfix added explicit prompt-level rules against both of these (plus &lt;code&gt;as keyof T&lt;/code&gt; to silence index-signature errors, and dropping arguments to silence TS2554). They're not magic — the model can still produce a bad fix — but they shift the prior. Across the bench, the cases that exercise these patterns went from 0/3 to 3/3 functional-and-secure.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bet I'm making
&lt;/h2&gt;

&lt;p&gt;The next moat in LLM coding tools is not on the model side. The frontier-model gap has narrowed to weeks at best; everyone codes against the same three providers. The moat is on the &lt;strong&gt;structured-knowledge side&lt;/strong&gt; — the layer that injects the things a model trained on five years of mixed-version code cannot reliably know: which libraries broke, in which versions, with which migration. Library-migration registries are one form. Framework-version-aware refactoring is another. Security-pattern recognition is a third.&lt;/p&gt;

&lt;p&gt;These are unsexy databases of "this is wrong now, do that instead," extended one entry at a time by humans who hit the failure mode and submitted a fix. The first project to ship a registry serious enough to embed into Cursor / Claude Code / Continue.dev / Cline as a sub-component wins the &lt;strong&gt;post-generation correctness&lt;/strong&gt; category. That's the integration that touches every one of those tools' users, every codegen pass, every day. It compounds: every new library entry makes your tool relatively more useful versus every alternative.&lt;/p&gt;

&lt;p&gt;We've open-sourced our registry under MIT. It currently knows about &lt;code&gt;vite-plugin-svgr&lt;/code&gt; v4, &lt;code&gt;next&lt;/code&gt; v15, the Vercel AI SDK v3, and &lt;code&gt;drizzle-orm&lt;/code&gt;. Four entries is a starting line, not a finish line. The interesting thing is that adding the fifth, sixth, and hundredth entries is &lt;em&gt;exactly&lt;/em&gt; the kind of contribution this codebase is structured to receive — see &lt;a href="https://github.com/owgreen-dev/tsfix/blob/main/src/libraryMigrations.ts" rel="noopener noreferrer"&gt;&lt;code&gt;src/libraryMigrations.ts&lt;/code&gt;&lt;/a&gt;, the &lt;a href="https://github.com/owgreen-dev/tsfix/blob/main/CONTRIBUTING.md" rel="noopener noreferrer"&gt;registry-extension guide in CONTRIBUTING.md&lt;/a&gt;, and the pinned discussion &lt;em&gt;&lt;a href="https://github.com/owgreen-dev/tsfix/discussions" rel="noopener noreferrer"&gt;"Which library should the migration registry cover next?"&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx @shipispec/tsfix &lt;span class="nt"&gt;--workspace&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="nt"&gt;--llm&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the first run, Layer 0/1 clears the trivial errors deterministically (typos, missing imports — no LLM, no network, no cost). Layer 2 takes whatever's left, with library hints firing automatically when one of the four currently-registered packages is in your &lt;code&gt;package.json&lt;/code&gt;. You'll see a per-error tally, per-iteration token / cost numbers, and either &lt;code&gt;stopReason=fixed&lt;/code&gt; or a list of remaining errors. Layer 0/1 by itself needs no API key. Layer 2 needs &lt;code&gt;ANTHROPIC_API_KEY&lt;/code&gt;, &lt;code&gt;OPENAI_API_KEY&lt;/code&gt;, or &lt;code&gt;GOOGLE_GENERATIVE_AI_API_KEY&lt;/code&gt; — your choice via &lt;code&gt;--llm-provider&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If your stack hits one of the patterns above and tsfix doesn't yet know about it, the registry-suggestion issue template is the fastest path to making sure no one else in your category hits it again.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Links&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;npm: &lt;a href="https://www.npmjs.com/package/@shipispec/tsfix" rel="noopener noreferrer"&gt;@shipispec/tsfix&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Source: &lt;a href="https://github.com/shipispec/tsfix" rel="noopener noreferrer"&gt;github.com/shipispec/tsfix&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Library-migration registry: &lt;a href="https://github.com/owgreen-dev/tsfix/blob/main/src/libraryMigrations.ts" rel="noopener noreferrer"&gt;src/libraryMigrations.ts&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;"Which library next?" discussion: &lt;a href="https://github.com/owgreen-dev/tsfix/discussions" rel="noopener noreferrer"&gt;github.com/owgreen-dev/tsfix/discussions&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>typescript</category>
      <category>ai</category>
      <category>webdev</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
