@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 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
- Open-source, **bring-your-own-LLM** i18n translation CLI. Translate your locale files with your own
4
- OpenAI or Anthropic key — no Shipi18n account, no hosted API.
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
- npm i -g @shipi18n/cli @anthropic-ai/sdk # or add `openai` for the OpenAI provider
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
- ## Quickstart
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.0.0",
4
- "description": "Open-source, bring-your-own-LLM i18n translation CLI. Translate locale files with your own OpenAI/Anthropic key.",
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.0.0"
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
+ }
@@ -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, '&amp;')
125
+ .replace(/</g, '&lt;')
126
+ .replace(/>/g, '&gt;')
127
+ .replace(/"/g, '&quot;')
128
+ .replace(/'/g, '&apos;')
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 }