@datafog/fogclaw 0.2.0 → 0.3.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.
Files changed (103) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/dist/backlog-tools.d.ts +57 -0
  3. package/dist/backlog-tools.d.ts.map +1 -0
  4. package/dist/backlog-tools.js +173 -0
  5. package/dist/backlog-tools.js.map +1 -0
  6. package/dist/backlog.d.ts +82 -0
  7. package/dist/backlog.d.ts.map +1 -0
  8. package/dist/backlog.js +169 -0
  9. package/dist/backlog.js.map +1 -0
  10. package/dist/config.d.ts.map +1 -1
  11. package/dist/config.js +6 -0
  12. package/dist/config.js.map +1 -1
  13. package/dist/index.d.ts +2 -1
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +87 -2
  16. package/dist/index.js.map +1 -1
  17. package/dist/message-sending-handler.d.ts +2 -1
  18. package/dist/message-sending-handler.d.ts.map +1 -1
  19. package/dist/message-sending-handler.js +5 -1
  20. package/dist/message-sending-handler.js.map +1 -1
  21. package/dist/tool-result-handler.d.ts +2 -1
  22. package/dist/tool-result-handler.d.ts.map +1 -1
  23. package/dist/tool-result-handler.js +5 -1
  24. package/dist/tool-result-handler.js.map +1 -1
  25. package/dist/types.d.ts +15 -0
  26. package/dist/types.d.ts.map +1 -1
  27. package/dist/types.js.map +1 -1
  28. package/openclaw.plugin.json +11 -1
  29. package/package.json +7 -1
  30. package/.github/workflows/harness-docs.yml +0 -30
  31. package/AGENTS.md +0 -28
  32. package/docs/DATA.md +0 -28
  33. package/docs/DESIGN.md +0 -17
  34. package/docs/DOMAIN_DOCS.md +0 -30
  35. package/docs/FRONTEND.md +0 -24
  36. package/docs/OBSERVABILITY.md +0 -32
  37. package/docs/PLANS.md +0 -171
  38. package/docs/PRODUCT_SENSE.md +0 -20
  39. package/docs/RELIABILITY.md +0 -60
  40. package/docs/SECURITY.md +0 -52
  41. package/docs/design-docs/core-beliefs.md +0 -17
  42. package/docs/design-docs/index.md +0 -8
  43. package/docs/generated/README.md +0 -36
  44. package/docs/generated/memory.md +0 -1
  45. package/docs/plans/2026-02-16-fogclaw-design.md +0 -172
  46. package/docs/plans/2026-02-16-fogclaw-implementation.md +0 -1606
  47. package/docs/plans/README.md +0 -15
  48. package/docs/plans/active/2026-02-16-feat-openclaw-official-submission-plan.md +0 -386
  49. package/docs/plans/active/2026-02-17-feat-release-fogclaw-via-datafog-package-plan.md +0 -328
  50. package/docs/plans/active/2026-02-17-feat-submit-fogclaw-to-openclaw-plan.md +0 -244
  51. package/docs/plans/active/2026-02-17-feat-tool-result-pii-scanning-plan.md +0 -293
  52. package/docs/plans/tech-debt-tracker.md +0 -42
  53. package/docs/plugins/fogclaw.md +0 -101
  54. package/docs/runbooks/address-review-findings.md +0 -30
  55. package/docs/runbooks/ci-failures.md +0 -46
  56. package/docs/runbooks/code-review.md +0 -34
  57. package/docs/runbooks/merge-change.md +0 -28
  58. package/docs/runbooks/pull-request.md +0 -45
  59. package/docs/runbooks/record-evidence.md +0 -43
  60. package/docs/runbooks/reproduce-bug.md +0 -42
  61. package/docs/runbooks/respond-to-feedback.md +0 -42
  62. package/docs/runbooks/review-findings.md +0 -31
  63. package/docs/runbooks/submit-openclaw-plugin.md +0 -68
  64. package/docs/runbooks/update-agents-md.md +0 -59
  65. package/docs/runbooks/update-domain-docs.md +0 -42
  66. package/docs/runbooks/validate-current-state.md +0 -41
  67. package/docs/runbooks/verify-release.md +0 -69
  68. package/docs/specs/2026-02-16-feat-openclaw-official-submission-spec.md +0 -115
  69. package/docs/specs/2026-02-17-feat-outbound-message-pii-scanning-spec.md +0 -93
  70. package/docs/specs/2026-02-17-feat-submit-fogclaw-to-openclaw.md +0 -125
  71. package/docs/specs/2026-02-17-feat-tool-result-pii-scanning-spec.md +0 -122
  72. package/docs/specs/README.md +0 -5
  73. package/docs/specs/index.md +0 -8
  74. package/docs/spikes/README.md +0 -8
  75. package/fogclaw.config.example.json +0 -33
  76. package/scripts/ci/he-docs-config.json +0 -123
  77. package/scripts/ci/he-docs-drift.sh +0 -112
  78. package/scripts/ci/he-docs-lint.sh +0 -234
  79. package/scripts/ci/he-plans-lint.sh +0 -354
  80. package/scripts/ci/he-runbooks-lint.sh +0 -445
  81. package/scripts/ci/he-specs-lint.sh +0 -258
  82. package/scripts/ci/he-spikes-lint.sh +0 -249
  83. package/scripts/runbooks/select-runbooks.sh +0 -154
  84. package/src/config.ts +0 -183
  85. package/src/engines/gliner.ts +0 -240
  86. package/src/engines/regex.ts +0 -71
  87. package/src/extract.ts +0 -98
  88. package/src/index.ts +0 -381
  89. package/src/message-sending-handler.ts +0 -87
  90. package/src/redactor.ts +0 -51
  91. package/src/scanner.ts +0 -196
  92. package/src/tool-result-handler.ts +0 -133
  93. package/src/types.ts +0 -75
  94. package/tests/config.test.ts +0 -78
  95. package/tests/extract.test.ts +0 -185
  96. package/tests/gliner.test.ts +0 -289
  97. package/tests/message-sending-handler.test.ts +0 -244
  98. package/tests/plugin-smoke.test.ts +0 -250
  99. package/tests/redactor.test.ts +0 -320
  100. package/tests/regex.test.ts +0 -345
  101. package/tests/scanner.test.ts +0 -348
  102. package/tests/tool-result-handler.test.ts +0 -329
  103. package/tsconfig.json +0 -20
