<?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: Sergey Emelyanov</title>
    <description>The latest articles on DEV Community by Sergey Emelyanov (@sergey_emelyanov).</description>
    <link>https://dev.to/sergey_emelyanov</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%2F3957365%2Fc30970d2-4d71-4260-a462-0f703c8b7cae.jpg</url>
      <title>DEV Community: Sergey Emelyanov</title>
      <link>https://dev.to/sergey_emelyanov</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/sergey_emelyanov"/>
    <language>en</language>
    <item>
      <title>I shipped 30+ releases of an AI job-search dashboard through vibecoding — here's the doctrine</title>
      <dc:creator>Sergey Emelyanov</dc:creator>
      <pubDate>Fri, 29 May 2026 03:33:59 +0000</pubDate>
      <link>https://dev.to/sergey_emelyanov/i-shipped-30-releases-of-an-ai-job-search-dashboard-through-vibecoding-heres-the-doctrine-2dpm</link>
      <guid>https://dev.to/sergey_emelyanov/i-shipped-30-releases-of-an-ai-job-search-dashboard-through-vibecoding-heres-the-doctrine-2dpm</guid>
      <description>&lt;p&gt;I'm Sergey. Over the last few months I built &lt;a href="https://github.com/Fighter90/career-ops-ui" rel="noopener noreferrer"&gt;career-ops-ui&lt;/a&gt; — a local-only browser dashboard for managing job applications — entirely through Claude Code vibecoding sessions. 30+ releases, 9 locales, 1000+ unit tests, 70+ Playwright cases, MIT licensed.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F3lvsnilrx453vb2h5j7g.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F3lvsnilrx453vb2h5j7g.png" alt=" "&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This post is a writeup of the &lt;strong&gt;doctrine&lt;/strong&gt; that made vibecoding actually scale to a production-grade codebase, and the &lt;strong&gt;footguns&lt;/strong&gt; that almost killed it. Skip the marketing — I'm sharing the rules I codified after enough failures to know they matter.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I built
&lt;/h2&gt;

&lt;p&gt;A self-hosted web UI on top of &lt;a href="https://github.com/santifer/career-ops" rel="noopener noreferrer"&gt;career-ops&lt;/a&gt; (an AI job-search pipeline that hit 27K GitHub stars in 4 days). Original is CLI-only. I was processing 100+ job postings a week and triaging through the terminal was eating my evenings, so I built the visual layer:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Paste a JD URL → AI scores it A–F → tailored resume generated&lt;/li&gt;
&lt;li&gt;Visual pipeline for hundreds of postings (filter, sort, dedupe inline)&lt;/li&gt;
&lt;li&gt;Application tracker with funnel (applied → interview → offer)&lt;/li&gt;
&lt;li&gt;CV editor with live markdown preview + PDF export&lt;/li&gt;
&lt;li&gt;Deep company research mode (with brief warning if upstream prompt drifts)&lt;/li&gt;
&lt;li&gt;Interview prep using STAR+R framework&lt;/li&gt;
&lt;li&gt;Apply checklist with per-URL persistence&lt;/li&gt;
&lt;li&gt;Salary range filter (currency-agnostic, NBSP-aware)&lt;/li&gt;
&lt;li&gt;Real-time SSE scanner across 12 sources (Greenhouse, Ashby, Lever, Workday, hh.ru, Habr Career, Trudvsem, RSS, etc.)&lt;/li&gt;
&lt;li&gt;9 locales: en, es, pt-BR, fr, ru, ja, ko, zh-CN, zh-TW&lt;/li&gt;
&lt;li&gt;Provider-agnostic LLM routing (Anthropic / Gemini / OpenAI / Qwen / OpenRouter)&lt;/li&gt;
&lt;li&gt;Runs at &lt;code&gt;127.0.0.1:4317&lt;/code&gt;, MIT licensed, no signup, no telemetry&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The vibecoding doctrine (5 rules that survived)
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. ONE fix per release. Never batch.
&lt;/h3&gt;

