@shipi18n/core 2.10.0 → 2.11.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shipi18n/core",
3
- "version": "2.10.0",
3
+ "version": "2.11.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",
@@ -67,6 +67,71 @@ function unescapeAndroid(s) {
67
67
  const isUntranslatable = (el) => el['@_translatable'] === 'false' || el['@_translatable'] === false
68
68
  const asArray = (v) => (Array.isArray(v) ? v : v == null ? [] : [v])
69
69
 
70
+ /**
71
+ * AAPT string-escaping rules, applied to the RAW text (before backslash
72
+ * unescaping). An apostrophe outside a "…"-quoted span must be `\'`, and every
73
+ * `"` must be balanced or escaped `\"` — an unescaped apostrophe is the classic
74
+ * `values-fr/strings.xml` build breaker ("Apostrophe not preceded by \\").
75
+ * Returns a finding descriptor, or null if the string is clean.
76
+ */
77
+ function androidEscapingProblem(raw) {
78
+ let inQuote = false
79
+ for (let i = 0; i < raw.length; i++) {
80
+ const c = raw[i]
81
+ if (c === '\\') {
82
+ i++ // the next char is escaped — skip it
83
+ continue
84
+ }
85
+ if (c === '"') {
86
+ inQuote = !inQuote
87
+ continue
88
+ }
89
+ if (c === "'" && !inQuote) {
90
+ return {
91
+ type: 'android-unescaped-apostrophe',
92
+ message: "unescaped apostrophe — Android needs \\' or a \"…\"-wrapped string (AAPT compile error)",
93
+ }
94
+ }
95
+ }
96
+ if (inQuote) {
97
+ return {
98
+ type: 'android-unbalanced-quote',
99
+ message: 'unbalanced double-quote — Android needs \\" for a literal quote (AAPT compile error)',
100
+ }
101
+ }
102
+ return null
103
+ }
104
+
105
+ /**
106
+ * Escaping findings for one strings.xml document — the errors AAPT would throw
107
+ * that the check engine can't see once the parser has decoded the values.
108
+ * Checked on the raw node text (backslashes/quotes intact). Untranslatable and
109
+ * unnamed entries are skipped, matching parseAndroidStrings.
110
+ * @param {string} xml
111
+ * @returns {Array<{type:string, severity:'error', path:string, message:string}>}
112
+ */
113
+ export function androidEscapingFindings(xml) {
114
+ const res = parser.parse(xml)?.resources || {}
115
+ const findings = []
116
+ const check = (raw, path) => {
117
+ const p = androidEscapingProblem(String(raw))
118
+ if (p) findings.push({ type: p.type, severity: 'error', path, message: p.message })
119
+ }
120
+ for (const s of res.string || []) {
121
+ if (!s['@_name'] || isUntranslatable(s)) continue
122
+ check(nodeText(s), s['@_name'])
123
+ }
124
+ for (const p of res.plurals || []) {
125
+ if (!p['@_name'] || isUntranslatable(p)) continue
126
+ for (const it of asArray(p.item)) if (it['@_quantity']) check(nodeText(it), `${p['@_name']}[${it['@_quantity']}]`)
127
+ }
128
+ for (const a of res['string-array'] || []) {
129
+ if (!a['@_name'] || isUntranslatable(a)) continue
130
+ asArray(a.item).forEach((it, i) => check(nodeText(it), `${a['@_name']}[${i}]`))
131
+ }
132
+ return findings
133
+ }
134
+
70
135
  /**
71
136
  * Parse a strings.xml document into a plain locale object.
72
137
  * @param {string} xml
package/src/formats/po.js CHANGED
@@ -14,7 +14,7 @@
14
14
  */
15
15
  import { extractPlaceholders } from '../placeholders.js'
16
16
 
17
- const CTXT_SEP = '\u0004'
17
+ export const CTXT_SEP = '\u0004'
18
18
 
19
19
  /** Decode the C-style escapes gettext uses in quoted strings. */
20
20
  function unescapePo(s) {
@@ -33,13 +33,13 @@ function npluralsFromHeader(headerMsgstr) {
33
33
  }
34
34
 
35
35
  /**
36
- * Parse a .po/.pot document.
36
+ * Low-level: parse a .po/.pot into raw entries (msgid/msgctxt/msgstr/msgstrs/
37
+ * fuzzy), before any check semantics. Shared by parsePo and the WordPress JED
38
+ * sync check, which needs the plural msgstrs that parsePo folds away.
37
39
  * @param {string} text
38
- * @returns {{ language: string|null, nplurals: number|null,
39
- * source: Record<string,string>, target: Record<string,string>,
40
- * findings: Array<object> }}
40
+ * @returns {Array<{msgid?:string,msgctxt?:string,msgidPlural?:string,msgstr?:string,msgstrs?:string[],fuzzy?:boolean}>}
41
41
  */
42
- export function parsePo(text) {
42
+ export function readPoEntries(text) {
43
43
  const lines = text.split(/\r?\n/)
44
44
  const entries = []
45
45
  let cur = null
@@ -94,7 +94,18 @@ export function parsePo(text) {
94
94
  }
95
95
  }
96
96
  flush()
97
+ return entries
98
+ }
97
99
 
100
+ /**
101
+ * Parse a .po/.pot document into source/target/findings for the check engine.
102
+ * @param {string} text
103
+ * @returns {{ language: string|null, nplurals: number|null,
104
+ * source: Record<string,string>, target: Record<string,string>,
105
+ * findings: Array<object> }}
106
+ */
107
+ export function parsePo(text) {
108
+ const entries = readPoEntries(text)
98
109
  const source = Object.create(null) // null-proto: crafted keys can't pollute
99
110
  const target = Object.create(null)
100
111
  const findings = []
@@ -0,0 +1,130 @@
1
+ /**
2
+ * WordPress `.po` ↔ JED `.json` sync check.
3
+ *
4
+ * Since WP 5.0, JavaScript strings are translated from a JED-format JSON that
5
+ * `wp i18n make-json` GENERATES from the `.po`. The trap: editing the `.po` and
6
+ * forgetting to re-run make-json. The PHP side then shows the new translation
7
+ * while the JS side silently serves the stale one — and nothing in the WP
8
+ * toolchain flags it. This check does.
9
+ *
10
+ * Direction matters. The JED file holds only the strings used in JS, so it is a
11
+ * SUBSET of the `.po`. We therefore validate JED → `.po`:
12
+ * - jed-drift : a string whose JED translation differs from the current `.po`
13
+ * msgstr → the JSON is stale, re-run `wp i18n make-json`.
14
+ * - jed-orphan: a string in the JED that no longer exists in the `.po` → a
15
+ * removed/renamed source string the JSON still references.
16
+ * A `.po` string absent from the JED is NOT flagged — that's a normal PHP-only
17
+ * string, and flagging it would bury the real signal in noise.
18
+ *
19
+ * JED shape (make-json output):
20
+ * { "domain": "messages",
21
+ * "locale_data": { "messages": {
22
+ * "": { "domain": ..., "lang": ..., "plural-forms": ... }, // metadata
23
+ * "Save": ["Guardar"], // singular
24
+ * "ctxtSave": ["Guardar (ctx)"], // + context
25
+ * "%s item": ["%s artículo", "%s artículos"] } } } // plural forms
26
+ */
27
+ import { readPoEntries, CTXT_SEP } from './po.js'
28
+
29
+ /** A key → translation-forms[] map from a `.po`, matching JED's keying. */
30
+ function poMap(text) {
31
+ const map = Object.create(null)
32
+ for (const e of readPoEntries(text)) {
33
+ if (e.msgid === undefined) continue
34
+ if (e.msgid === '' && e.msgctxt === undefined) continue // header entry
35
+ const key = e.msgctxt !== undefined ? `${e.msgctxt}${CTXT_SEP}${e.msgid}` : e.msgid
36
+ map[key] = e.msgidPlural !== undefined ? e.msgstrs || [] : [e.msgstr ?? '']
37
+ }
38
+ return map
39
+ }
40
+
41
+ /** True if a parsed JSON value looks like a JED locale file. */
42
+ export function isJed(json) {
43
+ return !!json && typeof json === 'object' && !!json.locale_data && typeof json.locale_data === 'object'
44
+ }
45
+
46
+ /** Pull the active domain's translation entries out of a JED object. */
47
+ function jedEntries(json) {
48
+ const ld = json.locale_data
49
+ const domains = Object.keys(ld)
50
+ // Prefer the declared domain when present; else the first (make-json emits one).
51
+ const domain = json.domain && ld[json.domain] ? json.domain : domains[0]
52
+ const block = ld[domain]
53
+ if (!block || typeof block !== 'object') throw new Error('JED locale_data has no usable domain block')
54
+ const entries = Object.create(null)
55
+ for (const k of Object.keys(block)) {
56
+ if (k === '') continue // the "" pseudo-entry is metadata, not a string
57
+ const v = block[k]
58
+ entries[k] = Array.isArray(v) ? v : [v] // tolerate a bare string value
59
+ }
60
+ return { domain, entries }
61
+ }
62
+
63
+ /** Human-readable form of a possibly context-qualified key. */
64
+ function showKey(key) {
65
+ return key.includes(CTXT_SEP) ? key.split(CTXT_SEP).join(' ⁄ ') : key
66
+ }
67
+
68
+ /**
69
+ * Compare one JED JSON against its source `.po`.
70
+ * @param {string} poText raw .po/.pot text
71
+ * @param {object} jed parsed JED JSON object
72
+ * @returns {{ domain?: string, findings: object[],
73
+ * stats: { checked:number, drift:number, orphan:number,
74
+ * jedEntries:number, poEntries:number } }}
75
+ */
76
+ export function checkJedSync(poText, jed) {
77
+ if (!isJed(jed)) {
78
+ return {
79
+ findings: [{ type: 'invalid-file', severity: 'error', path: '(jed)', message: 'not a JED file — no locale_data object' }],
80
+ stats: { checked: 0, drift: 0, orphan: 0, jedEntries: 0, poEntries: 0 },
81
+ }
82
+ }
83
+ const po = poMap(poText)
84
+ const { domain, entries } = jedEntries(jed)
85
+ const findings = []
86
+ let checked = 0
87
+ let drift = 0
88
+ let orphan = 0
89
+
90
+ for (const key of Object.keys(entries)) {
91
+ const jedForms = entries[key]
92
+ const poForms = po[key]
93
+ const shown = showKey(key)
94
+
95
+ if (poForms === undefined) {
96
+ orphan++
97
+ findings.push({
98
+ type: 'jed-orphan',
99
+ severity: 'warning',
100
+ path: shown,
101
+ message: 'in the JS JSON but not in the .po — a removed/renamed source string the build still ships',
102
+ })
103
+ continue
104
+ }
105
+
106
+ checked++
107
+ const n = Math.max(jedForms.length, poForms.length)
108
+ for (let i = 0; i < n; i++) {
109
+ const jt = jedForms[i] ?? ''
110
+ const pt = poForms[i] ?? ''
111
+ if (jt !== pt) {
112
+ drift++
113
+ findings.push({
114
+ type: 'jed-drift',
115
+ severity: 'error',
116
+ path: n > 1 ? `${shown} [form ${i}]` : shown,
117
+ message: 'JS JSON is out of sync with the .po — re-run `wp i18n make-json`',
118
+ source: pt,
119
+ translation: jt,
120
+ })
121
+ }
122
+ }
123
+ }
124
+
125
+ return {
126
+ domain,
127
+ findings,
128
+ stats: { checked, drift, orphan, jedEntries: Object.keys(entries).length, poEntries: Object.keys(po).length },
129
+ }
130
+ }
package/src/index.js CHANGED
@@ -14,12 +14,14 @@ export { translateJSON, translateStrings, flatten, unflatten } from './translate
14
14
  export { extractPlaceholders, validatePlaceholders } from './placeholders.js'
15
15
  export { isICUControl, checkICU } from './icu.js'
16
16
  export { checkTranslations } from './check.js'
17
- export { runCheck, runSemantic, discoverLayout, compileIgnores, statsFrom, aggregateLanguage, SEP } from './tree.js'
17
+ export { runCheck, runSemantic, discoverLayout, compileIgnores, statsFrom, aggregateLanguage, SEP, scanResultSecrets } from './tree.js'
18
+ export { scanSecrets, maskSecret } from './secrets.js'
18
19
  export { lockId, lockEntry, lockFinding, emptyLocks, normalizeLocks, LOCKS_VERSION } from './locks.js'
19
20
  export { parseArbBundle, arbLangFromFilename, arbLangFromContent, stripArbMetadata } from './formats/arb.js'
20
21
  export { parseXcstrings } from './formats/xcstrings.js'
21
- export { parseAndroidStrings, androidLangFromValuesDir } from './formats/android.js'
22
- export { parsePo } from './formats/po.js'
22
+ export { parseAndroidStrings, androidLangFromValuesDir, androidEscapingFindings } from './formats/android.js'
23
+ export { parsePo, readPoEntries, CTXT_SEP } from './formats/po.js'
24
+ export { checkJedSync, isJed } from './formats/wp-jed.js'
23
25
  export { parseXliff } from './formats/xliff.js'
24
26
  export { reviewTranslations, DEFAULT_JUDGE_MODELS, buildReviewPrompt, parseVerdicts, pairHash } from './review.js'
25
27
  export { getLanguageName, LANGUAGE_NAMES } from './languages.js'
@@ -42,8 +42,29 @@ export function extractPlaceholders(str) {
42
42
  return found.sort()
43
43
  }
44
44
 
45
+ // An ICU argument used with a function: {count, plural, …}, {gender, select, …},
46
+ // {n, selectordinal, …}, {v, number}, {d, date}, … The bare `{count,` form does
47
+ // not match the single-brace placeholder pattern, so a translation that upgrades
48
+ // a plain {count} to an ICU plural would otherwise read as "dropped {count}".
49
+ const ICU_ARG =
50
+ /\{\s*([a-zA-Z0-9_]+)\s*,\s*(?:plural|selectordinal|select|number|date|time|spellout|ordinal|duration)\b/g
51
+ const SIMPLE_BRACE = /^\{([a-zA-Z0-9_.]+)\}$/ // a react-intl/ICU {name} placeholder token
52
+
53
+ /** Names used as ICU function arguments in a string. */
54
+ function icuArgNames(str) {
55
+ const set = new Set()
56
+ if (typeof str !== 'string') return set
57
+ ICU_ARG.lastIndex = 0
58
+ let m
59
+ while ((m = ICU_ARG.exec(str)) !== null) set.add(m[1])
60
+ return set
61
+ }
62
+
45
63
  /**
46
64
  * Does the translation preserve exactly the placeholders of the source?
65
+ * A source `{x}` is considered present when the translation uses `x` as an ICU
66
+ * argument (`{x, plural|select|…}`) and vice-versa — upgrading a plain variable
67
+ * to an ICU plural is correct, not a dropped placeholder.
47
68
  * @param {string} source
48
69
  * @param {string} translation
49
70
  * @returns {{ ok: boolean, missing: string[], added: string[] }}
@@ -53,14 +74,20 @@ export function validatePlaceholders(source, translation) {
53
74
  const out = extractPlaceholders(translation)
54
75
  const outCounts = tally(out)
55
76
  const srcCounts = tally(src)
77
+ const srcIcu = icuArgNames(source)
78
+ const outIcu = icuArgNames(translation)
56
79
  const missing = []
57
80
  const added = []
58
81
  for (const [ph, n] of Object.entries(srcCounts)) {
59
- const diff = n - (outCounts[ph] || 0)
82
+ let diff = n - (outCounts[ph] || 0)
83
+ const b = SIMPLE_BRACE.exec(ph)
84
+ if (diff > 0 && b && outIcu.has(b[1])) diff = 0 // used as an ICU arg in the translation
60
85
  for (let i = 0; i < diff; i++) missing.push(ph)
61
86
  }
62
87
  for (const [ph, n] of Object.entries(outCounts)) {
63
- const diff = n - (srcCounts[ph] || 0)
88
+ let diff = n - (srcCounts[ph] || 0)
89
+ const b = SIMPLE_BRACE.exec(ph)
90
+ if (diff > 0 && b && srcIcu.has(b[1])) diff = 0 // source used it as an ICU arg
64
91
  for (let i = 0; i < diff; i++) added.push(ph)
65
92
  }
66
93
  return { ok: missing.length === 0 && added.length === 0, missing, added }
package/src/reporters.js CHANGED
@@ -76,6 +76,12 @@ export const RULE_META = {
76
76
  'semantic-mistranslation': 'LLM judge (majority vote): the translation states something different from the source.',
77
77
  'semantic-omission': 'LLM judge (majority vote): meaningful source content is missing from the translation.',
78
78
  'semantic-addition': 'LLM judge (majority vote): the translation contains claims the source does not make.',
79
+ 'jed-drift': 'A WordPress JED .json string is out of sync with the .po (re-run wp i18n make-json).',
80
+ 'jed-orphan': 'A WordPress JED .json carries a string the .po no longer has.',
81
+ 'secret-detected': 'A locale string contains a secret or PII (API key, private key, card, email).',
82
+ 'secret-preflight': 'A string was withheld from the LLM because it held a secret or PII.',
83
+ 'android-unescaped-apostrophe': "An Android strings.xml apostrophe is not escaped (\\') or double-quote-wrapped.",
84
+ 'android-unbalanced-quote': 'An Android strings.xml has an unbalanced or unescaped double-quote.',
79
85
  }
80
86
 
81
87
  /** SARIF 2.1.0 — one run, one rule per finding type, one result per finding. */
package/src/secrets.js ADDED
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Secret / PII detection — a pre-flight before `--semantic` ships strings to a
3
+ * third-party LLM, and an opt-in `check` rule.
4
+ *
5
+ * The BYO-LLM privacy story has a hole: a locale string that contains an API key,
6
+ * a private key, or a customer's email/card gets sent verbatim to the model
7
+ * provider the moment you run `--semantic`. No i18n tool guards that send. This
8
+ * does — and because the semantic pass only ever transmits the pairs we scan
9
+ * here, the pre-flight is complete by construction: what gets sent gets scanned.
10
+ *
11
+ * Bias: high precision over recall. False positives that fail a build teach users
12
+ * to add `--severity secret-preflight=off` and stop trusting the tool, so every
13
+ * pattern is prefix- or checksum-anchored (Luhn for cards, known key prefixes),
14
+ * example/test values are filtered out, and phone matching requires a `+` country
15
+ * code. Matches are always MASKED — a finding never echoes the secret it found.
16
+ */
17
+
18
+ /** Mask a matched value so it never appears in a finding, log, or report. */
19
+ export function maskSecret(s) {
20
+ if (s.length <= 6) return '•'.repeat(s.length)
21
+ return s.slice(0, 3) + '•'.repeat(Math.min(6, s.length - 5)) + s.slice(-2)
22
+ }
23
+
24
+ /** Luhn checksum — filters random digit runs from real card numbers. */
25
+ function luhnValid(digits) {
26
+ let sum = 0
27
+ let dbl = false
28
+ for (let i = digits.length - 1; i >= 0; i--) {
29
+ let d = digits.charCodeAt(i) - 48
30
+ if (d < 0 || d > 9) return false
31
+ if (dbl) {
32
+ d *= 2
33
+ if (d > 9) d -= 9
34
+ }
35
+ sum += d
36
+ dbl = !dbl
37
+ }
38
+ return sum % 10 === 0 && digits.length >= 13
39
+ }
40
+
41
+ // Placeholder / documentation emails that should not trip the PII detector.
42
+ const EXAMPLE_EMAIL =
43
+ /@(?:example\.(?:com|org|net)|test(?:\.\w+)?|localhost|(?:your|my)?(?:company|domain|email|site)\.\w+|acme\.\w+)$/i
44
+
45
+ // High-confidence secrets first; email/phone (PII) last. Order matters: the first
46
+ // pattern to claim a span wins, so a provider key is never re-reported as a
47
+ // generic token. `re` must be global (we exec in a loop).
48
+ const PATTERNS = [
49
+ { kind: 'private-key', severity: 'error', re: /-----BEGIN (?:[A-Z0-9]+ )*PRIVATE KEY-----/g },
50
+ { kind: 'aws-access-key', severity: 'error', re: /\b(?:AKIA|ASIA|AGPA|AIDA)[0-9A-Z]{16}\b/g },
51
+ { kind: 'gcp-api-key', severity: 'error', re: /\bAIza[0-9A-Za-z_-]{35}\b/g },
52
+ { kind: 'github-token', severity: 'error', re: /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36,}\b/g },
53
+ { kind: 'github-pat', severity: 'error', re: /\bgithub_pat_[A-Za-z0-9_]{22,}\b/g },
54
+ { kind: 'slack-token', severity: 'error', re: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g },
55
+ { kind: 'stripe-key', severity: 'error', re: /\b[rs]k_(?:live|test)_[0-9A-Za-z]{16,}\b/g },
56
+ { kind: 'anthropic-key', severity: 'error', re: /\bsk-ant-[A-Za-z0-9_-]{20,}/g },
57
+ { kind: 'openai-key', severity: 'error', re: /\bsk-(?:proj-)?[A-Za-z0-9]{20,}\b/g },
58
+ { kind: 'jwt', severity: 'error', re: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g },
59
+ { kind: 'credit-card', severity: 'error', re: /\b(?:\d[ -]?){13,19}\b/g, luhn: true },
60
+ {
61
+ kind: 'email',
62
+ severity: 'warning',
63
+ re: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g,
64
+ filter: (m) => !EXAMPLE_EMAIL.test(m),
65
+ },
66
+ // Phone: require a leading + country code — a bare 10-digit run is far too
67
+ // ambiguous (IDs, timestamps, quantities) to flag without crying wolf.
68
+ { kind: 'phone', severity: 'warning', re: /\+\d[\d ().-]{7,}\d/g },
69
+ ]
70
+
71
+ /**
72
+ * Scan one string for secrets / PII.
73
+ * @param {string} str
74
+ * @returns {Array<{kind:string, severity:'error'|'warning', masked:string}>}
75
+ * masked values only — never the raw secret.
76
+ */
77
+ export function scanSecrets(str) {
78
+ if (!str || typeof str !== 'string') return []
79
+ const hits = []
80
+ const claimed = [] // [start,end) spans already attributed to a higher-confidence pattern
81
+ const overlaps = (a, b) => claimed.some(([s, e]) => a < e && b > s)
82
+
83
+ for (const p of PATTERNS) {
84
+ p.re.lastIndex = 0
85
+ let m
86
+ while ((m = p.re.exec(str)) !== null) {
87
+ const val = m[0]
88
+ if (val === '') {
89
+ p.re.lastIndex++
90
+ continue
91
+ }
92
+ const start = m.index
93
+ const end = start + val.length
94
+ if (overlaps(start, end)) continue
95
+ if (p.filter && !p.filter(val)) continue
96
+ if (p.luhn && !luhnValid(val.replace(/\D/g, ''))) continue
97
+ claimed.push([start, end])
98
+ hits.push({ kind: p.kind, severity: p.severity, masked: maskSecret(val) })
99
+ if (hits.length >= 50) return hits // pathological input backstop
100
+ }
101
+ }
102
+ return hits
103
+ }
package/src/tree.js CHANGED
@@ -14,9 +14,10 @@ import { checkTranslations } from './check.js'
14
14
  import { parse as parseYaml } from 'yaml'
15
15
  import { parseArbBundle } from './formats/arb.js'
16
16
  import { parseXcstrings } from './formats/xcstrings.js'
17
- import { parseAndroidStrings, androidLangFromValuesDir } from './formats/android.js'
17
+ import { parseAndroidStrings, androidLangFromValuesDir, androidEscapingFindings } from './formats/android.js'
18
18
  import { parsePo } from './formats/po.js'
19
19
  import { parseXliff } from './formats/xliff.js'
20
+ import { scanSecrets } from './secrets.js'
20
21
 
21
22
  // Locale files are JSON or YAML. The check logic is format-agnostic once the
22
23
  // file is parsed to an object, so support is entirely a parse + discovery
@@ -316,15 +317,19 @@ export function androidStringsMode({ input, source, isIgnored, glossary }) {
316
317
  const file = join(dir, d, 'strings.xml')
317
318
  const ns = 'strings'
318
319
  let data
320
+ let rawXml
319
321
  try {
320
- data = parseAndroidStrings(readTextCapped(file))
322
+ rawXml = readTextCapped(file)
323
+ data = parseAndroidStrings(rawXml)
321
324
  } catch (err) {
322
325
  // A malformed/malicious target (bad XML, external entities, …) is a finding, not a crash.
323
326
  const findings = [{ type: 'invalid-file', severity: 'error', path: ns, message: `could not parse ${rel(file)}: ${err.message}` }]
324
327
  languages.push(aggregateLanguage(lang, [{ ns, file: rel(file), findings, stats: statsFrom(findings, 0, 0) }]))
325
328
  continue
326
329
  }
327
- const { findings, stats } = checkTranslations({ source: sourceData, target: data, targetLang: lang, glossary })
330
+ const { findings: engineFindings, stats } = checkTranslations({ source: sourceData, target: data, targetLang: lang, glossary })
331
+ // Escaping errors AAPT would throw but the decoded values can't reveal.
332
+ const findings = [...engineFindings, ...androidEscapingFindings(rawXml)]
328
333
  const kept = findings.filter((f) => !isIgnored(ns, f.path))
329
334
  addPairs(perLang, lang, ns, sourceData, data, isIgnored)
330
335
  languages.push(
@@ -508,7 +513,7 @@ export function runCheck({ input, source = 'en', ignoreKeys, glossary, locks } =
508
513
  */
509
514
 
510
515
  export async function runSemantic(result, { provider, apiKey, model, baseURL, passes, glossary, cache, fail = false }) {
511
- const totals = { judged: 0, cached: 0, flagged: 0, calls: 0, parseFailures: 0, excluded: 0 }
516
+ const totals = { judged: 0, cached: 0, flagged: 0, calls: 0, parseFailures: 0, excluded: 0, redacted: 0 }
512
517
 
513
518
  for (const l of result.languages) {
514
519
  const pairs = result.semanticPairs?.[l.lang]
@@ -526,31 +531,50 @@ export async function runSemantic(result, { provider, apiKey, model, baseURL, pa
526
531
  totals.excluded++
527
532
  continue
528
533
  }
534
+ // Privacy pre-flight: never transmit a pair carrying a secret/PII to the
535
+ // LLM. Withhold it (fail-closed) and record a finding instead of sending.
536
+ const leaks = [...scanSecrets(pairs.source[key]), ...scanSecrets(pairs.target[key])]
537
+ if (leaks.length) {
538
+ totals.redacted++
539
+ const sepAt = key.indexOf(SEP)
540
+ const nsEntry = l.namespaces.find((n) => n.ns === key.slice(0, sepAt))
541
+ if (nsEntry) {
542
+ nsEntry.findings.push({
543
+ type: 'secret-preflight',
544
+ severity: leaks.some((h) => h.severity === 'error') ? 'error' : 'warning',
545
+ path: key.slice(sepAt + 1),
546
+ message: `withheld from the LLM — detected ${[...new Set(leaks.map((h) => h.kind))].join(', ')}`,
547
+ })
548
+ }
549
+ continue
550
+ }
529
551
  src[key] = pairs.source[key]
530
552
  tgt[key] = pairs.target[key]
531
553
  }
532
- if (!Object.keys(src).length) continue
533
-
534
- const { findings, stats } = await reviewTranslations({
535
- source: src, target: tgt, from: result.source, to: l.lang,
536
- provider, apiKey, model, baseURL, passes, glossary, cache,
537
- })
538
- for (const k of Object.keys(stats)) totals[k] = (totals[k] ?? 0) + (stats[k] ?? 0)
539
-
540
- for (const f of findings) {
541
- const sepAt = f.path.indexOf(SEP)
542
- const ns = f.path.slice(0, sepAt)
543
- const path = f.path.slice(sepAt + 1)
544
- const nsEntry = l.namespaces.find((n) => n.ns === ns)
545
- if (!nsEntry) continue
546
- nsEntry.findings.push({
547
- type: `semantic-${f.category}`,
548
- severity: fail ? 'error' : 'warning',
549
- path,
550
- message: f.note || f.category,
551
- source: f.source,
552
- translation: f.translation,
554
+ // Even when nothing is left to send (all pairs excluded or withheld), the
555
+ // pre-flight may have added secret findings, so still recompute stats below.
556
+ if (Object.keys(src).length) {
557
+ const { findings, stats } = await reviewTranslations({
558
+ source: src, target: tgt, from: result.source, to: l.lang,
559
+ provider, apiKey, model, baseURL, passes, glossary, cache,
553
560
  })
561
+ for (const k of Object.keys(stats)) totals[k] = (totals[k] ?? 0) + (stats[k] ?? 0)
562
+
563
+ for (const f of findings) {
564
+ const sepAt = f.path.indexOf(SEP)
565
+ const ns = f.path.slice(0, sepAt)
566
+ const path = f.path.slice(sepAt + 1)
567
+ const nsEntry = l.namespaces.find((n) => n.ns === ns)
568
+ if (!nsEntry) continue
569
+ nsEntry.findings.push({
570
+ type: `semantic-${f.category}`,
571
+ severity: fail ? 'error' : 'warning',
572
+ path,
573
+ message: f.note || f.category,
574
+ source: f.source,
575
+ translation: f.translation,
576
+ })
577
+ }
554
578
  }
555
579
  for (const n of l.namespaces) n.stats = statsFrom(n.findings, n.stats.sourceKeys, n.stats.targetKeys)
556
580
  const re = aggregateLanguage(l.lang, l.namespaces)
@@ -560,3 +584,35 @@ export async function runSemantic(result, { provider, apiKey, model, baseURL, pa
560
584
  return totals
561
585
  }
562
586
 
587
+ /**
588
+ * Opt-in standalone scan: flag secrets/PII sitting in the locale strings
589
+ * themselves (a leak regardless of any LLM). Walks the same string set the
590
+ * semantic pass would send, attaches `secret-detected` findings, recomputes
591
+ * stats/totals, and returns the number of strings flagged.
592
+ */
593
+ export function scanResultSecrets(result) {
594
+ let flagged = 0
595
+ for (const l of result.languages) {
596
+ const pairs = result.semanticPairs?.[l.lang]
597
+ if (!pairs) continue
598
+ for (const key of Object.keys(pairs.target || {})) {
599
+ const hits = [...scanSecrets(pairs.source?.[key]), ...scanSecrets(pairs.target[key])]
600
+ if (!hits.length) continue
601
+ flagged++
602
+ const sepAt = key.indexOf(SEP)
603
+ const nsEntry = l.namespaces.find((n) => n.ns === key.slice(0, sepAt))
604
+ if (!nsEntry) continue
605
+ nsEntry.findings.push({
606
+ type: 'secret-detected',
607
+ severity: hits.some((h) => h.severity === 'error') ? 'error' : 'warning',
608
+ path: key.slice(sepAt + 1),
609
+ message: `possible secret/PII in a locale string: ${[...new Set(hits.map((h) => h.kind))].join(', ')}`,
610
+ })
611
+ }
612
+ for (const n of l.namespaces) n.stats = statsFrom(n.findings, n.stats.sourceKeys, n.stats.targetKeys)
613
+ l.stats = aggregateLanguage(l.lang, l.namespaces).stats
614
+ }
615
+ recomputeTotals(result)
616
+ return flagged
617
+ }
618
+