diff --git a/src/utils/__tests__/file-utils.test.ts b/src/utils/__tests__/file-utils.test.ts new file mode 100644 index 000000000..b9f9f85b7 --- /dev/null +++ b/src/utils/__tests__/file-utils.test.ts @@ -0,0 +1,83 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { walkProjectFiles } from '@utils/file-utils'; +import { analytics } from '@utils/analytics'; +import { logToFile } from '@utils/debug'; + +vi.mock('@utils/analytics', () => ({ + analytics: { captureException: vi.fn() }, +})); +vi.mock('@utils/debug', () => ({ + logToFile: vi.fn(), +})); + +const captureException = + analytics.captureException as unknown as MockedFunction< + typeof analytics.captureException + >; +const mockLogToFile = logToFile as unknown as MockedFunction; + +describe('walkProjectFiles', () => { + let tmpDir: string; + + beforeEach(() => { + vi.clearAllMocks(); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wizard-walk-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('visits regular files', () => { + fs.writeFileSync(path.join(tmpDir, 'a.txt'), 'hi'); + const seen: string[] = []; + walkProjectFiles(tmpDir, (name) => seen.push(name)); + expect(seen).toContain('a.txt'); + }); + + it('skips a broken symlink without reporting it as an exception', () => { + fs.writeFileSync(path.join(tmpDir, 'real.txt'), 'hi'); + // Point a symlink at a target that does not exist → statSync throws ENOENT. + fs.symlinkSync( + path.join(tmpDir, 'does-not-exist'), + path.join(tmpDir, 'broken-link'), + ); + + const seen: string[] = []; + walkProjectFiles(tmpDir, (name) => seen.push(name)); + + // Traversal stays best-effort: the real file is still found. + expect(seen).toContain('real.txt'); + // The broken link is skipped (not delivered as a file). + expect(seen).not.toContain('broken-link'); + // And — the whole point — it is NOT reported to error tracking. + expect(captureException).not.toHaveBeenCalled(); + // It is downgraded to a log line instead. + expect(mockLogToFile).toHaveBeenCalledWith( + expect.stringContaining('ENOENT'), + ); + }); + + it('still reports an unexpected readdir failure as an exception', () => { + // A non-benign error code must still surface in error tracking. + const realReaddir = fs.readdirSync; + const spy = vi.spyOn(fs, 'readdirSync').mockImplementation((( + p: fs.PathLike, + opts: unknown, + ) => { + if (p === tmpDir) { + const err = new Error('boom') as NodeJS.ErrnoException; + err.code = 'EIO'; + throw err; + } + return realReaddir(p, opts as Parameters[1]); + }) as typeof fs.readdirSync); + + walkProjectFiles(tmpDir, () => undefined); + + expect(captureException).toHaveBeenCalledTimes(1); + spy.mockRestore(); + }); +}); diff --git a/src/utils/file-utils.ts b/src/utils/file-utils.ts index 4d0c9f3c4..a65fd4fce 100644 --- a/src/utils/file-utils.ts +++ b/src/utils/file-utils.ts @@ -2,19 +2,35 @@ import path from 'path'; import fs from 'fs'; import type { Dirent } from 'fs'; import { analytics } from './analytics'; +import { logToFile } from './debug'; import type { WizardRunOptions } from './types'; /** - * Report a swallowed filesystem error to error tracking. Traversal stays - * best-effort — the caller still skips the failing entry — but the failure is - * no longer silent. Preserves the original Error (and its `code`, e.g. EACCES - * / ENOENT) when available. + * Filesystem error codes that are expected during best-effort traversal of an + * arbitrary project tree: a broken symlink whose target is gone (ENOENT) or an + * entry we lack permission to read (EACCES / EPERM). The caller already skips + * these entries and keeps going, so they are normal operating conditions — not + * exceptions worth surfacing in error tracking. (A stale `~/Library/Logs` + * symlink alone produced 39 noise events across 3 users in ~4 hours.) + */ +const BENIGN_FS_ERROR_CODES = new Set(['ENOENT', 'EACCES', 'EPERM']); + +/** + * Report a swallowed filesystem error from best-effort traversal. Traversal + * still skips the failing entry either way; this just decides where the failure + * is recorded. Expected codes (broken symlinks, unreadable entries — see + * `BENIGN_FS_ERROR_CODES`) are downgraded to a debug log line so they don't + * clutter error tracking; genuinely unexpected failures are still captured as + * exceptions. Preserves the original Error (and its `code`) when available. */ function reportFsError(step: string, path: string, error: unknown): void { - analytics.captureException( - error instanceof Error ? error : new Error(String(error)), - { step, path }, - ); + const err = error instanceof Error ? error : new Error(String(error)); + const code = (error as NodeJS.ErrnoException | undefined)?.code; + if (code && BENIGN_FS_ERROR_CODES.has(code)) { + logToFile(`[fs] ${step} skipped ${path} (${code})`); + return; + } + analytics.captureException(err, { step, path }); } export function getDotGitignore({ @@ -67,8 +83,8 @@ export const IGNORED_DIRS = new Set([ * regular file — including dotfiles like `.env` (the caller decides what it * cares about). Skips `IGNORED_DIRS` and hidden directories, follows symlinked * directories with realpath-based loop protection, and descends at most - * `maxDepth` levels below `rootDir`. Filesystem errors are reported to error - * tracking and then skipped: a missing/unreadable root simply yields no + * `maxDepth` levels below `rootDir`. Filesystem errors are recorded (see + * `reportFsError`) and then skipped: a missing/unreadable root simply yields no * callbacks (best-effort). * * Shared by the detection layers (warehouse sources, etc.) so traversal policy