@shipi18n/cli 2.5.1 → 2.7.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 +10 -0
- package/package.json +2 -2
- package/src/commands/check.js +4 -14
- package/src/reporters.js +4 -196
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# @shipi18n/cli
|
|
2
2
|
|
|
3
|
+
## 2.7.0
|
|
4
|
+
|
|
5
|
+
- New: `shipi18n check` now validates **YAML locale files** (`.yaml`/`.yml`), flat or nested,
|
|
6
|
+
the same as JSON (via core 2.7.0).
|
|
7
|
+
|
|
8
|
+
## 2.6.0
|
|
9
|
+
|
|
10
|
+
- Refactor: reporters and `verdict` now live in `@shipi18n/core` (2.6.0) and are re-exported here
|
|
11
|
+
unchanged. No behavior change; import sites keep working.
|
|
12
|
+
|
|
3
13
|
## 2.5.1
|
|
4
14
|
|
|
5
15
|
- New: every check finding now links to its rule page. The human reporter prints
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shipi18n/cli",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.7.0",
|
|
4
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": {
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
"chalk": "^5.3.0",
|
|
50
50
|
"commander": "^12.0.0",
|
|
51
51
|
"ora": "^8.0.1",
|
|
52
|
-
"@shipi18n/core": "^2.
|
|
52
|
+
"@shipi18n/core": "^2.7.0"
|
|
53
53
|
},
|
|
54
54
|
"peerDependencies": {
|
|
55
55
|
"@anthropic-ai/sdk": ">=0.30.0",
|
package/src/commands/check.js
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
statsFrom,
|
|
23
23
|
aggregateLanguage,
|
|
24
24
|
SEP,
|
|
25
|
+
verdict,
|
|
25
26
|
} from '@shipi18n/core'
|
|
26
27
|
import { REPORTERS } from '../reporters.js'
|
|
27
28
|
import { locksFor, DEFAULT_LOCKS_PATH } from './lock.js'
|
|
@@ -39,20 +40,9 @@ export { runCheck, runSemantic, discoverLayout, compileIgnores, statsFrom, aggre
|
|
|
39
40
|
|
|
40
41
|
/* ---------------------------------------------------------------- verdict */
|
|
41
42
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
}
|
|
43
|
+
// Lifted into @shipi18n/core in 2.6.0 (the Action needs it without the CLI).
|
|
44
|
+
// Re-exported because the CLI tests and semantic pass import it from here.
|
|
45
|
+
export { verdict }
|
|
56
46
|
|
|
57
47
|
/* ---------------------------------------------------------------- command */
|
|
58
48
|
|
package/src/reporters.js
CHANGED
|
@@ -1,200 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Output formats for `shipi18n check`.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Lifted into @shipi18n/core in 2.6.0 so the GitHub Action can run `check`
|
|
5
|
+
* without the CLI. Re-exported here because the CLI's tests, docs and any
|
|
6
|
+
* external importers are written against these names.
|
|
6
7
|
*/
|
|
7
|
-
|
|
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
|
-
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, '<')
|
|
137
|
-
.replace(/>/g, '>')
|
|
138
|
-
.replace(/"/g, '"')
|
|
139
|
-
.replace(/'/g, ''')
|
|
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 }
|
|
8
|
+
export { humanReport, jsonReport, sarifReport, junitReport, REPORTERS, RULE_META } from '@shipi18n/core'
|