@shipi18n/cli 2.9.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 +130 -1
- package/bin/shipi18n.js +2 -0
- package/package.json +2 -2
- package/src/commands/check.js +71 -1
- package/src/commands/wp-sync.js +207 -0
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
|
| --- | --- | --- |
|
|
@@ -104,12 +105,36 @@ plurals · empty values · untranslated copy · stale `.xcstrings` states.
|
|
|
104
105
|
| `-r, --reporter <name>` | `human` | `human` \| `json` \| `sarif` \| `junit` |
|
|
105
106
|
| `-o, --output <file>` | stdout | Write the report to a file |
|
|
106
107
|
| `--ignore-keys <globs>` | — | Silence keys: `'*.copyright,home:mcp.badge'` |
|
|
108
|
+
| `--severity <spec>` | — | Per-rule level override: `'untranslated=off,placeholder-added=error'` |
|
|
109
|
+
| `--baseline <file>` | — | Fail only on findings NOT already in the baseline |
|
|
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 |
|
|
107
112
|
| `--fail-on <level>` | `error` | `error` \| `warning` \| `none` |
|
|
108
113
|
| `--min-coverage <pct>` | — | Fail any language below this coverage |
|
|
109
114
|
|
|
110
115
|
Exit codes: `0` pass, `1` findings at the fail level, `2` usage error. Errors may fail CI; warnings
|
|
111
116
|
never do by default — a warning that blocks PRs gets the tool uninstalled.
|
|
112
117
|
|
|
118
|
+
### Adopting on a messy catalog — baseline & severity
|
|
119
|
+
|
|
120
|
+
A linter that fails on 2,000 pre-existing findings gets uninstalled by lunch. Baseline first, then
|
|
121
|
+
fail only on what's **new** — the Stylelint/RuboCop pattern:
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
# 1. Snapshot today's findings (commit the file).
|
|
125
|
+
npx @shipi18n/cli check ./locales --baseline .shipi18n/baseline.json --write-baseline
|
|
126
|
+
|
|
127
|
+
# 2. CI from now on fails only on NEW findings; the backlog is accepted.
|
|
128
|
+
npx @shipi18n/cli check ./locales --baseline .shipi18n/baseline.json
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
The baseline keys each finding on `(language, namespace, key path, rule)` — not the message text, so
|
|
132
|
+
rewording a message never invalidates it. A missing baseline file is a cold start (warn + report all),
|
|
133
|
+
not an error. Burn the backlog down by re-running `--write-baseline` whenever it shrinks.
|
|
134
|
+
|
|
135
|
+
`--severity` tunes or silences a rule everywhere: `error`, `warning`, `info` (reported, never fails),
|
|
136
|
+
or `off` (dropped entirely). Example: `--severity 'untranslated=off,empty-value=warning'`.
|
|
137
|
+
|
|
113
138
|
### GitHub Actions with PR annotations
|
|
114
139
|
|
|
115
140
|
```yaml
|
|
@@ -123,6 +148,91 @@ never do by default — a warning that blocks PRs gets the tool uninstalled.
|
|
|
123
148
|
sarif_file: i18n.sarif
|
|
124
149
|
```
|
|
125
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
|
+
|
|
126
236
|
## Semantic QA — `--semantic` (the judge)
|
|
127
237
|
|
|
128
238
|
The structural check cannot see a translation that is *fluent but wrong*. `--semantic` adds an
|
|
@@ -133,6 +243,25 @@ npx @shipi18n/cli check ./locales -s en --semantic # advisory: warnings
|
|
|
133
243
|
npx @shipi18n/cli check ./locales -s en --semantic --glossary glossary.json
|
|
134
244
|
```
|
|
135
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
|
+
|
|
136
265
|
**Honest limitations, up front:** the judge is probabilistic. Every key is judged across 3 passes
|
|
137
266
|
and flagged only on a majority vote, unparseable passes are discarded, and semantic findings are
|
|
138
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.
|
|
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.
|
|
52
|
+
"@shipi18n/core": "^2.11.0"
|
|
53
53
|
},
|
|
54
54
|
"peerDependencies": {
|
|
55
55
|
"@anthropic-ai/sdk": ">=0.30.0",
|
package/src/commands/check.js
CHANGED
|
@@ -24,6 +24,10 @@ import {
|
|
|
24
24
|
aggregateLanguage,
|
|
25
25
|
SEP,
|
|
26
26
|
verdict,
|
|
27
|
+
applyPolicy,
|
|
28
|
+
buildBaseline,
|
|
29
|
+
parseSeverity,
|
|
30
|
+
scanResultSecrets,
|
|
27
31
|
} from '@shipi18n/core'
|
|
28
32
|
import { REPORTERS } from '../reporters.js'
|
|
29
33
|
import { locksFor, DEFAULT_LOCKS_PATH } from './lock.js'
|
|
@@ -56,8 +60,12 @@ export function checkCommand(program) {
|
|
|
56
60
|
.option('-o, --output <file>', 'Write the report to a file instead of stdout')
|
|
57
61
|
.option('--json', 'Shorthand for --reporter json')
|
|
58
62
|
.option('--ignore-keys <patterns>', "Comma-separated '*' globs of keys to silence (path or ns:path)")
|
|
63
|
+
.option('--severity <spec>', "Per-rule severity overrides, e.g. 'untranslated=off,placeholder-added=error' (error|warning|info|off)")
|
|
64
|
+
.option('--baseline <file>', 'Baseline file: findings already recorded in it do not fail the build (only NEW ones do)')
|
|
65
|
+
.option('--write-baseline', 'Snapshot current findings into --baseline (default .shipi18n/baseline.json) and exit')
|
|
59
66
|
.option('--fail-on <level>', 'Exit non-zero on: error | warning | none', 'error')
|
|
60
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')
|
|
61
69
|
.option('--glossary <file>', 'Glossary JSON: DNT terms + locked per-language translations (deterministic)')
|
|
62
70
|
.option('--semantic', 'Add the LLM-as-judge pass (BYO key; advisory warnings by default)')
|
|
63
71
|
.option('--semantic-fail', 'Escalate semantic findings to errors (opt-in)')
|
|
@@ -99,6 +107,11 @@ export function checkCommand(program) {
|
|
|
99
107
|
return
|
|
100
108
|
}
|
|
101
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
|
+
|
|
102
115
|
if (opts.semantic) {
|
|
103
116
|
const cachePath = resolve(opts.semanticCache)
|
|
104
117
|
let cache = {}
|
|
@@ -130,7 +143,8 @@ export function checkCommand(program) {
|
|
|
130
143
|
console.error(
|
|
131
144
|
chalk.gray(
|
|
132
145
|
`semantic: judged ${judge.judged} (${judge.cached} cached), flagged ${judge.flagged}, ` +
|
|
133
|
-
`${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)` : '')
|
|
134
148
|
)
|
|
135
149
|
)
|
|
136
150
|
// "judged 0" on a badly broken tree is correct but reads as a broken
|
|
@@ -150,6 +164,62 @@ export function checkCommand(program) {
|
|
|
150
164
|
}
|
|
151
165
|
}
|
|
152
166
|
|
|
167
|
+
const DEFAULT_BASELINE = '.shipi18n/baseline.json'
|
|
168
|
+
|
|
169
|
+
// --write-baseline: snapshot every current finding and exit 0. Run after
|
|
170
|
+
// the (optional) semantic pass so a baseline can capture judge findings too.
|
|
171
|
+
if (opts.writeBaseline) {
|
|
172
|
+
const file = resolve(opts.baseline || DEFAULT_BASELINE)
|
|
173
|
+
const bl = buildBaseline(result)
|
|
174
|
+
try {
|
|
175
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
176
|
+
writeFileSync(file, JSON.stringify(bl, null, 2) + '\n')
|
|
177
|
+
} catch (err) {
|
|
178
|
+
console.error(chalk.red(`Error: cannot write baseline ${file}: ${err.message}`))
|
|
179
|
+
process.exitCode = 2
|
|
180
|
+
return
|
|
181
|
+
}
|
|
182
|
+
console.error(chalk.gray(`baseline: recorded ${bl.count} finding(s) → ${opts.baseline || DEFAULT_BASELINE}`))
|
|
183
|
+
return
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
let severityMap
|
|
187
|
+
if (opts.severity) {
|
|
188
|
+
try {
|
|
189
|
+
severityMap = parseSeverity(opts.severity)
|
|
190
|
+
} catch (err) {
|
|
191
|
+
console.error(chalk.red(`Error: ${err.message}`))
|
|
192
|
+
process.exitCode = 2
|
|
193
|
+
return
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
let baseline
|
|
198
|
+
if (opts.baseline) {
|
|
199
|
+
const file = resolve(opts.baseline)
|
|
200
|
+
if (existsSync(file)) {
|
|
201
|
+
try {
|
|
202
|
+
baseline = JSON.parse(readFileSync(file, 'utf8'))
|
|
203
|
+
} catch (err) {
|
|
204
|
+
console.error(chalk.red(`Error: cannot read baseline ${opts.baseline}: ${err.message}`))
|
|
205
|
+
process.exitCode = 2
|
|
206
|
+
return
|
|
207
|
+
}
|
|
208
|
+
} else {
|
|
209
|
+
// A missing baseline is a cold start, not an error: nothing is suppressed
|
|
210
|
+
// and the run reports every finding. Hint how to create one.
|
|
211
|
+
console.error(chalk.yellow(`note: baseline ${opts.baseline} not found — reporting all findings. Create it with --write-baseline.`))
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (severityMap || baseline) {
|
|
216
|
+
const suppressed = applyPolicy(result, { severity: severityMap, baseline })
|
|
217
|
+
const parts = []
|
|
218
|
+
if (suppressed.suppressedByBaseline) parts.push(`${suppressed.suppressedByBaseline} baselined`)
|
|
219
|
+
if (suppressed.suppressedBySeverity) parts.push(`${suppressed.suppressedBySeverity} silenced (severity=off)`)
|
|
220
|
+
if (parts.length) console.error(chalk.gray(`policy: ${parts.join(', ')}`))
|
|
221
|
+
}
|
|
222
|
+
|
|
153
223
|
const verdictResult = verdict(result, { failOn: opts.failOn, minCoverage: opts.minCoverage })
|
|
154
224
|
|
|
155
225
|
const name = opts.json ? 'json' : opts.reporter
|
|
@@ -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
|
+
}
|