@shipi18n/cli 2.9.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 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
  | --- | --- | --- |
@@ -104,12 +105,36 @@ plurals · empty values · untranslated copy · stale `.xcstrings` states.
104
105
  | `-r, --reporter <name>` | `human` | `human` \| `json` \| `sarif` \| `junit` |
105
106
  | `-o, --output <file>` | stdout | Write the report to a file |
106
107
  | `--ignore-keys <globs>` | — | Silence keys: `'*.copyright,home:mcp.badge'` |
108
+ | `--severity <spec>` | — | Per-rule level override: `'untranslated=off,placeholder-added=error'` |
109
+ | `--baseline <file>` | — | Fail only on findings NOT already in the baseline |
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 |
107
112
  | `--fail-on <level>` | `error` | `error` \| `warning` \| `none` |
108
113
  | `--min-coverage <pct>` | — | Fail any language below this coverage |
109
114
 
110
115
  Exit codes: `0` pass, `1` findings at the fail level, `2` usage error. Errors may fail CI; warnings
111
116
  never do by default — a warning that blocks PRs gets the tool uninstalled.
112
117
 
118
+ ### Adopting on a messy catalog — baseline & severity
119
+
120
+ A linter that fails on 2,000 pre-existing findings gets uninstalled by lunch. Baseline first, then
121
+ fail only on what's **new** — the Stylelint/RuboCop pattern:
122
+
123
+ ```bash
124
+ # 1. Snapshot today's findings (commit the file).
125
+ npx @shipi18n/cli check ./locales --baseline .shipi18n/baseline.json --write-baseline
126
+
127
+ # 2. CI from now on fails only on NEW findings; the backlog is accepted.
128
+ npx @shipi18n/cli check ./locales --baseline .shipi18n/baseline.json
129
+ ```
130
+
131
+ The baseline keys each finding on `(language, namespace, key path, rule)` — not the message text, so
132
+ rewording a message never invalidates it. A missing baseline file is a cold start (warn + report all),
133
+ not an error. Burn the backlog down by re-running `--write-baseline` whenever it shrinks.
134
+
135
+ `--severity` tunes or silences a rule everywhere: `error`, `warning`, `info` (reported, never fails),
136
+ or `off` (dropped entirely). Example: `--severity 'untranslated=off,empty-value=warning'`.
137
+
113
138
  ### GitHub Actions with PR annotations
114
139
 
115
140
  ```yaml
@@ -123,6 +148,91 @@ never do by default — a warning that blocks PRs gets the tool uninstalled.
123
148
  sarif_file: i18n.sarif
124
149
  ```
125
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
+
126
236
  ## Semantic QA — `--semantic` (the judge)
127
237
 
128
238
  The structural check cannot see a translation that is *fluent but wrong*. `--semantic` adds an
@@ -133,6 +243,25 @@ npx @shipi18n/cli check ./locales -s en --semantic # advisory: warnings
133
243
  npx @shipi18n/cli check ./locales -s en --semantic --glossary glossary.json
