Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions src/utils/__tests__/file-utils.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof logToFile>;

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<typeof fs.readdirSync>[1]);
}) as typeof fs.readdirSync);

walkProjectFiles(tmpDir, () => undefined);

expect(captureException).toHaveBeenCalledTimes(1);
spy.mockRestore();
});
});
36 changes: 26 additions & 10 deletions src/utils/file-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -67,8 +83,8 @@ export const IGNORED_DIRS = new Set<string>([
* 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
Expand Down
Loading