@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 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.0.0",
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.0.0"
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
+ }
@@ -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 }