@shipi18n/cli 2.8.1 → 2.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # @shipi18n/cli
2
2
 
3
+ ## 2.9.0
4
+
5
+ - New: `check` now supports **Android `strings.xml`, gettext `.po`/`.pot`, and XLIFF 1.2/2.0** on top
6
+ of JSON, YAML, Flutter `.arb` and Apple `.xcstrings` — auto-detected from the input path (via core
7
+ 2.9.0). The structural pass still needs no API key.
8
+
3
9
  ## 2.8.1
4
10
 
5
11
  - Security (hardening): pulls core 2.8.1 (translator prompt-injection guardrail + recursion depth
package/README.md CHANGED
@@ -91,7 +91,8 @@ npx @shipi18n/cli check ./locales --source en
91
91
  ```
92
92
 
93
93
  It detects both common layouts (`locales/en.json` and `locales/en/<ns>.json`), plus Flutter ARB
94
- directories and Apple String Catalogs (`shipi18n check Localizable.xcstrings`).
94
+ directories, Apple String Catalogs (`shipi18n check Localizable.xcstrings`), Android `res/values-*/`
95
+ `strings.xml` trees, gettext `.po`/`.pot`, and XLIFF (`.xlf`/`.xliff`, 1.2 and 2.0).
95
96
 
96
97
  **What it catches:** missing and orphaned keys · dropped or invented placeholders (`{{name}}`,
97
98
  `{count}`, `%s`, `%1$s`, `%@`, `%lld`, `$t(...)`, `%{name}`, HTML tags) · collapsed vue-i18n pipe
@@ -103,12 +104,35 @@ plurals · empty values · untranslated copy · stale `.xcstrings` states.
103
104
  | `-r, --reporter <name>` | `human` | `human` \| `json` \| `sarif` \| `junit` |
104
105
  | `-o, --output <file>` | stdout | Write the report to a file |
105
106
  | `--ignore-keys <globs>` | — | Silence keys: `'*.copyright,home:mcp.badge'` |
107
+ | `--severity <spec>` | — | Per-rule level override: `'untranslated=off,placeholder-added=error'` |
108
+ | `--baseline <file>` | — | Fail only on findings NOT already in the baseline |
109
+ | `--write-baseline` | — | Snapshot current findings into `--baseline` (default `.shipi18n/baseline.json`) and exit |
106
110
  | `--fail-on <level>` | `error` | `error` \| `warning` \| `none` |
107
111
  | `--min-coverage <pct>` | — | Fail any language below this coverage |
108
112
 
109
113
  Exit codes: `0` pass, `1` findings at the fail level, `2` usage error. Errors may fail CI; warnings
110
114
  never do by default — a warning that blocks PRs gets the tool uninstalled.
111
115
 
116
+ ### Adopting on a messy catalog — baseline & severity
117
+
118
+ A linter that fails on 2,000 pre-existing findings gets uninstalled by lunch. Baseline first, then
119
+ fail only on what's **new** — the Stylelint/RuboCop pattern:
120
+
121
+ ```bash
122
+ # 1. Snapshot today's findings (commit the file).
123
+ npx @shipi18n/cli check ./locales --baseline .shipi18n/baseline.json --write-baseline
124
+
125
+ # 2. CI from now on fails only on NEW findings; the backlog is accepted.
126
+ npx @shipi18n/cli check ./locales --baseline .shipi18n/baseline.json
127
+ ```
128
+
129
+ The baseline keys each finding on `(language, namespace, key path, rule)` — not the message text, so
130
+ rewording a message never invalidates it. A missing baseline file is a cold start (warn + report all),
131
+ not an error. Burn the backlog down by re-running `--write-baseline` whenever it shrinks.
132
+
133
+ `--severity` tunes or silences a rule everywhere: `error`, `warning`, `info` (reported, never fails),
134
+ or `off` (dropped entirely). Example: `--severity 'untranslated=off,empty-value=warning'`.
135
+
112
136
  ### GitHub Actions with PR annotations
113
137
 
114
138
  ```yaml
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shipi18n/cli",
3
- "version": "2.8.1",
3
+ "version": "2.10.0",
4
4
  "description": "Catch broken translations before you ship: dropped placeholders, missing keys, collapsed plurals and — with your own LLM key — mistranslations the structure checks cannot see. CI-ready (SARIF, JUnit, exit codes). Translates too.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -49,7 +49,7 @@
49
49
  "chalk": "^5.3.0",
50
50
  "commander": "^12.0.0",
51
51
  "ora": "^8.0.1",
52
- "@shipi18n/core": "^2.8.1"
52
+ "@shipi18n/core": "^2.10.0"
53
53
  },