&lt;p&gt;Every release ships exactly one logical change. HIGH → MEDIUM → LOW priority. Each ship gets:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Version bump&lt;/li&gt;
&lt;li&gt;CHANGELOG entries in &lt;strong&gt;all 9 locales&lt;/strong&gt; (parity-gated)&lt;/li&gt;
&lt;li&gt;A dedicated regression-lock test that must &lt;strong&gt;fail before the fix&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Playwright verify on the specific surface&lt;/li&gt;
&lt;li&gt;Pre-commit AI review approval&lt;/li&gt;
&lt;li&gt;CI green on Node 18 / 20 / 22 + Playwright e2e&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Sounds slow. &lt;strong&gt;Actually faster than batching&lt;/strong&gt; — bugs are attributable, rollback is trivial, and the AI never loses context about what we're shipping in a session.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. TDD-first means "RED BAR MANDATORY"
&lt;/h3&gt;

&lt;p&gt;The biggest lesson came from a regression I closed &lt;strong&gt;five times&lt;/strong&gt; before it actually held. Each previous "close" had passing tests:&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="c1"&gt;// What the test asserted (wrong)&lt;/span&gt;
&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;scroll-spy implementation present&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;public/js/views/help.js&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;utf-8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/IntersectionObserver/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;   &lt;span class="c1"&gt;// ✅ passes&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But the user-visible behavior was broken — active TOC entries never got highlighted on scroll. The test checked source-code &lt;strong&gt;shape&lt;/strong&gt;, not &lt;strong&gt;behavior&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;On the 6th attempt I forced myself to:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Write the failing Playwright test FIRST&lt;/li&gt;
&lt;li&gt;Commit it on a branch&lt;/li&gt;
&lt;li&gt;Push it&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Screenshot the red bar in the PR&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;ONLY THEN write the fix
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// What the test should have asserted&lt;/span&gt;
&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;TOC scroll-spy highlights active section&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;http://127.0.0.1:4317/#/help&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;evaluate&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;help-h-5&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;scrollIntoView&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;block&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;center&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitForTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;800&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;locator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.help-toc a.toc-current&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toHaveCount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&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;That cycle closed the bug in one shot.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The lesson:&lt;/strong&gt; the AI will happily write tests that pass against your existing broken code. You have to &lt;strong&gt;force the red bar visible&lt;/strong&gt; before any fix code touches the repo.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Methodology footguns get documented
&lt;/h3&gt;

&lt;p&gt;After enough false-negative sign-offs, I started a &lt;code&gt;§−1 Footguns&lt;/code&gt; section in the QA prompt. Three I hit hardest:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Footgun A: file-path assertions vs behavior assertions.&lt;/strong&gt; I asserted presence of a &lt;em&gt;suggested&lt;/em&gt; extracted file when the actual implementation inlined into an existing one. &lt;code&gt;git grep mountHelpToc public/&lt;/code&gt; returned 0 — not a regression, just a wrong probe. Assert &lt;strong&gt;behavior&lt;/strong&gt; (class applied? element painted?), never file paths.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Footgun B: client-side URL normalization.&lt;/strong&gt; &lt;code&gt;fetch()&lt;/code&gt; and &lt;code&gt;curl&lt;/code&gt; (without &lt;code&gt;--path-as-is&lt;/code&gt;) normalize URLs &lt;strong&gt;before sending&lt;/strong&gt;. They never exercise the server's raw &lt;code&gt;..&lt;/code&gt; traversal guard. To verify the middleware:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;--path-as-is&lt;/span&gt; &lt;span class="s1"&gt;'http://127.0.0.1:4317/api/jds/../../../etc/passwd'&lt;/span&gt;
&lt;span class="c"&gt;# Expect: {"error":"invalid path"}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Footgun C: vm-realm deepEqual.&lt;/strong&gt; Objects built inside &lt;code&gt;node:vm&lt;/code&gt; have a &lt;strong&gt;foreign prototype&lt;/strong&gt;. &lt;code&gt;assert.deepStrictEqual&lt;/code&gt; against a JSON snapshot fails even on identical values. Round-trip first:&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;snapshot&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;assembledInVm&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;deepStrictEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;snapshot&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// now works&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Documenting these saved hours of false-positive debugging in later sessions. New AI sessions read §−1 first and apply the right probe technique.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Locale-aware everything (I18N-SPLIT architecture)
&lt;/h3&gt;

