@shipi18n/cli 2.10.0 → 2.11.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/README.md CHANGED
@@ -96,7 +96,8 @@ directories, Apple String Catalogs (`shipi18n check Localizable.xcstrings`), And
96
96
 
97
97
  **What it catches:** missing and orphaned keys · dropped or invented placeholders (`{{name}}`,
98
98
  `{count}`, `%s`, `%1$s`, `%@`, `%lld`, `$t(...)`, `%{name}`, HTML tags) · collapsed vue-i18n pipe
99
- plurals · empty values · untranslated copy · stale `.xcstrings` states.
99
+ plurals · empty values · untranslated copy · stale `.xcstrings` states · Android unescaped apostrophes
100
+ and unbalanced quotes (the `values-fr/strings.xml` AAPT build breaker).
100
101
 
101
102
  | Flag | Default | Meaning |
102
103
  | --- | --- | --- |
@@ -107,6 +108,7 @@ plurals · empty values · untranslated copy · stale `.xcstrings` states.
107
108
  | `--severity <spec>` | — | Per-rule level override: `'untranslated=off,placeholder-added=error'` |
108
109
  | `--baseline <file>` | — | Fail only on findings NOT already in the baseline |
109
110
  | `--write-baseline` | — | Snapshot current findings into `--baseline` (default `.shipi18n/baseline.json`) and exit |
111
+ | `--detect-secrets` | — | Flag secrets/PII (API keys, private keys, emails, cards) in locale strings |
110
112
  | `--fail-on <level>` | `error` | `error` \| `warning` \| `none` |
111
113
  | `--min-coverage <pct>` | — | Fail any language below this coverage |
112
114
 
@@ -146,6 +148,91 @@ or `off` (dropped entirely). Example: `--severity 'untranslated=off,empty-value=
146
148
  sarif_file: i18n.sarif
147
149
  ```
148
150
 