54
54
  "peerDependencies": {
55
55
  "@anthropic-ai/sdk": ">=0.30.0",
@@ -7,8 +7,9 @@
7
7
  * pipeline can gate on it.
8
8
  *
9
9
  * Formats: plain JSON locale trees (flat `locales/<lang>.json` or nested
10
- * `locales/<lang>/<ns>.json`), Flutter ARB directories, and Apple String
11
- * Catalogs (`.xcstrings`). Reporters: human, json, sarif, junit.
10
+ * `locales/<lang>/<ns>.json`), YAML, Flutter ARB directories, Apple String
11
+ * Catalogs (`.xcstrings`), Android `strings.xml` trees, gettext `.po`/`.pot`,
12
+ * and XLIFF (`.xlf`/`.xliff`, 1.2 & 2.0). Reporters: human, json, sarif, junit.
12
13
  */
13
14
  import { readFileSync, existsSync, writeFileSync, mkdirSync } from 'node:fs'
14
15
  import { resolve, dirname, join } from 'node:path'
@@ -23,6 +24,9 @@ import {
23
24
  aggregateLanguage,
24
25
  SEP,
25
26
  verdict,
27
+ applyPolicy,
28
+ buildBaseline,
29
+ parseSeverity,
26
30
  } from '@shipi18n/core'
27
31
  import { REPORTERS } from '../reporters.js'
28
32
  import { locksFor, DEFAULT_LOCKS_PATH } from './lock.js'
@@ -55,6 +59,9 @@ export function checkCommand(program) {
55
59
  .option('-o, --output <file>', 'Write the report to a file instead of stdout')
56
60
  .option('--json', 'Shorthand for --reporter json')
57
61
  .option('--ignore-keys <patterns>', "Comma-separated '*' globs of keys to silence (path or ns:path)")
62
+ .option('--severity <spec>', "Per-rule severity overrides, e.g. 'untranslated=off,placeholder-added=error' (error|warning|info|off)")
63
+ .option('--baseline <file>', 'Baseline file: findings already recorded in it do not fail the build (only NEW ones do)')
64
+ .option('--write-baseline', 'Snapshot current findings into --baseline (default .shipi18n/baseline.json) and exit')
58
65
  .option('--fail-on <level>', 'Exit non-zero on: error | warning | none', 'error')
59
66
  .option('--min-coverage <pct>', 'Fail any language below this coverage percentage', parseFloat)
60
67
  .option('--glossary <file>', 'Glossary JSON: DNT terms + locked per-language translations (deterministic)')
@@ -149,6 +156,62 @@ export function checkCommand(program) {
149
156
  }
150
157
  }
151
158
 
159
+ const DEFAULT_BASELINE = '.shipi18n/baseline.json'
160
+
161
+ // --write-baseline: snapshot every current finding and exit 0. Run after
162
+ // the (optional) semantic pass so a baseline can capture judge findings too.
163
+ if (opts.writeBaseline) {
164
+ const file = resolve(opts.baseline || DEFAULT_BASELINE)
165
+ const bl = buildBaseline(result)
166
+ try {
167
+ mkdirSync(dirname(file), { recursive: true })
168
+ writeFileSync(file, JSON.stringify(bl, null, 2) + '\n')
169
+ } catch (err) {
170
+ console.error(chalk.red(`Error: cannot write baseline ${file}: ${err.message}`))
171
+ process.exitCode = 2
172
+ return
173
+ }
174
+ console.error(chalk.gray(`baseline: recorded ${bl.count} finding(s) → ${opts.baseline || DEFAULT_BASELINE}`))
175
+ return
176
+ }
177
+
178
+ let severityMap
179
+ if (opts.severity) {
180
+ try {
181
+ severityMap = parseSeverity(opts.severity)
182
+ } catch (err) {
183
+ console.error(chalk.red(`Error: ${err.message}`))
184
+ process.exitCode = 2
185
+ return
186
+ }
187
+ }
188
+
189
+ let baseline
190
+ if (opts.baseline) {
191
+ const file = resolve(opts.baseline)
192
+ if (existsSync(file)) {
193
+ try {
194
+ baseline = JSON.parse(readFileSync(file, 'utf8'))
195
+ } catch (err) {
196
+ console.error(chalk.red(`Error: cannot read baseline ${opts.baseline}: ${err.message}`))
197
+ process.exitCode = 2
198
+ return
199
+ }
200
+ } else {
201
+ // A missing baseline is a cold start, not an error: nothing is suppressed
202
+ // and the run reports every finding. Hint how to create one.
203
+ console.error(chalk.yellow(`note: baseline ${opts.baseline} not found — reporting all findings. Create it with --write-baseline.`))
204
+ }
205
+ }
206
+
207
+ if (severityMap || baseline) {
208
+ const suppressed = applyPolicy(result, { severity: severityMap, baseline })
209
+ const parts = []
210
+ if (suppressed.suppressedByBaseline) parts.push(`${suppressed.suppressedByBaseline} baselined`)
211
+ if (suppressed.suppressedBySeverity) parts.push(`${suppressed.suppressedBySeverity} silenced (severity=off)`)
212
+ if (parts.length) console.error(chalk.gray(`policy: ${parts.join(', ')}`))
213
+ }
214
+
152
215
  const verdictResult = verdict(result, { failOn: opts.failOn, minCoverage: opts.minCoverage })
153
216
 
154
217
  const name = opts.json ? 'json' : opts.reporter