@shipi18n/core 2.5.0 → 2.6.1

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,21 @@
1
1
  # @shipi18n/core
2
2
 
3
+ ## 2.6.1
4
+
5
+ - Fix: the OpenAI adapter sent `max_tokens`, which current OpenAI models (gpt-5.x) reject with a
6
+ 400 — `check --semantic -p openai` and `translate -p openai` failed against api.openai.com. The
7
+ adapter now sends `max_completion_tokens` to api.openai.com and keeps `max_tokens` for
8
+ OpenAI-compatible endpoints (Ollama, Gemini compat, LM Studio, vLLM), with a one-shot fallback
9
+ on the telltale 400 in either direction. Found by running the UIStringBench leaderboard against
10
+ live GPT judges.
11
+
12
+ ## 2.6.0
13
+
14
+ - New: reporters (`humanReport`, `jsonReport`, `sarifReport`, `junitReport`, `REPORTERS`,
15
+ `RULE_META`) and `verdict` lifted from the CLI into core, so any surface — the GitHub Action
16
+ first — can run a full `check` with exit-code semantics and SARIF output without depending on
17
+ the CLI. The CLI re-exports every name; nothing breaks.
18
+
3
19
  ## 2.5.0
4
20
 
5
21
  - New: the `openai` adapter accepts `baseURL`, pointing it at any OpenAI-compatible endpoint —
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shipi18n/core",
3
- "version": "2.5.0",
3
+ "version": "2.6.1",
4
4
  "description": "Translation QA for i18n locale files: placeholder and plural validation, key parity, coverage, and an LLM-as-judge semantic review. Also a structure-preserving translation engine — bring your own OpenAI or Anthropic key.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -30,7 +30,10 @@
30
30
  "openai",
31
31
  "anthropic",
32
32
  "localization",
33
- "bring-your-own-key"
33
+ "bring-your-own-key",
34
+ "linter",
35
+ "icu",
36
+ "l10n"
34
37
  ],
35
38
  "license": "Apache-2.0",
36
39
  "author": "Shipi18n",
@@ -54,6 +57,9 @@
54
57
  "devDependencies": {
55
58
  "jest": "^29.7.0"
56
59
  },