151
+ ### No Node? Run the check in any CI with Docker
152
+
153
+ GitHub runners already have Node — the [Action](https://github.com/Shipi18n/shipi18n-github-action) is
154
+ the easiest path there. Everywhere else (GitLab, Bitbucket, Jenkins, CircleCI, or local), the published
155
+ image runs the linter with **no Node toolchain** — built for the PHP / Python / Ruby shops the
156
+ `.po` / XLIFF / Android formats unlocked:
157
+
158
+ ```bash
159
+ docker run --rm -v "$PWD:/work" ghcr.io/shipi18n/cli:latest check ./locales -s en
160
+ ```
161
+
162
+ The check needs no API key. All CLI flags work — `--baseline .shipi18n/baseline.json`, `--reporter sarif`,
163
+ etc. (`--write-baseline` writes back into the mounted repo). Pass `-e ANTHROPIC_API_KEY=...` only if you
164
+ add `--semantic`.
165
+
166
+ **GitLab CI** (`.gitlab-ci.yml`):
167
+
168
+ ```yaml
169
+ i18n-check:
170
+ image: ghcr.io/shipi18n/cli:latest
171
+ script: [shipi18n check ./locales -s en]
172
+ ```
173
+
174
+ **Bitbucket Pipelines** (`bitbucket-pipelines.yml`):
175
+
176
+ ```yaml
177
+ pipelines:
178
+ default:
179
+ - step:
180
+ image: ghcr.io/shipi18n/cli:latest
181
+ script: [shipi18n check ./locales -s en]
182
+ ```
183
+
184
+ ### pre-commit
185
+
186
+ Catch a dropped placeholder before it's even committed. Add to `.pre-commit-config.yaml` — pre-commit
187
+ runs the Docker image, so **no Node is required**:
188
+
189
+ ```yaml
190
+ repos:
191
+ - repo: https://github.com/Shipi18n/shipi18n
192
+ rev: v2.11.0
193
+ hooks:
194
+ - id: shipi18n-check
195
+ # args: ['check', './i18n', '-s', 'en'] # if not ./locales
196
+ ```
197
+
198
+ Node users who prefer to skip Docker can use a repo-local hook instead:
199
+
200
+ ```yaml
201
+ repos:
202
+ - repo: local
203
+ hooks:
204
+ - id: shipi18n-check
205
+ name: shipi18n check
206
+ language: node
207
+ additional_dependencies: ['@shipi18n/cli@2.11.0']
208
+ entry: shipi18n check ./locales -s en
209
+ pass_filenames: false
210
+ files: '\.(json|ya?ml|po|xlf|xliff|xml|arb|xcstrings)$'
211
+ ```
212
+
213
+ ## WordPress — `.po` ↔ JED `.json` sync (`wp-sync`)
214
+
215
+ Since WP 5.0, JavaScript strings are translated from a JED-format JSON that `wp i18n make-json`
216
+ **generates** from your `.po`. Edit the `.po`, forget to re-run make-json, and the PHP side shows the
217
+ new translation while the JS side silently ships the stale one — and nothing in the WP toolchain
218
+ flags it. `wp-sync` does:
219
+
220
+ ```bash
221
+ # Auto-discovers the sibling *.json JED files next to the .po
222
+ npx @shipi18n/cli wp-sync languages/plugin-es_ES.po
223
+
224
+ # Or point at specific JED files / a directory
225
+ npx @shipi18n/cli wp-sync languages/plugin-es_ES.po languages/build/*.json
226
+ ```
227
+
228
+ It reports **`jed-drift`** (a JS translation that no longer matches the `.po` — re-run make-json) and
229
+ **`jed-orphan`** (a JS string the `.po` dropped). A `.po`-only string is *not* flagged — the JED is a
230
+ legitimate JS-only subset. Context and plural forms are compared independently.
231
+
232
+ Shares the check flags: `--reporter json|sarif|junit` (SARIF gives PR annotations), `--baseline` /
233
+ `--write-baseline` (fail only on new drift), `--severity` (e.g. `--severity 'jed-orphan=off'`),
234
+ `--fail-on`. Exit `0` in sync, `1` drift/orphan, `2` no JED files found.
235
+
149
236
  ## Semantic QA — `--semantic` (the judge)
150
237
 
151
238
  The structural check cannot see a translation that is *fluent but wrong*. `--semantic` adds an
@@ -156,6 +243,25 @@ npx @shipi18n/cli check ./locales -s en --semantic # advisory: warnings
156
243
  npx @shipi18n/cli check ./locales -s en --semantic --glossary glossary.json
157
244
  ```
158
245
 
246
+ ### Privacy — secret & PII pre-flight
247
+
248
+ `--semantic` sends your source/translation pairs to a third-party model. A **pre-flight runs
249
+ automatically** and *withholds* any pair containing an API key, private key, JWT, credit-card number,
250
+ email, or phone — it is never transmitted (fail-closed), and a `secret-preflight` finding is recorded
251
+ instead. Because the judge only ever sends what the pre-flight scans, nothing with a detected secret
252
+ leaves your machine.
253
+
254
+ You can also run the scan on its own, no LLM involved, to catch secrets that shouldn't be sitting in
255
+ locale strings at all:
256
+
257
+ ```bash
258
+ npx @shipi18n/cli check ./locales -s en --detect-secrets
259
+ ```
260
+
261
+ High-confidence secrets (keys, cards via Luhn) are errors; emails/phones are warnings. Detection is
262
+ precision-biased — known key prefixes, example/test emails ignored, phones need a `+` country code —
263
+ and matches are always masked, never echoed. Tune per rule with `--severity secret-detected=off`.
264
+
159
265
  **Honest limitations, up front:** the judge is probabilistic. Every key is judged across 3 passes
160
266
  and flagged only on a majority vote, unparseable passes are discarded, and semantic findings are
161
267
  **warnings by default** — they never fail CI unless you opt in with `--semantic-fail`. It augments
package/bin/shipi18n.js CHANGED
@@ -7,6 +7,7 @@ import { fileURLToPath } from 'node:url'
7
7
  import { translateCommand } from '../src/commands/translate.js'
8
8
  import { checkCommand } from '../src/commands/check.js'
9
9
  import { lockCommand } from '../src/commands/lock.js'
10
+ import { wpSyncCommand } from '../src/commands/wp-sync.js'
10
11
 
11
12
  const __dirname = dirname(fileURLToPath(import.meta.url))
12
13
  const pkg = JSON.parse(readFileSync(join(__dirname, '../package.json'), 'utf8'))
@@ -37,5 +38,6 @@ ${chalk.gray('https://github.com/Shipi18n/shipi18n')}
37
38
  translateCommand(program)
38
39
  checkCommand(program)
39
40
  lockCommand(program)
41
+ wpSyncCommand(program)
40
42
  program.parse(process.argv)
41
43
  if (!process.argv.slice(2).length) program.outputHelp()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shipi18n/cli",
3
- "version": "2.10.0",
3
+ "version": "2.11.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.10.0"
52
+ "@shipi18n/core": "^2.11.0"
53
53
  },
54
54
  "peerDependencies": {
55
55
  "@anthropic-ai/sdk": ">=0.30.0",
@@ -27,6 +27,7 @@ import {
27
27
  applyPolicy,
28
28
  buildBaseline,
29
29
  parseSeverity,
30
+ scanResultSecrets,
30
31
  } from '@shipi18n/core'
31
32
  import { REPORTERS } from '../reporters.js'
32
33
  import { locksFor, DEFAULT_LOCKS_PATH } from './lock.js'
@@ -64,6 +65,7 @@ export function checkCommand(program) {
64
65
  .option('--write-baseline', 'Snapshot current findings into --baseline (default .shipi18n/baseline.json) and exit')
65
66
  .option('--fail-on <level>', 'Exit non-zero on: error | warning | none', 'error')
66
67
  .option('--min-coverage <pct>', 'Fail any language below this coverage percentage', parseFloat)
68
+ .option('--detect-secrets', 'Flag secrets/PII (API keys, private keys, emails, cards) sitting in locale strings')
67
69
  .option('--glossary <file>', 'Glossary JSON: DNT terms + locked per-language translations (deterministic)')
68
70
  .option('--semantic', 'Add the LLM-as-judge pass (BYO key; advisory warnings by default)')
69
71
  .option('--semantic-fail', 'Escalate semantic findings to errors (opt-in)')
@@ -105,6 +107,11 @@ export function checkCommand(program) {
105
107
  return
106
108
  }
107
109
 
110
+ if (opts.detectSecrets) {
111
+ const flagged = scanResultSecrets(result)
112
+ if (flagged) console.error(chalk.gray(`secrets: ${flagged} locale string(s) flagged for possible secrets/PII`))
113
+ }
114
+
108
115
  if (opts.semantic) {
109
116
  const cachePath = resolve(opts.semanticCache)
110
117
  let cache = {}
@@ -136,7 +143,8 @@ export function checkCommand(program) {
136
143
  console.error(
137
144
  chalk.gray(
138
145
  `semantic: judged ${judge.judged} (${judge.cached} cached), flagged ${judge.flagged}, ` +
139
- `${judge.calls} model call(s), ${judge.parseFailures} discarded pass(es)`
146
+ `${judge.calls} model call(s), ${judge.parseFailures} discarded pass(es)` +
147
+ (judge.redacted ? `, ${judge.redacted} withheld (secrets/PII)` : '')
140
148
  )
141
149
  )
142
150
  // "judged 0" on a badly broken tree is correct but reads as a broken
@@ -0,0 +1,207 @@
1
+ /**
2
+ * `shipi18n wp-sync` — the WordPress .po ↔ JED .json drift check.
3
+ *
4
+ * WP JS strings are translated from a JED JSON that `wp i18n make-json` generates
5
+ * from the .po. Edit the .po, forget to re-run make-json, and the JS side quietly
6
+ * ships the stale translation. This command catches that, per JED file, and reuses
7
+ * the same reporters/verdict/baseline machinery as `check` (so SARIF PR annotations
8
+ * and --baseline "fail only on new" work here too).
9
+ */
10
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync } from 'node:fs'
11
+ import { resolve, dirname, join, basename, relative } from 'node:path'
12
+ import chalk from 'chalk'
13
+ import {
14
+ checkJedSync,
15
+ isJed,
16
+ statsFrom,
17
+ aggregateLanguage,
18
+ verdict,
19
+ applyPolicy,
20
+ buildBaseline,
21
+ parseSeverity,
22
+ } from '@shipi18n/core'
23
+ import { REPORTERS } from '../reporters.js'
24
+
25
+ const DEFAULT_BASELINE = '.shipi18n/baseline.json'
26
+ const rel = (p) => relative(process.cwd(), p) || p
27
+
28
+ /** JED .json files to check: explicit args (dirs expanded) or sibling auto-discovery. */
29
+ function resolveJedFiles(poFile, args) {
30
+ const collect = (dir) => {
31
+ let out = []
32
+ try {
33
+ out = readdirSync(dir)
34
+ .filter((n) => n.endsWith('.json'))
35
+ .map((n) => join(dir, n))
36
+ .filter((f) => {
37
+ try {
38
+ return isJed(JSON.parse(readFileSync(f, 'utf8')))
39
+ } catch {
40
+ return false
41
+ }
42
+ })
43
+ } catch {
44
+ /* unreadable dir → nothing */
45
+ }
46
+ return out
47
+ }
48
+ if (!args || args.length === 0) return collect(dirname(resolve(poFile)))
49
+ const files = []
50
+ for (const a of args) {
51
+ const p = resolve(a)
52
+ if (existsSync(p) && statSync(p).isDirectory()) files.push(...collect(p))
53
+ else files.push(p)
54
+ }
55
+ return files
56
+ }
57
+
58
+ /** Wrap one JED file's findings as a "language" in the standard result shape. */
59
+ function fileToLanguage(jedFile, findings) {
60
+ const ns = rel(jedFile)
61
+ const stats = statsFrom(findings, findings.length || 1, findings.length || 1)
62
+ return aggregateLanguage(basename(jedFile), [{ ns, findings, stats }])
63
+ }
64
+
65
+ /** Compact, wp-sync-flavoured human output (the default reporter). */
66
+ function humanWpReport(result, v) {
67
+ const lines = ['', `🔎 shipi18n wp-sync — .po '${result.source}' vs ${result.languages.length} JED file(s)`, '']
68
+ for (const l of result.languages) {
69
+ const all = l.namespaces.flatMap((n) => n.findings)
70
+ const n = (t) => all.filter((f) => f.type === t).length
71
+ const mark = l.stats.errors ? chalk.red('✗') : all.length ? chalk.yellow('⚠') : chalk.green('✓')
72
+ const bad = n('invalid-file')
73
+ lines.push(
74
+ `${mark} ${chalk.bold(l.lang)} ${n('jed-drift')} drift, ${n('jed-orphan')} orphan${bad ? `, ${bad} unreadable` : ''}`
75
+ )
76
+ for (const f of all.slice(0, 50)) {
77
+ const color = f.severity === 'error' ? chalk.red : f.severity === 'warning' ? chalk.yellow : chalk.gray
78
+ const detail =
79
+ f.type === 'jed-drift' ? chalk.gray(` (.po: "${f.source}" → JSON: "${f.translation}")`) : ''
80
+ lines.push(` ${color(f.severity)} ${chalk.cyan(f.path)} ${f.type} — ${f.message}${detail}`)
81
+ }
82
+ if (all.length > 50) lines.push(chalk.gray(` … and ${all.length - 50} more`))
83
+ }
84
+ lines.push('')
85
+ lines.push(v.ok ? chalk.green('✓ .po and JED in sync') : chalk.red(`✗ out of sync: ${v.failures.join('; ')}`))
86
+ return lines.join('\n')
87
+ }
88
+
89
+ export function wpSyncCommand(program) {
90
+ program
91
+ .command('wp-sync <po> [jed...]')
92
+ .description('WordPress: check that JED .json files are in sync with their source .po (re-run make-json?)')
93
+ .option('-r, --reporter <name>', 'Output format: human | json | sarif | junit', 'human')
94
+ .option('-o, --output <file>', 'Write the report to a file instead of stdout')
95
+ .option('--json', 'Shorthand for --reporter json')
96
+ .option('--severity <spec>', "Per-rule severity overrides, e.g. 'jed-orphan=off' (error|warning|info|off)")
97
+ .option('--baseline <file>', 'Baseline file: findings already recorded in it do not fail the build')
98
+ .option('--write-baseline', 'Snapshot current findings into --baseline (default .shipi18n/baseline.json) and exit')
99
+ .option('--fail-on <level>', 'Exit non-zero on: error | warning | none', 'error')
100
+ .action((po, jed, opts) => {
101
+ const poFile = resolve(po)
102
+ let poText
103
+ try {
104
+ poText = readFileSync(poFile, 'utf8')
105
+ } catch (err) {
106
+ console.error(chalk.red(`Error: cannot read .po ${po}: ${err.message}`))
107
+ process.exitCode = 2
108
+ return
109
+ }
110
+
111
+ const jedFiles = resolveJedFiles(poFile, jed)
112
+ if (jedFiles.length === 0) {
113
+ console.error(
114
+ chalk.yellow(
115
+ `No JED .json files found next to ${po}. Pass them explicitly, or run \`wp i18n make-json\` first.`
116
+ )
117
+ )
118
+ process.exitCode = 2
119
+ return
120
+ }
121
+
122
+ const languages = []
123
+ for (const jf of jedFiles) {
124
+ let json
125
+ try {
126
+ json = JSON.parse(readFileSync(jf, 'utf8'))
127
+ } catch (err) {
128
+ languages.push(
129
+ fileToLanguage(jf, [{ type: 'invalid-file', severity: 'error', path: rel(jf), message: `invalid JSON: ${err.message}` }])
130
+ )
131
+ continue
132
+ }
133
+ const { findings } = checkJedSync(poText, json)
134
+ languages.push(fileToLanguage(jf, findings))
135
+ }
136
+
137
+ const result = { layout: 'wp-jed', source: basename(poFile), languages }
138
+ result.totals = languages.reduce(
139
+ (a, l) => ({ errors: a.errors + l.stats.errors, warnings: a.warnings + l.stats.warnings }),
140
+ { errors: 0, warnings: 0 }
141
+ )
142
+
143
+ if (opts.writeBaseline) {
144
+ const file = resolve(opts.baseline || DEFAULT_BASELINE)
145
+ try {
146
+ mkdirSync(dirname(file), { recursive: true })
147
+ writeFileSync(file, JSON.stringify(buildBaseline(result), null, 2) + '\n')
148
+ } catch (err) {
149
+ console.error(chalk.red(`Error: cannot write baseline ${file}: ${err.message}`))
150
+ process.exitCode = 2
151
+ return
152
+ }
153
+ console.error(chalk.gray(`baseline: recorded findings → ${opts.baseline || DEFAULT_BASELINE}`))
154
+ return
155
+ }
156
+
157
+ let severityMap
158
+ if (opts.severity) {
159
+ try {
160
+ severityMap = parseSeverity(opts.severity)
161
+ } catch (err) {
162
+ console.error(chalk.red(`Error: ${err.message}`))
163
+ process.exitCode = 2
164
+ return
165
+ }
166
+ }
167
+ let baseline
168
+ if (opts.baseline) {
169
+ const file = resolve(opts.baseline)
170
+ if (existsSync(file)) {
171
+ try {
172
+ baseline = JSON.parse(readFileSync(file, 'utf8'))
173
+ } catch (err) {
174
+ console.error(chalk.red(`Error: cannot read baseline ${opts.baseline}: ${err.message}`))
175
+ process.exitCode = 2
176
+ return
177
+ }
178
+ } else {
179
+ console.error(chalk.yellow(`note: baseline ${opts.baseline} not found — reporting all findings.`))
180
+ }
181
+ }
182
+ if (severityMap || baseline) applyPolicy(result, { severity: severityMap, baseline })
183
+
184
+ const verdictResult = verdict(result, { failOn: opts.failOn })
185
+ const name = opts.json ? 'json' : opts.reporter
186
+ let report
187
+ if (name === 'human') {
188
+ report = humanWpReport(result, verdictResult)
189
+ } else {
190
+ const reporter = REPORTERS[name]
191
+ if (!reporter) {
192
+ console.error(chalk.red(`Error: unknown reporter '${name}' (human | json | sarif | junit)`))
193
+ process.exitCode = 2
194
+ return
195
+ }
196
+ report = reporter(result, verdictResult, {})
197
+ }
198
+
199
+ if (opts.output) {
200
+ writeFileSync(opts.output, report.endsWith('\n') ? report : report + '\n')
201
+ if (name !== 'human') console.error(chalk.gray(`report written to ${opts.output}`))
202
+ } else {
203
+ console.log(report)
204
+ }
205
+ if (!verdictResult.ok) process.exitCode = 1
206
+ })
207
+ }