euredact 0.3.9 → 0.5.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.
@@ -0,0 +1,239 @@
1
+ /**
2
+ * [CLOUD EXTENSION] The cloud client.
3
+ *
4
+ * Speaks to the euRedact inference service: a deterministic rules engine
5
+ * followed by a fine-tuned model asked only *what did the rules miss?* The
6
+ * service returns both the redacted document and located spans, so this
7
+ * module's job is transport and translation, not detection.
8
+ *
9
+ * Three things it hides from the caller:
10
+ *
11
+ * - **Whether the document finished inside the service's sync window.** A short
12
+ * document comes back on the original request; a long one, or one arriving
13
+ * while a GPU node is cold, returns a job handle and is polled. Callers
14
+ * should never write that branch, so `redact()` resolves either way.
15
+ * - **Retries.** Every request carries an `Idempotency-Key`, so a retry after a
16
+ * timeout cannot produce a second job or a second usage row.
17
+ * - **`Retry-After`.** When the service says how long to wait, that is obeyed
18
+ * rather than second-guessed with a backoff curve.
19
+ *
20
+ * Zero dependencies, like the rest of the package: this uses the platform's
21
+ * own `fetch`.
22
+ */
23
+ import { getConfig, } from "./config.js";
24
+ import { CloudError, NotConfiguredError, QuotaExceededError, TooLargeError, } from "./errors.js";
25
+ import { canonicalType, DetectionSource, EntityType, } from "../types.js";
26
+ const RETRY_STATUS = new Set([429, 500, 502, 503, 504]);
27
+ const MAX_BACKOFF_MS = 30_000;
28
+ function requireFetch(supplied) {
29
+ const impl = supplied ?? globalThis.fetch;
30
+ if (typeof impl !== "function") {
31
+ throw new NotConfiguredError("the cloud tier needs a global fetch. Node 18+ provides one; on Node 16 " +
32
+ "supply one via the fetchImpl option or install a polyfill.");
33
+ }
34
+ return impl;
35
+ }
36
+ function sleep(ms) {
37
+ return new Promise(resolve => setTimeout(resolve, ms));
38
+ }
39
+ function retryAfterMs(headers) {
40
+ const raw = headers?.get("Retry-After");
41
+ if (!raw)
42
+ return null;
43
+ const seconds = Number(raw);
44
+ // The HTTP-date form is legal too; fall back to backoff rather than parse it.
45
+ return Number.isFinite(seconds) ? Math.max(0, seconds * 1000) : null;
46
+ }
47
+ /**
48
+ * Full jitter. A synchronised retry storm from many clients is how a
49
+ * recovering service is knocked back over.
50
+ */
51
+ function backoffMs(attempt, retryAfter) {
52
+ if (retryAfter !== null)
53
+ return Math.min(retryAfter, MAX_BACKOFF_MS);
54
+ return Math.min(MAX_BACKOFF_MS, Math.random() * 2 ** attempt * 1000);
55
+ }
56
+ function uuid() {
57
+ const c = globalThis.crypto;
58
+ if (c && typeof c.randomUUID === "function")
59
+ return c.randomUUID();
60
+ return `idem-${Date.now().toString(16)}-${Math.random().toString(16).slice(2)}`;
61
+ }
62
+ /**
63
+ * Canon name -> EntityType, tolerating one this build has not heard of.
64
+ *
65
+ * An unknown type is passed through as a plain string rather than dropped:
66
+ * `Detection.entityType` is `EntityType | string` precisely so a client one
67
+ * release behind the service does not silently lose a whole category of PII.
68
+ */
69
+ function toEntityType(raw) {
70
+ const canonical = canonicalType(raw);
71
+ const known = Object.values(EntityType).includes(canonical);
72
+ return known ? canonical : canonical;
73
+ }
74
+ /**
75
+ * Map the service's offsets, which count code points, onto this SDK's, which
76
+ * count UTF-16 units.
77
+ *
78
+ * The two agree until the first character outside the BMP — an emoji, say —
79
+ * after which every service offset is short by one unit per such character.
80
+ * Returns the identity when the text has none, which is the common case and
81
+ * costs one regex test.
82
+ */
83
+ function codePointOffsets(text) {
84
+ if (!/[\uD800-\uDBFF]/.test(text))
85
+ return cp => cp;
86
+ const units = [];
87
+ let i = 0;
88
+ for (const ch of text) {
89
+ units.push(i);
90
+ i += ch.length;
91
+ }
92
+ units.push(i);
93
+ return cp => units[Math.min(cp, units.length - 1)];
94
+ }
95
+ function toResult(payload, text) {
96
+ const offset = codePointOffsets(text);
97
+ const detections = (payload.entities ?? []).map(span => ({
98
+ entityType: toEntityType(span.type),
99
+ start: offset(span.start),
100
+ end: offset(span.end),
101
+ text: span.text,
102
+ source: span.source === "model" ? DetectionSource.CLOUD : DetectionSource.RULES,
103
+ country: null,
104
+ confidence: span.confidence ?? "high",
105
+ }));
106
+ detections.sort((a, b) => a.start - b.start || b.end - a.end);
107
+ return {
108
+ redactedText: payload.redacted_text ?? text,
109
+ detections,
110
+ source: "cloud",
111
+ degraded: false,
112
+ inferredCountries: [],
113
+ evidence: [],
114
+ detectionMode: "declared",
115
+ tokens: {},
116
+ exempted: [],
117
+ };
118
+ }
119
+ export class CloudClient {
120
+ config;
121
+ constructor(config) {
122
+ const resolved = config ?? getConfig();
123
+ if (resolved === null)
124
+ throw new NotConfiguredError();
125
+ this.config = resolved;
126
+ }
127
+ headers(idempotencyKey) {
128
+ return {
129
+ Authorization: `Bearer ${this.config.apiKey}`,
130
+ "Content-Type": "application/json",
131
+ // A retry after a timeout must not create a second job or a second usage
132
+ // row. The key is per logical document, not per attempt.
133
+ "Idempotency-Key": idempotencyKey,
134
+ ...this.config.headers,
135
+ };
136
+ }
137
+ raiseFor(status, payload) {
138
+ const message = payload.error ?? `HTTP ${status}`;
139
+ const detail = payload.detail ?? {};
140
+ if (status === 401)
141
+ throw new CloudError(`authentication failed: ${message}`, status);
142
+ if (status === 413)
143
+ throw new TooLargeError(message, status, detail);
144
+ if (status === 429)
145
+ throw new QuotaExceededError(message, status, detail);
146
+ throw new CloudError(message, status, detail);
147
+ }
148
+ async redact(text, options) {
149
+ const doFetch = requireFetch(options.fetchImpl);
150
+ const key = options.idempotencyKey ?? uuid();
151
+ const body = JSON.stringify({
152
+ text,
153
+ country: options.country.toUpperCase(),
154
+ language: options.language ?? "",
155
+ priority: options.priority ?? "interactive",
156
+ rules_only: options.rulesOnly ?? false,
157
+ });
158
+ let attempt = 0;
159
+ for (;;) {
160
+ let response;
161
+ let payload = {};
162
+ try {
163
+ response = await this.withTimeout(doFetch, `${this.config.baseUrl}/v1/redact`, {
164
+ method: "POST",
165
+ headers: this.headers(key),
166
+ body,
167
+ });
168
+ payload = await readJson(response);
169
+ }
170
+ catch (e) {
171
+ if (attempt >= this.config.maxRetries) {
172
+ throw new CloudError(`cloud request failed: ${e instanceof Error ? e.message : String(e)}`);
173
+ }
174
+ }
175
+ if (response) {
176
+ if (response.status === 202) {
177
+ // Outlived the sync window. Location is where the answer will appear;
178
+ // polling it is this client's job, not the caller's.
179
+ const location = response.headers.get("Location") ??
180
+ payload.location ??
181
+ `/v1/jobs/${payload.job_id ?? ""}`;
182
+ return toResult(await this.poll(doFetch, location, key), text);
183
+ }
184
+ if (response.status === 200)
185
+ return toResult(payload, text);
186
+ if (!RETRY_STATUS.has(response.status) || attempt >= this.config.maxRetries) {
187
+ this.raiseFor(response.status, payload);
188
+ }
189
+ }
190
+ await sleep(backoffMs(attempt, retryAfterMs(response?.headers)));
191
+ attempt++;
192
+ }
193
+ }
194
+ async poll(doFetch, location, key) {
195
+ const url = location.startsWith("http")
196
+ ? location
197
+ : `${this.config.baseUrl}${location}`;
198
+ const deadline = Date.now() + this.config.pollTimeoutMs;
199
+ let delay = 500;
200
+ for (;;) {
201
+ const response = await this.withTimeout(doFetch, url, {
202
+ method: "GET",
203
+ headers: this.headers(key),
204
+ });
205
+ const payload = await readJson(response);
206
+ if (response.status === 200 && payload.redacted_text !== undefined) {
207
+ return payload;
208
+ }
209
+ if (response.status !== 200 && !RETRY_STATUS.has(response.status)) {
210
+ this.raiseFor(response.status, payload);
211
+ }
212
+ if (Date.now() >= deadline) {
213
+ throw new CloudError(`document did not complete within ${Math.round(this.config.pollTimeoutMs / 1000)}s (${url})`);
214
+ }
215
+ const after = retryAfterMs(response.headers);
216
+ await sleep(after ?? delay);
217
+ delay = Math.min(delay * 1.5, 5000);
218
+ }
219
+ }
220
+ async withTimeout(doFetch, url, init) {
221
+ const controller = new AbortController();
222
+ const timer = setTimeout(() => controller.abort(), this.config.timeoutMs);
223
+ try {
224
+ return await doFetch(url, { ...init, signal: controller.signal });
225
+ }
226
+ finally {
227
+ clearTimeout(timer);
228
+ }
229
+ }
230
+ }
231
+ async function readJson(response) {
232
+ try {
233
+ const data = await response.json();
234
+ return data && typeof data === "object" ? data : {};
235
+ }
236
+ catch {
237
+ return {};
238
+ }
239
+ }
@@ -0,0 +1,43 @@
1
+ /** [CLOUD EXTENSION] Cloud tier configuration. */
2
+ export declare const DEFAULT_BASE_URL = "https://api.euredact.dev";
3
+ /**
4
+ * How long to wait for a single document. The service holds a connection open
5
+ * for its sync window and then hands back a job handle, so this has to exceed
6
+ * that window or the client abandons requests the service is about to answer.
7
+ */
8
+ export declare const DEFAULT_TIMEOUT_MS = 90000;
9
+ /**
10
+ * Total wall-clock budget for a document that outlives the sync window and has
11
+ * to be polled. Cold-starting a GPU node and loading an ~18 GB checkpoint takes
12
+ * minutes, and an async job absorbs that invisibly — but not forever.
13
+ */
14
+ export declare const DEFAULT_POLL_TIMEOUT_MS = 900000;
15
+ export declare const DEFAULT_MAX_RETRIES = 3;
16
+ export interface CloudConfig {
17
+ apiKey: string;
18
+ baseUrl: string;
19
+ timeoutMs: number;
20
+ pollTimeoutMs: number;
21
+ maxRetries: number;
22
+ /** Extra headers, for a proxy or a self-hosted deployment. */
23
+ headers: Record<string, string>;
24
+ }
25
+ export interface ConfigureOptions {
26
+ apiKey?: string;
27
+ baseUrl?: string;
28
+ timeoutMs?: number;
29
+ pollTimeoutMs?: number;
30
+ maxRetries?: number;
31
+ headers?: Record<string, string>;
32
+ }
33
+ /**
34
+ * Configure the cloud tier.
35
+ *
36
+ * Falls back to `EUREDACT_API_KEY` and `EUREDACT_BASE_URL` so a key never has
37
+ * to be written into source to get started.
38
+ */
39
+ export declare function configure(options?: ConfigureOptions): CloudConfig;
40
+ /** The active configuration, or null. Never throws. */
41
+ export declare function getConfig(): CloudConfig | null;
42
+ /** Forget the configuration. Mainly for tests. */
43
+ export declare function reset(): void;
@@ -0,0 +1,51 @@
1
+ /** [CLOUD EXTENSION] Cloud tier configuration. */
2
+ export const DEFAULT_BASE_URL = "https://api.euredact.dev";
3
+ /**
4
+ * How long to wait for a single document. The service holds a connection open
5
+ * for its sync window and then hands back a job handle, so this has to exceed
6
+ * that window or the client abandons requests the service is about to answer.
7
+ */
8
+ export const DEFAULT_TIMEOUT_MS = 90_000;
9
+ /**
10
+ * Total wall-clock budget for a document that outlives the sync window and has
11
+ * to be polled. Cold-starting a GPU node and loading an ~18 GB checkpoint takes
12
+ * minutes, and an async job absorbs that invisibly — but not forever.
13
+ */
14
+ export const DEFAULT_POLL_TIMEOUT_MS = 900_000;
15
+ export const DEFAULT_MAX_RETRIES = 3;
16
+ let config = null;
17
+ /** Read an env var where there is an environment to read. Browsers have none. */
18
+ function env(name) {
19
+ const proc = globalThis.process;
20
+ return proc?.env?.[name];
21
+ }
22
+ /**
23
+ * Configure the cloud tier.
24
+ *
25
+ * Falls back to `EUREDACT_API_KEY` and `EUREDACT_BASE_URL` so a key never has
26
+ * to be written into source to get started.
27
+ */
28
+ export function configure(options = {}) {
29
+ const apiKey = options.apiKey ?? env("EUREDACT_API_KEY") ?? "";
30
+ if (!apiKey) {
31
+ throw new Error("no API key: pass configure({ apiKey }) or set EUREDACT_API_KEY");
32
+ }
33
+ config = {
34
+ apiKey,
35
+ baseUrl: (options.baseUrl ?? env("EUREDACT_BASE_URL") ?? DEFAULT_BASE_URL)
36
+ .replace(/\/+$/, ""),
37
+ timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
38
+ pollTimeoutMs: options.pollTimeoutMs ?? DEFAULT_POLL_TIMEOUT_MS,
39
+ maxRetries: options.maxRetries ?? DEFAULT_MAX_RETRIES,
40
+ headers: { ...(options.headers ?? {}) },
41
+ };
42
+ return config;
43
+ }
44
+ /** The active configuration, or null. Never throws. */
45
+ export function getConfig() {
46
+ return config;
47
+ }
48
+ /** Forget the configuration. Mainly for tests. */
49
+ export function reset() {
50
+ config = null;
51
+ }
@@ -0,0 +1,32 @@
1
+ /** [CLOUD EXTENSION] Errors the cloud tier can raise. */
2
+ export declare class CloudError extends Error {
3
+ readonly status?: number;
4
+ readonly detail: Record<string, unknown>;
5
+ constructor(message: string, status?: number, detail?: Record<string, unknown>);
6
+ }
7
+ /**
8
+ * Raised when the cloud tier is used without configuration.
9
+ *
10
+ * Deliberately an error rather than a quiet fallback to rules-only output.
11
+ * `mode: "cloud"` that silently returns rules-only results is the worst failure
12
+ * this library can have: the caller believes names, employers and diagnoses
13
+ * were checked, sees a plausible redacted document, and ships it with the PII
14
+ * still in it.
15
+ */
16
+ export declare class NotConfiguredError extends CloudError {
17
+ constructor(message?: string);
18
+ }
19
+ /** 429 after retries were exhausted. */
20
+ export declare class QuotaExceededError extends CloudError {
21
+ constructor(message: string, status?: number, detail?: Record<string, unknown>);
22
+ }
23
+ /**
24
+ * 413. The document is over the service's input cap.
25
+ *
26
+ * Permanent and not retryable. The model has never seen a chunk boundary, so
27
+ * the service refuses oversized input rather than splitting it and changing the
28
+ * accuracy story silently.
29
+ */
30
+ export declare class TooLargeError extends CloudError {
31
+ constructor(message: string, status?: number, detail?: Record<string, unknown>);
32
+ }
@@ -0,0 +1,48 @@
1
+ /** [CLOUD EXTENSION] Errors the cloud tier can raise. */
2
+ export class CloudError extends Error {
3
+ status;
4
+ detail;
5
+ constructor(message, status, detail) {
6
+ super(message);
7
+ this.name = "CloudError";
8
+ this.status = status;
9
+ this.detail = detail ?? {};
10
+ }
11
+ }
12
+ /**
13
+ * Raised when the cloud tier is used without configuration.
14
+ *
15
+ * Deliberately an error rather than a quiet fallback to rules-only output.
16
+ * `mode: "cloud"` that silently returns rules-only results is the worst failure
17
+ * this library can have: the caller believes names, employers and diagnoses
18
+ * were checked, sees a plausible redacted document, and ships it with the PII
19
+ * still in it.
20
+ */
21
+ export class NotConfiguredError extends CloudError {
22
+ constructor(message) {
23
+ super(message ??
24
+ "Cloud tier not configured. Call euredact.configure({ apiKey }) first, " +
25
+ "or set EUREDACT_API_KEY.");
26
+ this.name = "NotConfiguredError";
27
+ }
28
+ }
29
+ /** 429 after retries were exhausted. */
30
+ export class QuotaExceededError extends CloudError {
31
+ constructor(message, status, detail) {
32
+ super(message, status, detail);
33
+ this.name = "QuotaExceededError";
34
+ }
35
+ }
36
+ /**
37
+ * 413. The document is over the service's input cap.
38
+ *
39
+ * Permanent and not retryable. The model has never seen a chunk boundary, so
40
+ * the service refuses oversized input rather than splitting it and changing the
41
+ * accuracy story silently.
42
+ */
43
+ export class TooLargeError extends CloudError {
44
+ constructor(message, status, detail) {
45
+ super(message, status, detail);
46
+ this.name = "TooLargeError";
47
+ }
48
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * [CLOUD EXTENSION] Cloud tier — rules engine plus a fine-tuned model.
3
+ *
4
+ * The rule engine catches what has a shape: IBANs, national IDs, phone
5
+ * numbers, anything with a checksum. It cannot catch what does not — a
6
+ * person's name, an employer, a diagnosis, a job title. Those are what the
7
+ * cloud tier adds, and the `EntityType` members marked `[CLOUD EXTENSION]` are
8
+ * exactly that gap.
9
+ *
10
+ * ```ts
11
+ * import { configure, redactAsync } from "euredact";
12
+ *
13
+ * configure({ apiKey: "erk_..." });
14
+ * const result = await redactAsync(text, { countries: ["BE"], mode: "cloud" });
15
+ * ```
16
+ */
17
+ export { CloudClient, type CloudRedactOptions } from "./client.js";
18
+ export { configure, getConfig, reset, DEFAULT_BASE_URL, type CloudConfig, type ConfigureOptions, } from "./config.js";
19
+ export { CloudError, NotConfiguredError, QuotaExceededError, TooLargeError, } from "./errors.js";
@@ -0,0 +1,19 @@
1
+ /**
2
+ * [CLOUD EXTENSION] Cloud tier — rules engine plus a fine-tuned model.
3
+ *
4
+ * The rule engine catches what has a shape: IBANs, national IDs, phone
5
+ * numbers, anything with a checksum. It cannot catch what does not — a
6
+ * person's name, an employer, a diagnosis, a job title. Those are what the
7
+ * cloud tier adds, and the `EntityType` members marked `[CLOUD EXTENSION]` are
8
+ * exactly that gap.
9
+ *
10
+ * ```ts
11
+ * import { configure, redactAsync } from "euredact";
12
+ *
13
+ * configure({ apiKey: "erk_..." });
14
+ * const result = await redactAsync(text, { countries: ["BE"], mode: "cloud" });
15
+ * ```
16
+ */
17
+ export { CloudClient } from "./client.js";
18
+ export { configure, getConfig, reset, DEFAULT_BASE_URL, } from "./config.js";
19
+ export { CloudError, NotConfiguredError, QuotaExceededError, TooLargeError, } from "./errors.js";
@@ -1,13 +1,21 @@
1
- export { EntityType, DetectionSource, LEGACY_TYPE_ALIASES } from "./types.js";
2
- export type { Detection, RedactResult, PatternDef, CountryConfig, CountryEvidence } from "./types.js";
1
+ export { EntityType, DetectionSource, LEGACY_TYPE_ALIASES, canonicalType } from "./types.js";
2
+ export type { Detection, Exemption, RedactResult, PatternDef, CountryConfig, CountryEvidence } from "./types.js";
3
3
  export { DocumentContext } from "./rules/context.js";
4
- export { EuRedact, type RedactOptions } from "./sdk.js";
4
+ export { EuRedact, restore, type RedactOptions } from "./sdk.js";
5
5
  export { COUNTRY_CONFIGS } from "./rules/countries/index.js";
6
6
  export { setBicRegistry, getBicRegistry, SEED_BIC6_PREFIXES, type BicRegistryProvider } from "./rules/bicRegistry.js";
7
+ export { configure, getConfig, CloudClient, CloudError, NotConfiguredError, QuotaExceededError, TooLargeError, type CloudConfig, type ConfigureOptions, type CloudRedactOptions, } from "./cloud/index.js";
7
8
  import { type RedactOptions } from "./sdk.js";
8
9
  import type { RedactResult } from "./types.js";
9
10
  export declare function availableCountries(): string[];
10
11
  export declare function redact(text: string, options?: RedactOptions): RedactResult;
12
+ /**
13
+ * Redact, awaiting the cloud tier when `mode: "cloud"` is asked for.
14
+ *
15
+ * `mode: "rules"` resolves immediately with exactly what `redact()` returns,
16
+ * so a caller that may or may not use the cloud tier can hold one code path.
17
+ */
18
+ export declare function redactAsync(text: string, options?: RedactOptions): Promise<RedactResult>;
11
19
  export declare function addCustomPattern(name: string, pattern: string): void;
12
20
  export declare function clear(): void;
13
21
  export declare function redactBatch(texts: string[], options?: RedactOptions): RedactResult[];
package/dist/esm/index.js CHANGED
@@ -1,8 +1,9 @@
1
- export { EntityType, DetectionSource, LEGACY_TYPE_ALIASES } from "./types.js";
1
+ export { EntityType, DetectionSource, LEGACY_TYPE_ALIASES, canonicalType } from "./types.js";
2
2
  export { DocumentContext } from "./rules/context.js";
3
- export { EuRedact } from "./sdk.js";
3
+ export { EuRedact, restore } from "./sdk.js";
4
4
  export { COUNTRY_CONFIGS } from "./rules/countries/index.js";
5
5
  export { setBicRegistry, getBicRegistry, SEED_BIC6_PREFIXES } from "./rules/bicRegistry.js";
6
+ export { configure, getConfig, CloudClient, CloudError, NotConfiguredError, QuotaExceededError, TooLargeError, } from "./cloud/index.js";
6
7
  import { EuRedact } from "./sdk.js";
7
8
  import { COUNTRY_CONFIGS } from "./rules/countries/index.js";
8
9
  let _instance = null;
@@ -17,6 +18,15 @@ export function availableCountries() {
17
18
  export function redact(text, options) {
18
19
  return getInstance().redact(text, options);
19
20
  }
21
+ /**
22
+ * Redact, awaiting the cloud tier when `mode: "cloud"` is asked for.
23
+ *
24
+ * `mode: "rules"` resolves immediately with exactly what `redact()` returns,
25
+ * so a caller that may or may not use the cloud tier can hold one code path.
26
+ */
27
+ export function redactAsync(text, options) {
28
+ return getInstance().redactAsync(text, options);
29
+ }
20
30
  export function addCustomPattern(name, pattern) {
21
31
  getInstance().addCustomPattern(name, pattern);
22
32
  }
@@ -96,7 +96,14 @@ const SHARED = {
96
96
  // is Unicode-aware) matched local parts like "petras_ž@..." while this one
97
97
  // did not. \p{L}\p{N} with the `u` flag restores parity; the leading guard
98
98
  // is a Unicode-safe word boundary.
99
- p(EntityType.EMAIL, String.raw `(?<![\p{L}\p{N}._%+\-])[\p{L}\p{N}._%+\-]+@(?:[\p{L}\p{N}\-]+\.)+\p{L}{2,}\b`),
99
+ // The apostrophe is allowed only between word characters: it belongs in a
100
+ // local part, but putting it in the class would let the match absorb a
101
+ // quote from the surrounding text, while leaving it out made
102
+ // `johno'neill@outlook.ie` mask as `johno'[EMAIL]` (issue rules-engine#10).
103
+ // The second lookbehind refuses to start a match after `<word-char>'`,
104
+ // which is a match already in progress; without it every apostrophe was a
105
+ // fresh start offset and the scan went quadratic. Mirrors Python.
106
+ p(EntityType.EMAIL, String.raw `(?<![\p{L}\p{N}._%+\-])(?<![\p{L}\p{N}._%+\-]')[\p{L}\p{N}._%+\-]+(?:'[\p{L}\p{N}._%+\-]+)*@(?:[\p{L}\p{N}\-]+\.)+\p{L}{2,}\b`),
100
107
  // IBAN (any country) — structure + mod-97 only. Country-specific IBAN
101
108
  // patterns still exist and win on identical spans; this one guarantees an
102
109
  // IBAN is never missed just because its country was not requested.
@@ -106,7 +113,11 @@ const SHARED = {
106
113
  // "+43 664 8213 907" was invisible to the Austrian pattern, which expects
107
114
  // an unbroken subscriber number. A leading "+" is self-identifying, so
108
115
  // this is deliberately not country-gated.
109
- p(EntityType.PHONE, String.raw `\+\d{1,3}(?:[\s\-/]?\(?\d{1,4}\)?){2,7}`, "e164", "International phone number (E.164) — any country"),
116
+ // Parentheses are matched as a pair inside one group: `\(?...\)?` let the
117
+ // closing parenthesis of the surrounding text join the number, so
118
+ // "Call (+32 475 12 34 56) today." also masked the ")" (issue
119
+ // rules-engine#3). Mirrors the Python pattern character for character.
120
+ p(EntityType.PHONE, String.raw `\+\d{1,3}(?:[\s\-/]?(?:\(\d{1,4}\)|\d{1,4})){2,7}`, "e164", "International phone number (E.164) — any country"),
110
121
  p(EntityType.BIC, String.raw `\b[A-Z]{4}[A-Z]{2}[A-Z0-9]{2}(?:[A-Z0-9]{3})?\b`, "bic"),
111
122
  // Space-separated form: "RZBA AT WW", "NICA BE BB". Written as a separate
112
123
  // pattern requiring a space between *every* group, rather than making the
@@ -117,7 +128,15 @@ const SHARED = {
117
128
  p(EntityType.CREDIT_CARD, String.raw `\b(?:4[0-9]{3}[\s\-]?[0-9]{4}[\s\-]?[0-9]{4}[\s\-]?[0-9]{4}|5[1-5][0-9]{2}[\s\-]?[0-9]{4}[\s\-]?[0-9]{4}[\s\-]?[0-9]{4}|3[47][0-9]{2}[\s\-]?[0-9]{6}[\s\-]?[0-9]{5})\b`, "luhn"),
118
129
  p(EntityType.VIN, String.raw `\b[A-HJ-NPR-Z0-9]{17}\b`, "vin"),
119
130
  p(EntityType.IP_ADDRESS, String.raw `\b(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b`),
120
- p(EntityType.IPV6_ADDRESS, String.raw `\b(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}\b|\b(?:[0-9a-fA-F]{1,4}:){1,7}:\b|\b(?:[0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}\b|\b::(?:[0-9a-fA-F]{1,4}:){0,5}[0-9a-fA-F]{1,4}\b`),
131
+ // Branch order is load-bearing: alternation is leftmost-first, not
132
+ // longest-match, so the form that consumes most is tried first, and the
133
+ // IPv4-embedded branches lead because every group of a dotted quad is
134
+ // also valid hex. `\b` is the wrong anchor around ":" (a non-word
135
+ // character), which is why `::1` and `2001:db8::` were not detected at
136
+ // all. A bare "::" is deliberately not matched. Mirrors the Python
137
+ // pattern character for character -- see the comment there
138
+ // (issue rules-engine#5).
139
+ p(EntityType.IPV6_ADDRESS, String.raw `(?<![0-9A-Za-z_:.])(?:(?:[0-9A-Fa-f]{1,4}:){6}(?:(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])|(?:[0-9A-Fa-f]{1,4}:){1,5}:(?:[0-9A-Fa-f]{1,4}:)*(?:(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])|::(?:[0-9A-Fa-f]{1,4}:)*(?:(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])|(?:[0-9A-Fa-f]{1,4}:){7}[0-9A-Fa-f]{1,4}|(?:[0-9A-Fa-f]{1,4}:){1,7}(?::[0-9A-Fa-f]{1,4}){1,7}|(?:[0-9A-Fa-f]{1,4}:){1,7}:|:(?::[0-9A-Fa-f]{1,4}){1,7})(?:%[0-9A-Za-z][0-9A-Za-z._-]*)?(?![0-9A-Za-z_:])`),
121
140
  p(EntityType.MAC_ADDRESS, String.raw `\b(?:[0-9a-fA-F]{2}[:\-]){5}[0-9a-fA-F]{2}\b`, null, "colon/dash"),
122
141
  p(EntityType.MAC_ADDRESS, String.raw `\b[0-9a-fA-F]{4}\.[0-9a-fA-F]{4}\.[0-9a-fA-F]{4}\b`, null, "Cisco"),
123
142
  p(EntityType.IMEI, String.raw `\b\d{15}\b`, "imei"),
@@ -221,12 +240,12 @@ const DE = {
221
240
  patterns: [
222
241
  p(EntityType.TAX_ID, String.raw `\b[1-9]\d{10}\b`, "german_tax_id"),
223
242
  p(EntityType.TAX_ID, String.raw `\b[1-9]\d[\s.]?\d{3}[\s.]?\d{3}[\s.]?\d{3}\b`, "german_tax_id"),
224
- p(EntityType.NATIONAL_ID, String.raw `\b[CFGHJKLMNPRTVWXYZ][0-9A-Z]{8,9}\b`, null, "", ["Personalausweis", "Personalausweisnummer", "Ausweis", "Ausweisnummer", "Ausweis-Nr", "identity card", "Identitätskarte", "Perso", "PA-Nummer"], true),
243
+ p(EntityType.NATIONAL_ID, String.raw `\b[CFGHJKLMNPRTVWXYZ][0-9CFGHJKLMNPRTVWXYZ]{8}\d?\b`, null, "", ["Personalausweis", "Personalausweisnummer", "Ausweis", "Ausweisnummer", "Ausweis-Nr", "identity card", "Identitätskarte", "Perso", "PA-Nummer"], true),
225
244
  p(EntityType.IBAN, String.raw `\bDE\d{2}\s?\d{4}\s?\d{4}\s?\d{4}\s?\d{4}\s?\d{2}\b`, "iban"),
226
245
  p(EntityType.VAT, String.raw `\bDE\s?\d{9}\b`, "vat_de"),
227
246
  p(EntityType.PHONE, String.raw `\b0[1-9]\d{1,4}[\s/\-]?\d{3,8}\b`),
228
247
  p(EntityType.PHONE, String.raw `\+49\s?\d{2,5}[\s/\-]?\d{3,8}\b`),
229
- p(EntityType.PASSPORT, String.raw `\b[CFGHJK][0-9A-Z]{8,9}\b`, null, "", ["Reisepass", "passport", "Passnummer", "Reisepassnummer", "Reisepass Nummer", "Reisepass-Nr", "Pass Nr"], true),
248
+ p(EntityType.PASSPORT, String.raw `\b[CFGHJK][0-9CFGHJKLMNPRTVWXYZ]{8}\d?\b`, null, "", ["Reisepass", "passport", "Passnummer", "Reisepassnummer", "Reisepass Nummer", "Reisepass-Nr", "Pass Nr"], true),
230
249
  // The separator after the city code is mandatory. When it was optional, a
231
250
  // contiguous letter run split across both groups and the hyphen was
232
251
  // consumed as the *second* separator, so any "LETTERS-DIGITS" token