diff --git a/src/scanner/__tests__/engine.retry.test.ts b/src/scanner/__tests__/engine.retry.test.ts new file mode 100644 index 0000000..6e2193f --- /dev/null +++ b/src/scanner/__tests__/engine.retry.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect, vi } from 'vitest'; + +// Wrap the real node:fs, but make the first readdirSync call throw to simulate a +// transient, recoverable load failure (the rules directory being momentarily +// unavailable). Every other fs call — including the retry's readdirSync — +// delegates to the real implementation. We mock the module rather than vi.spyOn +// because ESM namespace exports aren't configurable and can't be spied on. +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal(); + let failedOnce = false; + return { + ...actual, + readdirSync: (...args: Parameters) => { + if (!failedOnce) { + failedOnce = true; + throw new Error('ENOENT: rules directory temporarily missing'); + } + return (actual.readdirSync as (...a: unknown[]) => unknown)(...args); + }, + }; +}); + +import { scan } from '../engine.js'; + +// Separate test file on purpose: engine.ts caches the compiled rules in a +// module-level promise that is shared across tests in a single file. Once any +// test loads the rules successfully, the failure path can't be exercised again. +// A fresh test file gets a clean module instance, so the very first scan() here +// is the one that triggers the (forced-to-fail) load. +describe('engine.scan rule-load recovery', () => { + it('retries rule loading after a transient failure', async () => { + await expect(scan('anything')).rejects.toThrow(/rules directory/); + + // The cache was cleared by the rejection, so this call reloads and succeeds. + const result = await scan('hello world, nothing dangerous here'); + expect(result.matched).toBe(false); + }); +}); diff --git a/src/scanner/engine.ts b/src/scanner/engine.ts index 90e9c48..c1626c0 100644 --- a/src/scanner/engine.ts +++ b/src/scanner/engine.ts @@ -86,7 +86,12 @@ function extractMatchedStrings( */ export async function scan(content: string): Promise { if (!rulesPromise) { - rulesPromise = loadRules(); + // Cache the compile work, but if it fails, clear the cache so the next + // scan() retries instead of replaying a permanently-rejected promise. + rulesPromise = loadRules().catch((err) => { + rulesPromise = null; + throw err; + }); } const rules = await rulesPromise;