&lt;p&gt;Originally all 9 locales lived in one 36KB JS dictionary. A community contributor (Mike from Discord) flagged:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;".po files would be proper for translators. Your current setup is painful."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;He was right. We refactored to &lt;strong&gt;per-locale files&lt;/strong&gt; with an &lt;code&gt;@alias&lt;/code&gt; mechanism for shared keys:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;public/js/lib/locales/
  i18n-dict.en.js          // window.__I18N_DICT_EN = { ... }
  i18n-dict.es.js
  i18n-dict.fr.js          // added by community via Ollama
  i18n-dict.ja.js
  i18n-dict.ko.js
  i18n-dict.pt-BR.js
  i18n-dict.ru.js
  i18n-dict.zh-CN.js
  i18n-dict.zh-TW.js
  i18n-dict.aliases.js     // shared canonical keys
public/js/lib/i18n-dict.js // assembler
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Load order in &lt;code&gt;index.html&lt;/code&gt;: 9 locale files → aliases → assembler → &lt;code&gt;i18n.js&lt;/code&gt;. The &lt;code&gt;t()&lt;/code&gt; function never changed. &lt;strong&gt;Zero call-sites edited.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The architecture validated when &lt;strong&gt;French was added two months later&lt;/strong&gt; by a community contributor using local Ollama + qwen2.5:14b for 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="c1"&gt;// translateChunk() called by the contributor&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;translateChunk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromEntries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;english&lt;/span&gt;&lt;span class="p"&gt;]));&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;prompt&lt;/span&gt; &lt;span class="o"&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;Translate this UI locale JSON object from English to French.&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;Return ONLY a valid JSON object with exactly the same keys.&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;Preserve placeholders like {n}, {path}, {hotkey}, URLs, env vars.&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;Keep English technical product names when natural in French.&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;Use concise, natural French for a software interface.&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="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;http://127.0.0.1:11434/api/generate&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="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&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;content-type&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;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;qwen2.5:14b-instruct&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;options&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;temperature&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;num_ctx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;8192&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;extractJsonObject&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nx"&gt;response&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;This same workflow now scales to a 10th, 11th locale without touching the main dict. &lt;strong&gt;Free local inference, zero coordination cost.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Read-only boundary tests catch destructive AI suggestions
&lt;/h3&gt;

&lt;p&gt;The parent &lt;code&gt;career-ops&lt;/code&gt; project is mutable user data. The web-ui has a hard rule: &lt;strong&gt;NEVER write to parent files&lt;/strong&gt; except on explicit user actions (Pipeline +Add, CV Save, Config write).&lt;/p&gt;

&lt;p&gt;Every test runs with &lt;code&gt;CAREER_OPS_ROOT&lt;/code&gt; pointed at &lt;code&gt;mktemp -d&lt;/code&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="c1"&gt;// tests/setup.mjs&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;mkdtempSync&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:fs&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;tmpdir&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:os&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;join&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:path&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;root&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mkdtempSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;tmpdir&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;career-ops-test-&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;CAREER_OPS_ROOT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;root&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;// PATHS resolves once per process; setup must run before any imports.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If any test writes to the real parent, it fails immediately. This makes &lt;strong&gt;AI-assisted code review trivially safe&lt;/strong&gt; — Claude can suggest any refactor; the boundary tests catch parent mutations automatically.&lt;/p&gt;

