From 8965720df6c66f2ad2babcc4b2f9d47a37f7dc8e Mon Sep 17 00:00:00 2001 From: Greg Priday Date: Thu, 26 Feb 2026 14:06:20 +1100 Subject: [PATCH] feat(cli): add convenience filter flags --ext, --max-depth, --min-size, --max-size - Add --ext flag to filter by file extensions (comma-separated, e.g. .js,.ts) - Add --max-depth flag to limit directory traversal depth (0 = root only) - Add --min-size / --max-size flags for human-readable size filtering (1KB, 10MB) - Implement depth tracking in ignoreWalker.js (recursive) and parallelWalker.js (BFS) - Apply extension and size filters in FileDiscoveryStage after discovery - parseExtensions() validates non-empty input, throws CommandError on degenerate input - parseSizeOption() reuses existing parseSize() from helpers with friendly error messages - All four flags work in combination (AND logic) with existing --filter patterns - Force-included files (--always / .copytreeinclude) bypass ext/size filters by design - Add 18 unit tests for filter combinations in FileDiscoveryStage.filters.test.js - Add 4 maxDepth tests to ignoreWalker.test.js - Add 5 maxDepth tests to parallelWalker.test.js including sequential/parallel parity check --- bin/copytree.js | 19 ++ src/commands/copy.js | 49 +++- src/pipeline/stages/FileDiscoveryStage.js | 39 ++- src/utils/ignoreWalker.js | 9 +- src/utils/parallelWalker.js | 21 +- .../stages/FileDiscoveryStage.filters.test.js | 234 ++++++++++++++++++ tests/unit/utils/ignoreWalker.test.js | 61 +++++ tests/unit/utils/parallelWalker.test.js | 47 ++++ 8 files changed, 464 insertions(+), 15 deletions(-) create mode 100644 tests/unit/pipeline/stages/FileDiscoveryStage.filters.test.js diff --git a/bin/copytree.js b/bin/copytree.js index 3abd69f..245712b 100755 --- a/bin/copytree.js +++ b/bin/copytree.js @@ -93,6 +93,25 @@ program .option('--no-instructions', 'Disable including instructions in output') .option('--instructions ', 'Use custom instructions set (default: default)') .option('--no-validate', 'Disable configuration validation') + .option( + '--ext ', + 'Filter by file extensions, comma-separated (e.g., .js,.ts,.tsx or js,ts)', + ) + .option( + '--max-depth ', + 'Maximum directory traversal depth (0 = root files only, 1 = one level deep, etc.)', + (val) => { + const n = parseInt(val, 10); + if (isNaN(n) || n < 0) { + throw new InvalidArgumentError( + `'${val}' is not a valid depth. Must be a non-negative integer.`, + ); + } + return n; + }, + ) + .option('--min-size ', 'Exclude files smaller than this size (e.g., 1KB, 500B, 10MB)') + .option('--max-size ', 'Exclude files larger than this size (e.g., 10MB, 1GB)') .option('--secrets-guard', 'Enable automatic secret detection and redaction (default: enabled)') .option('--no-secrets-guard', 'Disable secret detection and redaction') .option( diff --git a/src/commands/copy.js b/src/commands/copy.js index 275cd80..c40f2d7 100644 --- a/src/commands/copy.js +++ b/src/commands/copy.js @@ -12,6 +12,7 @@ import { fileURLToPath } from 'url'; import { summarize as getFsErrorSummary, reset as resetFsErrors } from '../utils/fsErrorReport.js'; import FolderProfileLoader from '../config/FolderProfileLoader.js'; import { Profiler, writeProfilingReport } from '../utils/profiler.js'; +import { parseSize } from '../utils/helpers.js'; // Lazy initialization for Jest compatibility let __filename, __dirname, pkg; @@ -237,8 +238,44 @@ async function copyCommand(targetPath = '.', options = {}) { } /** - * Load and prepare profile + * Parse comma-separated file extensions into a normalized array. + * Accepts formats like ".js,.ts" or "js,ts" (with or without leading dot). + * Returns an array of lowercase extensions with leading dots, e.g., ['.js', '.ts']. + * + * @param {string} extStr - Comma-separated extension string from --ext flag + * @returns {string[]} Normalized extensions array */ +function parseExtensions(extStr) { + const exts = extStr + .split(',') + .map((e) => e.trim()) + .filter(Boolean) + .map((e) => (e.startsWith('.') ? e.toLowerCase() : `.${e.toLowerCase()}`)); + if (exts.length === 0) { + throw new CommandError( + `Invalid --ext value '${extStr}'. Provide at least one extension, e.g., .js,.ts`, + ); + } + return exts; +} + +/** + * Parse a human-readable size string to bytes, throwing a CommandError on invalid input. + * + * @param {string} sizeStr - Size string (e.g., '1KB', '10MB') + * @param {string} flagName - Flag name for error messages + * @returns {number} Size in bytes + */ +function parseSizeOption(sizeStr, flagName) { + try { + return parseSize(sizeStr); + } catch { + throw new CommandError( + `Invalid --${flagName} value '${sizeStr}'. Use a format like 1KB, 500B, 10MB, 1GB.`, + ); + } +} + /** * Build profile configuration from CLI options, folder profiles, and config defaults * Integrates the new FolderProfileLoader system @@ -320,6 +357,11 @@ async function buildProfileFromCliOptions(options) { maxFileSize: options.maxFileSize ?? copytreeConfig.maxFileSize, maxTotalSize: options.maxTotalSize ?? copytreeConfig.maxTotalSize, maxFileCount: options.maxFileCount ?? copytreeConfig.maxFileCount, + // Convenience filter flags + extFilter: options.ext ? parseExtensions(options.ext) : null, + maxDepth: options.maxDepth !== undefined ? options.maxDepth : null, + minSizeBytes: options.minSize ? parseSizeOption(options.minSize, 'min-size') : null, + maxSizeBytes: options.maxSize ? parseSizeOption(options.maxSize, 'max-size') : null, }, // Transformer configuration @@ -380,6 +422,11 @@ async function setupPipelineStages(basePath, profile, options) { maxTotalSize: profile.options?.maxTotalSize, maxFileCount: profile.options?.maxFileCount, forceInclude: mergedAlways, + // Convenience filter flags + extFilter: profile.options?.extFilter ?? null, + maxDepth: profile.options?.maxDepth ?? null, + minSizeBytes: profile.options?.minSizeBytes ?? null, + maxSizeBytes: profile.options?.maxSizeBytes ?? null, }), ); diff --git a/src/pipeline/stages/FileDiscoveryStage.js b/src/pipeline/stages/FileDiscoveryStage.js index 9b0c015..7aa51c2 100644 --- a/src/pipeline/stages/FileDiscoveryStage.js +++ b/src/pipeline/stages/FileDiscoveryStage.js @@ -27,6 +27,18 @@ class FileDiscoveryStage extends Stage { this.respectGitignore = options.respectGitignore !== false; this.forceInclude = options.forceInclude || []; this.excludes = options.excludes || []; + // Convenience filter options + this.extFilter = options.extFilter || null; // e.g. ['.js', '.ts'] + this.maxDepth = + options.maxDepth !== undefined && options.maxDepth !== null ? options.maxDepth : null; + this.minSizeBytes = + options.minSizeBytes !== undefined && options.minSizeBytes !== null + ? options.minSizeBytes + : null; + this.maxSizeBytes = + options.maxSizeBytes !== undefined && options.maxSizeBytes !== null + ? options.maxSizeBytes + : null; } async process(input) { @@ -158,6 +170,7 @@ class FileDiscoveryStage extends Stage { explain: this.options.explain || false, initialLayers, config: this.config.all(), // Pass full config for retry settings + maxDepth: this.maxDepth !== null ? this.maxDepth : undefined, }; // Add parallel-specific options if enabled @@ -183,10 +196,21 @@ class FileDiscoveryStage extends Stage { if (!matches) continue; } + // Apply extension filter (--ext) + if (this.extFilter && this.extFilter.length > 0) { + const ext = path.extname(relativePath).toLowerCase(); + if (!this.extFilter.includes(ext)) continue; + } + + // Apply size filters (--min-size, --max-size) + const fileSize = fileInfo.stats.size; + if (this.minSizeBytes !== null && fileSize < this.minSizeBytes) continue; + if (this.maxSizeBytes !== null && fileSize > this.maxSizeBytes) continue; + discoveredFiles.push({ path: relativePath, absolutePath: fileInfo.path, - size: fileInfo.stats.size, + size: fileSize, modified: fileInfo.stats.mtime, stats: fileInfo.stats, }); @@ -232,8 +256,17 @@ class FileDiscoveryStage extends Stage { const byPath = new Map(merged.map((f) => [f.path, f])); const finalFiles = [...byPath.values()]; + const filterDesc = [ + this.extFilter ? `ext: ${this.extFilter.join(',')}` : null, + this.maxDepth !== null ? `max-depth: ${this.maxDepth}` : null, + this.minSizeBytes !== null ? `min-size: ${this.minSizeBytes}B` : null, + this.maxSizeBytes !== null ? `max-size: ${this.maxSizeBytes}B` : null, + ] + .filter(Boolean) + .join(', '); + this.log( - `Discovered ${finalFiles.length} files (${forcedEntries.length} force-included) in ${this.getElapsedTime(startTime)}`, + `Discovered ${finalFiles.length} files (${forcedEntries.length} force-included)${filterDesc ? ` [filters: ${filterDesc}]` : ''} in ${this.getElapsedTime(startTime)}`, 'info', ); @@ -243,7 +276,7 @@ class FileDiscoveryStage extends Stage { files: finalFiles, stats: { totalFiles: discoveredFiles.length, - filteredFiles: discoveredFiles.length, // No post-filtering needed + filteredFiles: discoveredFiles.length, forcedFiles: forcedEntries.length, excludedFiles: 0, // Not tracked with walker approach }, diff --git a/src/utils/ignoreWalker.js b/src/utils/ignoreWalker.js index 8b28a70..b31ecf4 100644 --- a/src/utils/ignoreWalker.js +++ b/src/utils/ignoreWalker.js @@ -147,6 +147,7 @@ export async function* walkWithIgnore(root, options = {}) { initialLayers = [], config = {}, cache = false, + maxDepth = undefined, } = options; // Extract retry configuration with defaults @@ -162,7 +163,7 @@ export async function* walkWithIgnore(root, options = {}) { stats.directoriesPruned = 0; stats.filesExcluded = 0; - async function* walk(dir, layers) { + async function* walk(dir, layers, depth = 0) { stats.directoriesScanned++; // Load ignore rules at this level @@ -262,8 +263,10 @@ export async function* walkWithIgnore(root, options = {}) { yield result; } - // Recurse into subdirectory - yield* walk(absPath, nextLayers); + // Recurse into subdirectory (respect maxDepth if set) + if (maxDepth === undefined || depth < maxDepth) { + yield* walk(absPath, nextLayers, depth + 1); + } } else { stats.filesScanned++; diff --git a/src/utils/parallelWalker.js b/src/utils/parallelWalker.js index 584ef6c..e425481 100644 --- a/src/utils/parallelWalker.js +++ b/src/utils/parallelWalker.js @@ -119,6 +119,7 @@ export async function* walkParallel(root, options = {}) { concurrency = 5, highWaterMark = concurrency * 2, signal, + maxDepth = undefined, } = options; // Extract retry configuration with defaults @@ -213,8 +214,9 @@ export async function* walkParallel(root, options = {}) { * @param {string} dir - Parent directory path * @param {fs.Dirent} entry - Directory entry * @param {Array} layers - Current ignore layers + * @param {number} depth - Current traversal depth (0 = root) */ - async function processEntry(dir, entry, layers) { + async function processEntry(dir, entry, layers, depth) { if (signal?.aborted) { throw new Error('Traversal aborted'); } @@ -288,8 +290,10 @@ export async function* walkParallel(root, options = {}) { queuedResult = result; } - // Add subdirectory to queue for processing - queue.push({ dir: absPath, layers }); + // Add subdirectory to queue for processing (respect maxDepth) + if (maxDepth === undefined || depth < maxDepth) { + queue.push({ dir: absPath, layers, depth: depth + 1 }); + } return queuedResult; } else { stats.filesScanned++; @@ -329,8 +333,9 @@ export async function* walkParallel(root, options = {}) { * Process a single directory: read entries and schedule them * @param {string} dir - Directory path * @param {Array} layers - Current ignore layers + * @param {number} depth - Current traversal depth (0 = root) */ - async function processDirectory(dir, layers) { + async function processDirectory(dir, layers, depth) { stats.directoriesScanned++; // Load ignore rules at this level @@ -368,13 +373,13 @@ export async function* walkParallel(root, options = {}) { // Process all entries in this directory in parallel (bounded by limit) const entryResults = await Promise.all( - entries.map((entry) => limit(() => processEntry(dir, entry, nextLayers))), + entries.map((entry) => limit(() => processEntry(dir, entry, nextLayers, depth))), ); return entryResults.filter(Boolean); } // Initialize queue with root directory - queue.push({ dir: root, layers: initialLayers }); + queue.push({ dir: root, layers: initialLayers, depth: 0 }); // Main traversal loop try { @@ -386,8 +391,8 @@ export async function* walkParallel(root, options = {}) { } while (queue.length > 0 && running.size < concurrency) { - const { dir, layers } = queue.shift(); - const task = processDirectory(dir, layers).then(async (results) => { + const { dir, layers, depth } = queue.shift(); + const task = processDirectory(dir, layers, depth).then(async (results) => { // Guard against undefined results (when readdir fails) if (results) { for (const result of results) { diff --git a/tests/unit/pipeline/stages/FileDiscoveryStage.filters.test.js b/tests/unit/pipeline/stages/FileDiscoveryStage.filters.test.js new file mode 100644 index 0000000..9cd5233 --- /dev/null +++ b/tests/unit/pipeline/stages/FileDiscoveryStage.filters.test.js @@ -0,0 +1,234 @@ +// Unmock fs-extra for this test (real filesystem operations) +jest.unmock('fs-extra'); + +import fs from 'fs-extra'; +import path from 'path'; +import os from 'os'; +import { randomUUID } from 'crypto'; + +let FileDiscoveryStage; + +beforeAll(async () => { + const mod = await import('../../../../src/pipeline/stages/FileDiscoveryStage.js'); + FileDiscoveryStage = mod.default; +}); + +/** + * Helper to create a stage with sensible defaults for filter tests. + */ +function makeStage(overrides = {}) { + return new FileDiscoveryStage({ + patterns: ['**/*'], + respectGitignore: false, + ...overrides, + }); +} + +/** + * Helper to run the stage and return file paths sorted alphabetically. + */ +async function discover(stage, basePath) { + const result = await stage.process({ basePath, options: {} }); + return result.files.map((f) => f.path).sort(); +} + +describe('FileDiscoveryStage — convenience filter flags', () => { + let tempDir; + + beforeEach(async () => { + tempDir = path.join(os.tmpdir(), `copytree-filters-${randomUUID()}`); + await fs.ensureDir(tempDir); + }); + + afterEach(async () => { + await fs.remove(tempDir); + }); + + // ─── Extension filter (--ext) ─────────────────────────────────────────────── + + describe('extFilter (--ext)', () => { + beforeEach(async () => { + await fs.ensureDir(path.join(tempDir, 'src')); + await fs.writeFile(path.join(tempDir, 'index.js'), 'js'); + await fs.writeFile(path.join(tempDir, 'index.ts'), 'ts'); + await fs.writeFile(path.join(tempDir, 'README.md'), 'md'); + await fs.writeFile(path.join(tempDir, 'src', 'util.js'), 'js'); + await fs.writeFile(path.join(tempDir, 'src', 'types.ts'), 'ts'); + }); + + it('should return only files matching a single extension', async () => { + const stage = makeStage({ basePath: tempDir, extFilter: ['.js'] }); + const paths = await discover(stage, tempDir); + expect(paths).toEqual(['index.js', 'src/util.js']); + }); + + it('should return files matching multiple extensions', async () => { + const stage = makeStage({ basePath: tempDir, extFilter: ['.js', '.ts'] }); + const paths = await discover(stage, tempDir); + expect(paths).toEqual(['index.js', 'index.ts', 'src/types.ts', 'src/util.js']); + }); + + it('should be case-insensitive for extensions', async () => { + await fs.writeFile(path.join(tempDir, 'image.JS'), 'content'); + const stage = makeStage({ basePath: tempDir, extFilter: ['.js'] }); + const paths = await discover(stage, tempDir); + // Both .js and .JS files should match + expect(paths).toContain('index.js'); + expect(paths).toContain('image.JS'); + expect(paths).not.toContain('README.md'); + expect(paths).not.toContain('index.ts'); + }); + + it('should return nothing when no files match the extension', async () => { + const stage = makeStage({ basePath: tempDir, extFilter: ['.py'] }); + const paths = await discover(stage, tempDir); + expect(paths).toHaveLength(0); + }); + + it('should work in combination with --filter patterns (AND logic)', async () => { + // --filter limits to src/** only; --ext limits to .ts — both must match + const stage = makeStage({ + basePath: tempDir, + patterns: ['src/**'], + extFilter: ['.ts'], + }); + const paths = await discover(stage, tempDir); + expect(paths).toEqual(['src/types.ts']); + }); + }); + + // ─── Max depth (--max-depth) ───────────────────────────────────────────────── + + describe('maxDepth (--max-depth)', () => { + beforeEach(async () => { + await fs.ensureDir(path.join(tempDir, 'a')); + await fs.ensureDir(path.join(tempDir, 'a', 'b')); + await fs.ensureDir(path.join(tempDir, 'a', 'b', 'c')); + await fs.writeFile(path.join(tempDir, 'root.txt'), 'root'); + await fs.writeFile(path.join(tempDir, 'a', 'depth1.txt'), 'd1'); + await fs.writeFile(path.join(tempDir, 'a', 'b', 'depth2.txt'), 'd2'); + await fs.writeFile(path.join(tempDir, 'a', 'b', 'c', 'depth3.txt'), 'd3'); + }); + + it('should return only root-level files at maxDepth=0', async () => { + const stage = makeStage({ basePath: tempDir, maxDepth: 0 }); + const paths = await discover(stage, tempDir); + expect(paths).toEqual(['root.txt']); + }); + + it('should include one level deep at maxDepth=1', async () => { + const stage = makeStage({ basePath: tempDir, maxDepth: 1 }); + const paths = await discover(stage, tempDir); + expect(paths).toEqual(['a/depth1.txt', 'root.txt']); + }); + + it('should include two levels deep at maxDepth=2', async () => { + const stage = makeStage({ basePath: tempDir, maxDepth: 2 }); + const paths = await discover(stage, tempDir); + expect(paths).toEqual(['a/b/depth2.txt', 'a/depth1.txt', 'root.txt']); + }); + + it('should include all files when maxDepth exceeds actual depth', async () => { + const stage = makeStage({ basePath: tempDir, maxDepth: 100 }); + const paths = await discover(stage, tempDir); + expect(paths).toEqual(['a/b/c/depth3.txt', 'a/b/depth2.txt', 'a/depth1.txt', 'root.txt']); + }); + + it('should include all files when maxDepth is not set', async () => { + const stage = makeStage({ basePath: tempDir }); + const paths = await discover(stage, tempDir); + expect(paths).toEqual(['a/b/c/depth3.txt', 'a/b/depth2.txt', 'a/depth1.txt', 'root.txt']); + }); + + it('should return empty array at maxDepth=0 when root has no files (only subdirs)', async () => { + // Create a directory with ONLY subdirectories at the root level + const subdirOnlyDir = path.join(tempDir, 'subdir-only'); + await fs.ensureDir(path.join(subdirOnlyDir, 'sub')); + await fs.writeFile(path.join(subdirOnlyDir, 'sub', 'nested.txt'), 'nested'); + const stage = makeStage({ basePath: subdirOnlyDir, maxDepth: 0 }); + const paths = await discover(stage, subdirOnlyDir); + expect(paths).toHaveLength(0); + }); + }); + + // ─── Min/max size filters (--min-size / --max-size) ────────────────────────── + + describe('size filters (--min-size / --max-size)', () => { + beforeEach(async () => { + // Create files of known sizes + await fs.writeFile(path.join(tempDir, 'empty.txt'), ''); // 0 bytes + await fs.writeFile(path.join(tempDir, 'small.txt'), 'a'.repeat(100)); // 100 bytes + await fs.writeFile(path.join(tempDir, 'medium.txt'), 'b'.repeat(1000)); // 1000 bytes + await fs.writeFile(path.join(tempDir, 'large.txt'), 'c'.repeat(10000)); // 10000 bytes + }); + + it('should exclude files smaller than minSizeBytes', async () => { + const stage = makeStage({ basePath: tempDir, minSizeBytes: 500 }); + const paths = await discover(stage, tempDir); + expect(paths).toEqual(['large.txt', 'medium.txt']); + }); + + it('should exclude files larger than maxSizeBytes', async () => { + const stage = makeStage({ basePath: tempDir, maxSizeBytes: 500 }); + const paths = await discover(stage, tempDir); + expect(paths).toEqual(['empty.txt', 'small.txt']); + }); + + it('should apply both min and max size filters simultaneously', async () => { + const stage = makeStage({ basePath: tempDir, minSizeBytes: 50, maxSizeBytes: 5000 }); + const paths = await discover(stage, tempDir); + expect(paths).toEqual(['medium.txt', 'small.txt']); + }); + + it('should include files exactly at the boundary values', async () => { + const stage = makeStage({ basePath: tempDir, minSizeBytes: 100, maxSizeBytes: 1000 }); + const paths = await discover(stage, tempDir); + expect(paths).toContain('small.txt'); // exactly 100 bytes + expect(paths).toContain('medium.txt'); // exactly 1000 bytes + }); + + it('should return nothing when size range excludes all files', async () => { + const stage = makeStage({ basePath: tempDir, minSizeBytes: 50000 }); + const paths = await discover(stage, tempDir); + expect(paths).toHaveLength(0); + }); + + it('should include all files when no size filters are set', async () => { + const stage = makeStage({ basePath: tempDir }); + const paths = await discover(stage, tempDir); + expect(paths).toHaveLength(4); + }); + }); + + // ─── Combined filters ──────────────────────────────────────────────────────── + + describe('combined filters', () => { + beforeEach(async () => { + await fs.ensureDir(path.join(tempDir, 'src')); + await fs.ensureDir(path.join(tempDir, 'src', 'deep')); + await fs.writeFile(path.join(tempDir, 'root.js'), 'a'.repeat(200)); + await fs.writeFile(path.join(tempDir, 'root.md'), 'b'.repeat(200)); + await fs.writeFile(path.join(tempDir, 'src', 'lib.js'), 'c'.repeat(5000)); + await fs.writeFile(path.join(tempDir, 'src', 'lib.ts'), 'd'.repeat(50)); + await fs.writeFile(path.join(tempDir, 'src', 'deep', 'nested.js'), 'e'.repeat(300)); + }); + + it('should combine ext, maxDepth, and size filters', async () => { + const stage = makeStage({ + basePath: tempDir, + extFilter: ['.js'], + maxDepth: 1, + minSizeBytes: 100, + maxSizeBytes: 1000, + }); + const paths = await discover(stage, tempDir); + // Only .js files, max 1 level deep, between 100 and 1000 bytes + // root.js (200B, depth 0) ✓ + // root.md — wrong ext + // src/lib.js (5000B) — too large + // src/lib.ts — wrong ext + // src/deep/nested.js — depth 2, excluded + expect(paths).toEqual(['root.js']); + }); + }); +}); diff --git a/tests/unit/utils/ignoreWalker.test.js b/tests/unit/utils/ignoreWalker.test.js index 0ea37b4..fba28ee 100644 --- a/tests/unit/utils/ignoreWalker.test.js +++ b/tests/unit/utils/ignoreWalker.test.js @@ -303,4 +303,65 @@ describe('ignoreWalker', () => { }); }); }); + + describe('maxDepth traversal limiting', () => { + it('should return only root-level files at maxDepth=0', async () => { + await withTempDir('depth-0', async (tempDir) => { + await createProject(tempDir, { + 'root.txt': 'root', + 'sub/depth1.txt': 'd1', + 'sub/deep/depth2.txt': 'd2', + }); + await settleFs(50); + + const result = await getAllFiles(tempDir, { maxDepth: 0 }); + const names = result.map((f) => path.basename(f.path)).sort(); + expect(names).toEqual(['root.txt']); + }); + }); + + it('should include one directory level at maxDepth=1', async () => { + await withTempDir('depth-1', async (tempDir) => { + await createProject(tempDir, { + 'root.txt': 'root', + 'sub/depth1.txt': 'd1', + 'sub/deep/depth2.txt': 'd2', + }); + await settleFs(50); + + const result = await getAllFiles(tempDir, { maxDepth: 1 }); + const names = result.map((f) => path.basename(f.path)).sort(); + expect(names).toEqual(['depth1.txt', 'root.txt']); + }); + }); + + it('should include two directory levels at maxDepth=2', async () => { + await withTempDir('depth-2', async (tempDir) => { + await createProject(tempDir, { + 'root.txt': 'root', + 'sub/depth1.txt': 'd1', + 'sub/deep/depth2.txt': 'd2', + 'sub/deep/deeper/depth3.txt': 'd3', + }); + await settleFs(50); + + const result = await getAllFiles(tempDir, { maxDepth: 2 }); + const names = result.map((f) => path.basename(f.path)).sort(); + expect(names).toEqual(['depth1.txt', 'depth2.txt', 'root.txt']); + }); + }); + + it('should include all files when maxDepth is undefined', async () => { + await withTempDir('depth-unlimited', async (tempDir) => { + await createProject(tempDir, { + 'root.txt': 'root', + 'a/b/c/deep.txt': 'deep', + }); + await settleFs(50); + + const result = await getAllFiles(tempDir, { maxDepth: undefined }); + expect(result).toHaveLength(2); + }); + }); + }); }); diff --git a/tests/unit/utils/parallelWalker.test.js b/tests/unit/utils/parallelWalker.test.js index fcb75c9..74cec6a 100644 --- a/tests/unit/utils/parallelWalker.test.js +++ b/tests/unit/utils/parallelWalker.test.js @@ -280,4 +280,51 @@ describe('parallelWalker', () => { expect(files[0].explanation.ignored).toBe(false); }); }); + + describe('maxDepth traversal limiting', () => { + let deepDir; + + beforeEach(async () => { + deepDir = path.join(testDir, 'root'); + await fs.ensureDir(path.join(deepDir, 'a', 'b', 'c')); + await fs.writeFile(path.join(deepDir, 'depth0.txt'), 'root'); + await fs.writeFile(path.join(deepDir, 'a', 'depth1.txt'), 'd1'); + await fs.writeFile(path.join(deepDir, 'a', 'b', 'depth2.txt'), 'd2'); + await fs.writeFile(path.join(deepDir, 'a', 'b', 'c', 'depth3.txt'), 'd3'); + }); + + it('should return only root-level files at maxDepth=0', async () => { + const files = await getAllFilesParallel(deepDir, { concurrency: 2, maxDepth: 0 }); + const names = files.map((f) => path.basename(f.path)).sort(); + expect(names).toEqual(['depth0.txt']); + }); + + it('should include one directory level at maxDepth=1', async () => { + const files = await getAllFilesParallel(deepDir, { concurrency: 2, maxDepth: 1 }); + const names = files.map((f) => path.basename(f.path)).sort(); + expect(names).toEqual(['depth0.txt', 'depth1.txt']); + }); + + it('should include two directory levels at maxDepth=2', async () => { + const files = await getAllFilesParallel(deepDir, { concurrency: 2, maxDepth: 2 }); + const names = files.map((f) => path.basename(f.path)).sort(); + expect(names).toEqual(['depth0.txt', 'depth1.txt', 'depth2.txt']); + }); + + it('should include all files when maxDepth is undefined', async () => { + const files = await getAllFilesParallel(deepDir, { concurrency: 2, maxDepth: undefined }); + const names = files.map((f) => path.basename(f.path)).sort(); + expect(names).toEqual(['depth0.txt', 'depth1.txt', 'depth2.txt', 'depth3.txt']); + }); + + it('should produce the same results as ignoreWalker for maxDepth=1', async () => { + const { getAllFiles } = await import('../../../src/utils/ignoreWalker.js'); + const parallelFiles = await getAllFilesParallel(deepDir, { concurrency: 2, maxDepth: 1 }); + const sequentialFiles = await getAllFiles(deepDir, { maxDepth: 1 }); + + const parallelPaths = parallelFiles.map((f) => f.path).sort(); + const sequentialPaths = sequentialFiles.map((f) => f.path).sort(); + expect(parallelPaths).toEqual(sequentialPaths); + }); + }); });