@@ -1,250 +0,0 @@
1
- import { describe, it, expect, vi, beforeEach, beforeAll, afterAll } from "vitest";
2
-
3
- import plugin from "../src/index.js";
4
-
5
- function createApi() {
6
- const hooks: Array<{ event: string; handler: (event: any) => any }> = [];
7
- const tools: any[] = [];
8
-
9
- return {
10
- pluginConfig: {
11
- model: "invalid:/not/real/model",
12
- auditEnabled: true,
13
- },
14
- hooks,
15
- tools,
16
- logger: {
17
- info: vi.fn(),
18
- warn: vi.fn(),
19
- error: vi.fn(),
20
- },
21
- on: vi.fn((event: string, handler: (event: any) => any) => {
22
- hooks.push({ event, handler });
23
- }),
24
- registerTool: vi.fn((tool: any) => {
25
- tools.push(tool);
26
- }),
27
- };
28
- }
29
-
30
- describe("FogClaw OpenClaw plugin contract (integration path)", () => {
31
- beforeAll(() => {
32
- vi.spyOn(console, "warn").mockImplementation(() => undefined);
33
- vi.spyOn(console, "log").mockImplementation(() => undefined);
34
- });
35
-
36
- afterAll(() => {
37
- vi.restoreAllMocks();
38
- });
39
-
40
- beforeEach(() => {
41
- vi.clearAllMocks();
42
- });
43
-
44
- it("registers plugin, hook, and tools", async () => {
45
- const api = createApi();
46
-
47
- plugin.register(api);
48
-
49
- expect(typeof plugin.register).toBe("function");
50
- expect(api.on).toHaveBeenCalledWith("before_agent_start", expect.any(Function));
51
- expect(api.on).toHaveBeenCalledWith("tool_result_persist", expect.any(Function));
52
- expect(api.on).toHaveBeenCalledWith("message_sending", expect.any(Function));
53
- expect(api.registerTool).toHaveBeenCalledTimes(3);
54
-
55
- const scanTool = api.tools.find((tool: any) => tool.id === "fogclaw_scan");
56
- const previewTool = api.tools.find((tool: any) => tool.id === "fogclaw_preview");
57
- const redactTool = api.tools.find((tool: any) => tool.id === "fogclaw_redact");
58
-
59
- expect(scanTool).toBeDefined();
60
- expect(previewTool).toBeDefined();
61
- expect(redactTool).toBeDefined();
62
-
63
- expect(scanTool.schema.required).toContain("text");
64
- expect(previewTool.schema.required).toContain("text");
65
- expect(redactTool.schema.required).toContain("text");
66
- });
67
-
68
- it("validates hook and tool behavior against real Scanner execution path", async () => {
69
- const api = createApi();
70
-
71
- plugin.register(api);
72
-
73
- const hook = api.hooks.find((entry: any) => entry.event === "before_agent_start");
74
- expect(hook).toBeDefined();
75
-
76
- const hookResult = await hook!.handler({
77
- prompt: "Email me at john@example.com today.",
78
- });
79
-
80
- expect(hookResult).toBeDefined();
81
- expect(hookResult?.prependContext).toContain("[FOGCLAW REDACTED]");
82
- expect(hookResult?.prependContext).not.toContain("john@example.com");
83
-
84
- const scanTool = api.tools.find((tool: any) => tool.id === "fogclaw_scan");
85
- const scanOutput = await scanTool.handler({
86
- text: "Email me at john@example.com today.",
87
- });
88
-
89
- expect(scanOutput.content?.[0]?.type).toBe("text");
90
-
91
- const scanParsed = JSON.parse(scanOutput.content[0].text);
92
- expect(Array.isArray(scanParsed.entities)).toBe(true);
93
- expect(scanParsed.count).toBe(scanParsed.entities.length);
94
- expect(scanParsed.entities[0].label).toBe("EMAIL");
95
-
96
- const redactTool = api.tools.find((tool: any) => tool.id === "fogclaw_redact");
97
- const redactOutput = await redactTool.handler({
98
- text: "Email me at john@example.com today.",
99
- strategy: "token",
100
- });
101
-
102
- const redactParsed = JSON.parse(redactOutput.content[0].text);
103
- expect(redactParsed.redacted_text).toContain("[EMAIL_");
104
- expect(redactParsed.redacted_text).not.toContain("john@example.com");
105
- });
106
-
107
- it("supports preview output with action plan and redacted text", async () => {
108
- const api = createApi();
109
-
110
- plugin.register(api);
111
-
112
- const previewTool = api.tools.find((tool: any) => tool.id === "fogclaw_preview");
113
-
114
- const previewOutput = await previewTool.handler({
115
- text: "Email me at john@example.com about Acme Corp tomorrow.",
116
- });
117
-
118
- const parsed = JSON.parse(previewOutput.content[0].text);
119
- expect(parsed.totalEntities).toBeGreaterThan(0);
120
- expect(parsed.actionPlan).toEqual(
121
- expect.objectContaining({
122
- blocked: expect.objectContaining({ count: expect.any(Number) }),
123
- warned: expect.objectContaining({ count: expect.any(Number) }),
124
- redacted: expect.objectContaining({ count: expect.any(Number) }),
125
- }),
126
- );
127
- expect(typeof parsed.redactedText).toBe("string");
128
- });
129
-
130
- it("passes custom_labels through tool path in real execution", async () => {
131
- const api = createApi();
132
-
133
- plugin.register(api);
134
- const scanTool = api.tools.find((tool: any) => tool.id === "fogclaw_scan");
135
-
136
- const scanOutput = await scanTool.handler({
137
- text: "Confidential note for Acme project roadmap",
138
- custom_labels: ["project", "competitor name"],
139
- });
140
-
141
- const parsed = JSON.parse(scanOutput.content[0].text);
142
- expect(parsed.count).toBe(parsed.entities.length);
143
- expect(parsed.entities).toEqual(expect.any(Array));
144
- });
145
-
146
- it("registers tool_result_persist hook", () => {
147
- const api = createApi();
148
- plugin.register(api);
149
-
150
- const hook = api.hooks.find((entry: any) => entry.event === "tool_result_persist");
151
- expect(hook).toBeDefined();
152
- });
153
-
154
- it("tool_result_persist hook redacts PII in tool result messages", () => {
155
- const api = createApi();
156
- plugin.register(api);
157
-
158
- const hook = api.hooks.find((entry: any) => entry.event === "tool_result_persist");
159
- expect(hook).toBeDefined();
160
-
161
- // Simulate a tool result containing an SSN
162
- const result = hook!.handler({
163
- toolName: "file_read",
164
- message: "The patient SSN is 123-45-6789 and phone is 555-123-4567.",
165
- });
166
-
167
- // Handler is synchronous — result should not be a Promise
168
- expect(result).toBeDefined();
169
- expect(result?.message).toBeDefined();
170
- const text = result.message as string;
171
- expect(text).toContain("[SSN_1]");
172
- expect(text).toContain("[PHONE_1]");
173
- expect(text).not.toContain("123-45-6789");
174
- expect(text).not.toContain("555-123-4567");
175
- });
176
-
177
- it("tool_result_persist hook returns void for clean tool results", () => {
178
- const api = createApi();
179
- plugin.register(api);
180
-
181
- const hook = api.hooks.find((entry: any) => entry.event === "tool_result_persist");
182
- const result = hook!.handler({
183
- toolName: "file_read",
184
- message: "This file contains no sensitive information.",
185
- });
186
-
187
- expect(result).toBeUndefined();
188
- });
189
-
190
- it("tool_result_persist hook emits audit log with source tool_result", () => {
191
- const api = createApi();
192
- plugin.register(api);
193
-
194
- const hook = api.hooks.find((entry: any) => entry.event === "tool_result_persist");
195
- hook!.handler({
196
- toolName: "web_fetch",
197
- message: "Contact john@example.com for details.",
198
- });
199
-
200
- // auditEnabled is true in createApi config
201
- const auditCalls = api.logger.info.mock.calls.filter(
202
- (call: any[]) => typeof call[0] === "string" && call[0].includes("tool_result_scan"),
203
- );
204
- expect(auditCalls.length).toBe(1);
205
- expect(auditCalls[0][0]).toContain('"source":"tool_result"');
206
- expect(auditCalls[0][0]).toContain('"toolName":"web_fetch"');
207
- expect(auditCalls[0][0]).not.toContain("john@example.com");
208
- });
209
-
210
- it("registers message_sending hook", () => {
211
- const api = createApi();
212
- plugin.register(api);
213
-
214
- const hook = api.hooks.find((entry: any) => entry.event === "message_sending");
215
- expect(hook).toBeDefined();
216
- });
217
-
218
- it("message_sending hook redacts PII in outbound messages", async () => {
219
- const api = createApi();
220
- plugin.register(api);
221
-
222
- const hook = api.hooks.find((entry: any) => entry.event === "message_sending");
223
- expect(hook).toBeDefined();
224
-
225
- const result = await hook!.handler({
226
- to: "user123",
227
- content: "Your SSN is 123-45-6789 and email is john@example.com.",
228
- }, { channelId: "telegram" });
229
-
230
- expect(result).toBeDefined();
231
- expect(result.content).toContain("[SSN_1]");
232
- expect(result.content).toContain("[EMAIL_1]");
233
- expect(result.content).not.toContain("123-45-6789");
234
- expect(result.content).not.toContain("john@example.com");
235
- expect(result.cancel).toBeUndefined();
236
- });
237
-
238
- it("message_sending hook returns void for clean messages", async () => {
239
- const api = createApi();
240
- plugin.register(api);
241
-
242
- const hook = api.hooks.find((entry: any) => entry.event === "message_sending");
243
- const result = await hook!.handler({
244
- to: "user123",
245
- content: "Hello, how can I help?",
246
- }, { channelId: "slack" });
247
-
248
- expect(result).toBeUndefined();
249
- });
250
- });
@@ -1,320 +0,0 @@
1
- import { describe, it, expect } from "vitest";
2
- import { createHash } from "node:crypto";
3
- import { redact } from "../src/redactor.js";
4
- import type { Entity } from "../src/types.js";
5
-
6
- // Helper to build Entity objects concisely
7
- function entity(
8
- text: string,
9
- label: string,
10
- start: number,
11
- end: number,
12
- source: "regex" | "gliner" = "regex",
13
- confidence = 1.0,
14
- ): Entity {
15
- return { text, label, start, end, confidence, source };
16
- }
17
-
18
- describe("redact", () => {
19
- // ── token strategy ──────────────────────────────────────────────
20
-
21
- describe("token strategy", () => {
22
- it("replaces a single EMAIL entity with [EMAIL_1]", () => {
23
- const text = "Contact john@example.com for info.";
24
- const entities: Entity[] = [
25
- entity("john@example.com", "EMAIL", 8, 24),
26
- ];
27
-
28
- const result = redact(text, entities, "token");
29
-
30
- expect(result.redacted_text).toBe("Contact [EMAIL_1] for info.");
31
- });
32
-
33
- it("replaces a single PHONE entity with [PHONE_1]", () => {
34
- const text = "Call 555-123-4567 now.";
35
- const entities: Entity[] = [
36
- entity("555-123-4567", "PHONE", 5, 17),
37
- ];
38
-
39
- const result = redact(text, entities, "token");
40
-
41
- expect(result.redacted_text).toBe("Call [PHONE_1] now.");
42
- });
43
-
44
- it("increments counter for multiple entities of the same type", () => {
45
- const text = "Email alice@a.com and bob@b.com please.";
46
- const entities: Entity[] = [
47
- entity("alice@a.com", "EMAIL", 6, 17),
48
- entity("bob@b.com", "EMAIL", 22, 31),
49
- ];
50
-
51
- const result = redact(text, entities, "token");
52
-
53
- // Processing order is descending by start position, so bob@b.com
54
- // (start=22) gets [EMAIL_1] and alice@a.com (start=6) gets [EMAIL_2]
55
- expect(result.redacted_text).toBe(
56
- "Email [EMAIL_2] and [EMAIL_1] please.",
57
- );
58
- expect(result.mapping["[EMAIL_1]"]).toBe("bob@b.com");
59
- expect(result.mapping["[EMAIL_2]"]).toBe("alice@a.com");
60
- });
61
-
62
- it("uses separate counters for different entity types", () => {
63
- const text = "Email john@example.com or call 555-0000.";
64
- const entities: Entity[] = [
65
- entity("john@example.com", "EMAIL", 6, 22),
66
- entity("555-0000", "PHONE", 31, 39),
67
- ];
68
-
69
- const result = redact(text, entities, "token");
70
-
71
- expect(result.redacted_text).toBe(
72
- "Email [EMAIL_1] or call [PHONE_1].",
73
- );
74
- });
75
-
76
- it("defaults to token strategy when none is specified", () => {
77
- const text = "Hi john@example.com";
78
- const entities: Entity[] = [
79
- entity("john@example.com", "EMAIL", 3, 19),
80
- ];
81
-
82
- const result = redact(text, entities);
83
-
84
- expect(result.redacted_text).toBe("Hi [EMAIL_1]");
85
- });
86
- });
87
-
88
- // ── mask strategy ───────────────────────────────────────────────
89
-
90
- describe("mask strategy", () => {
91
- it("replaces entity with asterisks matching original length", () => {
92
- const text = "Contact john@example.com for info.";
93
- // ^ ^
94
- // 8 24 (16 chars)
95
- const entities: Entity[] = [
96
- entity("john@example.com", "EMAIL", 8, 24),
97
- ];
98
-
99
- const result = redact(text, entities, "mask");
100
-
101
- expect(result.redacted_text).toBe("Contact **************** for info.");
102
- // "john@example.com" is 16 chars -> 16 asterisks
103
- expect(result.redacted_text.slice(8, 24)).toBe("*".repeat(16));
104
- });
105
-
106
- it("uses at least one asterisk for empty-text entity", () => {
107
- const text = "A B";
108
- const entities: Entity[] = [
109
- entity("", "UNKNOWN", 1, 1),
110
- ];
111
-
112
- const result = redact(text, entities, "mask");
113
-
114
- expect(result.redacted_text).toBe("A* B");
115
- });
116
-
117
- it("masks multiple entities independently", () => {
118
- const text = "Name: Alice, Phone: 12345";
119
- const entities: Entity[] = [
120
- entity("Alice", "PERSON", 6, 11),
121
- entity("12345", "PHONE", 20, 25),
122
- ];
123
-
124
- const result = redact(text, entities, "mask");
125
-
126
- expect(result.redacted_text).toBe("Name: *****, Phone: *****");
127
- });
128
- });
129
-
130
- // ── hash strategy ───────────────────────────────────────────────
131
-
132
- describe("hash strategy", () => {
133
- it("replaces entity with [LABEL_sha256prefix]", () => {
134
- const text = "Contact john@example.com for info.";
135
- const entities: Entity[] = [
136
- entity("john@example.com", "EMAIL", 8, 24),
137
- ];
138
-
139
- const expectedDigest = createHash("sha256")
140
- .update("john@example.com")
141
- .digest("hex")
142
- .slice(0, 12);
143
-
144
- const result = redact(text, entities, "hash");
145
-
146
- expect(result.redacted_text).toBe(
147
- `Contact [EMAIL_${expectedDigest}] for info.`,
148
- );
149
- });
150
-
151
- it("produces consistent hashes across calls", () => {
152
- const text = "Hi john@example.com";
153
- const entities: Entity[] = [
154
- entity("john@example.com", "EMAIL", 3, 19),
155
- ];
156
-
157
- const r1 = redact(text, entities, "hash");
158
- const r2 = redact(text, entities, "hash");
159
-
160
- expect(r1.redacted_text).toBe(r2.redacted_text);
161
- });
162
-
163
- it("produces different hashes for different entity text", () => {
164
- const text1 = "Hi alice@a.com";
165
- const text2 = "Hi bobby@b.com";
166
- const e1: Entity[] = [entity("alice@a.com", "EMAIL", 3, 14)];
167
- const e2: Entity[] = [entity("bobby@b.com", "EMAIL", 3, 14)];
168
-
169
- const r1 = redact(text1, e1, "hash");
170
- const r2 = redact(text2, e2, "hash");
171
-
172
- expect(r1.redacted_text).not.toBe(r2.redacted_text);
173
- });
174
- });
175
-
176
- // ── mapping ─────────────────────────────────────────────────────
177
-
178
- describe("mapping", () => {
179
- it("maps replacement tokens back to original text (token strategy)", () => {
180
- const text = "Email john@example.com or call 555-0000.";
181
- const entities: Entity[] = [
182
- entity("john@example.com", "EMAIL", 6, 22),
183
- entity("555-0000", "PHONE", 31, 39),
184
- ];
185
-
186
- const result = redact(text, entities, "token");
187
-
188
- expect(result.mapping).toEqual({
189
- "[EMAIL_1]": "john@example.com",
190
- "[PHONE_1]": "555-0000",
191
- });
192
- });
193
-
194
- it("maps replacement masks back to original text (mask strategy)", () => {
195
- const text = "Call 555-0000 now.";
196
- const entities: Entity[] = [
197
- entity("555-0000", "PHONE", 5, 13),
198
- ];
199
-
200
- const result = redact(text, entities, "mask");
201
-
202
- expect(result.mapping["********"]).toBe("555-0000");
203
- });
204
-
205
- it("maps replacement hashes back to original text (hash strategy)", () => {
206
- const text = "Hi Alice";
207
- const entities: Entity[] = [
208
- entity("Alice", "PERSON", 3, 8),
209
- ];
210
-
211
- const result = redact(text, entities, "hash");
212
-
213
- const digest = createHash("sha256")
214
- .update("Alice")
215
- .digest("hex")
216
- .slice(0, 12);
217
- expect(result.mapping[`[PERSON_${digest}]`]).toBe("Alice");
218
- });
219
- });
220
-
221
- // ── empty entities ──────────────────────────────────────────────
222
-
223
- describe("empty entities", () => {
224
- it("returns original text unchanged when entities array is empty", () => {
225
- const text = "Nothing to redact here.";
226
-
227
- const result = redact(text, []);
228
-
229
- expect(result.redacted_text).toBe(text);
230
- expect(result.mapping).toEqual({});
231
- expect(result.entities).toEqual([]);
232
- });
233
-
234
- it("returns original text with all strategies when no entities", () => {
235
- const text = "Still nothing.";
236
-
237
- for (const strategy of ["token", "mask", "hash"] as const) {
238
- const result = redact(text, [], strategy);
239
- expect(result.redacted_text).toBe(text);
240
- expect(result.mapping).toEqual({});
241
- }
242
- });
243
- });
244
-
245
- // ── entity ordering / offset integrity ──────────────────────────
246
-
247
- describe("entity ordering", () => {
248
- it("handles entities given in reverse order without offset corruption", () => {
249
- const text = "Name: Alice, Email: alice@a.com";
250
- const entities: Entity[] = [
251
- // Provided in reverse order (end of string first)
252
- entity("alice@a.com", "EMAIL", 20, 31),
253
- entity("Alice", "PERSON", 6, 11),
254
- ];
255
-
256
- const result = redact(text, entities, "token");
257
-
258
- expect(result.redacted_text).toBe(
259
- "Name: [PERSON_1], Email: [EMAIL_1]",
260
- );
261
- });
262
-
263
- it("handles entities given in forward order without offset corruption", () => {
264
- const text = "Name: Alice, Email: alice@a.com";
265
- const entities: Entity[] = [
266
- entity("Alice", "PERSON", 6, 11),
267
- entity("alice@a.com", "EMAIL", 20, 31),
268
- ];
269
-
270
- const result = redact(text, entities, "token");
271
-
272
- expect(result.redacted_text).toBe(
273
- "Name: [PERSON_1], Email: [EMAIL_1]",
274
- );
275
- });
276
-
277
- it("handles three entities in random order", () => {
278
- const text = "A: Alice B: bob@b.com C: 555-0000";
279
- const entities: Entity[] = [
280
- entity("bob@b.com", "EMAIL", 12, 21),
281
- entity("555-0000", "PHONE", 25, 33),
282
- entity("Alice", "PERSON", 3, 8),
283
- ];
284
-
285
- const result = redact(text, entities, "token");
286
-
287
- expect(result.redacted_text).toBe(
288
- "A: [PERSON_1] B: [EMAIL_1] C: [PHONE_1]",
289
- );
290
- });
291
-
292
- it("does not mutate the original entities array", () => {
293
- const text = "Name: Alice, Email: alice@a.com";
294
- const entities: Entity[] = [
295
- entity("alice@a.com", "EMAIL", 20, 31),
296
- entity("Alice", "PERSON", 6, 11),
297
- ];
298
- const originalOrder = [...entities];
299
-
300
- redact(text, entities, "token");
301
-
302
- expect(entities).toEqual(originalOrder);
303
- });
304
- });
305
-
306
- // ── returned entities ───────────────────────────────────────────
307
-
308
- describe("returned entities", () => {
309
- it("returns the original entities array in the result", () => {
310
- const text = "Hi john@example.com";
311
- const entities: Entity[] = [
312
- entity("john@example.com", "EMAIL", 3, 19),
313
- ];
314
-
315
- const result = redact(text, entities, "token");
316
-
317
- expect(result.entities).toBe(entities);
318
- });
319
- });
320
- });