feat: harden generic preset, add LLM retry, and finish tooling/docs

- PatternDef.validate + Luhn-gated credit-card detection; de-overlap the
  generic phone/date/IP patterns; presets.swiss unchanged (production behavior)
- strip g/y flags from nameHint so .test() is stateless (latent footgun)
- openAICompatibleProvider: bounded retry on transient failures (network /
  timeout / 429 / 5xx), configurable via retries + retryDelayMs
- eslint + prettier + vitest coverage (97%); CI runs lint/format/coverage
- docs: README badges + new-option docs, SECURITY.md, issue/PR templates

26 tests passing; build emits ESM+CJS+types.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mobiletic
2026-06-24 17:03:20 +01:00
parent f09b917f1a
commit 81b6b03239
21 changed files with 2039 additions and 27 deletions

View File

@@ -46,13 +46,71 @@ describe('openAICompatibleProvider', () => {
expect(system).toContain('[PER_1.NOM:M]');
});
it('throws on a non-OK HTTP status', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 500 }));
await expect(openAICompatibleProvider(opts).anonymize('x')).rejects.toThrow('LLM_HTTP_500');
it('throws on a non-OK HTTP status (no retry when retries: 0)', async () => {
const fetchMock = vi.fn().mockResolvedValue({ ok: false, status: 500 });
vi.stubGlobal('fetch', fetchMock);
await expect(openAICompatibleProvider({ ...opts, retries: 0 }).anonymize('x')).rejects.toThrow(
'LLM_HTTP_500',
);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('throws on a malformed response shape', async () => {
vi.stubGlobal('fetch', mockFetchJson({ wrong: true }));
await expect(openAICompatibleProvider(opts).anonymize('x')).rejects.toThrow('LLM_BAD_SHAPE');
await expect(openAICompatibleProvider({ ...opts, retries: 0 }).anonymize('x')).rejects.toThrow(
'LLM_BAD_SHAPE',
);
});
describe('retry/backoff', () => {
const retryOpts = { ...opts, retries: 1, retryDelayMs: 0 };
it('retries once on a transient 500 then succeeds (2 calls)', async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce({ ok: false, status: 500 })
.mockResolvedValueOnce({
ok: true,
json: async () => ({
choices: [{ message: { content: JSON.stringify({ texte_anonymise: 'ok', mapping: {} }) } }],
}),
});
vi.stubGlobal('fetch', fetchMock);
const r = await openAICompatibleProvider(retryOpts).anonymize('x');
expect(r.anon).toBe('ok');
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it('retries on a network error then succeeds', async () => {
const fetchMock = vi
.fn()
.mockRejectedValueOnce(new Error('ECONNRESET'))
.mockResolvedValueOnce({
ok: true,
json: async () => ({
choices: [{ message: { content: JSON.stringify({ texte_anonymise: 'ok', mapping: {} }) } }],
}),
});
vi.stubGlobal('fetch', fetchMock);
const r = await openAICompatibleProvider(retryOpts).anonymize('x');
expect(r.anon).toBe('ok');
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it('does NOT retry a 400 (non-transient)', async () => {
const fetchMock = vi.fn().mockResolvedValue({ ok: false, status: 400 });
vi.stubGlobal('fetch', fetchMock);
await expect(openAICompatibleProvider(retryOpts).anonymize('x')).rejects.toThrow('LLM_HTTP_400');
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('gives up after exhausting retries on persistent 503', async () => {
const fetchMock = vi.fn().mockResolvedValue({ ok: false, status: 503 });
vi.stubGlobal('fetch', fetchMock);
await expect(openAICompatibleProvider(retryOpts).anonymize('x')).rejects.toThrow('LLM_HTTP_503');
expect(fetchMock).toHaveBeenCalledTimes(2); // initial + 1 retry
});
});
});