@shipi18n/cli 2.0.0 → 2.4.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 +42 -0
- package/README.md +158 -4
- package/bin/shipi18n.js +6 -0
- package/package.json +11 -3
- package/src/commands/check.js +170 -0
- package/src/commands/lock.js +131 -0
- package/src/reporters.js +189 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,47 @@
|
|
|
1
1
|
# @shipi18n/cli
|
|
2
2
|
|
|
3
|
+
## 2.4.0
|
|
4
|
+
|
|
5
|
+
- Fix: `check --semantic` explains a `judged 0`. When every translated key has a structural error
|
|
6
|
+
there is nothing for the judge to look at — correct, but it looked broken. The CLI now says how
|
|
7
|
+
many keys it skipped and to fix those first.
|
|
8
|
+
- Fix (via core 2.4.0): the missing-SDK error names the install that works for the `npx` path.
|
|
9
|
+
- Note: the npm description on this page was stale until this release — npm only refreshes it on
|
|
10
|
+
publish, so the registry still led with translation after the project repositioned around QA.
|
|
11
|
+
|
|
12
|
+
## 2.3.0
|
|
13
|
+
|
|
14
|
+
- New: `shipi18n lock [path]` — record hand-edited translations in a readable, commit-friendly
|
|
15
|
+
`.shipi18n/locks.json`. `--keys` globs, `--lang`, `--relock`.
|
|
16
|
+
- New: `check` reports `manual-translation-clobbered` and `manual-translation-stale` (warnings only —
|
|
17
|
+
locks protect human work and must never fail a pipeline). `--no-locks` disables.
|
|
18
|
+
- Fix: `--no-locks` did nothing. Commander pairs it with `--locks <file>`, so the negation arrives as
|
|
19
|
+
`locks: false`; the code only checked `noLocks`.
|
|
20
|
+
|
|
21
|
+
## 2.2.0
|
|
22
|
+
|
|
23
|
+
- New: `shipi18n check --semantic` — BYO-key LLM-judge pass on top of the structural check.
|
|
24
|
+
- Advisory by default: semantic findings are warnings and never fail CI unless you opt in with
|
|
25
|
+
`--semantic-fail`.
|
|
26
|
+
- Structural-first: keys that already have structural errors are not sent to the judge.
|
|
27
|
+
- Incremental: verdicts are cached (`--semantic-cache`, default `.shipi18n/semantic-cache.json`);
|
|
28
|
+
unchanged strings cost zero model calls on re-runs.
|
|
29
|
+
- `--glossary <file>` enforces do-not-translate and locked terms deterministically (no LLM) and
|
|
30
|
+
feeds the glossary to the judge as context.
|
|
31
|
+
- New flags: `-p/--provider`, `--api-key`, `--semantic-model`, `--semantic-passes`.
|
|
32
|
+
- Semantic findings flow through all reporters; SARIF remains schema-valid.
|
|
33
|
+
|
|
34
|
+
## 2.1.0
|
|
35
|
+
|
|
36
|
+
- New command: `shipi18n check [path]` — validate translated locale files against the source
|
|
37
|
+
language in CI. No LLM, no API key, no network.
|
|
38
|
+
- Auto-detects flat (`locales/en.json`) and nested (`locales/en/<ns>.json`) JSON trees, Flutter
|
|
39
|
+
ARB directories and Apple `.xcstrings` catalogs.
|
|
40
|
+
- Reporters: `human`, `json`, `sarif` (GitHub code-scanning / PR annotations) and `junit`.
|
|
41
|
+
- `--fail-on error|warning|none`, `--min-coverage <pct>`, `--ignore-keys <globs>`,
|
|
42
|
+
`--output <file>`. Exit codes: 0 pass, 1 findings, 2 usage error.
|
|
43
|
+
- Errors can fail CI; warnings never do by default.
|
|
44
|
+
|
|
3
45
|
## 2.0.0
|
|
4
46
|
|
|
5
47
|
**Breaking — bring-your-own-LLM.** Rebuilt on `@shipi18n/core`; no Shipi18n account or hosted API.
|
package/README.md
CHANGED
|
@@ -1,15 +1,42 @@
|
|
|
1
1
|
# @shipi18n/cli
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
**Catch broken translations before you ship them** — and translate with your own LLM key when you
|
|
4
|
+
want to. Open-source, no account, no hosted API.
|
|
5
|
+
|
|
6
|
+
## Quickstart — check, no API key
|
|
5
7
|
|
|
6
8
|
```bash
|
|
7
|
-
|
|
9
|
+
npx @shipi18n/cli check ./locales -s en
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Reports missing and orphaned keys, dropped placeholders, collapsed plurals, empty values,
|
|
13
|
+
untranslated copy and per-language coverage. Deterministic and offline — no LLM, no key, nothing
|
|
14
|
+
leaves your machine. Exit code `1` when there are errors, so it works as a CI gate as-is.
|
|
15
|
+
|
|
16
|
+
Then, for the errors structure cannot see, bring a key. The judge needs a provider SDK next to the
|
|
17
|
+
CLI, so install both:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npm i -D @shipi18n/cli @anthropic-ai/sdk # or `openai`
|
|
21
|
+
export ANTHROPIC_API_KEY=sk-ant-...
|
|
22
|
+
npx shipi18n check ./locales -s en --semantic
|
|
8
23
|
```
|
|
9
24
|
|
|
10
|
-
|
|
25
|
+
An LLM reads each source/translation pair and reports mistranslations, omissions and additions —
|
|
26
|
+
advisory by default, so it never fails your build unless you ask it to.
|
|
27
|
+
|
|
28
|
+
> **Note.** `--semantic` only judges keys that pass the structural checks, so if a tree is full of
|
|
29
|
+
> missing keys and dropped placeholders you will see `judged 0` and no model calls. That is by
|
|
30
|
+
> design — fix the structural errors first, then re-run for meaning.
|
|
31
|
+
|
|
32
|
+
**Measured** on a 228-pair corpus committed before the judge was written: **100% of planted errors
|
|
33
|
+
caught, 7.1% false positives.** Full harness in the repo under `evals/semantic/` — run it against
|
|
34
|
+
your own model.
|
|
35
|
+
|
|
36
|
+
## Translating
|
|
11
37
|
|
|
12
38
|
```bash
|
|
39
|
+
npm i -g @shipi18n/cli @anthropic-ai/sdk # or add `openai` for the OpenAI provider
|
|
13
40
|
export ANTHROPIC_API_KEY=sk-ant-...
|
|
14
41
|
shipi18n translate en.json --target es,fr,de
|
|
15
42
|
```
|
|
@@ -45,6 +72,133 @@ shipi18n translate en.json -p openai -t de --api-key $OPENAI_API_KEY
|
|
|
45
72
|
shipi18n translate en.json -t es --incremental
|
|
46
73
|
```
|
|
47
74
|
|
|
75
|
+
## Check — validate translations in CI (no LLM, no key)
|
|
76
|
+
|
|
77
|
+
`shipi18n check` is a deterministic QA gate for translated locale files. It works on output from
|
|
78
|
+
**any** translator — this CLI, another tool, an agent, or a human — and needs no API key, so it can
|
|
79
|
+
run on every push.
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
npx @shipi18n/cli check ./locales --source en
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
It detects both common layouts (`locales/en.json` and `locales/en/<ns>.json`), plus Flutter ARB
|
|
86
|
+
directories and Apple String Catalogs (`shipi18n check Localizable.xcstrings`).
|
|
87
|
+
|
|
88
|
+
**What it catches:** missing and orphaned keys · dropped or invented placeholders (`{{name}}`,
|
|
89
|
+
`{count}`, `%s`, `%1$s`, `%@`, `%lld`, `$t(...)`, `%{name}`, HTML tags) · collapsed vue-i18n pipe
|
|
90
|
+
plurals · empty values · untranslated copy · stale `.xcstrings` states.
|
|
91
|
+
|
|
92
|
+
| Flag | Default | Meaning |
|
|
93
|
+
| --- | --- | --- |
|
|
94
|
+
| `-s, --source <lang>` | `en` | Source language |
|
|
95
|
+
| `-r, --reporter <name>` | `human` | `human` \| `json` \| `sarif` \| `junit` |
|
|
96
|
+
| `-o, --output <file>` | stdout | Write the report to a file |
|
|
97
|
+
| `--ignore-keys <globs>` | — | Silence keys: `'*.copyright,home:mcp.badge'` |
|
|
98
|
+
| `--fail-on <level>` | `error` | `error` \| `warning` \| `none` |
|
|
99
|
+
| `--min-coverage <pct>` | — | Fail any language below this coverage |
|
|
100
|
+
|
|
101
|
+
Exit codes: `0` pass, `1` findings at the fail level, `2` usage error. Errors may fail CI; warnings
|
|
102
|
+
never do by default — a warning that blocks PRs gets the tool uninstalled.
|
|
103
|
+
|
|
104
|
+
### GitHub Actions with PR annotations
|
|
105
|
+
|
|
106
|
+
```yaml
|
|
107
|
+
- name: Check translations
|
|
108
|
+
run: npx @shipi18n/cli check ./locales -s en --reporter sarif --output i18n.sarif
|
|
109
|
+
|
|
110
|
+
- name: Upload findings
|
|
111
|
+
if: always()
|
|
112
|
+
uses: github/codeql-action/upload-sarif@v3
|
|
113
|
+
with:
|
|
114
|
+
sarif_file: i18n.sarif
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Semantic QA — `--semantic` (the judge)
|
|
118
|
+
|
|
119
|
+
The structural check cannot see a translation that is *fluent but wrong*. `--semantic` adds an
|
|
120
|
+
LLM-as-judge pass with your own key:
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
npx @shipi18n/cli check ./locales -s en --semantic # advisory: warnings only
|
|
124
|
+
npx @shipi18n/cli check ./locales -s en --semantic --glossary glossary.json
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
**Honest limitations, up front:** the judge is probabilistic. Every key is judged across 3 passes
|
|
128
|
+
and flagged only on a majority vote, unparseable passes are discarded, and semantic findings are
|
|
129
|
+
**warnings by default** — they never fail CI unless you opt in with `--semantic-fail`. It augments
|
|
130
|
+
review; it does not replace it. You pay your provider for the tokens; the verdict cache
|
|
131
|
+
(`.shipi18n/semantic-cache.json`, safe to commit) makes unchanged re-runs free, and keys that
|
|
132
|
+
already failed the structural check are never sent to the judge.
|
|
133
|
+
|
|
134
|
+
What it flags: `semantic-mistranslation` (says something different), `semantic-omission` (meaning
|
|
135
|
+
dropped), `semantic-addition` (meaning invented).
|
|
136
|
+
|
|
137
|
+
**Measured** (committed 228-pair corpus, thresholds fixed before the judge was built, default judge
|
|
138
|
+
`claude-haiku-4-5`, 3 passes). Two independent runs, 2026-08-16 and 2026-08-17:
|
|
139
|
+
|
|
140
|
+
| | 2026-08-16 | 2026-08-17 |
|
|
141
|
+
| --- | --- | --- |
|
|
142
|
+
| planted errors caught | 54/54 (100%) | 54/54 (100%) |
|
|
143
|
+
| per-category recall | 100% | 100% |
|
|
144
|
+
| label accuracy | 100% | 53/54 (98.1%) |
|
|
145
|
+
| false positives on clean pairs | 12/168 (7.1%) | 12/168 (7.1%) |
|
|
146
|
+
| glossary violations | 6/6, 0 false | 6/6, 0 false |
|
|
147
|
+
| cost | ~62k tokens / 141s | ~59k tokens / 156s, 48 calls |
|
|
148
|
+
|
|
149
|
+
The judge is a model, so treat these as a range, not a constant — label accuracy moved between runs
|
|
150
|
+
while catch and false-positive rates held. On a real 478-pair production tree it flagged 3.6% of keys;
|
|
151
|
+
the warm-cache rerun made **zero** model calls. Full harness: `evals/semantic/` in the repo — run it
|
|
152
|
+
against your own model and publish what you get.
|
|
153
|
+
|
|
154
|
+
### Glossary (deterministic — no LLM)
|
|
155
|
+
|
|
156
|
+
```json
|
|
157
|
+
{
|
|
158
|
+
"Shipi18n": { "dnt": true },
|
|
159
|
+
"dashboard": { "es": "panel", "de": "Dashboard", "ja": "ダッシュボード" }
|
|
160
|
+
}
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
`"dnt"` terms must survive verbatim; language entries are required translations. Violations are
|
|
164
|
+
`glossary-violation` **errors**, caught by string matching at zero cost, and the glossary is also
|
|
165
|
+
given to the judge as context.
|
|
166
|
+
|
|
167
|
+
## Protect hand-edited translations — `shipi18n lock`
|
|
168
|
+
|
|
169
|
+
The oldest complaint about machine translation: you fix a string by hand, the tool runs again, and
|
|
170
|
+
your fix is gone. Lock the translations a human has blessed, and `check` tells you when that happens.
|
|
171
|
+
|
|
172
|
+
```bash
|
|
173
|
+
# bless everything currently in the tree
|
|
174
|
+
npx @shipi18n/cli lock ./locales
|
|
175
|
+
|
|
176
|
+
# or just the strings you actually hand-edited
|
|
177
|
+
npx @shipi18n/cli lock ./locales --keys 'legal.*,checkout.cta'
|
|
178
|
+
npx @shipi18n/cli lock ./locales --lang de,ja # narrow to some languages
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
This writes `.shipi18n/locks.json` — **commit it**, it is the record of which translations a person
|
|
182
|
+
reviewed. It stores only hashes, never your strings.
|
|
183
|
+
|
|
184
|
+
Afterwards `check` reports two new findings:
|
|
185
|
+
|
|
186
|
+
| Finding | Meaning |
|
|
187
|
+
| --- | --- |
|
|
188
|
+
| `manual-translation-clobbered` | the locked translation's text changed — someone re-translated over a human edit |
|
|
189
|
+
| `manual-translation-stale` | the **source** changed underneath a locked translation, so the human edit may no longer be right |
|
|
190
|
+
|
|
191
|
+
Both are **warnings, never errors**: this feature exists to protect people's work, not to block their
|
|
192
|
+
pipeline. A lock that failed CI would just get deleted. Use `--fail-on warning` if you disagree, or
|
|
193
|
+
`--no-locks` to ignore the lock file entirely.
|
|
194
|
+
|
|
195
|
+
```bash
|
|
196
|
+
# accept the current state as the new blessed baseline
|
|
197
|
+
npx @shipi18n/cli lock ./locales --relock
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
A missing or corrupt lock file is a cold start, not a crash.
|
|
201
|
+
|
|
48
202
|
## Bring your own LLM
|
|
49
203
|
|
|
50
204
|
Set `ANTHROPIC_API_KEY` (default provider) or use `-p openai` with `OPENAI_API_KEY`. Your keys, your
|
package/bin/shipi18n.js
CHANGED
|
@@ -5,6 +5,8 @@ import { readFileSync } from 'node:fs'
|
|
|
5
5
|
import { dirname, join } from 'node:path'
|
|
6
6
|
import { fileURLToPath } from 'node:url'
|
|
7
7
|
import { translateCommand } from '../src/commands/translate.js'
|
|
8
|
+
import { checkCommand } from '../src/commands/check.js'
|
|
9
|
+
import { lockCommand } from '../src/commands/lock.js'
|
|
8
10
|
|
|
9
11
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
10
12
|
const pkg = JSON.parse(readFileSync(join(__dirname, '../package.json'), 'utf8'))
|
|
@@ -21,6 +23,8 @@ ${chalk.cyan('Examples:')}
|
|
|
21
23
|
$ export ANTHROPIC_API_KEY=sk-ant-...
|
|
22
24
|
$ shipi18n translate en.json --target es,fr,de
|
|
23
25
|
$ shipi18n translate en.json -p openai --target ja --incremental
|
|
26
|
+
$ shipi18n check ./locales --source en --min-coverage 95
|
|
27
|
+
$ shipi18n lock ./locales --keys 'legal.*' # protect hand-edited strings
|
|
24
28
|
|
|
25
29
|
${chalk.cyan('Bring your own LLM:')}
|
|
26
30
|
Set ${chalk.yellow('ANTHROPIC_API_KEY')} (default) or use ${chalk.yellow('-p openai')} with ${chalk.yellow('OPENAI_API_KEY')}.
|
|
@@ -31,5 +35,7 @@ ${chalk.gray('https://github.com/Shipi18n/shipi18n')}
|
|
|
31
35
|
)
|
|
32
36
|
|
|
33
37
|
translateCommand(program)
|
|
38
|
+
checkCommand(program)
|
|
39
|
+
lockCommand(program)
|
|
34
40
|
program.parse(process.argv)
|
|
35
41
|
if (!process.argv.slice(2).length) program.outputHelp()
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shipi18n/cli",
|
|
3
|
-
"version": "2.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "2.4.0",
|
|
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": {
|
|
7
7
|
"shipi18n": "./bin/shipi18n.js"
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
"bin",
|
|
11
11
|
"src",
|
|
12
|
+
"!src/__tests__",
|
|
12
13
|
"LICENSE",
|
|
13
14
|
"NOTICE",
|
|
14
15
|
"README.md",
|
|
@@ -18,6 +19,11 @@
|
|
|
18
19
|
"access": "public"
|
|
19
20
|
},
|
|
20
21
|
"keywords": [
|
|
22
|
+
"sarif",
|
|
23
|
+
"ci",
|
|
24
|
+
"lint",
|
|
25
|
+
"translation-quality",
|
|
26
|
+
"i18n-qa",
|
|
21
27
|
"i18n",
|
|
22
28
|
"cli",
|
|
23
29
|
"translation",
|
|
@@ -38,7 +44,7 @@
|
|
|
38
44
|
"chalk": "^5.3.0",
|
|
39
45
|
"commander": "^12.0.0",
|
|
40
46
|
"ora": "^8.0.1",
|
|
41
|
-
"@shipi18n/core": "^2.
|
|
47
|
+
"@shipi18n/core": "^2.4.0"
|
|
42
48
|
},
|
|
43
49
|
"peerDependencies": {
|
|
44
50
|
"@anthropic-ai/sdk": ">=0.30.0",
|
|
@@ -53,6 +59,8 @@
|
|
|
53
59
|
}
|
|
54
60
|
},
|
|
55
61
|
"devDependencies": {
|
|
62
|
+
"ajv": "^8.20.0",
|
|
63
|
+
"fast-xml-parser": "^5.10.1",
|
|
56
64
|
"jest": "^29.7.0"
|
|
57
65
|
},
|
|
58
66
|
"scripts": {
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `shipi18n check` — structural QA for locale files, designed to live in CI.
|
|
3
|
+
*
|
|
4
|
+
* Deterministic and offline: no LLM, no API key, no network. Reports the
|
|
5
|
+
* failure modes machine translation actually produces — dropped placeholders,
|
|
6
|
+
* collapsed plurals, missing keys, untranslated copy — and exits non-zero so a
|
|
7
|
+
* pipeline can gate on it.
|
|
8
|
+
*
|
|
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.
|
|
12
|
+
*/
|
|
13
|
+
import { readFileSync, existsSync, writeFileSync, mkdirSync } from 'node:fs'
|
|
14
|
+
import { resolve, dirname, join } from 'node:path'
|
|
15
|
+
import { fileURLToPath } from 'node:url'
|
|
16
|
+
import chalk from 'chalk'
|
|
17
|
+
import {
|
|
18
|
+
runCheck,
|
|
19
|
+
runSemantic,
|
|
20
|
+
discoverLayout,
|
|
21
|
+
compileIgnores,
|
|
22
|
+
statsFrom,
|
|
23
|
+
aggregateLanguage,
|
|
24
|
+
SEP,
|
|
25
|
+
} from '@shipi18n/core'
|
|
26
|
+
import { REPORTERS } from '../reporters.js'
|
|
27
|
+
import { locksFor, DEFAULT_LOCKS_PATH } from './lock.js'
|
|
28
|
+
|
|
29
|
+
const pkg = JSON.parse(
|
|
30
|
+
readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'package.json'), 'utf8')
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
/* ------------------------------------------------------------------ engine */
|
|
34
|
+
|
|
35
|
+
// Layout discovery and tree walking live in @shipi18n/core so the CLI and the
|
|
36
|
+
// MCP validator tools cannot drift apart. Re-exported here because the CLI's
|
|
37
|
+
// tests and semantic pass are written against these names.
|
|
38
|
+
export { runCheck, runSemantic, discoverLayout, compileIgnores, statsFrom, aggregateLanguage, SEP }
|
|
39
|
+
|
|
40
|
+
/* ---------------------------------------------------------------- verdict */
|
|
41
|
+
|
|
42
|
+
/** Decide the exit code from findings and flags. Reporters never influence this. */
|
|
43
|
+
export function verdict(result, { failOn = 'error', minCoverage } = {}) {
|
|
44
|
+
const failures = []
|
|
45
|
+
if (failOn === 'error' && result.totals.errors > 0) failures.push(`${result.totals.errors} error(s)`)
|
|
46
|
+
if (failOn === 'warning' && result.totals.errors + result.totals.warnings > 0)
|
|
47
|
+
failures.push(`${result.totals.errors} error(s), ${result.totals.warnings} warning(s)`)
|
|
48
|
+
if (minCoverage != null) {
|
|
49
|
+
for (const l of result.languages) {
|
|
50
|
+
if (l.stats.coverage * 100 < minCoverage)
|
|
51
|
+
failures.push(`${l.lang} coverage ${(l.stats.coverage * 100).toFixed(1)}% < ${minCoverage}%`)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return { ok: failures.length === 0, failures }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/* ---------------------------------------------------------------- command */
|
|
58
|
+
|
|
59
|
+
export function checkCommand(program) {
|
|
60
|
+
program
|
|
61
|
+
.command('check [input]')
|
|
62
|
+
.description('Validate translated locale files against the source language (no LLM, no key)')
|
|
63
|
+
.option('-s, --source <language>', 'Source language', 'en')
|
|
64
|
+
.option('-r, --reporter <name>', 'Output format: human | json | sarif | junit', 'human')
|
|
65
|
+
.option('-o, --output <file>', 'Write the report to a file instead of stdout')
|
|
66
|
+
.option('--json', 'Shorthand for --reporter json')
|
|
67
|
+
.option('--ignore-keys <patterns>', "Comma-separated '*' globs of keys to silence (path or ns:path)")
|
|
68
|
+
.option('--fail-on <level>', 'Exit non-zero on: error | warning | none', 'error')
|
|
69
|
+
.option('--min-coverage <pct>', 'Fail any language below this coverage percentage', parseFloat)
|
|
70
|
+
.option('--glossary <file>', 'Glossary JSON: DNT terms + locked per-language translations (deterministic)')
|
|
71
|
+
.option('--semantic', 'Add the LLM-as-judge pass (BYO key; advisory warnings by default)')
|
|
72
|
+
.option('--semantic-fail', 'Escalate semantic findings to errors (opt-in)')
|
|
73
|
+
.option('-p, --provider <name>', 'LLM provider for --semantic: anthropic | openai', 'anthropic')
|
|
74
|
+
.option('--api-key <key>', 'LLM API key for --semantic (else provider env var)')
|
|
75
|
+
.option('--semantic-model <model>', 'Judge model override')
|
|
76
|
+
.option('--semantic-passes <n>', 'Judge passes for the majority vote', (v) => parseInt(v, 10), 3)
|
|
77
|
+
.option('--semantic-cache <file>', 'Verdict cache path', '.shipi18n/semantic-cache.json')
|
|
78
|
+
.option('--locks <file>', 'Manual-translation lock file', DEFAULT_LOCKS_PATH)
|
|
79
|
+
.option('--no-locks', 'Ignore manual-translation locks')
|
|
80
|
+
.action(async (input = './locales', opts) => {
|
|
81
|
+
let glossary
|
|
82
|
+
if (opts.glossary) {
|
|
83
|
+
try {
|
|
84
|
+
glossary = JSON.parse(readFileSync(opts.glossary, 'utf8'))
|
|
85
|
+
} catch (err) {
|
|
86
|
+
console.error(chalk.red(`Error: cannot read glossary ${opts.glossary}: ${err.message}`))
|
|
87
|
+
process.exitCode = 2
|
|
88
|
+
return
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
let result
|
|
93
|
+
try {
|
|
94
|
+
result = runCheck({
|
|
95
|
+
input,
|
|
96
|
+
source: opts.source,
|
|
97
|
+
ignoreKeys: opts.ignoreKeys,
|
|
98
|
+
glossary,
|
|
99
|
+
locks: locksFor(opts),
|
|
100
|
+
})
|
|
101
|
+
} catch (err) {
|
|
102
|
+
console.error(chalk.red(`Error: ${err.message}`))
|
|
103
|
+
process.exitCode = 2
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (opts.semantic) {
|
|
108
|
+
const cachePath = resolve(opts.semanticCache)
|
|
109
|
+
let cache = {}
|
|
110
|
+
if (existsSync(cachePath)) {
|
|
111
|
+
try {
|
|
112
|
+
cache = JSON.parse(readFileSync(cachePath, 'utf8'))
|
|
113
|
+
} catch {
|
|
114
|
+
cache = {} // a corrupt cache is just a cold cache
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
try {
|
|
118
|
+
const judge = await runSemantic(result, {
|
|
119
|
+
provider: opts.provider,
|
|
120
|
+
apiKey: opts.apiKey,
|
|
121
|
+
model: opts.semanticModel,
|
|
122
|
+
passes: opts.semanticPasses,
|
|
123
|
+
glossary,
|
|
124
|
+
cache,
|
|
125
|
+
fail: Boolean(opts.semanticFail),
|
|
126
|
+
})
|
|
127
|
+
mkdirSync(dirname(cachePath), { recursive: true })
|
|
128
|
+
writeFileSync(cachePath, JSON.stringify(cache, null, 2) + '\n')
|
|
129
|
+
console.error(
|
|
130
|
+
chalk.gray(
|
|
131
|
+
`semantic: judged ${judge.judged} (${judge.cached} cached), flagged ${judge.flagged}, ` +
|
|
132
|
+
`${judge.calls} model call(s), ${judge.parseFailures} discarded pass(es)`
|
|
133
|
+
)
|
|
134
|
+
)
|
|
135
|
+
// "judged 0" on a badly broken tree is correct but reads as a broken
|
|
136
|
+
// feature: the judge skips keys that already carry a structural error.
|
|
137
|
+
if (judge.judged === 0 && judge.excluded > 0) {
|
|
138
|
+
console.error(
|
|
139
|
+
chalk.yellow(
|
|
140
|
+
`note: nothing was judged — all ${judge.excluded} translated key(s) have structural ` +
|
|
141
|
+
`errors, which the semantic pass skips. Fix those first, then re-run with --semantic.`
|
|
142
|
+
)
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
} catch (err) {
|
|
146
|
+
console.error(chalk.red(`Semantic pass failed: ${err.message}`))
|
|
147
|
+
process.exitCode = 2
|
|
148
|
+
return
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const verdictResult = verdict(result, { failOn: opts.failOn, minCoverage: opts.minCoverage })
|
|
153
|
+
|
|
154
|
+
const name = opts.json ? 'json' : opts.reporter
|
|
155
|
+
const reporter = REPORTERS[name]
|
|
156
|
+
if (!reporter) {
|
|
157
|
+
console.error(chalk.red(`Error: unknown reporter '${name}' (human | json | sarif | junit)`))
|
|
158
|
+
process.exitCode = 2
|
|
159
|
+
return
|
|
160
|
+
}
|
|
161
|
+
const report = reporter(result, verdictResult, { toolVersion: pkg.version })
|
|
162
|
+
if (opts.output) {
|
|
163
|
+
writeFileSync(opts.output, report.endsWith('\n') ? report : report + '\n')
|
|
164
|
+
if (name !== 'human') console.error(chalk.gray(`report written to ${opts.output}`))
|
|
165
|
+
} else {
|
|
166
|
+
console.log(report)
|
|
167
|
+
}
|
|
168
|
+
if (!verdictResult.ok) process.exitCode = 1
|
|
169
|
+
})
|
|
170
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `shipi18n lock` — mark translations as hand-edited so `check` can tell you
|
|
3
|
+
* when something overwrites them, or when their source moves underneath.
|
|
4
|
+
*
|
|
5
|
+
* Writes `.shipi18n/locks.json` (safe to commit — it is the record of which
|
|
6
|
+
* translations a human has blessed).
|
|
7
|
+
*/
|
|
8
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'
|
|
9
|
+
import { resolve, dirname } from 'node:path'
|
|
10
|
+
import chalk from 'chalk'
|
|
11
|
+
import {
|
|
12
|
+
runCheck,
|
|
13
|
+
discoverLayout,
|
|
14
|
+
compileIgnores,
|
|
15
|
+
lockId,
|
|
16
|
+
lockEntry,
|
|
17
|
+
emptyLocks,
|
|
18
|
+
normalizeLocks,
|
|
19
|
+
flatten,
|
|
20
|
+
} from '@shipi18n/core'
|
|
21
|
+
|
|
22
|
+
export const DEFAULT_LOCKS_PATH = '.shipi18n/locks.json'
|
|
23
|
+
|
|
24
|
+
/** Tolerant read — a missing or corrupt file is simply "no locks yet". */
|
|
25
|
+
export function readLocks(path) {
|
|
26
|
+
if (!existsSync(path)) return emptyLocks()
|
|
27
|
+
try {
|
|
28
|
+
return normalizeLocks(JSON.parse(readFileSync(path, 'utf8')))
|
|
29
|
+
} catch {
|
|
30
|
+
return emptyLocks()
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function writeLocks(path, locks) {
|
|
35
|
+
mkdirSync(dirname(resolve(path)), { recursive: true })
|
|
36
|
+
writeFileSync(resolve(path), JSON.stringify(locks, null, 2) + '\n')
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Build the lock set for a tree. Only pairs that actually exist in both source
|
|
41
|
+
* and target are lockable — you cannot bless a translation that isn't there.
|
|
42
|
+
*/
|
|
43
|
+
export function buildLocks({ input, source = 'en', keys, existing = emptyLocks(), langs }) {
|
|
44
|
+
const isSelected = compileIgnores(keys) // same glob syntax as --ignore-keys
|
|
45
|
+
const selectAll = !keys
|
|
46
|
+
const layout = discoverLayout(input, source)
|
|
47
|
+
const locked = { ...existing.locked }
|
|
48
|
+
let added = 0
|
|
49
|
+
|
|
50
|
+
const sourceData = {}
|
|
51
|
+
for (const [ns, file] of Object.entries(layout.source)) {
|
|
52
|
+
sourceData[ns] = JSON.parse(readFileSync(file, 'utf8'))
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
for (const { lang, files } of layout.targets) {
|
|
56
|
+
if (langs && !langs.includes(lang)) continue
|
|
57
|
+
for (const [ns, srcObj] of Object.entries(sourceData)) {
|
|
58
|
+
const file = files[ns]
|
|
59
|
+
if (!file || !existsSync(file)) continue
|
|
60
|
+
let targetObj
|
|
61
|
+
try {
|
|
62
|
+
targetObj = JSON.parse(readFileSync(file, 'utf8'))
|
|
63
|
+
} catch {
|
|
64
|
+
continue // an unparseable file has nothing lockable in it
|
|
65
|
+
}
|
|
66
|
+
const src = flatten(srcObj)
|
|
67
|
+
const tgt = flatten(targetObj)
|
|
68
|
+
for (const [path, value] of Object.entries(src)) {
|
|
69
|
+
if (typeof value !== 'string' || typeof tgt[path] !== 'string') continue
|
|
70
|
+
if (!selectAll && !isSelected(ns, path)) continue
|
|
71
|
+
const id = lockId(lang, ns, path)
|
|
72
|
+
if (!(id in locked)) added++
|
|
73
|
+
locked[id] = lockEntry(value, tgt[path])
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return { locks: { ...emptyLocks(), locked }, added, total: Object.keys(locked).length }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function lockCommand(program) {
|
|
81
|
+
program
|
|
82
|
+
.command('lock [input]')
|
|
83
|
+
.description('Record translations as hand-edited, so check warns when they are overwritten')
|
|
84
|
+
.option('-s, --source <language>', 'Source language', 'en')
|
|
85
|
+
.option('-k, --keys <patterns>', "Only lock keys matching these '*' globs (default: all)")
|
|
86
|
+
.option('-l, --lang <languages>', 'Only lock these languages (comma-separated)')
|
|
87
|
+
.option('--locks <file>', 'Lock file path', DEFAULT_LOCKS_PATH)
|
|
88
|
+
.option('--relock', 'Update hashes for keys already locked (accept current state as blessed)')
|
|
89
|
+
.action((input = './locales', opts) => {
|
|
90
|
+
try {
|
|
91
|
+
const path = resolve(opts.locks)
|
|
92
|
+
const existing = opts.relock ? emptyLocks() : readLocks(path)
|
|
93
|
+
const langs = opts.lang ? opts.lang.split(',').map((l) => l.trim()) : undefined
|
|
94
|
+
const { locks, added, total } = buildLocks({
|
|
95
|
+
input,
|
|
96
|
+
source: opts.source,
|
|
97
|
+
keys: opts.keys,
|
|
98
|
+
existing,
|
|
99
|
+
langs,
|
|
100
|
+
})
|
|
101
|
+
writeLocks(path, locks)
|
|
102
|
+
console.log(
|
|
103
|
+
chalk.green(`✓ locked ${added} new translation(s); ${total} total in ${opts.locks}`)
|
|
104
|
+
)
|
|
105
|
+
if (!opts.keys) {
|
|
106
|
+
console.log(
|
|
107
|
+
chalk.gray(' Tip: --keys narrows this to the strings you actually hand-edited.')
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
} catch (err) {
|
|
111
|
+
console.error(chalk.red(`Error: ${err.message}`))
|
|
112
|
+
process.exitCode = 2
|
|
113
|
+
}
|
|
114
|
+
})
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Used by `check` to load locks unless disabled.
|
|
119
|
+
*
|
|
120
|
+
* Commander pairs `--no-locks` with `--locks <file>`, so the negation arrives as
|
|
121
|
+
* `opts.locks === false` rather than `opts.noLocks` — checking only the latter
|
|
122
|
+
* made --no-locks silently do nothing (caught by an end-to-end run, not by a
|
|
123
|
+
* unit test that hand-built the options object).
|
|
124
|
+
*/
|
|
125
|
+
export function locksFor(opts = {}) {
|
|
126
|
+
if (opts.locks === false || opts.noLocks) return undefined
|
|
127
|
+
const path = resolve(typeof opts.locks === 'string' ? opts.locks : DEFAULT_LOCKS_PATH)
|
|
128
|
+
if (!existsSync(path)) return undefined
|
|
129
|
+
const locks = readLocks(path)
|
|
130
|
+
return Object.keys(locks.locked).length ? locks : undefined
|
|
131
|
+
}
|
package/src/reporters.js
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Output formats for `shipi18n check`.
|
|
3
|
+
*
|
|
4
|
+
* Reporters SERIALIZE a result; they never decide it. Exit codes come from
|
|
5
|
+
* `verdict()` alone, so switching reporter can never change whether CI fails.
|
|
6
|
+
*/
|
|
7
|
+
import chalk from 'chalk'
|
|
8
|
+
|
|
9
|
+
/* ------------------------------------------------------------------ human */
|
|
10
|
+
|
|
11
|
+
export function humanReport(result, verdictResult) {
|
|
12
|
+
const lines = []
|
|
13
|
+
lines.push('')
|
|
14
|
+
lines.push(
|
|
15
|
+
`🔎 shipi18n check — ${result.layout} layout, source '${result.source}', ${result.languages.length} target language(s)`
|
|
16
|
+
)
|
|
17
|
+
lines.push('')
|
|
18
|
+
for (const l of result.languages) {
|
|
19
|
+
const all = l.namespaces.flatMap((n) => n.findings.map((f) => ({ ...f, ns: n.ns })))
|
|
20
|
+
const mark = l.stats.errors ? chalk.red('✗') : all.length ? chalk.yellow('⚠') : chalk.green('✓')
|
|
21
|
+
lines.push(
|
|
22
|
+
`${mark} ${chalk.bold(l.lang)} coverage ${(l.stats.coverage * 100).toFixed(1)}% ${l.stats.errors} error(s), ${l.stats.warnings} warning(s)`
|
|
23
|
+
)
|
|
24
|
+
for (const f of all.slice(0, 50)) {
|
|
25
|
+
const color = f.severity === 'error' ? chalk.red : chalk.yellow
|
|
26
|
+
const where = result.layout === 'flat' ? f.path : `${f.ns}:${f.path}`
|
|
27
|
+
lines.push(` ${color(f.severity)} ${chalk.cyan(where)} ${f.type} — ${f.message}`)
|
|
28
|
+
}
|
|
29
|
+
if (all.length > 50) lines.push(chalk.gray(` … and ${all.length - 50} more`))
|
|
30
|
+
}
|
|
31
|
+
lines.push('')
|
|
32
|
+
lines.push(
|
|
33
|
+
verdictResult.ok
|
|
34
|
+
? chalk.green('✓ check passed')
|
|
35
|
+
: chalk.red(`✗ check failed: ${verdictResult.failures.join('; ')}`)
|
|
36
|
+
)
|
|
37
|
+
return lines.join('\n')
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/* ------------------------------------------------------------------- json */
|
|
41
|
+
|
|
42
|
+
export function jsonReport(result, verdictResult) {
|
|
43
|
+
return JSON.stringify({ ...result, ok: verdictResult.ok, failures: verdictResult.failures }, null, 2)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/* ------------------------------------------------------------------ sarif */
|
|
47
|
+
|
|
48
|
+
const RULE_META = {
|
|
49
|
+
'missing-key': 'A key present in the source language is missing from a translation.',
|
|
50
|
+
'orphan-key': 'A key present in a translation does not exist in the source language.',
|
|
51
|
+
'placeholder-missing': 'A placeholder from the source string was dropped in the translation.',
|
|
52
|
+
'placeholder-added': 'The translation contains a placeholder the source does not have.',
|
|
53
|
+
'plural-forms': 'A pipe-separated plural lost one or more of its forms in translation.',
|
|
54
|
+
'empty-value': 'The translation of a non-empty source string is empty.',
|
|
55
|
+
'untranslated': 'The translation is identical to a multi-word source string.',
|
|
56
|
+
'type-mismatch': 'Source and translation values have different JSON types.',
|
|
57
|
+
'invalid-json': 'A locale file could not be parsed as JSON.',
|
|
58
|
+
'missing-file': 'An expected locale file does not exist.',
|
|
59
|
+
'stale-translation': 'The catalog marks this translation as needing review.',
|
|
60
|
+
'glossary-violation': 'A do-not-translate or locked glossary term was not respected.',
|
|
61
|
+
'manual-translation-clobbered': 'A translation locked as hand-edited has been overwritten.',
|
|
62
|
+
'manual-translation-stale': 'The source changed after this translation was locked by hand.',
|
|
63
|
+
'semantic-mistranslation': 'LLM judge (majority vote): the translation states something different from the source.',
|
|
64
|
+
'semantic-omission': 'LLM judge (majority vote): meaningful source content is missing from the translation.',
|
|
65
|
+
'semantic-addition': 'LLM judge (majority vote): the translation contains claims the source does not make.',
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** SARIF 2.1.0 — one run, one rule per finding type, one result per finding. */
|
|
69
|
+
export function sarifReport(result, _verdictResult, { toolVersion = '0.0.0' } = {}) {
|
|
70
|
+
const findings = []
|
|
71
|
+
for (const l of result.languages) {
|
|
72
|
+
for (const n of l.namespaces) {
|
|
73
|
+
for (const f of n.findings) findings.push({ lang: l.lang, ns: n.ns, file: n.file, ...f })
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
// Deterministic output: stable ordering makes committed SARIF diffable.
|
|
77
|
+
findings.sort((a, b) =>
|
|
78
|
+
`${a.file}|${a.path}|${a.type}`.localeCompare(`${b.file}|${b.path}|${b.type}`)
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
const usedTypes = [...new Set(findings.map((f) => f.type))].sort()
|
|
82
|
+
const ruleIndex = Object.fromEntries(usedTypes.map((t, i) => [t, i]))
|
|
83
|
+
|
|
84
|
+
const sarif = {
|
|
85
|
+
$schema: 'https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json',
|
|
86
|
+
version: '2.1.0',
|
|
87
|
+
runs: [
|
|
88
|
+
{
|
|
89
|
+
tool: {
|
|
90
|
+
driver: {
|
|
91
|
+
name: 'shipi18n-check',
|
|
92
|
+
informationUri: 'https://github.com/Shipi18n/shipi18n',
|
|
93
|
+
version: toolVersion,
|
|
94
|
+
rules: usedTypes.map((t) => ({
|
|
95
|
+
id: t,
|
|
96
|
+
shortDescription: { text: RULE_META[t] || t },
|
|
97
|
+
helpUri: 'https://shipi18n.com/docs/cli/commands',
|
|
98
|
+
})),
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
results: findings.map((f) => ({
|
|
102
|
+
ruleId: f.type,
|
|
103
|
+
ruleIndex: ruleIndex[f.type],
|
|
104
|
+
level: f.severity === 'error' ? 'error' : 'warning',
|
|
105
|
+
message: { text: `[${f.lang}] ${f.path}: ${f.message}` },
|
|
106
|
+
locations: [
|
|
107
|
+
{
|
|
108
|
+
physicalLocation: {
|
|
109
|
+
artifactLocation: { uri: (f.file || '').split('\\').join('/') },
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
],
|
|
113
|
+
})),
|
|
114
|
+
},
|
|
115
|
+
],
|
|
116
|
+
}
|
|
117
|
+
return JSON.stringify(sarif, null, 2)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/* ------------------------------------------------------------------ junit */
|
|
121
|
+
|
|
122
|
+
const xmlEscape = (s) =>
|
|
123
|
+
String(s)
|
|
124
|
+
.replace(/&/g, '&')
|
|
125
|
+
.replace(/</g, '<')
|
|
126
|
+
.replace(/>/g, '>')
|
|
127
|
+
.replace(/"/g, '"')
|
|
128
|
+
.replace(/'/g, ''')
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* One <testsuite> per language, one <testcase> per namespace.
|
|
132
|
+
* Errors become <failure>; warnings go to <system-out> — a warning that fails
|
|
133
|
+
* CI gets the tool uninstalled.
|
|
134
|
+
*/
|
|
135
|
+
export function junitReport(result) {
|
|
136
|
+
const suites = []
|
|
137
|
+
let totalTests = 0
|
|
138
|
+
let totalFailures = 0
|
|
139
|
+
|
|
140
|
+
for (const l of result.languages) {
|
|
141
|
+
const cases = []
|
|
142
|
+
let failures = 0
|
|
143
|
+
for (const n of l.namespaces) {
|
|
144
|
+
totalTests++
|
|
145
|
+
const errors = n.findings.filter((f) => f.severity === 'error')
|
|
146
|
+
const warnings = n.findings.filter((f) => f.severity === 'warning')
|
|
147
|
+
const body = []
|
|
148
|
+
if (errors.length) {
|
|
149
|
+
failures++
|
|
150
|
+
totalFailures++
|
|
151
|
+
// Include the offending strings: "dropped {{name}}" is not actionable
|
|
152
|
+
// without seeing WHICH string dropped it.
|
|
153
|
+
const detail = errors
|
|
154
|
+
.map((f) => {
|
|
155
|
+
const lines = [`${f.path}: ${f.type} — ${f.message}`]
|
|
156
|
+
if (f.source != null) lines.push(` source: ${f.source}`)
|
|
157
|
+
if (f.translation != null) lines.push(` translation: ${f.translation}`)
|
|
158
|
+
return lines.join('\n')
|
|
159
|
+
})
|
|
160
|
+
.join('\n')
|
|
161
|
+
body.push(
|
|
162
|
+
` <failure message="${xmlEscape(`${errors.length} error(s) in ${l.lang}/${n.ns}`)}">${xmlEscape(detail)}</failure>`
|
|
163
|
+
)
|
|
164
|
+
}
|
|
165
|
+
if (warnings.length) {
|
|
166
|
+
const detail = warnings.map((f) => `${f.path}: ${f.type} — ${f.message}`).join('\n')
|
|
167
|
+
body.push(` <system-out>${xmlEscape(detail)}</system-out>`)
|
|
168
|
+
}
|
|
169
|
+
cases.push(
|
|
170
|
+
body.length
|
|
171
|
+
? ` <testcase classname="${xmlEscape(l.lang)}" name="${xmlEscape(n.ns)}">\n${body.join('\n')}\n </testcase>`
|
|
172
|
+
: ` <testcase classname="${xmlEscape(l.lang)}" name="${xmlEscape(n.ns)}"/>`
|
|
173
|
+
)
|
|
174
|
+
}
|
|
175
|
+
suites.push(
|
|
176
|
+
` <testsuite name="${xmlEscape(l.lang)}" tests="${l.namespaces.length}" failures="${failures}">\n${cases.join('\n')}\n </testsuite>`
|
|
177
|
+
)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return [
|
|
181
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
182
|
+
`<testsuites name="shipi18n-check" tests="${totalTests}" failures="${totalFailures}">`,
|
|
183
|
+
...suites,
|
|
184
|
+
'</testsuites>',
|
|
185
|
+
'',
|
|
186
|
+
].join('\n')
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export const REPORTERS = { human: humanReport, json: jsonReport, sarif: sarifReport, junit: junitReport }
|