@shipi18n/cli 2.10.0 → 2.11.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/README.md +107 -1
- package/bin/shipi18n.js +2 -0
- package/package.json +2 -2
- package/src/commands/check.js +9 -1
- package/src/commands/wp-sync.js +207 -0
package/README.md
CHANGED
|
@@ -96,7 +96,8 @@ directories, Apple String Catalogs (`shipi18n check Localizable.xcstrings`), And
|
|
|
96
96
|
|
|
97
97
|
**What it catches:** missing and orphaned keys · dropped or invented placeholders (`{{name}}`,
|
|
98
98
|
`{count}`, `%s`, `%1$s`, `%@`, `%lld`, `$t(...)`, `%{name}`, HTML tags) · collapsed vue-i18n pipe
|
|
99
|
-
plurals · empty values · untranslated copy · stale `.xcstrings` states
|
|
99
|
+
plurals · empty values · untranslated copy · stale `.xcstrings` states · Android unescaped apostrophes
|
|
100
|
+
and unbalanced quotes (the `values-fr/strings.xml` AAPT build breaker).
|
|
100
101
|
|
|
101
102
|
| Flag | Default | Meaning |
|
|
102
103
|
| --- | --- | --- |
|
|
@@ -107,6 +108,7 @@ plurals · empty values · untranslated copy · stale `.xcstrings` states.
|
|
|
107
108
|
| `--severity <spec>` | — | Per-rule level override: `'untranslated=off,placeholder-added=error'` |
|
|
108
109
|
| `--baseline <file>` | — | Fail only on findings NOT already in the baseline |
|
|
109
110
|
| `--write-baseline` | — | Snapshot current findings into `--baseline` (default `.shipi18n/baseline.json`) and exit |
|
|
111
|
+
| `--detect-secrets` | — | Flag secrets/PII (API keys, private keys, emails, cards) in locale strings |
|
|
110
112
|
| `--fail-on <level>` | `error` | `error` \| `warning` \| `none` |
|
|
111
113
|
| `--min-coverage <pct>` | — | Fail any language below this coverage |
|
|
112
114
|
|
|
@@ -146,6 +148,91 @@ or `off` (dropped entirely). Example: `--severity 'untranslated=off,empty-value=
|
|
|
146
148
|
sarif_file: i18n.sarif
|
|
147
149
|
```
|
|
148
150
|
|
|
151
|
+
### No Node? Run the check in any CI with Docker
|
|
152
|
+
|
|
153
|
+
GitHub runners already have Node — the [Action](https://github.com/Shipi18n/shipi18n-github-action) is
|
|
154
|
+
the easiest path there. Everywhere else (GitLab, Bitbucket, Jenkins, CircleCI, or local), the published
|
|
155
|
+
image runs the linter with **no Node toolchain** — built for the PHP / Python / Ruby shops the
|
|
156
|
+
`.po` / XLIFF / Android formats unlocked:
|
|
157
|
+
|
|
158
|
+
```bash
|
|
159
|
+
docker run --rm -v "$PWD:/work" ghcr.io/shipi18n/cli:latest check ./locales -s en
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
The check needs no API key. All CLI flags work — `--baseline .shipi18n/baseline.json`, `--reporter sarif`,
|
|
163
|
+
etc. (`--write-baseline` writes back into the mounted repo). Pass `-e ANTHROPIC_API_KEY=...` only if you
|
|
164
|
+
add `--semantic`.
|
|
165
|
+
|
|
166
|
+
**GitLab CI** (`.gitlab-ci.yml`):
|
|
167
|
+
|
|
168
|
+
```yaml
|
|
169
|
+
i18n-check:
|
|
170
|
+
image: ghcr.io/shipi18n/cli:latest
|
|
171
|
+
script: [shipi18n check ./locales -s en]
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
**Bitbucket Pipelines** (`bitbucket-pipelines.yml`):
|
|
175
|
+
|
|
176
|
+
```yaml
|
|
177
|
+
pipelines:
|
|
178
|
+
default:
|
|
179
|
+
- step:
|
|
180
|
+
image: ghcr.io/shipi18n/cli:latest
|
|
181
|
+
script: [shipi18n check ./locales -s en]
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
### pre-commit
|
|
185
|
+
|
|
186
|
+
Catch a dropped placeholder before it's even committed. Add to `.pre-commit-config.yaml` — pre-commit
|
|
187
|
+
runs the Docker image, so **no Node is required**:
|
|
188
|
+
|
|
189
|
+
```yaml
|
|
190
|
+
repos:
|
|
191
|
+
- repo: https://github.com/Shipi18n/shipi18n
|
|
192
|
+
rev: v2.11.0
|
|
193
|
+
hooks:
|
|
194
|
+
- id: shipi18n-check
|
|
195
|
+
# args: ['check', './i18n', '-s', 'en'] # if not ./locales
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
Node users who prefer to skip Docker can use a repo-local hook instead:
|
|
199
|
+
|
|
200
|
+
```yaml
|
|
201
|
+
repos:
|
|
202
|
+
- repo: local
|
|
203
|
+
hooks:
|
|
204
|
+
- id: shipi18n-check
|
|
205
|
+
name: shipi18n check
|
|
206
|
+
language: node
|
|
207
|
+
additional_dependencies: ['@shipi18n/cli@2.11.0']
|
|
208
|
+
entry: shipi18n check ./locales -s en
|
|
209
|
+
pass_filenames: false
|
|
210
|
+
files: '\.(json|ya?ml|po|xlf|xliff|xml|arb|xcstrings)$'
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
## WordPress — `.po` ↔ JED `.json` sync (`wp-sync`)
|
|
214
|
+
|
|
215
|
+
Since WP 5.0, JavaScript strings are translated from a JED-format JSON that `wp i18n make-json`
|
|
216
|
+
**generates** from your `.po`. Edit the `.po`, forget to re-run make-json, and the PHP side shows the
|
|
217
|
+
new translation while the JS side silently ships the stale one — and nothing in the WP toolchain
|
|
218
|
+
flags it. `wp-sync` does:
|
|
219
|
+
|
|
220
|
+
```bash
|
|
221
|
+
# Auto-discovers the sibling *.json JED files next to the .po
|
|
222
|
+
npx @shipi18n/cli wp-sync languages/plugin-es_ES.po
|
|
223
|
+
|
|
224
|
+
# Or point at specific JED files / a directory
|
|
225
|
+
npx @shipi18n/cli wp-sync languages/plugin-es_ES.po languages/build/*.json
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
It reports **`jed-drift`** (a JS translation that no longer matches the `.po` — re-run make-json) and
|
|
229
|
+
**`jed-orphan`** (a JS string the `.po` dropped). A `.po`-only string is *not* flagged — the JED is a
|
|
230
|
+
legitimate JS-only subset. Context and plural forms are compared independently.
|
|
231
|
+
|
|
232
|
+
Shares the check flags: `--reporter json|sarif|junit` (SARIF gives PR annotations), `--baseline` /
|
|
233
|
+
`--write-baseline` (fail only on new drift), `--severity` (e.g. `--severity 'jed-orphan=off'`),
|
|
234
|
+
`--fail-on`. Exit `0` in sync, `1` drift/orphan, `2` no JED files found.
|
|
235
|
+
|
|
149
236
|
## Semantic QA — `--semantic` (the judge)
|
|
150
237
|
|
|
151
238
|
The structural check cannot see a translation that is *fluent but wrong*. `--semantic` adds an
|
|
@@ -156,6 +243,25 @@ npx @shipi18n/cli check ./locales -s en --semantic # advisory: warnings
|
|
|
156
243
|
npx @shipi18n/cli check ./locales -s en --semantic --glossary glossary.json
|
|
157
244
|
```
|
|
158
245
|
|
|
246
|
+
### Privacy — secret & PII pre-flight
|
|
247
|
+
|
|
248
|
+
`--semantic` sends your source/translation pairs to a third-party model. A **pre-flight runs
|
|
249
|
+
automatically** and *withholds* any pair containing an API key, private key, JWT, credit-card number,
|
|
250
|
+
email, or phone — it is never transmitted (fail-closed), and a `secret-preflight` finding is recorded
|
|
251
|
+
instead. Because the judge only ever sends what the pre-flight scans, nothing with a detected secret
|
|
252
|
+
leaves your machine.
|
|
253
|
+
|
|
254
|
+
You can also run the scan on its own, no LLM involved, to catch secrets that shouldn't be sitting in
|
|
255
|
+
locale strings at all:
|
|
256
|
+
|
|
257
|
+
```bash
|
|
258
|
+
npx @shipi18n/cli check ./locales -s en --detect-secrets
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
High-confidence secrets (keys, cards via Luhn) are errors; emails/phones are warnings. Detection is
|
|
262
|
+
precision-biased — known key prefixes, example/test emails ignored, phones need a `+` country code —
|
|
263
|
+
and matches are always masked, never echoed. Tune per rule with `--severity secret-detected=off`.
|
|
264
|
+
|
|
159
265
|
**Honest limitations, up front:** the judge is probabilistic. Every key is judged across 3 passes
|
|
160
266
|
and flagged only on a majority vote, unparseable passes are discarded, and semantic findings are
|
|
161
267
|
**warnings by default** — they never fail CI unless you opt in with `--semantic-fail`. It augments
|
package/bin/shipi18n.js
CHANGED
|
@@ -7,6 +7,7 @@ import { fileURLToPath } from 'node:url'
|
|
|
7
7
|
import { translateCommand } from '../src/commands/translate.js'
|
|
8
8
|
import { checkCommand } from '../src/commands/check.js'
|
|
9
9
|
import { lockCommand } from '../src/commands/lock.js'
|
|
10
|
+
import { wpSyncCommand } from '../src/commands/wp-sync.js'
|
|
10
11
|
|
|
11
12
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
12
13
|
const pkg = JSON.parse(readFileSync(join(__dirname, '../package.json'), 'utf8'))
|
|
@@ -37,5 +38,6 @@ ${chalk.gray('https://github.com/Shipi18n/shipi18n')}
|
|
|
37
38
|
translateCommand(program)
|
|
38
39
|
checkCommand(program)
|
|
39
40
|
lockCommand(program)
|
|
41
|
+
wpSyncCommand(program)
|
|
40
42
|
program.parse(process.argv)
|
|
41
43
|
if (!process.argv.slice(2).length) program.outputHelp()
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shipi18n/cli",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.11.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.11.0"
|
|
53
53
|
},
|
|
54
54
|
"peerDependencies": {
|
|
55
55
|
"@anthropic-ai/sdk": ">=0.30.0",
|
package/src/commands/check.js
CHANGED
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
applyPolicy,
|
|
28
28
|
buildBaseline,
|
|
29
29
|
parseSeverity,
|
|
30
|
+
scanResultSecrets,
|
|
30
31
|
} from '@shipi18n/core'
|
|
31
32
|
import { REPORTERS } from '../reporters.js'
|
|
32
33
|
import { locksFor, DEFAULT_LOCKS_PATH } from './lock.js'
|
|
@@ -64,6 +65,7 @@ export function checkCommand(program) {
|
|
|
64
65
|
.option('--write-baseline', 'Snapshot current findings into --baseline (default .shipi18n/baseline.json) and exit')
|
|
65
66
|
.option('--fail-on <level>', 'Exit non-zero on: error | warning | none', 'error')
|
|
66
67
|
.option('--min-coverage <pct>', 'Fail any language below this coverage percentage', parseFloat)
|
|
68
|
+
.option('--detect-secrets', 'Flag secrets/PII (API keys, private keys, emails, cards) sitting in locale strings')
|
|
67
69
|
.option('--glossary <file>', 'Glossary JSON: DNT terms + locked per-language translations (deterministic)')
|
|
68
70
|
.option('--semantic', 'Add the LLM-as-judge pass (BYO key; advisory warnings by default)')
|
|
69
71
|
.option('--semantic-fail', 'Escalate semantic findings to errors (opt-in)')
|
|
@@ -105,6 +107,11 @@ export function checkCommand(program) {
|
|
|
105
107
|
return
|
|
106
108
|
}
|
|
107
109
|
|
|
110
|
+
if (opts.detectSecrets) {
|
|
111
|
+
const flagged = scanResultSecrets(result)
|
|
112
|
+
if (flagged) console.error(chalk.gray(`secrets: ${flagged} locale string(s) flagged for possible secrets/PII`))
|
|
113
|
+
}
|
|
114
|
+
|
|
108
115
|
if (opts.semantic) {
|
|
109
116
|
const cachePath = resolve(opts.semanticCache)
|
|
110
117
|
let cache = {}
|
|
@@ -136,7 +143,8 @@ export function checkCommand(program) {
|
|
|
136
143
|
console.error(
|
|
137
144
|
chalk.gray(
|
|
138
145
|
`semantic: judged ${judge.judged} (${judge.cached} cached), flagged ${judge.flagged}, ` +
|
|
139
|
-
`${judge.calls} model call(s), ${judge.parseFailures} discarded pass(es)`
|
|
146
|
+
`${judge.calls} model call(s), ${judge.parseFailures} discarded pass(es)` +
|
|
147
|
+
(judge.redacted ? `, ${judge.redacted} withheld (secrets/PII)` : '')
|
|
140
148
|
)
|
|
141
149
|
)
|
|
142
150
|
// "judged 0" on a badly broken tree is correct but reads as a broken
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `shipi18n wp-sync` — the WordPress .po ↔ JED .json drift check.
|
|
3
|
+
*
|
|
4
|
+
* WP JS strings are translated from a JED JSON that `wp i18n make-json` generates
|
|
5
|
+
* from the .po. Edit the .po, forget to re-run make-json, and the JS side quietly
|
|
6
|
+
* ships the stale translation. This command catches that, per JED file, and reuses
|
|
7
|
+
* the same reporters/verdict/baseline machinery as `check` (so SARIF PR annotations
|
|
8
|
+
* and --baseline "fail only on new" work here too).
|
|
9
|
+
*/
|
|
10
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync } from 'node:fs'
|
|
11
|
+
import { resolve, dirname, join, basename, relative } from 'node:path'
|
|
12
|
+
import chalk from 'chalk'
|
|
13
|
+
import {
|
|
14
|
+
checkJedSync,
|
|
15
|
+
isJed,
|
|
16
|
+
statsFrom,
|
|
17
|
+
aggregateLanguage,
|
|
18
|
+
verdict,
|
|
19
|
+
applyPolicy,
|
|
20
|
+
buildBaseline,
|
|
21
|
+
parseSeverity,
|
|
22
|
+
} from '@shipi18n/core'
|
|
23
|
+
import { REPORTERS } from '../reporters.js'
|
|
24
|
+
|
|
25
|
+
const DEFAULT_BASELINE = '.shipi18n/baseline.json'
|
|
26
|
+
const rel = (p) => relative(process.cwd(), p) || p
|
|
27
|
+
|
|
28
|
+
/** JED .json files to check: explicit args (dirs expanded) or sibling auto-discovery. */
|
|
29
|
+
function resolveJedFiles(poFile, args) {
|
|
30
|
+
const collect = (dir) => {
|
|
31
|
+
let out = []
|
|
32
|
+
try {
|
|
33
|
+
out = readdirSync(dir)
|
|
34
|
+
.filter((n) => n.endsWith('.json'))
|
|
35
|
+
.map((n) => join(dir, n))
|
|
36
|
+
.filter((f) => {
|
|
37
|
+
try {
|
|
38
|
+
return isJed(JSON.parse(readFileSync(f, 'utf8')))
|
|
39
|
+
} catch {
|
|
40
|
+
return false
|
|
41
|
+
}
|
|
42
|
+
})
|
|
43
|
+
} catch {
|
|
44
|
+
/* unreadable dir → nothing */
|
|
45
|
+
}
|
|
46
|
+
return out
|
|
47
|
+
}
|
|
48
|
+
if (!args || args.length === 0) return collect(dirname(resolve(poFile)))
|
|
49
|
+
const files = []
|
|
50
|
+
for (const a of args) {
|
|
51
|
+
const p = resolve(a)
|
|
52
|
+
if (existsSync(p) && statSync(p).isDirectory()) files.push(...collect(p))
|
|
53
|
+
else files.push(p)
|
|
54
|
+
}
|
|
55
|
+
return files
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Wrap one JED file's findings as a "language" in the standard result shape. */
|
|
59
|
+
function fileToLanguage(jedFile, findings) {
|
|
60
|
+
const ns = rel(jedFile)
|
|
61
|
+
const stats = statsFrom(findings, findings.length || 1, findings.length || 1)
|
|
62
|
+
return aggregateLanguage(basename(jedFile), [{ ns, findings, stats }])
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Compact, wp-sync-flavoured human output (the default reporter). */
|
|
66
|
+
function humanWpReport(result, v) {
|
|
67
|
+
const lines = ['', `🔎 shipi18n wp-sync — .po '${result.source}' vs ${result.languages.length} JED file(s)`, '']
|
|
68
|
+
for (const l of result.languages) {
|
|
69
|
+
const all = l.namespaces.flatMap((n) => n.findings)
|
|
70
|
+
const n = (t) => all.filter((f) => f.type === t).length
|
|
71
|
+
const mark = l.stats.errors ? chalk.red('✗') : all.length ? chalk.yellow('⚠') : chalk.green('✓')
|
|
72
|
+
const bad = n('invalid-file')
|
|
73
|
+
lines.push(
|
|
74
|
+
`${mark} ${chalk.bold(l.lang)} ${n('jed-drift')} drift, ${n('jed-orphan')} orphan${bad ? `, ${bad} unreadable` : ''}`
|
|
75
|
+
)
|
|
76
|
+
for (const f of all.slice(0, 50)) {
|
|
77
|
+
const color = f.severity === 'error' ? chalk.red : f.severity === 'warning' ? chalk.yellow : chalk.gray
|
|
78
|
+
const detail =
|
|
79
|
+
f.type === 'jed-drift' ? chalk.gray(` (.po: "${f.source}" → JSON: "${f.translation}")`) : ''
|
|
80
|
+
lines.push(` ${color(f.severity)} ${chalk.cyan(f.path)} ${f.type} — ${f.message}${detail}`)
|
|
81
|
+
}
|
|
82
|
+
if (all.length > 50) lines.push(chalk.gray(` … and ${all.length - 50} more`))
|
|
83
|
+
}
|
|
84
|
+
lines.push('')
|
|
85
|
+
lines.push(v.ok ? chalk.green('✓ .po and JED in sync') : chalk.red(`✗ out of sync: ${v.failures.join('; ')}`))
|
|
86
|
+
return lines.join('\n')
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function wpSyncCommand(program) {
|
|
90
|
+
program
|
|
91
|
+
.command('wp-sync <po> [jed...]')
|
|
92
|
+
.description('WordPress: check that JED .json files are in sync with their source .po (re-run make-json?)')
|
|
93
|
+
.option('-r, --reporter <name>', 'Output format: human | json | sarif | junit', 'human')
|
|
94
|
+
.option('-o, --output <file>', 'Write the report to a file instead of stdout')
|
|
95
|
+
.option('--json', 'Shorthand for --reporter json')
|
|
96
|
+
.option('--severity <spec>', "Per-rule severity overrides, e.g. 'jed-orphan=off' (error|warning|info|off)")
|
|
97
|
+
.option('--baseline <file>', 'Baseline file: findings already recorded in it do not fail the build')
|
|
98
|
+
.option('--write-baseline', 'Snapshot current findings into --baseline (default .shipi18n/baseline.json) and exit')
|
|
99
|
+
.option('--fail-on <level>', 'Exit non-zero on: error | warning | none', 'error')
|
|
100
|
+
.action((po, jed, opts) => {
|
|
101
|
+
const poFile = resolve(po)
|
|
102
|
+
let poText
|
|
103
|
+
try {
|
|
104
|
+
poText = readFileSync(poFile, 'utf8')
|
|
105
|
+
} catch (err) {
|
|
106
|
+
console.error(chalk.red(`Error: cannot read .po ${po}: ${err.message}`))
|
|
107
|
+
process.exitCode = 2
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const jedFiles = resolveJedFiles(poFile, jed)
|
|
112
|
+
if (jedFiles.length === 0) {
|
|
113
|
+
console.error(
|
|
114
|
+
chalk.yellow(
|
|
115
|
+
`No JED .json files found next to ${po}. Pass them explicitly, or run \`wp i18n make-json\` first.`
|
|
116
|
+
)
|
|
117
|
+
)
|
|
118
|
+
process.exitCode = 2
|
|
119
|
+
return
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const languages = []
|
|
123
|
+
for (const jf of jedFiles) {
|
|
124
|
+
let json
|
|
125
|
+
try {
|
|
126
|
+
json = JSON.parse(readFileSync(jf, 'utf8'))
|
|
127
|
+
} catch (err) {
|
|
128
|
+
languages.push(
|
|
129
|
+
fileToLanguage(jf, [{ type: 'invalid-file', severity: 'error', path: rel(jf), message: `invalid JSON: ${err.message}` }])
|
|
130
|
+
)
|
|
131
|
+
continue
|
|
132
|
+
}
|
|
133
|
+
const { findings } = checkJedSync(poText, json)
|
|
134
|
+
languages.push(fileToLanguage(jf, findings))
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const result = { layout: 'wp-jed', source: basename(poFile), languages }
|
|
138
|
+
result.totals = languages.reduce(
|
|
139
|
+
(a, l) => ({ errors: a.errors + l.stats.errors, warnings: a.warnings + l.stats.warnings }),
|
|
140
|
+
{ errors: 0, warnings: 0 }
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
if (opts.writeBaseline) {
|
|
144
|
+
const file = resolve(opts.baseline || DEFAULT_BASELINE)
|
|
145
|
+
try {
|
|
146
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
147
|
+
writeFileSync(file, JSON.stringify(buildBaseline(result), null, 2) + '\n')
|
|
148
|
+
} catch (err) {
|
|
149
|
+
console.error(chalk.red(`Error: cannot write baseline ${file}: ${err.message}`))
|
|
150
|
+
process.exitCode = 2
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
console.error(chalk.gray(`baseline: recorded findings → ${opts.baseline || DEFAULT_BASELINE}`))
|
|
154
|
+
return
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
let severityMap
|
|
158
|
+
if (opts.severity) {
|
|
159
|
+
try {
|
|
160
|
+
severityMap = parseSeverity(opts.severity)
|
|
161
|
+
} catch (err) {
|
|
162
|
+
console.error(chalk.red(`Error: ${err.message}`))
|
|
163
|
+
process.exitCode = 2
|
|
164
|
+
return
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
let baseline
|
|
168
|
+
if (opts.baseline) {
|
|
169
|
+
const file = resolve(opts.baseline)
|
|
170
|
+
if (existsSync(file)) {
|
|
171
|
+
try {
|
|
172
|
+
baseline = JSON.parse(readFileSync(file, 'utf8'))
|
|
173
|
+
} catch (err) {
|
|
174
|
+
console.error(chalk.red(`Error: cannot read baseline ${opts.baseline}: ${err.message}`))
|
|
175
|
+
process.exitCode = 2
|
|
176
|
+
return
|
|
177
|
+
}
|
|
178
|
+
} else {
|
|
179
|
+
console.error(chalk.yellow(`note: baseline ${opts.baseline} not found — reporting all findings.`))
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (severityMap || baseline) applyPolicy(result, { severity: severityMap, baseline })
|
|
183
|
+
|
|
184
|
+
const verdictResult = verdict(result, { failOn: opts.failOn })
|
|
185
|
+
const name = opts.json ? 'json' : opts.reporter
|
|
186
|
+
let report
|
|
187
|
+
if (name === 'human') {
|
|
188
|
+
report = humanWpReport(result, verdictResult)
|
|
189
|
+
} else {
|
|
190
|
+
const reporter = REPORTERS[name]
|
|
191
|
+
if (!reporter) {
|
|
192
|
+
console.error(chalk.red(`Error: unknown reporter '${name}' (human | json | sarif | junit)`))
|
|
193
|
+
process.exitCode = 2
|
|
194
|
+
return
|
|
195
|
+
}
|
|
196
|
+
report = reporter(result, verdictResult, {})
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (opts.output) {
|
|
200
|
+
writeFileSync(opts.output, report.endsWith('\n') ? report : report + '\n')
|
|
201
|
+
if (name !== 'human') console.error(chalk.gray(`report written to ${opts.output}`))
|
|
202
|
+
} else {
|
|
203
|
+
console.log(report)
|
|
204
|
+
}
|
|
205
|
+
if (!verdictResult.ok) process.exitCode = 1
|
|
206
|
+
})
|
|
207
|
+
}
|