@studio-foundation/anonymizer 0.3.0-beta.1 → 0.3.0-beta.6
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 +4 -1
- package/ARCHITECTURE.md +0 -40
- package/src/detector.ts +0 -97
- package/src/index.ts +0 -52
- package/src/tokenizer.ts +0 -63
- package/src/types.ts +0 -24
- package/tests/anonymizer.test.ts +0 -88
- package/tests/detector.test.ts +0 -47
- package/tests/tokenizer.test.ts +0 -55
- package/tsconfig.json +0 -20
package/package.json
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@studio-foundation/anonymizer",
|
|
3
|
-
"version": "0.3.0-beta.
|
|
3
|
+
"version": "0.3.0-beta.6",
|
|
4
4
|
"description": "PII detection and anonymization with consistent token mapping",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
7
|
+
"files": [
|
|
8
|
+
"dist"
|
|
9
|
+
],
|
|
7
10
|
"keywords": [
|
|
8
11
|
"pii",
|
|
9
12
|
"anonymization",
|
package/ARCHITECTURE.md
DELETED
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
# @studio-foundation/anonymizer
|
|
2
|
-
|
|
3
|
-
Bibliothèque de détection et anonymisation de PII. Remplace les données sensibles par des tokens avant envoi au LLM, avec keymap pour restaurer les valeurs originales.
|
|
4
|
-
|
|
5
|
-
## Concept
|
|
6
|
-
|
|
7
|
-
`anonymize(text)` → `{ text: "Hi [PERSON_1]...", keymap: { "PERSON_1": "Marie" } }`
|
|
8
|
-
`deanonymize(text, keymap)` → texte original restauré.
|
|
9
|
-
|
|
10
|
-
Même valeur → même token, au sein d'un appel et entre appels si `seedKeymap` fourni.
|
|
11
|
-
|
|
12
|
-
## Règles
|
|
13
|
-
|
|
14
|
-
- **ZERO dépendance `@studio/*`** — bibliothèque pure, réutilisable hors de Studio
|
|
15
|
-
- Stateless : `Tokenizer` créé à chaque appel (ou seedé via `seedKeymap`)
|
|
16
|
-
- Détection en 2 phases : regex haute précision (email, phone, SSN, credit card) + noms via patterns de salutation (best-effort)
|
|
17
|
-
- La détection de personnes est non-fatale — silencieusement skippée sur erreur
|
|
18
|
-
- Les spans sont non-overlapping : premier match gagne, position marquée occupée
|
|
19
|
-
|
|
20
|
-
## Fichiers clés
|
|
21
|
-
|
|
22
|
-
- `index.ts` — `anonymize(text, options?)`, `deanonymize(text, keymap)`, exports publics
|
|
23
|
-
- `detector.ts` — `detectPII(text)` : détection regex + noms par salutation
|
|
24
|
-
- `tokenizer.ts` — `Tokenizer` : génération et résolution de tokens séquentiels
|
|
25
|
-
- `types.ts` — `PIICategory`, `PIISpan`, `PIIDetectionResult`, `AnonymizerOptions`
|
|
26
|
-
|
|
27
|
-
## Catégories PII
|
|
28
|
-
|
|
29
|
-
`person` → `PERSON_N`, `email` → `EMAIL_N`, `phone` → `PHONE_N`,
|
|
30
|
-
`ssn` → `SSN_N`, `credit_card` → `CREDIT_CARD_N`, `address` → `ADDRESS_N` (réservé)
|
|
31
|
-
|
|
32
|
-
## Intégration dans Studio
|
|
33
|
-
|
|
34
|
-
Utilisé par `runner/src/middleware/anonymization.ts` (`AnonymizationMiddleware`).
|
|
35
|
-
Activé via `--anonymize` sur `studio run` ou `anonymize: true` dans l'agent YAML.
|
|
36
|
-
Keymap persisté dans `.studio/runs/anonymization/<run-id>.keymap.json`.
|
|
37
|
-
|
|
38
|
-
## Dépendances
|
|
39
|
-
|
|
40
|
-
Aucune `@studio/*`. Bibliothèque autonome.
|
package/src/detector.ts
DELETED
|
@@ -1,97 +0,0 @@
|
|
|
1
|
-
import type { PIICategory, PIISpan } from './types.js';
|
|
2
|
-
|
|
3
|
-
// Regex patterns for structural PII (high precision)
|
|
4
|
-
const PATTERNS: Array<{ category: PIICategory; regex: RegExp }> = [
|
|
5
|
-
{
|
|
6
|
-
category: 'email',
|
|
7
|
-
regex: /\b[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\b/g,
|
|
8
|
-
},
|
|
9
|
-
{
|
|
10
|
-
category: 'phone',
|
|
11
|
-
// US phone: must be at least 10 digits. Require optional country code + area code.
|
|
12
|
-
// Anchored to avoid matching partial SSN-like strings.
|
|
13
|
-
regex: /\b(?:\+?1[-.\s]?)?\(?\d{3}\)?[-.\s]\d{3}[-.\s]\d{4}\b/g,
|
|
14
|
-
},
|
|
15
|
-
{
|
|
16
|
-
category: 'ssn',
|
|
17
|
-
// SSN: exactly ddd-dd-dddd with hyphens (strict format, not dots or spaces
|
|
18
|
-
// to avoid collision with phone numbers already consumed)
|
|
19
|
-
regex: /\b\d{3}-\d{2}-\d{4}\b/g,
|
|
20
|
-
},
|
|
21
|
-
{
|
|
22
|
-
category: 'credit_card',
|
|
23
|
-
regex: /\b(?:\d{4}[-\s]?){3}\d{4}\b/g,
|
|
24
|
-
},
|
|
25
|
-
];
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* Detect PII spans in text.
|
|
29
|
-
* Returns non-overlapping spans sorted by position.
|
|
30
|
-
*/
|
|
31
|
-
export function detectPII(text: string): PIISpan[] {
|
|
32
|
-
const spans: PIISpan[] = [];
|
|
33
|
-
const occupied = new Set<number>();
|
|
34
|
-
|
|
35
|
-
// Phase 1: Structural PII via regex
|
|
36
|
-
for (const { category, regex } of PATTERNS) {
|
|
37
|
-
regex.lastIndex = 0;
|
|
38
|
-
let match: RegExpExecArray | null;
|
|
39
|
-
while ((match = regex.exec(text)) !== null) {
|
|
40
|
-
const start = match.index;
|
|
41
|
-
const end = start + match[0].length;
|
|
42
|
-
if (isOccupied(occupied, start, end)) continue;
|
|
43
|
-
markOccupied(occupied, start, end);
|
|
44
|
-
spans.push({ start, end, category, value: match[0] });
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
// Phase 2: Person names — best-effort via @redactpii/node
|
|
49
|
-
try {
|
|
50
|
-
detectPersons(text, occupied, spans);
|
|
51
|
-
} catch {
|
|
52
|
-
// Person detection is best-effort; silently skip on any error
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
return spans.sort((a, b) => a.start - b.start);
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
function isOccupied(occupied: Set<number>, start: number, end: number): boolean {
|
|
59
|
-
for (let i = start; i < end; i++) {
|
|
60
|
-
if (occupied.has(i)) return true;
|
|
61
|
-
}
|
|
62
|
-
return false;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function markOccupied(occupied: Set<number>, start: number, end: number): void {
|
|
66
|
-
for (let i = start; i < end; i++) {
|
|
67
|
-
occupied.add(i);
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
function detectPersons(text: string, occupied: Set<number>, spans: PIISpan[]): void {
|
|
72
|
-
// The @redactpii/node NAME pattern only catches names after salutations
|
|
73
|
-
// (Dear, Hi, Hello, Hey, etc.) — not bare names. We replicate that pattern
|
|
74
|
-
// here and use it directly, avoiding the ESM/CJS import complexity.
|
|
75
|
-
// This gives best-effort detection for explicitly addressed names.
|
|
76
|
-
const salutationPattern =
|
|
77
|
-
/(?:dear|hi|hello|greetings|hey(?:\s+there)?|mr\.?|mrs\.?|ms\.?|dr\.?|prof\.?)\s+([A-Z][a-zA-ZÀ-ÿ'\-]+(?:\s+[A-Z][a-zA-ZÀ-ÿ'\-]+)*)/gi;
|
|
78
|
-
|
|
79
|
-
let match: RegExpExecArray | null;
|
|
80
|
-
salutationPattern.lastIndex = 0;
|
|
81
|
-
|
|
82
|
-
while ((match = salutationPattern.exec(text)) !== null) {
|
|
83
|
-
// match[1] is the captured name group
|
|
84
|
-
const nameValue = match[1];
|
|
85
|
-
if (!nameValue) continue;
|
|
86
|
-
|
|
87
|
-
// Find the actual start position of the captured name within the full match
|
|
88
|
-
const fullMatchStart = match.index;
|
|
89
|
-
const nameOffset = match[0].indexOf(nameValue);
|
|
90
|
-
const start = fullMatchStart + nameOffset;
|
|
91
|
-
const end = start + nameValue.length;
|
|
92
|
-
|
|
93
|
-
if (isOccupied(occupied, start, end)) continue;
|
|
94
|
-
markOccupied(occupied, start, end);
|
|
95
|
-
spans.push({ start, end, category: 'person', value: nameValue });
|
|
96
|
-
}
|
|
97
|
-
}
|
package/src/index.ts
DELETED
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
import { detectPII } from './detector.js';
|
|
2
|
-
import { Tokenizer } from './tokenizer.js';
|
|
3
|
-
import type { PIIDetectionResult, AnonymizerOptions } from './types.js';
|
|
4
|
-
|
|
5
|
-
export type { PIICategory, PIIDetectionResult, AnonymizerOptions } from './types.js';
|
|
6
|
-
export { Tokenizer } from './tokenizer.js';
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Anonymize PII in text. Returns anonymized text + keymap (token → original).
|
|
10
|
-
* Same PII value always gets the same token within a call.
|
|
11
|
-
* Pass seedKeymap to maintain consistency across multiple calls.
|
|
12
|
-
*/
|
|
13
|
-
export function anonymize(text: string, options?: AnonymizerOptions): PIIDetectionResult {
|
|
14
|
-
const spans = detectPII(text);
|
|
15
|
-
const filtered = options?.categories
|
|
16
|
-
? spans.filter(s => options.categories!.includes(s.category))
|
|
17
|
-
: spans;
|
|
18
|
-
|
|
19
|
-
const tokenizer = new Tokenizer();
|
|
20
|
-
// Seed from existing keymap for cross-call consistency
|
|
21
|
-
if (options?.seedKeymap && Object.keys(options.seedKeymap).length > 0) {
|
|
22
|
-
tokenizer.loadKeymap(options.seedKeymap);
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
if (filtered.length === 0) {
|
|
26
|
-
return { text, keymap: tokenizer.getKeymap() };
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
// Replace spans from right to left to preserve character positions
|
|
30
|
-
const sortedDesc = [...filtered].sort((a, b) => b.start - a.start);
|
|
31
|
-
let result = text;
|
|
32
|
-
for (const span of sortedDesc) {
|
|
33
|
-
const token = tokenizer.tokenize(span.value, span.category);
|
|
34
|
-
result = result.slice(0, span.start) + token + result.slice(span.end);
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
return { text: result, keymap: tokenizer.getKeymap() };
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* Restore original PII values from keymap.
|
|
42
|
-
* Tokens not in the keymap are left unchanged.
|
|
43
|
-
*/
|
|
44
|
-
export function deanonymize(text: string, keymap: Record<string, string>): string {
|
|
45
|
-
let result = text;
|
|
46
|
-
// Sort by token length descending so EMAIL_10 is replaced before EMAIL_1
|
|
47
|
-
const sorted = Object.entries(keymap).sort((a, b) => b[0].length - a[0].length);
|
|
48
|
-
for (const [token, original] of sorted) {
|
|
49
|
-
result = result.replaceAll(token, original);
|
|
50
|
-
}
|
|
51
|
-
return result;
|
|
52
|
-
}
|
package/src/tokenizer.ts
DELETED
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
import type { PIICategory } from './types.js';
|
|
2
|
-
|
|
3
|
-
const CATEGORY_PREFIX: Record<PIICategory, string> = {
|
|
4
|
-
person: 'PERSON',
|
|
5
|
-
email: 'EMAIL',
|
|
6
|
-
phone: 'PHONE',
|
|
7
|
-
address: 'ADDRESS',
|
|
8
|
-
ssn: 'SSN',
|
|
9
|
-
credit_card: 'CREDIT_CARD',
|
|
10
|
-
};
|
|
11
|
-
|
|
12
|
-
export class Tokenizer {
|
|
13
|
-
// original value → token
|
|
14
|
-
private inverse = new Map<string, string>();
|
|
15
|
-
// token → original value
|
|
16
|
-
private keymap = new Map<string, string>();
|
|
17
|
-
// category → counter
|
|
18
|
-
private counters = new Map<PIICategory, number>();
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* Get or create a consistent sequential token for a PII value.
|
|
22
|
-
*/
|
|
23
|
-
tokenize(value: string, category: PIICategory): string {
|
|
24
|
-
const existing = this.inverse.get(value);
|
|
25
|
-
if (existing) return existing;
|
|
26
|
-
|
|
27
|
-
const counter = (this.counters.get(category) ?? 0) + 1;
|
|
28
|
-
this.counters.set(category, counter);
|
|
29
|
-
|
|
30
|
-
const prefix = CATEGORY_PREFIX[category];
|
|
31
|
-
const token = `${prefix}_${counter}`;
|
|
32
|
-
|
|
33
|
-
this.inverse.set(value, token);
|
|
34
|
-
this.keymap.set(token, value);
|
|
35
|
-
return token;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
getKeymap(): Record<string, string> {
|
|
39
|
-
return Object.fromEntries(this.keymap);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* Load an existing keymap (for cross-stage continuity).
|
|
44
|
-
* Populates inverse map so existing tokens are reused.
|
|
45
|
-
*/
|
|
46
|
-
loadKeymap(keymap: Record<string, string>): void {
|
|
47
|
-
for (const [token, value] of Object.entries(keymap)) {
|
|
48
|
-
this.keymap.set(token, value);
|
|
49
|
-
this.inverse.set(value, token);
|
|
50
|
-
// Restore counter state from token name (e.g. PERSON_3 → counter = 3)
|
|
51
|
-
const match = token.match(/^([A-Z_]+)_(\d+)$/);
|
|
52
|
-
if (match) {
|
|
53
|
-
const cat = Object.entries(CATEGORY_PREFIX).find(([, p]) => p === match[1])?.[0] as PIICategory | undefined;
|
|
54
|
-
if (cat) {
|
|
55
|
-
const n = parseInt(match[2], 10);
|
|
56
|
-
if ((this.counters.get(cat) ?? 0) < n) {
|
|
57
|
-
this.counters.set(cat, n);
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
}
|
package/src/types.ts
DELETED
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
export type PIICategory =
|
|
2
|
-
| 'person'
|
|
3
|
-
| 'email'
|
|
4
|
-
| 'phone'
|
|
5
|
-
| 'address'
|
|
6
|
-
| 'ssn'
|
|
7
|
-
| 'credit_card';
|
|
8
|
-
|
|
9
|
-
export interface PIISpan {
|
|
10
|
-
start: number;
|
|
11
|
-
end: number;
|
|
12
|
-
category: PIICategory;
|
|
13
|
-
value: string;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export interface PIIDetectionResult {
|
|
17
|
-
text: string;
|
|
18
|
-
keymap: Record<string, string>; // "PERSON_1" → "Marie-Claire"
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export interface AnonymizerOptions {
|
|
22
|
-
categories?: PIICategory[];
|
|
23
|
-
seedKeymap?: Record<string, string>;
|
|
24
|
-
}
|
package/tests/anonymizer.test.ts
DELETED
|
@@ -1,88 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { anonymize, deanonymize } from '../src/index.js';
|
|
3
|
-
|
|
4
|
-
describe('anonymize', () => {
|
|
5
|
-
it('replaces emails with tokens', () => {
|
|
6
|
-
const result = anonymize('Send to mc@acme.com please');
|
|
7
|
-
expect(result.text).not.toContain('mc@acme.com');
|
|
8
|
-
expect(result.text).toContain('EMAIL_1');
|
|
9
|
-
expect(result.keymap['EMAIL_1']).toBe('mc@acme.com');
|
|
10
|
-
});
|
|
11
|
-
|
|
12
|
-
it('replaces the same PII twice in one call with the same token', () => {
|
|
13
|
-
const result = anonymize('Email mc@acme.com then mc@acme.com again');
|
|
14
|
-
const emails = result.text.match(/EMAIL_\d+/g) ?? [];
|
|
15
|
-
expect(emails.every(e => e === 'EMAIL_1')).toBe(true);
|
|
16
|
-
expect(result.text.split('EMAIL_1').length - 1).toBe(2);
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
it('assigns different tokens to different PII of same category', () => {
|
|
20
|
-
const result = anonymize('Email mc@acme.com and other@example.com');
|
|
21
|
-
expect(result.keymap['EMAIL_1']).toBeDefined();
|
|
22
|
-
expect(result.keymap['EMAIL_2']).toBeDefined();
|
|
23
|
-
expect(result.keymap['EMAIL_1']).not.toBe(result.keymap['EMAIL_2']);
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
it('handles multiple categories', () => {
|
|
27
|
-
const result = anonymize('Email: mc@acme.com, Phone: 514-555-1234');
|
|
28
|
-
expect(Object.keys(result.keymap).length).toBeGreaterThanOrEqual(2);
|
|
29
|
-
expect(result.text).not.toContain('mc@acme.com');
|
|
30
|
-
expect(result.text).not.toContain('514-555-1234');
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
it('returns unchanged text when no PII found', () => {
|
|
34
|
-
const result = anonymize('This text has no sensitive data');
|
|
35
|
-
expect(result.text).toBe('This text has no sensitive data');
|
|
36
|
-
expect(result.keymap).toEqual({});
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
it('seedKeymap ensures cross-call consistency', () => {
|
|
40
|
-
// First call establishes EMAIL_1 for mc@acme.com
|
|
41
|
-
const first = anonymize('mc@acme.com');
|
|
42
|
-
// Second call with the keymap as seed
|
|
43
|
-
const second = anonymize('other@example.com and mc@acme.com', {
|
|
44
|
-
seedKeymap: first.keymap,
|
|
45
|
-
});
|
|
46
|
-
// mc@acme.com should still be EMAIL_1 (from seed)
|
|
47
|
-
expect(second.keymap['EMAIL_1']).toBe('mc@acme.com');
|
|
48
|
-
// other@example.com gets EMAIL_2
|
|
49
|
-
expect(second.keymap['EMAIL_2']).toBe('other@example.com');
|
|
50
|
-
});
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
describe('deanonymize', () => {
|
|
54
|
-
it('restores original values from keymap', () => {
|
|
55
|
-
const { text, keymap } = anonymize('Send to mc@acme.com please');
|
|
56
|
-
const restored = deanonymize(text, keymap);
|
|
57
|
-
expect(restored).toBe('Send to mc@acme.com please');
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
it('leaves unknown tokens unchanged', () => {
|
|
61
|
-
const result = deanonymize('Hello PERSON_99', {});
|
|
62
|
-
expect(result).toBe('Hello PERSON_99');
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
it('restores multiple tokens', () => {
|
|
66
|
-
const keymap = { 'EMAIL_1': 'a@b.com', 'PERSON_1': 'Alice' };
|
|
67
|
-
const restored = deanonymize('EMAIL_1 sent by PERSON_1', keymap);
|
|
68
|
-
expect(restored).toBe('a@b.com sent by Alice');
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
it('round-trip: anonymize then deanonymize restores original', () => {
|
|
72
|
-
const original = 'Email: mc@acme.com, Phone: 514-555-1234';
|
|
73
|
-
const { text, keymap } = anonymize(original);
|
|
74
|
-
const restored = deanonymize(text, keymap);
|
|
75
|
-
expect(restored).toBe(original);
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
it('deanonymizes correctly when there are more than 9 PII values of same category', () => {
|
|
79
|
-
// Build a keymap with EMAIL_1 through EMAIL_11
|
|
80
|
-
const keymap: Record<string, string> = {};
|
|
81
|
-
for (let i = 1; i <= 11; i++) {
|
|
82
|
-
keymap[`EMAIL_${i}`] = `user${i}@example.com`;
|
|
83
|
-
}
|
|
84
|
-
const text = 'EMAIL_1 EMAIL_10 EMAIL_11 EMAIL_2';
|
|
85
|
-
const restored = deanonymize(text, keymap);
|
|
86
|
-
expect(restored).toBe('user1@example.com user10@example.com user11@example.com user2@example.com');
|
|
87
|
-
});
|
|
88
|
-
});
|
package/tests/detector.test.ts
DELETED
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { detectPII } from '../src/detector.js';
|
|
3
|
-
|
|
4
|
-
describe('detectPII', () => {
|
|
5
|
-
it('detects email addresses', () => {
|
|
6
|
-
const spans = detectPII('Contact mc@acme.com for info');
|
|
7
|
-
const emails = spans.filter(s => s.category === 'email');
|
|
8
|
-
expect(emails.length).toBeGreaterThan(0);
|
|
9
|
-
expect(emails[0].value).toBe('mc@acme.com');
|
|
10
|
-
});
|
|
11
|
-
|
|
12
|
-
it('detects phone numbers', () => {
|
|
13
|
-
const spans = detectPII('Call 514-555-1234 now');
|
|
14
|
-
const phones = spans.filter(s => s.category === 'phone');
|
|
15
|
-
expect(phones.length).toBeGreaterThan(0);
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
it('detects credit card numbers', () => {
|
|
19
|
-
const spans = detectPII('Card number: 4111111111111111');
|
|
20
|
-
const cards = spans.filter(s => s.category === 'credit_card');
|
|
21
|
-
expect(cards.length).toBeGreaterThan(0);
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
it('returns empty array for clean text', () => {
|
|
25
|
-
const spans = detectPII('This is a normal sentence without PII');
|
|
26
|
-
expect(spans).toEqual([]);
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
it('returns spans with correct position bounds', () => {
|
|
30
|
-
const text = 'Email: mc@acme.com here';
|
|
31
|
-
const spans = detectPII(text);
|
|
32
|
-
const email = spans.find(s => s.category === 'email');
|
|
33
|
-
expect(email).toBeDefined();
|
|
34
|
-
expect(text.slice(email!.start, email!.end)).toBe(email!.value);
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
it('does not return overlapping spans', () => {
|
|
38
|
-
const text = 'Email mc@acme.com and phone 514-555-1234';
|
|
39
|
-
const spans = detectPII(text);
|
|
40
|
-
// Verify no two spans overlap
|
|
41
|
-
for (let i = 0; i < spans.length; i++) {
|
|
42
|
-
for (let j = i + 1; j < spans.length; j++) {
|
|
43
|
-
expect(spans[i].end <= spans[j].start || spans[j].end <= spans[i].start).toBe(true);
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
});
|
|
47
|
-
});
|
package/tests/tokenizer.test.ts
DELETED
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
-
import { Tokenizer } from '../src/tokenizer.js';
|
|
3
|
-
|
|
4
|
-
describe('Tokenizer', () => {
|
|
5
|
-
let tokenizer: Tokenizer;
|
|
6
|
-
|
|
7
|
-
beforeEach(() => {
|
|
8
|
-
tokenizer = new Tokenizer();
|
|
9
|
-
});
|
|
10
|
-
|
|
11
|
-
it('assigns sequential tokens per category', () => {
|
|
12
|
-
const t1 = tokenizer.tokenize('Marie-Claire', 'person');
|
|
13
|
-
const t2 = tokenizer.tokenize('Jean-François', 'person');
|
|
14
|
-
const t3 = tokenizer.tokenize('mc@acme.com', 'email');
|
|
15
|
-
expect(t1).toBe('PERSON_1');
|
|
16
|
-
expect(t2).toBe('PERSON_2');
|
|
17
|
-
expect(t3).toBe('EMAIL_1');
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
it('returns the same token for the same value', () => {
|
|
21
|
-
const t1 = tokenizer.tokenize('Marie-Claire', 'person');
|
|
22
|
-
const t2 = tokenizer.tokenize('Marie-Claire', 'person');
|
|
23
|
-
expect(t1).toBe(t2);
|
|
24
|
-
expect(t1).toBe('PERSON_1');
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
it('builds keymap correctly', () => {
|
|
28
|
-
tokenizer.tokenize('Marie-Claire', 'person');
|
|
29
|
-
tokenizer.tokenize('mc@acme.com', 'email');
|
|
30
|
-
const keymap = tokenizer.getKeymap();
|
|
31
|
-
expect(keymap).toEqual({
|
|
32
|
-
'PERSON_1': 'Marie-Claire',
|
|
33
|
-
'EMAIL_1': 'mc@acme.com',
|
|
34
|
-
});
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
it('handles all supported categories', () => {
|
|
38
|
-
const categories = ['person', 'email', 'phone', 'address', 'ssn', 'credit_card'] as const;
|
|
39
|
-
for (const cat of categories) {
|
|
40
|
-
const token = tokenizer.tokenize('test-value', cat);
|
|
41
|
-
expect(token).toMatch(/^[A-Z_]+_1$/);
|
|
42
|
-
}
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
it('loadKeymap restores state for cross-call consistency', () => {
|
|
46
|
-
// Simulate loading a pre-existing keymap
|
|
47
|
-
tokenizer.loadKeymap({ 'PERSON_1': 'Alice', 'EMAIL_1': 'alice@example.com' });
|
|
48
|
-
// New tokenization should continue from counter 1
|
|
49
|
-
const t = tokenizer.tokenize('Bob', 'person');
|
|
50
|
-
expect(t).toBe('PERSON_2');
|
|
51
|
-
// Existing value should reuse its token
|
|
52
|
-
const tAlice = tokenizer.tokenize('Alice', 'person');
|
|
53
|
-
expect(tAlice).toBe('PERSON_1');
|
|
54
|
-
});
|
|
55
|
-
});
|
package/tsconfig.json
DELETED
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"compilerOptions": {
|
|
3
|
-
"target": "ES2022",
|
|
4
|
-
"module": "commonjs",
|
|
5
|
-
"lib": ["ES2022"],
|
|
6
|
-
"outDir": "./dist",
|
|
7
|
-
"rootDir": "./src",
|
|
8
|
-
"declaration": true,
|
|
9
|
-
"declarationMap": true,
|
|
10
|
-
"sourceMap": true,
|
|
11
|
-
"strict": true,
|
|
12
|
-
"esModuleInterop": true,
|
|
13
|
-
"skipLibCheck": true,
|
|
14
|
-
"forceConsistentCasingInFileNames": true,
|
|
15
|
-
"resolveJsonModule": true,
|
|
16
|
-
"moduleResolution": "node"
|
|
17
|
-
},
|
|
18
|
-
"include": ["src/**/*"],
|
|
19
|
-
"exclude": ["node_modules", "dist", "tests"]
|
|
20
|
-
}
|