$npx -y skills add tody-agent/codymaster --skill cm-safe-i18nUse when translating, extracting, or mass-converting hardcoded strings to i18n t() calls. Enforces multi-pass batching, parallel-per-language dispatch, 8 audit gates, and HTML integrity checks. Battle-tested through 21+ batches and 12 bug categories from the March 2026 incidents.
| 1 | # Safe i18n Translation v2.0 |
| 2 | |
| 3 | ## Overview |
| 4 | |
| 5 | Mass i18n conversion is the most dangerous code transformation in a frontend monolith. A single-pass conversion of 600+ strings corrupted `app.js` beyond repair while 572 backend tests passed green. Additional incidents include HTML tag corruption, variable shadowing, and placeholder translation errors. |
| 6 | |
| 7 | **Core principle:** Every batch of i18n changes MUST pass ALL 8 audit gates before proceeding. No exceptions. |
| 8 | |
| 9 | **Violating the letter of this rule is violating the spirit of this rule.** |
| 10 | |
| 11 | ## The Iron Law |
| 12 | |
| 13 | ``` |
| 14 | NO BATCH WITHOUT PASSING ALL 8 AUDIT GATES. |
| 15 | NO LANGUAGE FILE WITHOUT KEY PARITY. |
| 16 | NO DEPLOY WITHOUT FULL SYNTAX VALIDATION. |
| 17 | NO HTML TAG MODIFICATION — TEXT CONTENT ONLY. |
| 18 | NO REGEX TO FIX REGEX ERRORS — USE LEXICAL SCANNER. |
| 19 | ``` |
| 20 | |
| 21 | ## When to Use |
| 22 | |
| 23 | **ALWAYS** when any of these happen: |
| 24 | - Extracting hardcoded strings to `t()` calls |
| 25 | - Adding new language file (e.g., `ph.json`) |
| 26 | - Mass-converting strings across >10 lines |
| 27 | - Updating translation keys or namespaces |
| 28 | - Migrating i18n library or pattern |
| 29 | |
| 30 | **Don't use for:** |
| 31 | - Adding 1-3 translation keys (just add manually + test) |
| 32 | - Fixing a single typo in a JSON file |
| 33 | |
| 34 | ## The Protocol |
| 35 | |
| 36 | ```dot |
| 37 | digraph i18n_flow { |
| 38 | rankdir=TB; |
| 39 | "0. Pre-flight" [shape=box]; |
| 40 | "1. Scan ALL files" [shape=box]; |
| 41 | ">10 strings?" [shape=diamond]; |
| 42 | "Manual add + test" [shape=box]; |
| 43 | "2. Plan passes" [shape=box]; |
| 44 | "3. Extract batch (max 30)" [shape=box]; |
| 45 | "4. 8-Gate Audit" [shape=box, style=filled, fillcolor="#ffffcc"]; |
| 46 | "All 8 pass?" [shape=diamond]; |
| 47 | "FIX or ROLLBACK" [shape=box, style=filled, fillcolor="#ffcccc"]; |
| 48 | "More batches?" [shape=diamond]; |
| 49 | "5. Parallel language sync" [shape=box]; |
| 50 | "6. Final validation" [shape=box]; |
| 51 | |
| 52 | "0. Pre-flight" -> "1. Scan ALL files"; |
| 53 | "1. Scan ALL files" -> ">10 strings?"; |
| 54 | ">10 strings?" -> "Manual add + test" [label="no"]; |
| 55 | ">10 strings?" -> "2. Plan passes" [label="yes"]; |
| 56 | "2. Plan passes" -> "3. Extract batch (max 30)"; |
| 57 | "3. Extract batch (max 30)" -> "4. 8-Gate Audit"; |
| 58 | "4. 8-Gate Audit" -> "All 8 pass?"; |
| 59 | "All 8 pass?" -> "FIX or ROLLBACK" [label="no"]; |
| 60 | "FIX or ROLLBACK" -> "4. 8-Gate Audit"; |
| 61 | "All 8 pass?" -> "More batches?" [label="yes"]; |
| 62 | "More batches?" -> "3. Extract batch (max 30)" [label="yes"]; |
| 63 | "More batches?" -> "5. Parallel language sync" [label="no"]; |
| 64 | "5. Parallel language sync" -> "6. Final validation"; |
| 65 | } |
| 66 | ``` |
| 67 | |
| 68 | --- |
| 69 | |
| 70 | ### Phase 0: Pre-Flight Checks (NEW) |
| 71 | |
| 72 | Before ANY i18n work: |
| 73 | |
| 74 | ```bash |
| 75 | # NEVER work on main |
| 76 | git checkout -b i18n/$(date +%Y%m%d)-target-description |
| 77 | |
| 78 | # Verify baseline is clean |
| 79 | node -c public/static/app.js |
| 80 | npm run test:gate |
| 81 | ``` |
| 82 | |
| 83 | If either fails, DO NOT PROCEED. Fix the baseline first. |
| 84 | |
| 85 | --- |
| 86 | |
| 87 | ### Phase 1: Scan ALL Frontend Files (IMPROVED) |
| 88 | |
| 89 | > [!CAUTION] |
| 90 | > **Lesson #11:** `import-adapters.js` and `import-engine.js` had 60+ hardcoded strings that were initially missed because only `app.js` was scanned. |
| 91 | |
| 92 | Scan EVERY file that produces user-visible UI text: |
| 93 | ```bash |
| 94 | # Scan ALL .js files for Vietnamese strings |
| 95 | node scripts/i18n-lint.js |
| 96 | |
| 97 | # Also check non-app.js files |
| 98 | grep -rnP '[àáạảãâầấậẩẫăằắặẳẵ]' public/static/*.js --include="*.js" | grep -v "\.backup" | grep -v "i18n" |
| 99 | ``` |
| 100 | |
| 101 | Group strings by **functional domain** — never by file position: |
| 102 | |
| 103 | | Pass | Domain | Example Keys | |
| 104 | |------|--------|-------------| |
| 105 | | 1 | Core UI | `sidebar.*`, `common.*`, `login.*` | |
| 106 | | 2 | Primary Feature | `vio.*`, `emp.*`, `scores.*` | |
| 107 | | 3 | Config & Settings | `config.*`, `benconf.*` | |
| 108 | | 4 | Reports & Export | `report.*`, `export.*` | |
| 109 | | 5 | Secondary Files | `import-adapters.js`, `import-engine.js` | |
| 110 | | 6 | Edge cases | Tooltips, error messages, dynamic labels | |
| 111 | |
| 112 | **Output:** A numbered list of passes with estimated string count per pass per FILE. |
| 113 | |
| 114 | --- |
| 115 | |
| 116 | ### Phase 2: Extract Batch (MAX 30 strings per batch) |
| 117 | |
| 118 | > [!CAUTION] |
| 119 | > **MAX 30 strings per batch. Not 31. Not "about 30". Exactly 30 or fewer.** |
| 120 | > The i18n crash happened because 600+ strings were done in one pass. |
| 121 | |
| 122 | For each batch: |
| 123 | |
| 124 | 1. **Identify** up to 30 hardcoded strings in the current pass domain |
| 125 | 2. **Generate** namespace-compliant keys: `domain.descriptive_key` |
| 126 | 3. **Replace** strings with `t('domain.key')` calls |
| 127 | 4. **Add** keys to the **primary** language JSON (usually `vi.json`) |
| 128 | |
| 129 | #### String Replacement Rules (12 Bug Categories Encoded) |
| 130 | |
| 131 | ```javascript |
| 132 | // ✅ CORRECT — backtick template with t() inside |
| 133 | `<div>${t('login.welcome')}</div>` |
| 134 | |
| 135 | // ✅ CORRECT — concatenation |
| 136 | '<div>' + t('login.welcome') + '</div>' |
| 137 | |
| 138 | // ❌ BUG #1 (FATAL) — single-quote wrapping template expression |
| 139 | '${t("login.welcome")}' // ← THIS DESTROYED APP.JS |
| 140 | |
| 141 | // ❌ BUG #4 — mismatched delimiters |
| 142 | t('login.welcome`) |