134
244
  ```
135
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
+
136
265
  **Honest limitations, up front:** the judge is probabilistic. Every key is judged across 3 passes
137
266
  and flagged only on a majority vote, unparseable passes are discarded, and semantic findings are
138
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.9.0",
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.9.0"
52
+ "@shipi18n/core": "^2.11.0"
53
53
  },
54
54
  "peerDependencies": {
55
55
  "@anthropic-ai/sdk": ">=0.30.0",
@@ -24,6 +24,10 @@ import {
24
24
  aggregateLanguage,
25
25
  SEP,
26
26
  verdict,
27
+ applyPolicy,
28
+ buildBaseline,
29
+ parseSeverity,
30
+ scanResultSecrets,
27
31
  } from '@shipi18n/core'
28
32
  import { REPORTERS } from '../reporters.js'
29
33
  import { locksFor, DEFAULT_LOCKS_PATH } from './lock.js'
@@ -56,8 +60,12 @@ export function checkCommand(program) {
56
60
  .option('-o, --output <file>', 'Write the report to a file instead of stdout')
57
61
  .option('--json', 'Shorthand for --reporter json')
58
62
  .option('--ignore-keys <patterns>', "Comma-separated '*' globs of keys to silence (path or ns:path)")
63
+ .option('--severity <spec>', "Per-rule severity overrides, e.g. 'untranslated=off,placeholder-added=error' (error|warning|info|off)")
64
+ .option('--baseline <file>', 'Baseline file: findings already recorded in it do not fail the build (only NEW ones do)')
65
+ .option('--write-baseline', 'Snapshot current findings into --baseline (default .shipi18n/baseline.json) and exit')
59
66
  .option('--fail-on <level>', 'Exit non-zero on: error | warning | none', 'error')
60
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')
61
69
  .option('--glossary <file>', 'Glossary JSON: DNT terms + locked per-language translations (deterministic)')
62
70
  .option('--semantic', 'Add the LLM-as-judge pass (BYO key; advisory warnings by default)')
63
71
  .option('--semantic-fail', 'Escalate semantic findings to errors (opt-in)')
@@ -99,6 +107,11 @@ export function checkCommand(program) {
99
107
  return
100
108
  }
101
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
+
102
115
  if (opts.semantic) {
103
116
  const cachePath = resolve(opts.semanticCache)
104
117
  let cache = {}
@@ -130,7 +143,8 @@ export function checkCommand(program) {
130
143
  console.error(
131
144
  chalk.gray(
132
145
  `semantic: judged ${judge.judged} (${judge.cached} cached), flagged ${judge.flagged}, ` +
133
- `${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)` : '')
134
148
  )
135
149
  )
136
150
  // "judged 0" on a badly broken tree is correct but reads as a broken
@@ -150,6 +164,62 @@ export function checkCommand(program) {
150
164
  }
151
165
  }
152
166
 
167
+ const DEFAULT_BASELINE = '.shipi18n/baseline.json'
168
+
169
+ // --write-baseline: snapshot every current finding and exit 0. Run after
170
+ // the (optional) semantic pass so a baseline can capture judge findings too.
171
+ if (opts.writeBaseline) {
172
+ const file = resolve(opts.baseline || DEFAULT_BASELINE)
173
+ const bl = buildBaseline(result)
174
+ try {
175
+ mkdirSync(dirname(file), { recursive: true })
176
+ writeFileSync(file, JSON.stringify(bl, null, 2) + '\n')
177
+ } catch (err) {
178
+ console.error(chalk.red(`Error: cannot write baseline ${file}: ${err.message}`))
179
+ process.exitCode = 2
180
+ return
181
+ }
182
+ console.error(chalk.gray(`baseline: recorded ${bl.count} finding(s) → ${opts.baseline || DEFAULT_BASELINE}`))
183
+ return
184
+ }
185
+
186
+ let severityMap
187
+ if (opts.severity) {
188
+ try {
189
+ severityMap = parseSeverity(opts.severity)
190
+ } catch (err) {
191
+ console.error(chalk.red(`Error: ${err.message}`))
192
+ process.exitCode = 2
193
+ return
194
+ }
195
+ }
196
+
197
+ let baseline
198
+ if (opts.baseline) {
199
+ const file = resolve(opts.baseline)
200
+ if (existsSync(file)) {
201
+ try {
202
+ baseline = JSON.parse(readFileSync(file, 'utf8'))
203
+ } catch (err) {
204
+ console.error(chalk.red(`Error: cannot read baseline ${opts.baseline}: ${err.message}`))
205
+ process.exitCode = 2
206
+ return
207
+ }
208
+ } else {
209
+ // A missing baseline is a cold start, not an error: nothing is suppressed
210
+ // and the run reports every finding. Hint how to create one.
211
+ console.error(chalk.yellow(`note: baseline ${opts.baseline} not found — reporting all findings. Create it with --write-baseline.`))
212
+ }
213
+ }
214
+
215
+ if (severityMap || baseline) {
216
+ const suppressed = applyPolicy(result, { severity: severityMap, baseline })
217
+ const parts = []
218
+ if (suppressed.suppressedByBaseline) parts.push(`${suppressed.suppressedByBaseline} baselined`)
219
+ if (suppressed.suppressedBySeverity) parts.push(`${suppressed.suppressedBySeverity} silenced (severity=off)`)
220
+ if (parts.length) console.error(chalk.gray(`policy: ${parts.join(', ')}`))
221
+ }
222
+
153
223
  const verdictResult = verdict(result, { failOn: opts.failOn, minCoverage: opts.minCoverage })
154
224
 
155
225
  const name = opts.json ? 'json' : opts.reporter
@@ -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
+ }