@shipi18n/cli 2.0.0 → 2.3.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 +33 -0
- package/README.md +117 -0
- package/bin/shipi18n.js +6 -0
- package/package.json +5 -2
- package/src/commands/check.js +160 -0
- package/src/commands/lock.js +131 -0
- package/src/reporters.js +189 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,38 @@
|
|
|
1
1
|
# @shipi18n/cli
|
|
2
2
|
|
|
3
|
+
## 2.3.0
|
|
4
|
+
|
|
5
|
+
- New: `shipi18n lock [path]` — record hand-edited translations in a readable, commit-friendly
|
|
6
|
+
`.shipi18n/locks.json`. `--keys` globs, `--lang`, `--relock`.
|
|
7
|
+
- New: `check` reports `manual-translation-clobbered` and `manual-translation-stale` (warnings only —
|
|
8
|
+
locks protect human work and must never fail a pipeline). `--no-locks` disables.
|
|
9
|
+
- Fix: `--no-locks` did nothing. Commander pairs it with `--locks <file>`, so the negation arrives as
|
|
10
|
+
`locks: false`; the code only checked `noLocks`.
|
|
11
|
+
|
|
12
|
+
## 2.2.0
|
|
13
|
+
|
|
14
|
+
- New: `shipi18n check --semantic` — BYO-key LLM-judge pass on top of the structural check.
|
|
15
|
+
- Advisory by default: semantic findings are warnings and never fail CI unless you opt in with
|
|
16
|
+
`--semantic-fail`.
|
|
17
|
+
- Structural-first: keys that already have structural errors are not sent to the judge.
|
|
18
|
+
- Incremental: verdicts are cached (`--semantic-cache`, default `.shipi18n/semantic-cache.json`);
|
|
19
|
+
unchanged strings cost zero model calls on re-runs.
|
|
20
|
+
- `--glossary <file>` enforces do-not-translate and locked terms deterministically (no LLM) and
|
|
21
|
+
feeds the glossary to the judge as context.
|
|
22
|
+
- New flags: `-p/--provider`, `--api-key`, `--semantic-model`, `--semantic-passes`.
|
|
23
|
+
- Semantic findings flow through all reporters; SARIF remains schema-valid.
|
|
24
|
+
|
|
25
|
+
## 2.1.0
|
|
26
|
+
|
|
27
|
+
- New command: `shipi18n check [path]` — validate translated locale files against the source
|
|
28
|
+
language in CI. No LLM, no API key, no network.
|
|
29
|
+
- Auto-detects flat (`locales/en.json`) and nested (`locales/en/<ns>.json`) JSON trees, Flutter
|
|
30
|
+
ARB directories and Apple `.xcstrings` catalogs.
|
|
31
|
+
- Reporters: `human`, `json`, `sarif` (GitHub code-scanning / PR annotations) and `junit`.
|
|
32
|
+
- `--fail-on error|warning|none`, `--min-coverage <pct>`, `--ignore-keys <globs>`,
|
|
33
|
+
`--output <file>`. Exit codes: 0 pass, 1 findings, 2 usage error.
|
|
34
|
+
- Errors can fail CI; warnings never do by default.
|
|
35
|
+
|
|
3
36
|
## 2.0.0
|
|
4
37
|
|
|
5
38
|
**Breaking — bring-your-own-LLM.** Rebuilt on `@shipi18n/core`; no Shipi18n account or hosted API.
|
package/README.md
CHANGED
|
@@ -45,6 +45,123 @@ shipi18n translate en.json -p openai -t de --api-key $OPENAI_API_KEY
|
|
|
45
45
|
shipi18n translate en.json -t es --incremental
|
|
46
46
|
```
|
|
47
47
|
|
|
48
|
+
## Check — validate translations in CI (no LLM, no key)
|
|
49
|
+
|
|
50
|
+
`shipi18n check` is a deterministic QA gate for translated locale files. It works on output from
|
|
51
|
+
**any** translator — this CLI, another tool, an agent, or a human — and needs no API key, so it can
|
|
52
|
+
run on every push.
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
npx @shipi18n/cli check ./locales --source en
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
It detects both common layouts (`locales/en.json` and `locales/en/<ns>.json`), plus Flutter ARB
|
|
59
|
+
directories and Apple String Catalogs (`shipi18n check Localizable.xcstrings`).
|
|
60
|
+
|
|
61
|
+
**What it catches:** missing and orphaned keys · dropped or invented placeholders (`{{name}}`,
|
|
62
|
+
`{count}`, `%s`, `%1$s`, `%@`, `%lld`, `$t(...)`, `%{name}`, HTML tags) · collapsed vue-i18n pipe
|
|
63
|
+
plurals · empty values · untranslated copy · stale `.xcstrings` states.
|
|
64
|
+
|
|
65
|
+
| Flag | Default | Meaning |
|
|
66
|
+
| --- | --- | --- |
|
|
67
|
+
| `-s, --source <lang>` | `en` | Source language |
|
|
68
|
+
| `-r, --reporter <name>` | `human` | `human` \| `json` \| `sarif` \| `junit` |
|
|
69
|
+
| `-o, --output <file>` | stdout | Write the report to a file |
|
|
70
|
+
| `--ignore-keys <globs>` | — | Silence keys: `'*.copyright,home:mcp.badge'` |
|
|
71
|
+
| `--fail-on <level>` | `error` | `error` \| `warning` \| `none` |
|
|
72
|
+
| `--min-coverage <pct>` | — | Fail any language below this coverage |
|
|
73
|
+
|
|
74
|
+
Exit codes: `0` pass, `1` findings at the fail level, `2` usage error. Errors may fail CI; warnings
|
|
75
|
+
never do by default — a warning that blocks PRs gets the tool uninstalled.
|
|
76
|
+
|
|
77
|
+
### GitHub Actions with PR annotations
|
|
78
|
+
|
|
79
|
+
```yaml
|
|
80
|
+
- name: Check translations
|
|
81
|
+
run: npx @shipi18n/cli check ./locales -s en --reporter sarif --output i18n.sarif
|
|
82
|
+
|
|
83
|
+
- name: Upload findings
|
|
84
|
+
if: always()
|
|
85
|
+
uses: github/codeql-action/upload-sarif@v3
|
|
86
|
+
with:
|
|
87
|
+
sarif_file: i18n.sarif
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Semantic QA — `--semantic` (the judge)
|
|
91
|
+
|
|
92
|
+
The structural check cannot see a translation that is *fluent but wrong*. `--semantic` adds an
|
|
93
|
+
LLM-as-judge pass with your own key:
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
npx @shipi18n/cli check ./locales -s en --semantic # advisory: warnings only
|
|
97
|
+
npx @shipi18n/cli check ./locales -s en --semantic --glossary glossary.json
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
**Honest limitations, up front:** the judge is probabilistic. Every key is judged across 3 passes
|
|
101
|
+
and flagged only on a majority vote, unparseable passes are discarded, and semantic findings are
|
|
102
|
+
**warnings by default** — they never fail CI unless you opt in with `--semantic-fail`. It augments
|
|
103
|
+
review; it does not replace it. You pay your provider for the tokens; the verdict cache
|
|
104
|
+
(`.shipi18n/semantic-cache.json`, safe to commit) makes unchanged re-runs free, and keys that
|
|
105
|
+
already failed the structural check are never sent to the judge.
|
|
106
|
+
|
|
107
|
+
What it flags: `semantic-mistranslation` (says something different), `semantic-omission` (meaning
|
|
108
|
+
dropped), `semantic-addition` (meaning invented).
|
|
109
|
+
|
|
110
|
+
**Measured** (2026-08-16, committed 228-pair corpus, thresholds fixed before the judge was built,
|
|
111
|
+
default judge `claude-haiku-4-5`, 3 passes): **100%** of planted errors caught (100% per category,
|
|
112
|
+
100% label accuracy), **7.1%** false positives on clean pairs, 6/6 glossary violations with 0 false,
|
|
113
|
+
~62k tokens in 141s. On a real 478-pair production tree it flagged 3.6% of keys; the warm-cache
|
|
114
|
+
rerun made **zero** model calls. Full harness: `evals/semantic/` in the repo — run it against your
|
|
115
|
+
own model.
|
|
116
|
+
|
|
117
|
+
### Glossary (deterministic — no LLM)
|
|
118
|
+
|
|
119
|
+
```json
|
|
120
|
+
{
|
|
121
|
+
"Shipi18n": { "dnt": true },
|
|
122
|
+
"dashboard": { "es": "panel", "de": "Dashboard", "ja": "ダッシュボード" }
|
|
123
|
+
}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
`"dnt"` terms must survive verbatim; language entries are required translations. Violations are
|
|
127
|
+
`glossary-violation` **errors**, caught by string matching at zero cost, and the glossary is also
|
|
128
|
+
given to the judge as context.
|
|
129
|
+
|
|
130
|
+
## Protect hand-edited translations — `shipi18n lock`
|
|
131
|
+
|
|
132
|
+
The oldest complaint about machine translation: you fix a string by hand, the tool runs again, and
|
|
133
|
+
your fix is gone. Lock the translations a human has blessed, and `check` tells you when that happens.
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
# bless everything currently in the tree
|
|
137
|
+
npx @shipi18n/cli lock ./locales
|
|
138
|
+
|
|
139
|
+
# or just the strings you actually hand-edited
|
|
140
|
+
npx @shipi18n/cli lock ./locales --keys 'legal.*,checkout.cta'
|
|
141
|
+
npx @shipi18n/cli lock ./locales --lang de,ja # narrow to some languages
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
This writes `.shipi18n/locks.json` — **commit it**, it is the record of which translations a person
|
|
145
|
+
reviewed. It stores only hashes, never your strings.
|
|
146
|
+
|
|
147
|
+
Afterwards `check` reports two new findings:
|
|
148
|
+
|
|
149
|
+
| Finding | Meaning |
|
|
150
|
+
| --- | --- |
|
|
151
|
+
| `manual-translation-clobbered` | the locked translation's text changed — someone re-translated over a human edit |
|
|
152
|
+
| `manual-translation-stale` | the **source** changed underneath a locked translation, so the human edit may no longer be right |
|
|
153
|
+
|
|
154
|
+
Both are **warnings, never errors**: this feature exists to protect people's work, not to block their
|
|
155
|
+
pipeline. A lock that failed CI would just get deleted. Use `--fail-on warning` if you disagree, or
|
|
156
|
+
`--no-locks` to ignore the lock file entirely.
|
|
157
|
+
|
|
158
|
+
```bash
|
|
159
|
+
# accept the current state as the new blessed baseline
|
|
160
|
+
npx @shipi18n/cli lock ./locales --relock
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
A missing or corrupt lock file is a cold start, not a crash.
|
|
164
|
+
|
|
48
165
|
## Bring your own LLM
|
|
49
166
|
|
|
50
167
|
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,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shipi18n/cli",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.0",
|
|
4
4
|
"description": "Open-source, bring-your-own-LLM i18n translation CLI. Translate locale files with your own OpenAI/Anthropic key.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -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",
|
|
@@ -38,7 +39,7 @@
|
|
|
38
39
|
"chalk": "^5.3.0",
|
|
39
40
|
"commander": "^12.0.0",
|
|
40
41
|
"ora": "^8.0.1",
|
|
41
|
-
"@shipi18n/core": "^2.
|
|
42
|
+
"@shipi18n/core": "^2.3.0"
|
|
42
43
|
},
|
|
43
44
|
"peerDependencies": {
|
|
44
45
|
"@anthropic-ai/sdk": ">=0.30.0",
|
|
@@ -53,6 +54,8 @@
|
|
|
53
54
|
}
|
|
54
55
|
},
|
|
55
56
|
"devDependencies": {
|
|
57
|
+
"ajv": "^8.20.0",
|
|
58
|
+
"fast-xml-parser": "^5.10.1",
|
|
56
59
|
"jest": "^29.7.0"
|
|
57
60
|
},
|
|
58
61
|
"scripts": {
|
|
@@ -0,0 +1,160 @@
|
|
|
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
|
+
} catch (err) {
|
|
136
|
+
console.error(chalk.red(`Semantic pass failed: ${err.message}`))
|
|
137
|
+
process.exitCode = 2
|
|
138
|
+
return
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const verdictResult = verdict(result, { failOn: opts.failOn, minCoverage: opts.minCoverage })
|
|
143
|
+
|
|
144
|
+
const name = opts.json ? 'json' : opts.reporter
|
|
145
|
+
const reporter = REPORTERS[name]
|
|
146
|
+
if (!reporter) {
|
|
147
|
+
console.error(chalk.red(`Error: unknown reporter '${name}' (human | json | sarif | junit)`))
|
|
148
|
+
process.exitCode = 2
|
|
149
|
+
return
|
|
150
|
+
}
|
|
151
|
+
const report = reporter(result, verdictResult, { toolVersion: pkg.version })
|
|
152
|
+
if (opts.output) {
|
|
153
|
+
writeFileSync(opts.output, report.endsWith('\n') ? report : report + '\n')
|
|
154
|
+
if (name !== 'human') console.error(chalk.gray(`report written to ${opts.output}`))
|
|
155
|
+
} else {
|
|
156
|
+
console.log(report)
|
|
157
|
+
}
|
|
158
|
+
if (!verdictResult.ok) process.exitCode = 1
|
|
159
|
+
})
|
|
160
|
+
}
|
|
@@ -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 }
|