&lt;h2&gt;
  
  
  What didn't work
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Batching fixes "just this once"&lt;/strong&gt; — every doctrine-exception bundled release later required a follow-up fix-ship. Pure overhead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Implement and write tests" in one prompt&lt;/strong&gt; — produces happy-path tests that pass against broken code. Split: write failing test → confirm red → only then code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;npm test 2&amp;gt;&amp;amp;1 | grep&lt;/code&gt;&lt;/strong&gt; — &lt;code&gt;grep&lt;/code&gt; returns 0 on any match, masking the exit code. Run &lt;code&gt;npm test&lt;/code&gt;, capture &lt;code&gt;$?&lt;/code&gt;, &lt;strong&gt;then&lt;/strong&gt; grep.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Static lock-tests for behavioral promises&lt;/strong&gt; — &lt;code&gt;git grep&lt;/code&gt; for a symbol doesn't prove the user-visible behavior works.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What surprised me
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Community pulled the project forward harder than I expected.&lt;/strong&gt; Mike flagged the i18n architecture problem; the French contributor used local Ollama to translate the entire dict in 48 hours. Zero coordination cost.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;9 locales × 19 routes × 75 H3 sections are testable.&lt;/strong&gt; Every cycle runs the same 9-locale × route-sweep automated test. Took 30 min to write, saves hours every release.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vibecoding scales to architecture-level decisions.&lt;/strong&gt; The I18N-SPLIT refactor was a 12-hour Claude Code session. The AI walked through 8 locale files, suggested the &lt;code&gt;@alias&lt;/code&gt; pattern, wrote the migration script, regenerated the snapshot, and pushed parity tests — all in one session.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Stack
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Backend:&lt;/strong&gt; Node.js 18+ Express server, ~130 LOC &lt;code&gt;server/index.mjs&lt;/code&gt; orchestrator + 15 route modules&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Frontend:&lt;/strong&gt; no framework, vanilla JS, hash-router SPA&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prod deps:&lt;/strong&gt; &lt;code&gt;express + js-yaml + multer&lt;/code&gt; only&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tests:&lt;/strong&gt; &lt;code&gt;node --test&lt;/code&gt; (1000+ unit), 70+ Playwright cases, 23 comprehensive e2e&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LLM routing:&lt;/strong&gt; Anthropic / Gemini / OpenAI / Qwen / OpenRouter (auto-route to whichever key is set; manual fallback works without any key)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;i18n:&lt;/strong&gt; 9 per-locale dict files + &lt;code&gt;@alias&lt;/code&gt; mechanism, server-side fallback to English&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Security:&lt;/strong&gt; CSP without &lt;code&gt;unsafe-inline&lt;/code&gt;/&lt;code&gt;unsafe-eval&lt;/code&gt;, SSRF guard, &lt;code&gt;stripDangerousMarkdown()&lt;/code&gt; on CV ingress, masked secrets, JSON-404 on &lt;code&gt;/api/*&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Streaming:&lt;/strong&gt; Server-Sent Events for long ops (scan, auto-pipeline, batch)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Data:&lt;/strong&gt; Markdown for all user state (CV, applications, reports) — version-controllable, &lt;code&gt;cat&lt;/code&gt;-able, never proprietary format&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Takeaways for other vibecoding builders
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Write the failing test first, screenshot the red bar.&lt;/strong&gt; The AI will happily produce green tests against broken code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Document methodology footguns as you hit them.&lt;/strong&gt; Future sessions read them first. Saves hours.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One-fix-per-release scales surprisingly well.&lt;/strong&gt; Bugs are attributable; rollback is trivial; AI doesn't lose context.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Per-language-file i18n architecture is worth the cost.&lt;/strong&gt; Community contributors can fork single files. Painless onboarding.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read-only boundary tests catch destructive AI suggestions.&lt;/strong&gt; The AI can suggest anything; the tests enforce the contract.&lt;/li&gt;
&lt;/ol&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;git clone https://github.com/Fighter90/career-ops-ui
&lt;span class="nb"&gt;cd &lt;/span&gt;career-ops-ui
npm &lt;span class="nb"&gt;install
&lt;/span&gt;npm start
&lt;span class="c"&gt;# open http://127.0.0.1:4317&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Free, MIT, no signup, runs locally. AI keys optional (works in manual-prompt mode too).&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;GitHub: &lt;a href="https://github.com/Fighter90/career-ops-ui" rel="noopener noreferrer"&gt;Fighter90/career-ops-ui&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;LinkedIn (open for chat): &lt;a href="https://www.linkedin.com/in/sergey-emelyanov-in-job/" rel="noopener noreferrer"&gt;sergey-emelyanov-in-job&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;strong&gt;Question for the dev.to community:&lt;/strong&gt; what's your hardest-learned vibecoding lesson? I'm especially curious about doctrines other builders shipped through when the AI confidently suggested broken solutions. Drop a comment or share your own architecture takeaways below.&lt;/p&gt;

</description>
      <category>vibecoding</category>
      <category>opensource</category>
      <category>node</category>
      <category>ai</category>
    </item>
  </channel>
</rss>