60
+ "dependencies": {
61
+ "chalk": "^5.3.0"
62
+ },
57
63
  "scripts": {
58
64
  "test": "NODE_OPTIONS='--experimental-vm-modules' jest",
59
65
  "test:watch": "NODE_OPTIONS='--experimental-vm-modules' jest --watch"
@@ -103,15 +103,35 @@ export function openaiAdapter(config = {}) {
103
103
  return clientPromise
104
104
  }
105
105
 
106
+ // Current OpenAI models (gpt-5.x era) reject `max_tokens` and require
107
+ // `max_completion_tokens`; OpenAI-compatible endpoints (Ollama, Gemini
108
+ // compat, LM Studio, vLLM) mostly still speak `max_tokens`. Try the modern
109
+ // name against api.openai.com and the legacy name against custom baseURLs,
110
+ // and fall back once on the telltale 400 either way.
111
+ let tokenParam = config.baseURL ? 'max_tokens' : 'max_completion_tokens'
106
112
  return {
107
113
  name: 'openai',
108
114
  async complete(prompt, opts = {}) {
109
115
  const client = await getClient()
110
- const res = await client.chat.completions.create({
111
- model,
112
- max_tokens: opts.maxTokens || 4096,
113
- messages: [{ role: 'user', content: prompt }],
114
- })
116
+ const request = (param) =>
117
+ client.chat.completions.create({
118
+ model,
119
+ [param]: opts.maxTokens || 4096,
120
+ messages: [{ role: 'user', content: prompt }],
121
+ })
122
+ let res
123
+ try {
124
+ res = await request(tokenParam)
125
+ } catch (err) {
126
+ const msg = String(err?.message || '')
127
+ const other = tokenParam === 'max_tokens' ? 'max_completion_tokens' : 'max_tokens'
128
+ if (err?.status === 400 && msg.includes(other)) {
129
+ tokenParam = other // remember for subsequent calls
130
+ res = await request(other)
131
+ } else {
132
+ throw err
133
+ }
134
+ }
115
135
  const content = res.choices?.[0]?.message?.content
116
136
  if (!content) throw new Error('OpenAI response contained no content')
117
137
  return content.trim()
package/src/index.js CHANGED
@@ -20,3 +20,7 @@ export { parseXcstrings } from './formats/xcstrings.js'
20
20
  export { reviewTranslations, DEFAULT_JUDGE_MODELS, buildReviewPrompt, parseVerdicts, pairHash } from './review.js'
21
21
  export { getLanguageName, LANGUAGE_NAMES } from './languages.js'
22
22
  export { anthropicAdapter, openaiAdapter, resolveAdapter } from './adapters/index.js'
23
+ // Reporters + verdict lived in the CLI through 2.5.x; lifted here (core 2.6.0)
24
+ // so the GitHub Action can run `check` without depending on the CLI. The CLI
25
+ // re-exports these names for compatibility.
26
+ export { humanReport, jsonReport, sarifReport, junitReport, REPORTERS, RULE_META, verdict } from './reporters.js'
@@ -0,0 +1,217 @@
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
+ /** Every rule has a documentation page; SARIF helpUri and the human footer
10
+ * point at it. Ids must match RULE_META below and the site's checkRules.js. */
11
+ const ruleUrl = (type) => `https://shipi18n.com/docs/rules/${type}`
12
+
13
+ /* ------------------------------------------------------------------ human */
14
+
15
+ export function humanReport(result, verdictResult) {
16
+ const lines = []
17
+ lines.push('')
18
+ lines.push(
19
+ `🔎 shipi18n check — ${result.layout} layout, source '${result.source}', ${result.languages.length} target language(s)`
20
+ )
21
+ lines.push('')
22
+ for (const l of result.languages) {
23
+ const all = l.namespaces.flatMap((n) => n.findings.map((f) => ({ ...f, ns: n.ns })))
24
+ const mark = l.stats.errors ? chalk.red('✗') : all.length ? chalk.yellow('⚠') : chalk.green('✓')
25
+ lines.push(
26
+ `${mark} ${chalk.bold(l.lang)} coverage ${(l.stats.coverage * 100).toFixed(1)}% ${l.stats.errors} error(s), ${l.stats.warnings} warning(s)`
27
+ )
28
+ for (const f of all.slice(0, 50)) {
29
+ const color = f.severity === 'error' ? chalk.red : chalk.yellow
30
+ const where = result.layout === 'flat' ? f.path : `${f.ns}:${f.path}`
31
+ lines.push(` ${color(f.severity)} ${chalk.cyan(where)} ${f.type} — ${f.message}`)
32
+ }
33
+ if (all.length > 50) lines.push(chalk.gray(` … and ${all.length - 50} more`))
34
+ }
35
+ const seenTypes = [
36
+ ...new Set(result.languages.flatMap((l) => l.namespaces.flatMap((n) => n.findings.map((f) => f.type)))),
37
+ ].sort()
38
+ if (seenTypes.length > 0) {
39
+ lines.push('')
40
+ for (const t of seenTypes) lines.push(chalk.gray(` ${t} → ${ruleUrl(t)}`))
41
+ }
42
+ lines.push('')
43
+ lines.push(
44
+ verdictResult.ok
45
+ ? chalk.green('✓ check passed')
46
+ : chalk.red(`✗ check failed: ${verdictResult.failures.join('; ')}`)
47
+ )
48
+ return lines.join('\n')
49
+ }
50
+
51
+ /* ------------------------------------------------------------------- json */
52
+
53
+ export function jsonReport(result, verdictResult) {
54
+ return JSON.stringify({ ...result, ok: verdictResult.ok, failures: verdictResult.failures }, null, 2)
55
+ }
56
+
57
+ /* ------------------------------------------------------------------ sarif */
58
+
59
+ export const RULE_META = {
60
+ 'missing-key': 'A key present in the source language is missing from a translation.',
61
+ 'orphan-key': 'A key present in a translation does not exist in the source language.',
62
+ 'placeholder-missing': 'A placeholder from the source string was dropped in the translation.',
63
+ 'placeholder-added': 'The translation contains a placeholder the source does not have.',
64
+ 'plural-forms': 'A pipe-separated plural lost one or more of its forms in translation.',
65
+ 'empty-value': 'The translation of a non-empty source string is empty.',
66
+ 'untranslated': 'The translation is identical to a multi-word source string.',
67
+ 'type-mismatch': 'Source and translation values have different JSON types.',
68
+ 'invalid-json': 'A locale file could not be parsed as JSON.',
69
+ 'missing-file': 'An expected locale file does not exist.',
70
+ 'stale-translation': 'The catalog marks this translation as needing review.',
71
+ 'glossary-violation': 'A do-not-translate or locked glossary term was not respected.',
72
+ 'manual-translation-clobbered': 'A translation locked as hand-edited has been overwritten.',
73
+ 'manual-translation-stale': 'The source changed after this translation was locked by hand.',
74
+ 'semantic-mistranslation': 'LLM judge (majority vote): the translation states something different from the source.',
75
+ 'semantic-omission': 'LLM judge (majority vote): meaningful source content is missing from the translation.',
76
+ 'semantic-addition': 'LLM judge (majority vote): the translation contains claims the source does not make.',
77
+ }
78
+
79
+ /** SARIF 2.1.0 — one run, one rule per finding type, one result per finding. */
80
+ export function sarifReport(result, _verdictResult, { toolVersion = '0.0.0' } = {}) {
81
+ const findings = []
82
+ for (const l of result.languages) {
83
+ for (const n of l.namespaces) {
84
+ for (const f of n.findings) findings.push({ lang: l.lang, ns: n.ns, file: n.file, ...f })
85
+ }
86
+ }
87
+ // Deterministic output: stable ordering makes committed SARIF diffable.
88
+ findings.sort((a, b) =>
89
+ `${a.file}|${a.path}|${a.type}`.localeCompare(`${b.file}|${b.path}|${b.type}`)
90
+ )
91
+
92
+ const usedTypes = [...new Set(findings.map((f) => f.type))].sort()
93
+ const ruleIndex = Object.fromEntries(usedTypes.map((t, i) => [t, i]))
94
+
95
+ const sarif = {
96
+ $schema: 'https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json',
97
+ version: '2.1.0',
98
+ runs: [
99
+ {
100
+ tool: {
101
+ driver: {
102
+ name: 'shipi18n-check',
103
+ informationUri: 'https://github.com/Shipi18n/shipi18n',
104
+ version: toolVersion,
105
+ rules: usedTypes.map((t) => ({
106
+ id: t,
107
+ shortDescription: { text: RULE_META[t] || t },
108
+ helpUri: ruleUrl(t),
109
+ })),
110
+ },
111
+ },
112
+ results: findings.map((f) => ({
113
+ ruleId: f.type,
114
+ ruleIndex: ruleIndex[f.type],
115
+ level: f.severity === 'error' ? 'error' : 'warning',
116
+ message: { text: `[${f.lang}] ${f.path}: ${f.message}` },
117
+ locations: [
118
+ {
119
+ physicalLocation: {
120
+ artifactLocation: { uri: (f.file || '').split('\\').join('/') },
121
+ },
122
+ },
123
+ ],
124
+ })),
125
+ },
126
+ ],
127
+ }
128
+ return JSON.stringify(sarif, null, 2)
129
+ }
130
+
131
+ /* ------------------------------------------------------------------ junit */
132
+
133
+ const xmlEscape = (s) =>
134
+ String(s)
135
+ .replace(/&/g, '&')
136
+ .replace(/</g, '&lt;')
137
+ .replace(/>/g, '&gt;')
138
+ .replace(/"/g, '&quot;')
139
+ .replace(/'/g, '&apos;')
140
+
141
+ /**
142
+ * One <testsuite> per language, one <testcase> per namespace.
143
+ * Errors become <failure>; warnings go to <system-out> — a warning that fails
144
+ * CI gets the tool uninstalled.
145
+ */
146
+ export function junitReport(result) {
147
+ const suites = []
148
+ let totalTests = 0
149
+ let totalFailures = 0
150
+
151
+ for (const l of result.languages) {
152
+ const cases = []
153
+ let failures = 0
154
+ for (const n of l.namespaces) {
155
+ totalTests++
156
+ const errors = n.findings.filter((f) => f.severity === 'error')
157
+ const warnings = n.findings.filter((f) => f.severity === 'warning')
158
+ const body = []
159
+ if (errors.length) {
160
+ failures++
161
+ totalFailures++
162
+ // Include the offending strings: "dropped {{name}}" is not actionable
163
+ // without seeing WHICH string dropped it.
164
+ const detail = errors
165
+ .map((f) => {
166
+ const lines = [`${f.path}: ${f.type} — ${f.message}`]
167
+ if (f.source != null) lines.push(` source: ${f.source}`)
168
+ if (f.translation != null) lines.push(` translation: ${f.translation}`)
169
+ return lines.join('\n')
170
+ })
171
+ .join('\n')
172
+ body.push(
173
+ ` <failure message="${xmlEscape(`${errors.length} error(s) in ${l.lang}/${n.ns}`)}">${xmlEscape(detail)}</failure>`
174
+ )
175
+ }
176
+ if (warnings.length) {
177
+ const detail = warnings.map((f) => `${f.path}: ${f.type} — ${f.message}`).join('\n')
178
+ body.push(` <system-out>${xmlEscape(detail)}</system-out>`)
179
+ }
180
+ cases.push(
181
+ body.length
182
+ ? ` <testcase classname="${xmlEscape(l.lang)}" name="${xmlEscape(n.ns)}">\n${body.join('\n')}\n </testcase>`
183
+ : ` <testcase classname="${xmlEscape(l.lang)}" name="${xmlEscape(n.ns)}"/>`
184
+ )
185
+ }
186
+ suites.push(
187
+ ` <testsuite name="${xmlEscape(l.lang)}" tests="${l.namespaces.length}" failures="${failures}">\n${cases.join('\n')}\n </testsuite>`
188
+ )
189
+ }
190
+
191
+ return [
192
+ '<?xml version="1.0" encoding="UTF-8"?>',
193
+ `<testsuites name="shipi18n-check" tests="${totalTests}" failures="${totalFailures}">`,
194
+ ...suites,
195
+ '</testsuites>',
196
+ '',
197
+ ].join('\n')
198
+ }
199
+
200
+ export const REPORTERS = { human: humanReport, json: jsonReport, sarif: sarifReport, junit: junitReport }
201
+
202
+ /* ---------------------------------------------------------------- verdict */
203
+
204
+ /** Decide the exit code from findings and flags. Reporters never influence this. */
205
+ export function verdict(result, { failOn = 'error', minCoverage } = {}) {
206
+ const failures = []
207
+ if (failOn === 'error' && result.totals.errors > 0) failures.push(`${result.totals.errors} error(s)`)
208
+ if (failOn === 'warning' && result.totals.errors + result.totals.warnings > 0)
209
+ failures.push(`${result.totals.errors} error(s), ${result.totals.warnings} warning(s)`)
210
+ if (minCoverage != null) {
211
+ for (const l of result.languages) {
212
+ if (l.stats.coverage * 100 < minCoverage)
213
+ failures.push(`${l.lang} coverage ${(l.stats.coverage * 100).toFixed(1)}% < ${minCoverage}%`)
214
+ }
215
+ }
216
+ return { ok: failures.length === 0, failures }
217
+ }