disarm 0.12.0 → 0.14.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/binding.d.ts CHANGED
@@ -29,6 +29,13 @@ export declare class Pipeline {
29
29
  process(text: string): string
30
30
  }
31
31
 
32
+ /**
33
+ * Analyze a hostname for Unicode homoglyph spoofing, returning the full
34
+ * [`HostnameAnalysis`] (verdict + granular signals). `isSuspiciousHostname` is
35
+ * the boolean shorthand for `.suspicious`.
36
+ */
37
+ export declare function analyzeHostname(host: string, contractions: boolean): HostnameAnalysis
38
+
32
39
  /** Structured anomaly report. */
33
40
  export interface AnomalyReport {
34
41
  /** Whether any token tripped (the same value `hasAnomalies` returns). */
@@ -64,6 +71,14 @@ export declare function catalogKey(text: string, lang: string | undefined | null
64
71
 
65
72
  export declare function collapseWhitespace(text: string): string
66
73
 
74
+ /**
75
+ * The Unicode `confusables.txt` release the bundled confusable tables were folded
76
+ * from (#560). Deliberately not called a "Unicode version": disarm's other tables
77
+ * track different releases (see docs/provenance.md), so this answers one question —
78
+ * how current is the confusables fold?
79
+ */
80
+ export declare function confusablesVersion(): string
81
+
67
82
  /** Replace emoji with their plain names; `strip_modifiers` drops skin-tone marks. */
68
83
  export declare function demojize(text: string, stripModifiers: boolean): string
69
84
 
@@ -89,6 +104,18 @@ export interface Finding {
89
104
  reason: string
90
105
  }
91
106
 
107
+ /**
108
+ * Which of `values` reduce to the same identity key under `key` (#620).
109
+ *
110
+ * `key` is one of `"fold_case"`, `"search_key"`, `"catalog_key"`,
111
+ * `"canonicalize"`, `"canonicalize_strict"`, `"normalize_confusables"`. There is
112
+ * no default: the choice is the policy.
113
+ */
114
+ export declare function findKeyCollisions(values: Array<string>, key: string, lang?: string | undefined | null): Array<KeyCollision>
115
+
116
+ /** Scan `text` for confusable sources the bundled `target` table does not fold (#563). */
117
+ export declare function findUnmappedConfusables(text: string, target: string): Array<UnmappedConfusable>
118
+
92
119
  /** Characters with no romanization, as `{ char, offset }` (byte offset), in order. */
93
120
  export declare function findUntranslatable(text: string, scheme: string, lang?: string | undefined | null): Array<Untranslatable>
94
121
 
@@ -123,6 +150,61 @@ export declare function hasAnomalies(text: string, lexicon: Array<string> | Lexi
123
150
  */
124
151
  export declare function hasBidiConflict(text: string): boolean
125
152
 
153
+ /**
154
+ * Findings from a hostname homoglyph analysis (#549). Factual signals only — a
155
+ * `suspicious == false` result is not a safety guarantee.
156
+ */
157
+ export interface HostnameAnalysis {
158
+ /**
159
+ * Overall verdict — a **maximally conservative screen** (any non-Latin
160
+ * confusable trips it), not a precise verdict.
161
+ */
162
+ suspicious: boolean
163
+ /** Scripts across all labels, first-appearance order (Common/Inherited excluded). */
164
+ scripts: Array<string>
165
+ /** Whether any single label mixes more than one script. */
166
+ mixedScript: boolean
167
+ /** Whether any label contains a character confusable with a Latin one. */
168
+ hasConfusables: boolean
169
+ /**
170
+ * Whether the decoded hostname mixes strong LTR and RTL characters (the
171
+ * "BiDi Swap" precondition). Folded into `suspicious`.
172
+ */
173
+ bidiConflict: boolean
174
+ /**
175
+ * Whether the decoded hostname contains a UAX #9 bidi control character —
176
+ * override, embedding, isolate or directional mark (#603). Disjoint from
177
+ * `bidiConflict`, which reads strong-direction letters only. Folded into
178
+ * `suspicious`; the characters are stripped from `canonical`.
179
+ */
180
+ bidiControl: boolean
181
+ /**
182
+ * Whether the decoded hostname contains a zero-width or invisible-format
183
+ * character — `U+200B`-`U+200D`, `U+2060`-`U+2064`, `U+FEFF`, `U+180E` (#605).
184
+ * Disjoint from `bidiControl`: these carry no direction at all. Folded into
185
+ * `suspicious`; the characters are stripped before any other field is computed,
186
+ * so they never reach `scripts`, `mixedScript` or `canonical`.
187
+ */
188
+ hasInvisible: boolean
189
+ /**
190
+ * Whether the labels span more than one script. Broader/noisier than
191
+ * `bidiConflict`; NOT folded into `suspicious`.
192
+ */
193
+ crossLabelScript: boolean
194
+ /** Per-label resolved scripts, left to right. */
195
+ labelScripts: Array<Array<string>>
196
+ /**
197
+ * Whether any label is a whole-script confusable (single-script, non-Latin,
198
+ * skeletoning to all-Latin, e.g. `аррӏе`→`apple`). A graded **signal, not a
199
+ * verdict** — NOT folded into `suspicious` (fires on `ру`→`py`, `оса`→`oca`).
200
+ */
201
+ wholeScriptConfusable: boolean
202
+ /** Per-label whole-script-confusable flags, parallel to `labelScripts`. */
203
+ labelWholeScriptConfusable: Array<boolean>
204
+ /** The Latin-normalized (canonical) form of the hostname. */
205
+ canonical: string
206
+ }
207
+
126
208
  /**
127
209
  * `inspectAnomalies(text, lexicon)` — full analysis with per-token findings.
128
210
  * `lexicon` is either an array of common words or a prebuilt `Lexicon` handle.
@@ -132,6 +214,12 @@ export declare function inspectAnomalies(text: string, lexicon: Array<string> |
132
214
  /** Explain how auto-language detection resolves `text`. */
133
215
  export declare function inspectAutoLang(text: string): AutoLangInspection
134
216
 
217
+ /**
218
+ * Whether case folding and simple lowercasing agree, so the value is a stable
219
+ * identity key (#619).
220
+ */
221
+ export declare function isCaseFoldStable(text: string): boolean
222
+
135
223
  /** Whether `text` contains a character confusable with `target`. */
136
224
  export declare function isConfusable(text: string, target: string): boolean
137
225
 
@@ -151,6 +239,20 @@ export declare function isSuspiciousHostname(host: string): boolean
151
239
 
152
240
  export declare function isZalgo(text: string, threshold: number): boolean
153
241
 
242
+ /** One group of distinct inputs that reduce to the same key (#620). */
243
+ export interface KeyCollision {
244
+ /** The reduced form every member of the group shares. */
245
+ key: string
246
+ /** The distinct inputs that reduce to it, in order of first appearance. */
247
+ values: Array<string>
248
+ /**
249
+ * Every position in the input array that belongs to this group, ascending.
250
+ * Not parallel to `values`: a value repeated verbatim appears once there and
251
+ * once per occurrence here.
252
+ */
253
+ indices: Array<number>
254
+ }
255
+
154
256
  /**
155
257
  * Look up static facts about a language `code`. An unknown code raises a
156
258
  * `DisarmInvalidArgument`-tagged error.
@@ -175,11 +277,21 @@ export declare function listContextLangs(): Array<string>
175
277
  /** Every Unicode script name known to the transliteration tables. */
176
278
  export declare function listScripts(): Array<string>
177
279
 
280
+ /**
281
+ * ML/NLP normalization: NFKC → emoji→text → transliterate → strip accents →
282
+ * [case fold] → strip control → strip zero-width → collapse whitespace.
283
+ *
284
+ * `fold_case` defaults to `true`. Pass `false` in front of a CASED model: the fold is
285
+ * destructive, cannot be undone downstream, and an uncased evaluation harness cannot
286
+ * measure what it cost. It restores case, not diacritics — `strip_accents` still runs.
287
+ */
288
+ export declare function mlNormalize(text: string, lang: string | undefined | null, emojiStyle: string, foldCase: boolean): string
289
+
178
290
  /** Apply a normalization form: `"NFC"` | `"NFD"` | `"NFKC"` | `"NFKD"`. */
179
291
  export declare function normalize(text: string, form: string): string
180
292
 
181
293
  /** Fold cross-script confusables toward `target` (`"latin"` | `"cyrillic"`). */
182
- export declare function normalizeConfusables(text: string, target: string): string
294
+ export declare function normalizeConfusables(text: string, target: string, digitPolicy: string): string
183
295
 
184
296
  /** Reverse-transliterate Latin → native script. `lang` is `"el"` | `"ru"` | `"uk"`. */
185
297
  export declare function reverseTransliterate(text: string, lang: string): string
@@ -274,6 +386,20 @@ export declare function transliterate(text: string): string
274
386
  */
275
387
  export declare function transliterateOpts(text: string, scheme: string, lang?: string | undefined | null): string
276
388
 
389
+ /**
390
+ * An upstream confusable source the bundled table does not fold, located in the
391
+ * input (#563). Mirrors `Untranslatable`, its transliteration analogue.
392
+ */
393
+ export interface UnmappedConfusable {
394
+ /** The unmapped character. */
395
+ char: string
396
+ /** Its byte offset in the input string. */
397
+ offset: number
398
+ }
399
+
400
+ /** Every upstream confusable source the bundled `target` table does not fold (#563). */
401
+ export declare function unmappedConfusables(target: string): Array<string>
402
+
277
403
  /** A character with no transliteration, located in the input. */
278
404
  export interface Untranslatable {
279
405
  /** The untranslatable character. */
package/binding.js CHANGED
@@ -77,8 +77,8 @@ function requireNative() {
77
77
  try {
78
78
  const binding = require('disarm-android-arm64')
79
79
  const bindingPackageVersion = require('disarm-android-arm64/package.json').version
80
- if (bindingPackageVersion !== '0.12.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
81
- throw new Error(`Native binding package version mismatch, expected 0.12.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
80
+ if (bindingPackageVersion !== '0.14.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
81
+ throw new Error(`Native binding package version mismatch, expected 0.14.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
82
82
  }
83
83
  return binding
84
84
  } catch (e) {
@@ -93,8 +93,8 @@ function requireNative() {
93
93
  try {
94
94
  const binding = require('disarm-android-arm-eabi')
95
95
  const bindingPackageVersion = require('disarm-android-arm-eabi/package.json').version
96
- if (bindingPackageVersion !== '0.12.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
97
- throw new Error(`Native binding package version mismatch, expected 0.12.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
96
+ if (bindingPackageVersion !== '0.14.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
97
+ throw new Error(`Native binding package version mismatch, expected 0.14.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
98
98
  }
99
99
  return binding
100
100
  } catch (e) {
@@ -114,8 +114,8 @@ function requireNative() {
114
114
  try {
115
115
  const binding = require('disarm-win32-x64-gnu')
116
116
  const bindingPackageVersion = require('disarm-win32-x64-gnu/package.json').version
117
- if (bindingPackageVersion !== '0.12.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
118
- throw new Error(`Native binding package version mismatch, expected 0.12.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
117
+ if (bindingPackageVersion !== '0.14.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
118
+ throw new Error(`Native binding package version mismatch, expected 0.14.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
119
119
  }
120
120
  return binding
121
121
  } catch (e) {
@@ -130,8 +130,8 @@ function requireNative() {
130
130
  try {
131
131
  const binding = require('disarm-win32-x64-msvc')
132
132
  const bindingPackageVersion = require('disarm-win32-x64-msvc/package.json').version
133
- if (bindingPackageVersion !== '0.12.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
134
- throw new Error(`Native binding package version mismatch, expected 0.12.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
133
+ if (bindingPackageVersion !== '0.14.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
134
+ throw new Error(`Native binding package version mismatch, expected 0.14.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
135
135
  }
136
136
  return binding
137
137
  } catch (e) {
@@ -147,8 +147,8 @@ function requireNative() {
147
147
  try {
148
148
  const binding = require('disarm-win32-ia32-msvc')
149
149
  const bindingPackageVersion = require('disarm-win32-ia32-msvc/package.json').version
150
- if (bindingPackageVersion !== '0.12.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
151
- throw new Error(`Native binding package version mismatch, expected 0.12.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
150
+ if (bindingPackageVersion !== '0.14.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
151
+ throw new Error(`Native binding package version mismatch, expected 0.14.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
152
152
  }
153
153
  return binding
154
154
  } catch (e) {
@@ -163,8 +163,8 @@ function requireNative() {
163
163
  try {
164
164
  const binding = require('disarm-win32-arm64-msvc')
165
165
  const bindingPackageVersion = require('disarm-win32-arm64-msvc/package.json').version
166
- if (bindingPackageVersion !== '0.12.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
167
- throw new Error(`Native binding package version mismatch, expected 0.12.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
166
+ if (bindingPackageVersion !== '0.14.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
167
+ throw new Error(`Native binding package version mismatch, expected 0.14.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
168
168
  }
169
169
  return binding
170
170
  } catch (e) {
@@ -182,8 +182,8 @@ function requireNative() {
182
182
  try {
183
183
  const binding = require('disarm-darwin-universal')
184
184
  const bindingPackageVersion = require('disarm-darwin-universal/package.json').version
185
- if (bindingPackageVersion !== '0.12.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
186
- throw new Error(`Native binding package version mismatch, expected 0.12.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
185
+ if (bindingPackageVersion !== '0.14.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
186
+ throw new Error(`Native binding package version mismatch, expected 0.14.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
187
187
  }
188
188
  return binding
189
189
  } catch (e) {
@@ -198,8 +198,8 @@ function requireNative() {
198
198
  try {
199
199
  const binding = require('disarm-darwin-x64')
200
200
  const bindingPackageVersion = require('disarm-darwin-x64/package.json').version
201
- if (bindingPackageVersion !== '0.12.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
202
- throw new Error(`Native binding package version mismatch, expected 0.12.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
201
+ if (bindingPackageVersion !== '0.14.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
202
+ throw new Error(`Native binding package version mismatch, expected 0.14.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
203
203
  }
204
204
  return binding
205
205
  } catch (e) {
@@ -214,8 +214,8 @@ function requireNative() {
214
214
  try {
215
215
  const binding = require('disarm-darwin-arm64')
216
216
  const bindingPackageVersion = require('disarm-darwin-arm64/package.json').version
217
- if (bindingPackageVersion !== '0.12.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
218
- throw new Error(`Native binding package version mismatch, expected 0.12.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
217
+ if (bindingPackageVersion !== '0.14.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
218
+ throw new Error(`Native binding package version mismatch, expected 0.14.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
219
219
  }
220
220
  return binding
221
221
  } catch (e) {
@@ -234,8 +234,8 @@ function requireNative() {
234
234
  try {
235
235
  const binding = require('disarm-freebsd-x64')
236
236
  const bindingPackageVersion = require('disarm-freebsd-x64/package.json').version
237
- if (bindingPackageVersion !== '0.12.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
238
- throw new Error(`Native binding package version mismatch, expected 0.12.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
237
+ if (bindingPackageVersion !== '0.14.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
238
+ throw new Error(`Native binding package version mismatch, expected 0.14.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
239
239
  }
240
240
  return binding
241
241
  } catch (e) {
@@ -250,8 +250,8 @@ function requireNative() {
250
250
  try {
251
251
  const binding = require('disarm-freebsd-arm64')
252
252
  const bindingPackageVersion = require('disarm-freebsd-arm64/package.json').version
253
- if (bindingPackageVersion !== '0.12.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
254
- throw new Error(`Native binding package version mismatch, expected 0.12.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
253
+ if (bindingPackageVersion !== '0.14.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
254
+ throw new Error(`Native binding package version mismatch, expected 0.14.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
255
255
  }
256
256
  return binding
257
257
  } catch (e) {
@@ -271,8 +271,8 @@ function requireNative() {
271
271
  try {
272
272
  const binding = require('disarm-linux-x64-musl')
273
273
  const bindingPackageVersion = require('disarm-linux-x64-musl/package.json').version
274
- if (bindingPackageVersion !== '0.12.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
275
- throw new Error(`Native binding package version mismatch, expected 0.12.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
274
+ if (bindingPackageVersion !== '0.14.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
275
+ throw new Error(`Native binding package version mismatch, expected 0.14.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
276
276
  }
277
277
  return binding
278
278
  } catch (e) {
@@ -287,8 +287,8 @@ function requireNative() {
287
287
  try {
288
288
  const binding = require('disarm-linux-x64-gnu')
289
289
  const bindingPackageVersion = require('disarm-linux-x64-gnu/package.json').version
290
- if (bindingPackageVersion !== '0.12.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
291
- throw new Error(`Native binding package version mismatch, expected 0.12.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
290
+ if (bindingPackageVersion !== '0.14.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
291
+ throw new Error(`Native binding package version mismatch, expected 0.14.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
292
292
  }
293
293
  return binding
294
294
  } catch (e) {
@@ -305,8 +305,8 @@ function requireNative() {
305
305
  try {
306
306
  const binding = require('disarm-linux-arm64-musl')
307
307
  const bindingPackageVersion = require('disarm-linux-arm64-musl/package.json').version
308
- if (bindingPackageVersion !== '0.12.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
309
- throw new Error(`Native binding package version mismatch, expected 0.12.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
308
+ if (bindingPackageVersion !== '0.14.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
309
+ throw new Error(`Native binding package version mismatch, expected 0.14.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
310
310
  }
311
311
  return binding
312
312
  } catch (e) {
@@ -321,8 +321,8 @@ function requireNative() {
321
321
  try {
322
322
  const binding = require('disarm-linux-arm64-gnu')
323
323
  const bindingPackageVersion = require('disarm-linux-arm64-gnu/package.json').version
324
- if (bindingPackageVersion !== '0.12.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
325
- throw new Error(`Native binding package version mismatch, expected 0.12.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
324
+ if (bindingPackageVersion !== '0.14.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
325
+ throw new Error(`Native binding package version mismatch, expected 0.14.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
326
326
  }
327
327
  return binding
328
328
  } catch (e) {
@@ -339,8 +339,8 @@ function requireNative() {
339
339
  try {
340
340
  const binding = require('disarm-linux-arm-musleabihf')
341
341
  const bindingPackageVersion = require('disarm-linux-arm-musleabihf/package.json').version
342
- if (bindingPackageVersion !== '0.12.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
343
- throw new Error(`Native binding package version mismatch, expected 0.12.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
342
+ if (bindingPackageVersion !== '0.14.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
343
+ throw new Error(`Native binding package version mismatch, expected 0.14.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
344
344
  }
345
345
  return binding
346
346
  } catch (e) {
@@ -355,8 +355,8 @@ function requireNative() {
355
355
  try {
356
356
  const binding = require('disarm-linux-arm-gnueabihf')
357
357
  const bindingPackageVersion = require('disarm-linux-arm-gnueabihf/package.json').version
358
- if (bindingPackageVersion !== '0.12.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
359
- throw new Error(`Native binding package version mismatch, expected 0.12.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
358
+ if (bindingPackageVersion !== '0.14.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
359
+ throw new Error(`Native binding package version mismatch, expected 0.14.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
360
360
  }
361
361
  return binding
362
362
  } catch (e) {
@@ -373,8 +373,8 @@ function requireNative() {
373
373
  try {
374
374
  const binding = require('disarm-linux-loong64-musl')
375
375
  const bindingPackageVersion = require('disarm-linux-loong64-musl/package.json').version
376
- if (bindingPackageVersion !== '0.12.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
377
- throw new Error(`Native binding package version mismatch, expected 0.12.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
376
+ if (bindingPackageVersion !== '0.14.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
377
+ throw new Error(`Native binding package version mismatch, expected 0.14.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
378
378
  }
379
379
  return binding
380
380
  } catch (e) {
@@ -389,8 +389,8 @@ function requireNative() {
389
389
  try {
390
390
  const binding = require('disarm-linux-loong64-gnu')
391
391
  const bindingPackageVersion = require('disarm-linux-loong64-gnu/package.json').version
392
- if (bindingPackageVersion !== '0.12.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
393
- throw new Error(`Native binding package version mismatch, expected 0.12.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
392
+ if (bindingPackageVersion !== '0.14.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
393
+ throw new Error(`Native binding package version mismatch, expected 0.14.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
394
394
  }
395
395
  return binding
396
396
  } catch (e) {
@@ -407,8 +407,8 @@ function requireNative() {
407
407
  try {
408
408
  const binding = require('disarm-linux-riscv64-musl')
409
409
  const bindingPackageVersion = require('disarm-linux-riscv64-musl/package.json').version
410
- if (bindingPackageVersion !== '0.12.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
411
- throw new Error(`Native binding package version mismatch, expected 0.12.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
410
+ if (bindingPackageVersion !== '0.14.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
411
+ throw new Error(`Native binding package version mismatch, expected 0.14.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
412
412
  }
413
413
  return binding
414
414
  } catch (e) {
@@ -423,8 +423,8 @@ function requireNative() {
423
423
  try {
424
424
  const binding = require('disarm-linux-riscv64-gnu')
425
425
  const bindingPackageVersion = require('disarm-linux-riscv64-gnu/package.json').version
426
- if (bindingPackageVersion !== '0.12.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
427
- throw new Error(`Native binding package version mismatch, expected 0.12.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
426
+ if (bindingPackageVersion !== '0.14.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
427
+ throw new Error(`Native binding package version mismatch, expected 0.14.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
428
428
  }
429
429
  return binding
430
430
  } catch (e) {
@@ -440,8 +440,8 @@ function requireNative() {
440
440
  try {
441
441
  const binding = require('disarm-linux-ppc64-gnu')
442
442
  const bindingPackageVersion = require('disarm-linux-ppc64-gnu/package.json').version
443
- if (bindingPackageVersion !== '0.12.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
444
- throw new Error(`Native binding package version mismatch, expected 0.12.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
443
+ if (bindingPackageVersion !== '0.14.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
444
+ throw new Error(`Native binding package version mismatch, expected 0.14.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
445
445
  }
446
446
  return binding
447
447
  } catch (e) {
@@ -456,8 +456,8 @@ function requireNative() {
456
456
  try {
457
457
  const binding = require('disarm-linux-s390x-gnu')
458
458
  const bindingPackageVersion = require('disarm-linux-s390x-gnu/package.json').version
459
- if (bindingPackageVersion !== '0.12.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
460
- throw new Error(`Native binding package version mismatch, expected 0.12.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
459
+ if (bindingPackageVersion !== '0.14.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
460
+ throw new Error(`Native binding package version mismatch, expected 0.14.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
461
461
  }
462
462
  return binding
463
463
  } catch (e) {
@@ -476,8 +476,8 @@ function requireNative() {
476
476
  try {
477
477
  const binding = require('disarm-openharmony-arm64')
478
478
  const bindingPackageVersion = require('disarm-openharmony-arm64/package.json').version
479
- if (bindingPackageVersion !== '0.12.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
480
- throw new Error(`Native binding package version mismatch, expected 0.12.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
479
+ if (bindingPackageVersion !== '0.14.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
480
+ throw new Error(`Native binding package version mismatch, expected 0.14.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
481
481
  }
482
482
  return binding
483
483
  } catch (e) {
@@ -492,8 +492,8 @@ function requireNative() {
492
492
  try {
493
493
  const binding = require('disarm-openharmony-x64')
494
494
  const bindingPackageVersion = require('disarm-openharmony-x64/package.json').version
495
- if (bindingPackageVersion !== '0.12.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
496
- throw new Error(`Native binding package version mismatch, expected 0.12.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
495
+ if (bindingPackageVersion !== '0.14.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
496
+ throw new Error(`Native binding package version mismatch, expected 0.14.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
497
497
  }
498
498
  return binding
499
499
  } catch (e) {
@@ -508,8 +508,8 @@ function requireNative() {
508
508
  try {
509
509
  const binding = require('disarm-openharmony-arm')
510
510
  const bindingPackageVersion = require('disarm-openharmony-arm/package.json').version
511
- if (bindingPackageVersion !== '0.12.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
512
- throw new Error(`Native binding package version mismatch, expected 0.12.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
511
+ if (bindingPackageVersion !== '0.14.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
512
+ throw new Error(`Native binding package version mismatch, expected 0.14.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
513
513
  }
514
514
  return binding
515
515
  } catch (e) {
@@ -589,11 +589,15 @@ if (!nativeBinding) {
589
589
  module.exports = nativeBinding
590
590
  module.exports.Lexicon = nativeBinding.Lexicon
591
591
  module.exports.Pipeline = nativeBinding.Pipeline
592
+ module.exports.analyzeHostname = nativeBinding.analyzeHostname
592
593
  module.exports.canonicalize = nativeBinding.canonicalize
593
594
  module.exports.catalogKey = nativeBinding.catalogKey
594
595
  module.exports.collapseWhitespace = nativeBinding.collapseWhitespace
596
+ module.exports.confusablesVersion = nativeBinding.confusablesVersion
595
597
  module.exports.demojize = nativeBinding.demojize
596
598
  module.exports.detectScripts = nativeBinding.detectScripts
599
+ module.exports.findKeyCollisions = nativeBinding.findKeyCollisions
600
+ module.exports.findUnmappedConfusables = nativeBinding.findUnmappedConfusables
597
601
  module.exports.findUntranslatable = nativeBinding.findUntranslatable
598
602
  module.exports.foldCase = nativeBinding.foldCase
599
603
  module.exports.getPipeline = nativeBinding.getPipeline
@@ -605,6 +609,7 @@ module.exports.hasAnomalies = nativeBinding.hasAnomalies
605
609
  module.exports.hasBidiConflict = nativeBinding.hasBidiConflict
606
610
  module.exports.inspectAnomalies = nativeBinding.inspectAnomalies
607
611
  module.exports.inspectAutoLang = nativeBinding.inspectAutoLang
612
+ module.exports.isCaseFoldStable = nativeBinding.isCaseFoldStable
608
613
  module.exports.isConfusable = nativeBinding.isConfusable
609
614
  module.exports.isMixedScript = nativeBinding.isMixedScript
610
615
  module.exports.isNormalized = nativeBinding.isNormalized
@@ -613,6 +618,7 @@ module.exports.isZalgo = nativeBinding.isZalgo
613
618
  module.exports.langInfo = nativeBinding.langInfo
614
619
  module.exports.listContextLangs = nativeBinding.listContextLangs
615
620
  module.exports.listScripts = nativeBinding.listScripts
621
+ module.exports.mlNormalize = nativeBinding.mlNormalize
616
622
  module.exports.normalize = nativeBinding.normalize
617
623
  module.exports.normalizeConfusables = nativeBinding.normalizeConfusables
618
624
  module.exports.reverseTransliterate = nativeBinding.reverseTransliterate
@@ -634,3 +640,4 @@ module.exports.stripZeroWidthChars = nativeBinding.stripZeroWidthChars
634
640
  module.exports.terminalWidth = nativeBinding.terminalWidth
635
641
  module.exports.transliterate = nativeBinding.transliterate
636
642
  module.exports.transliterateOpts = nativeBinding.transliterateOpts
643
+ module.exports.unmappedConfusables = nativeBinding.unmappedConfusables
Binary file
Binary file
Binary file
Binary file
Binary file
package/index.d.ts CHANGED
@@ -1,6 +1,9 @@
1
1
  import { Lexicon, Pipeline } from './binding';
2
- import type { Untranslatable, AutoLangInspection, LangMeta, ScriptMeta, Finding as NativeFinding, AnomalyReport as NativeAnomalyReport } from './binding';
3
- export type { Untranslatable, AutoLangInspection, LangMeta, ScriptMeta };
2
+ import type { Untranslatable, UnmappedConfusable, AutoLangInspection, KeyCollision, LangMeta, ScriptMeta, Finding as NativeFinding, AnomalyReport as NativeAnomalyReport, HostnameAnalysis as NativeHostnameAnalysis } from './binding';
3
+ export type { Untranslatable, UnmappedConfusable, AutoLangInspection, KeyCollision, LangMeta, ScriptMeta, };
4
+ /** Findings from {@link analyzeHostname}. `suspicious` is a maximally
5
+ * conservative screen, not a precise verdict (#549). */
6
+ export type HostnameAnalysis = NativeHostnameAnalysis;
4
7
  /**
5
8
  * A reusable, opaque lexicon handle (HAI-SDLC 6.1). `hasAnomalies` /
6
9
  * `inspectAnomalies` rebuild an internal set from the caller's word array on
@@ -22,7 +25,7 @@ export { Lexicon };
22
25
  */
23
26
  export { Pipeline };
24
27
  /** The anomaly branch that fired for a finding. */
25
- export type AnomalyKind = 'invisible' | 'bidi' | 'zalgo' | 'mixed_script' | 'leet' | 'segmentation';
28
+ export type AnomalyKind = 'invisible' | 'bidi' | 'bidi_mixed' | 'zalgo' | 'mixed_script' | 'leet' | 'segmentation' | 'control' | 'compat_fold';
26
29
  /**
27
30
  * One reason a token is anomalous. Re-typed over the generated {@link NativeFinding}
28
31
  * so `kind` is the {@link AnomalyKind} string-union rather than a bare `string`.
@@ -46,6 +49,21 @@ export declare class DisarmInvalidArgument extends DisarmError {
46
49
  export type Scheme = 'default' | 'strict_iso9' | 'gost7034';
47
50
  /** Confusable-folding target script. */
48
51
  export type TargetScript = 'latin' | 'cyrillic';
52
+ /**
53
+ * How the fold treats non-Latin digits.
54
+ *
55
+ * `'numeric'` (default) sends them to the ASCII digit — `०` becomes `0` — which is right
56
+ * for prose. `'tr39'` uses upstream's targets, which send most of them to a Latin letter
57
+ * (`०` → `o`), and that is what an identifier *skeleton* wants. The two differ on 45 rows.
58
+ *
59
+ * Three of those rows do not land on a letter: `٠` and `۰` fold to `.`, and `𑣣` folds to
60
+ * the two characters `rn`. A skeleton feeding a label- or path-shaped key has to allow
61
+ * for that extra `.`.
62
+ *
63
+ * Scoped to `target: 'latin'`: the override rows are generated from the Latin table and
64
+ * carry TR39's Latin-script targets, so with `target: 'cyrillic'` the option is a no-op.
65
+ */
66
+ export type DigitPolicy = 'numeric' | 'tr39';
49
67
  /** Unicode normalization form. */
50
68
  export type NormalizationForm = 'NFC' | 'NFD' | 'NFKC' | 'NFKD';
51
69
  /** Filename-safety platform ruleset. */
@@ -69,11 +87,31 @@ export declare function findUntranslatable(text: string, options?: Transliterate
69
87
  /** Fold cross-script confusables toward `target` (default `'latin'`). */
70
88
  export declare function normalizeConfusables(text: string, options?: {
71
89
  target?: TargetScript;
90
+ digitPolicy?: DigitPolicy;
72
91
  }): string;
73
92
  /** Whether `text` contains a character confusable with `target` (default `'latin'`). */
74
93
  export declare function isConfusable(text: string, options?: {
75
94
  target?: TargetScript;
76
95
  }): boolean;
96
+ /**
97
+ * Every upstream confusable source the bundled `target` table does not fold.
98
+ *
99
+ * Read as exposure, not as a score — this is where an adaptive attacker goes when the
100
+ * mapped sources stop working. Note it includes five ASCII characters (`%`, `0`, `1`,
101
+ * `I`, `m`): TR39 is a skeleton transform, and disarm deliberately does not apply those
102
+ * rows because folding a legitimate `m` to `rn` corrupts prose.
103
+ */
104
+ export declare function unmappedConfusables(options?: {
105
+ target?: TargetScript;
106
+ }): Set<string>;
107
+ /**
108
+ * Confusable sources in `text` the bundled `target` table does not fold, as
109
+ * `{ char, offset }` (byte offset), in order — the confusables analogue of
110
+ * {@link findUntranslatable}.
111
+ */
112
+ export declare function findUnmappedConfusables(text: string, options?: {
113
+ target?: TargetScript;
114
+ }): UnmappedConfusable[];
77
115
  export interface SlugifyOptions {
78
116
  separator?: string;
79
117
  lowercase?: boolean;
@@ -94,6 +132,39 @@ export declare function slugify(text: string, options?: SlugifyOptions): string;
94
132
  export declare function stripAccents(text: string): string;
95
133
  /** Full Unicode case fold — more aggressive than `String.toLowerCase()`. */
96
134
  export declare function foldCase(text: string): string;
135
+ /**
136
+ * Whether `text` is a stable identity key under case folding — that is, whether
137
+ * {@link foldCase} and `String.toLowerCase()` agree on it (#619).
138
+ *
139
+ * `false` means some *other* string folds to the same value, so a table keyed on
140
+ * this one can collide: `'groß.txt'` and `'gross.txt'` are the pair node-tar
141
+ * collided on (CVE-2026-23950). It is a fact about the string and not an
142
+ * accusation — `groß` is an ordinary German word — so it is deliberately not
143
+ * folded into {@link hasAnomalies}.
144
+ */
145
+ export declare function isCaseFoldStable(text: string): boolean;
146
+ /** Which reducer {@link findKeyCollisions} builds its keys with. No default: the
147
+ * choice is the policy. */
148
+ export type CollisionKey = 'fold_case' | 'search_key' | 'catalog_key' | 'canonicalize' | 'canonicalize_strict' | 'normalize_confusables';
149
+ /**
150
+ * Which of `values` are the same name under `key` (#620).
151
+ *
152
+ * Every other disarm detector is a single-string predicate, and a collision is not
153
+ * a property of a single string — `groß.txt` is an ordinary German filename, and
154
+ * `аdmin` is only a problem next to `admin`. This is the set-shaped question that
155
+ * node-tar's `PathReservations` guard failed to ask before extracting two paths in
156
+ * parallel (CVE-2026-23950).
157
+ *
158
+ * A stronger `key` finds more collisions, including ones nobody attacked:
159
+ * `search_key` collides `Muller` with `Müller`. That is the cost of the key you
160
+ * chose, not a false positive. A group is reported only when two or more *distinct*
161
+ * inputs share a key; the same name twice is the same name twice.
162
+ *
163
+ * `options.lang` reaches `search_key` and `catalog_key` and is ignored by the rest.
164
+ */
165
+ export declare function findKeyCollisions(values: string[], key: CollisionKey, options?: {
166
+ lang?: string;
167
+ }): KeyCollision[];
97
168
  /** Replace emoji with their plain names. `stripModifiers` drops skin-tone/variation marks. */
98
169
  export declare function demojize(text: string, options?: {
99
170
  stripModifiers?: boolean;
@@ -198,6 +269,29 @@ export declare function catalogKey(text: string, options?: {
198
269
  lang?: string;
199
270
  strictIso9?: boolean;
200
271
  }): string;
272
+ /** Options for {@link mlNormalize}. */
273
+ export interface MlNormalizeOptions {
274
+ /** Language code selecting the transliteration table; omit for none. */
275
+ lang?: string;
276
+ /** `'cldr'` (default) expands emoji to CLDR short names; `'none'` leaves them. */
277
+ emojiStyle?: 'cldr' | 'none';
278
+ /**
279
+ * Apply Unicode case folding (default `true`).
280
+ *
281
+ * Pass `false` in front of a **cased** model: folding is destructive, cannot be undone
282
+ * downstream, and an uncased evaluation harness cannot measure what it cost. It
283
+ * restores case, not diacritics — accents are still stripped.
284
+ */
285
+ foldCase?: boolean;
286
+ }
287
+ /**
288
+ * ML/NLP normalization: NFKC → emoji→text → transliterate → strip accents →
289
+ * [case fold] → strip control → strip zero-width → collapse whitespace.
290
+ *
291
+ * Note this folds no confusables — it is not a homoglyph defence at any setting. Put
292
+ * {@link normalizeConfusables} in front of it when a model needs both.
293
+ */
294
+ export declare function mlNormalize(text: string, options?: MlNormalizeOptions): string;
201
295
  /** Number of grapheme clusters (user-perceived characters). */
202
296
  export declare function graphemeLen(text: string): number;
203
297
  /** Split `text` into grapheme-cluster strings. */
@@ -214,6 +308,14 @@ export declare function terminalWidth(text: string, options?: {
214
308
  }): number;
215
309
  /** Whether the hostname looks like a mixed-script / confusable IDN spoof (a `false` is not a safety guarantee). */
216
310
  export declare function isSuspiciousHostname(host: string): boolean;
311
+ /**
312
+ * Analyze a hostname for Unicode homoglyph spoofing, returning the full
313
+ * {@link HostnameAnalysis} (verdict + granular signals). `isSuspiciousHostname`
314
+ * is the boolean shorthand for `.suspicious`.
315
+ */
316
+ export declare function analyzeHostname(host: string, options?: {
317
+ contractions?: boolean;
318
+ }): HostnameAnalysis;
217
319
  /** The Unicode scripts present, in first-appearance order (Common/Inherited excluded). */
218
320
  export declare function detectScripts(text: string): string[];
219
321
  /** Whether `text` mixes characters from more than one script. */
@@ -239,6 +341,15 @@ export declare function langInfo(code: string): LangMeta;
239
341
  * unknown name throws {@link DisarmInvalidArgument}.
240
342
  */
241
343
  export declare function scriptInfo(name: string): ScriptMeta;
344
+ /**
345
+ * The Unicode `confusables.txt` release the bundled confusable tables were folded
346
+ * from, e.g. `"17.0.0"`.
347
+ *
348
+ * Not a Unicode version for the library as a whole — disarm's case-folding and width
349
+ * tables track different releases (see docs/provenance.md). Use this to answer "is my
350
+ * confusables fold stale?" without inferring it from behaviour.
351
+ */
352
+ export declare function confusablesVersion(): string;
242
353
  /** Every Unicode script name known to the transliteration tables. */
243
354
  export declare function listScripts(): string[];
244
355
  /** Every language code that has a context-aware transliteration profile. */
package/index.js CHANGED
@@ -39,9 +39,13 @@ exports.reverseTransliterate = reverseTransliterate;
39
39
  exports.findUntranslatable = findUntranslatable;
40
40
  exports.normalizeConfusables = normalizeConfusables;
41
41
  exports.isConfusable = isConfusable;
42
+ exports.unmappedConfusables = unmappedConfusables;
43
+ exports.findUnmappedConfusables = findUnmappedConfusables;
42
44
  exports.slugify = slugify;
43
45
  exports.stripAccents = stripAccents;
44
46
  exports.foldCase = foldCase;
47
+ exports.isCaseFoldStable = isCaseFoldStable;
48
+ exports.findKeyCollisions = findKeyCollisions;
45
49
  exports.demojize = demojize;
46
50
  exports.normalize = normalize;
47
51
  exports.isNormalized = isNormalized;
@@ -63,18 +67,21 @@ exports.sanitizeFilename = sanitizeFilename;
63
67
  exports.searchKey = searchKey;
64
68
  exports.sortKey = sortKey;
65
69
  exports.catalogKey = catalogKey;
70
+ exports.mlNormalize = mlNormalize;
66
71
  exports.graphemeLen = graphemeLen;
67
72
  exports.graphemeSplit = graphemeSplit;
68
73
  exports.graphemeTruncate = graphemeTruncate;
69
74
  exports.graphemeWidth = graphemeWidth;
70
75
  exports.terminalWidth = terminalWidth;
71
76
  exports.isSuspiciousHostname = isSuspiciousHostname;
77
+ exports.analyzeHostname = analyzeHostname;
72
78
  exports.detectScripts = detectScripts;
73
79
  exports.isMixedScript = isMixedScript;
74
80
  exports.hasBidiConflict = hasBidiConflict;
75
81
  exports.inspectAutoLang = inspectAutoLang;
76
82
  exports.langInfo = langInfo;
77
83
  exports.scriptInfo = scriptInfo;
84
+ exports.confusablesVersion = confusablesVersion;
78
85
  exports.listScripts = listScripts;
79
86
  exports.listContextLangs = listContextLangs;
80
87
  exports.hasAnomalies = hasAnomalies;
@@ -153,12 +160,31 @@ function findUntranslatable(text, options = {}) {
153
160
  // ── Confusables (TR39) ──────────────────────────────────────────────────────
154
161
  /** Fold cross-script confusables toward `target` (default `'latin'`). */
155
162
  function normalizeConfusables(text, options = {}) {
156
- return call(() => native.normalizeConfusables(text, options.target ?? 'latin'));
163
+ return call(() => native.normalizeConfusables(text, options.target ?? 'latin', options.digitPolicy ?? 'numeric'));
157
164
  }
158
165
  /** Whether `text` contains a character confusable with `target` (default `'latin'`). */
159
166
  function isConfusable(text, options = {}) {
160
167
  return call(() => native.isConfusable(text, options.target ?? 'latin'));
161
168
  }
169
+ /**
170
+ * Every upstream confusable source the bundled `target` table does not fold.
171
+ *
172
+ * Read as exposure, not as a score — this is where an adaptive attacker goes when the
173
+ * mapped sources stop working. Note it includes five ASCII characters (`%`, `0`, `1`,
174
+ * `I`, `m`): TR39 is a skeleton transform, and disarm deliberately does not apply those
175
+ * rows because folding a legitimate `m` to `rn` corrupts prose.
176
+ */
177
+ function unmappedConfusables(options = {}) {
178
+ return new Set(call(() => native.unmappedConfusables(options.target ?? 'latin')));
179
+ }
180
+ /**
181
+ * Confusable sources in `text` the bundled `target` table does not fold, as
182
+ * `{ char, offset }` (byte offset), in order — the confusables analogue of
183
+ * {@link findUntranslatable}.
184
+ */
185
+ function findUnmappedConfusables(text, options = {}) {
186
+ return call(() => native.findUnmappedConfusables(text, options.target ?? 'latin'));
187
+ }
162
188
  /** Generate a URL-safe slug. Mirrors the core's `SlugConfig` defaults. */
163
189
  function slugify(text, options = {}) {
164
190
  return call(() => native.slugify(text, {
@@ -185,6 +211,38 @@ function stripAccents(text) {
185
211
  function foldCase(text) {
186
212
  return native.foldCase(text);
187
213
  }
214
+ /**
215
+ * Whether `text` is a stable identity key under case folding — that is, whether
216
+ * {@link foldCase} and `String.toLowerCase()` agree on it (#619).
217
+ *
218
+ * `false` means some *other* string folds to the same value, so a table keyed on
219
+ * this one can collide: `'groß.txt'` and `'gross.txt'` are the pair node-tar
220
+ * collided on (CVE-2026-23950). It is a fact about the string and not an
221
+ * accusation — `groß` is an ordinary German word — so it is deliberately not
222
+ * folded into {@link hasAnomalies}.
223
+ */
224
+ function isCaseFoldStable(text) {
225
+ return native.isCaseFoldStable(text);
226
+ }
227
+ /**
228
+ * Which of `values` are the same name under `key` (#620).
229
+ *
230
+ * Every other disarm detector is a single-string predicate, and a collision is not
231
+ * a property of a single string — `groß.txt` is an ordinary German filename, and
232
+ * `аdmin` is only a problem next to `admin`. This is the set-shaped question that
233
+ * node-tar's `PathReservations` guard failed to ask before extracting two paths in
234
+ * parallel (CVE-2026-23950).
235
+ *
236
+ * A stronger `key` finds more collisions, including ones nobody attacked:
237
+ * `search_key` collides `Muller` with `Müller`. That is the cost of the key you
238
+ * chose, not a false positive. A group is reported only when two or more *distinct*
239
+ * inputs share a key; the same name twice is the same name twice.
240
+ *
241
+ * `options.lang` reaches `search_key` and `catalog_key` and is ignored by the rest.
242
+ */
243
+ function findKeyCollisions(values, key, options = {}) {
244
+ return call(() => native.findKeyCollisions(values, key, options.lang));
245
+ }
188
246
  /** Replace emoji with their plain names. `stripModifiers` drops skin-tone/variation marks. */
189
247
  function demojize(text, options = {}) {
190
248
  return native.demojize(text, options.stripModifiers ?? false);
@@ -311,6 +369,16 @@ function sortKey(text, options = {}) {
311
369
  function catalogKey(text, options = {}) {
312
370
  return call(() => native.catalogKey(text, options.lang ?? undefined, options.strictIso9 ?? false));
313
371
  }
372
+ /**
373
+ * ML/NLP normalization: NFKC → emoji→text → transliterate → strip accents →
374
+ * [case fold] → strip control → strip zero-width → collapse whitespace.
375
+ *
376
+ * Note this folds no confusables — it is not a homoglyph defence at any setting. Put
377
+ * {@link normalizeConfusables} in front of it when a model needs both.
378
+ */
379
+ function mlNormalize(text, options = {}) {
380
+ return call(() => native.mlNormalize(text, options.lang ?? undefined, options.emojiStyle ?? 'cldr', options.foldCase ?? true));
381
+ }
314
382
  // ── Grapheme clusters ───────────────────────────────────────────────────────
315
383
  /** Number of grapheme clusters (user-perceived characters). */
316
384
  function graphemeLen(text) {
@@ -337,6 +405,14 @@ function terminalWidth(text, options = {}) {
337
405
  function isSuspiciousHostname(host) {
338
406
  return native.isSuspiciousHostname(host);
339
407
  }
408
+ /**
409
+ * Analyze a hostname for Unicode homoglyph spoofing, returning the full
410
+ * {@link HostnameAnalysis} (verdict + granular signals). `isSuspiciousHostname`
411
+ * is the boolean shorthand for `.suspicious`.
412
+ */
413
+ function analyzeHostname(host, options = {}) {
414
+ return call(() => native.analyzeHostname(host, options.contractions ?? false));
415
+ }
340
416
  /** The Unicode scripts present, in first-appearance order (Common/Inherited excluded). */
341
417
  function detectScripts(text) {
342
418
  return native.detectScripts(text);
@@ -375,6 +451,17 @@ function langInfo(code) {
375
451
  function scriptInfo(name) {
376
452
  return call(() => native.scriptInfo(name));
377
453
  }
454
+ /**
455
+ * The Unicode `confusables.txt` release the bundled confusable tables were folded
456
+ * from, e.g. `"17.0.0"`.
457
+ *
458
+ * Not a Unicode version for the library as a whole — disarm's case-folding and width
459
+ * tables track different releases (see docs/provenance.md). Use this to answer "is my
460
+ * confusables fold stale?" without inferring it from behaviour.
461
+ */
462
+ function confusablesVersion() {
463
+ return native.confusablesVersion();
464
+ }
378
465
  /** Every Unicode script name known to the transliteration tables. */
379
466
  function listScripts() {
380
467
  return native.listScripts();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "disarm",
3
- "version": "0.12.0",
3
+ "version": "0.14.0",
4
4
  "description": "Unicode confusable/text-security building blocks, powered by Rust",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",