filemayor 2.1.0 → 3.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/core/organizer.js CHANGED
@@ -14,7 +14,10 @@ const fs = require('fs');
14
14
  const path = require('path');
15
15
  const { categorize, getCategories } = require('./categories');
16
16
  const { scan } = require('./scanner');
17
- const { validatePath, isFileSafe, sanitizeFilename, canWrite, createSnapshot } = require('./security');
17
+ const {
18
+ validatePath, isFileSafe, sanitizeFilename, canWrite, createSnapshot,
19
+ findNearestSubstrate
20
+ } = require('./security');
18
21
  const { formatBytes } = require('./scanner');
19
22
 
20
23
  // ─── Naming Conventions ───────────────────────────────────────────
@@ -175,6 +178,13 @@ function generatePlan(srcDir, options = {}) {
175
178
 
176
179
  // Build the plan
177
180
  for (const file of scanResult.files) {
181
+ // Substrate Protection (v3.5)
182
+ // If file is inside a project substrate, DO NOT scatter it by default
183
+ const projectRoot = findNearestSubstrate(file.path);
184
+ if (projectRoot) {
185
+ continue; // Skip project-locked files
186
+ }
187
+
178
188
  const category = file.category;
179
189
  categoryCounts[category] = (categoryCounts[category] || 0);
180
190
 
@@ -241,22 +251,23 @@ function generatePlan(srcDir, options = {}) {
241
251
 
242
252
  // ─── Execution ────────────────────────────────────────────────────
243
253
 
254
+ const FileMayorFS = require('./fs-abstraction');
255
+ const fmfs = new FileMayorFS();
256
+
244
257
  /**
245
258
  * Execute a move plan with journal logging for rollback
246
259
  * @param {MovePlanItem[]} plan - The move plan to execute
247
260
  * @param {Object} options - Execution options
248
261
  * @returns {{ results: Object[], journal: Object[], summary: Object }}
249
262
  */
250
- function executePlan(plan, options = {}) {
263
+ async function executePlan(plan, options = {}) {
251
264
  const {
252
265
  onProgress = null,
253
266
  onError = null,
254
- journalPath = null, // Path to save rollback journal
255
- abortOnError = false, // Stop on first error
267
+ abortOnError = false,
256
268
  } = options;
257
269
 
258
270
  const results = [];
259
- const journal = [];
260
271
  let succeeded = 0;
261
272
  let failed = 0;
262
273
  let skipped = 0;
@@ -264,7 +275,6 @@ function executePlan(plan, options = {}) {
264
275
  for (let i = 0; i < plan.length; i++) {
265
276
  const item = plan[i];
266
277
 
267
- // Progress callback
268
278
  if (onProgress) {
269
279
  onProgress({
270
280
  current: i + 1,
@@ -276,104 +286,21 @@ function executePlan(plan, options = {}) {
276
286
  });
277
287
  }
278
288
 
279
- // Skip items marked as skip
280
289
  if (item.action === 'skip') {
281
290
  results.push({ ...item, status: 'skipped', error: null });
282
291
  skipped++;
283
292
  continue;
284
293
  }
285
294
 
286
- // Safety check
287
- const safeCheck = isFileSafe(item.source);
288
- if (!safeCheck.safe) {
289
- const error = `Safety check failed: ${safeCheck.reason}`;
290
- results.push({ ...item, status: 'error', error });
291
- failed++;
292
- if (onError) onError({ item, error });
293
- if (abortOnError) break;
294
- continue;
295
- }
296
-
297
- // Create destination directory
298
- const destDir = path.dirname(item.destination);
299
295
  try {
300
- fs.mkdirSync(destDir, { recursive: true });
301
- } catch (err) {
302
- const error = `Failed to create directory: ${err.message}`;
303
- results.push({ ...item, status: 'error', error });
304
- failed++;
305
- if (onError) onError({ item, error });
306
- if (abortOnError) break;
307
- continue;
308
- }
309
-
310
- // Create pre-move snapshot for integrity
311
- const snapshot = createSnapshot(item.source);
312
-
313
- // Execute the move
314
- try {
315
- // Handle overwrite
316
- if (item.action === 'overwrite' && fs.existsSync(item.destination)) {
317
- fs.unlinkSync(item.destination);
318
- }
319
-
320
- // Perform the move
321
- fs.renameSync(item.source, item.destination);
322
-
323
- // Journal entry for rollback
324
- const journalEntry = {
325
- source: item.source,
326
- destination: item.destination,
327
- originalName: item.originalName,
328
- newName: item.newName,
329
- size: item.size,
330
- timestamp: Date.now(),
331
- snapshot
332
- };
333
- journal.push(journalEntry);
334
-
296
+ await fmfs.move(item.source, item.destination);
335
297
  results.push({ ...item, status: 'success', error: null });
336
298
  succeeded++;
337
299
  } catch (err) {
338
- // If rename fails (cross-device), try copy + delete
339
- try {
340
- fs.copyFileSync(item.source, item.destination);
341
- fs.unlinkSync(item.source);
342
-
343
- const journalEntry = {
344
- source: item.source,
345
- destination: item.destination,
346
- originalName: item.originalName,
347
- newName: item.newName,
348
- size: item.size,
349
- timestamp: Date.now(),
350
- snapshot,
351
- crossDevice: true
352
- };
353
- journal.push(journalEntry);
354
-
355
- results.push({ ...item, status: 'success', error: null });
356
- succeeded++;
357
- } catch (copyErr) {
358
- const error = `Move failed: ${copyErr.message}`;
359
- results.push({ ...item, status: 'error', error });
360
- failed++;
361
- if (onError) onError({ item, error });
362
- if (abortOnError) break;
363
- }
364
- }
365
- }
366
-
367
- // Save journal to disk if path provided
368
- if (journalPath && journal.length > 0) {
369
- try {
370
- const existing = fs.existsSync(journalPath)
371
- ? JSON.parse(fs.readFileSync(journalPath, 'utf8'))
372
- : [];
373
- const merged = [...existing, ...journal];
374
- fs.writeFileSync(journalPath, JSON.stringify(merged, null, 2), 'utf8');
375
- } catch (err) {
376
- // Journal save failed — non-fatal
300
+ results.push({ ...item, status: 'error', error: err.message });
301
+ failed++;
302
+ if (onError) onError({ item, error: err.message });
303
+ if (abortOnError) break;
377
304
  }
378
305
  }
379
306
 
@@ -386,7 +313,7 @@ function executePlan(plan, options = {}) {
386
313
  totalSizeHuman: formatBytes(plan.reduce((sum, p) => sum + p.size, 0))
387
314
  };
388
315
 
389
- return { results, journal, summary };
316
+ return { results, journal: fmfs.getJournal(), summary };
390
317
  }
391
318
 
392
319
  // ─── Rollback (Undo) ──────────────────────────────────────────────
@@ -395,50 +322,11 @@ function executePlan(plan, options = {}) {
395
322
  * Rollback operations using a journal
396
323
  * @param {Object[]} journal - Journal entries from executePlan
397
324
  * @param {Object} options - Rollback options
398
- * @returns {{ undone: number, failed: number, errors: string[] }}
399
325
  */
400
- function rollback(journal, options = {}) {
401
- const { onProgress = null } = options;
402
- let undone = 0;
403
- let failed = 0;
404
- const errors = [];
405
-
406
- // Process in reverse order (most recent first)
407
- const reversed = [...journal].reverse();
408
-
409
- for (let i = 0; i < reversed.length; i++) {
410
- const entry = reversed[i];
411
-
412
- if (onProgress) {
413
- onProgress({
414
- current: i + 1,
415
- total: reversed.length,
416
- percent: Math.round(((i + 1) / reversed.length) * 100),
417
- file: entry.originalName
418
- });
419
- }
420
-
421
- try {
422
- // Ensure source directory exists
423
- const sourceDir = path.dirname(entry.source);
424
- fs.mkdirSync(sourceDir, { recursive: true });
425
-
426
- // Move back
427
- if (entry.crossDevice) {
428
- fs.copyFileSync(entry.destination, entry.source);
429
- fs.unlinkSync(entry.destination);
430
- } else {
431
- fs.renameSync(entry.destination, entry.source);
432
- }
433
-
434
- undone++;
435
- } catch (err) {
436
- failed++;
437
- errors.push(`Failed to undo ${entry.originalName}: ${err.message}`);
438
- }
439
- }
440
-
441
- return { undone, failed, errors };
326
+ async function rollback(journal, options = {}) {
327
+ // Fill the fmfs internal journal with the passed journal to use its logic
328
+ fmfs.sessionJournal = journal;
329
+ return await fmfs.rollback();
442
330
  }
443
331
 
444
332
  /**
@@ -463,13 +351,12 @@ function loadJournal(journalPath) {
463
351
  * @param {Object} options - Full options
464
352
  * @returns {{ results: Object[], journal: Object[], planSummary: Object, execSummary: Object }}
465
353
  */
466
- function organize(dirPath, options = {}) {
354
+ async function organize(dirPath, options = {}) {
467
355
  const {
468
356
  dryRun = false,
469
357
  naming = 'original',
470
358
  outputDir = null,
471
359
  duplicateStrategy = 'rename',
472
- journalPath = null,
473
360
  onProgress = null,
474
361
  onError = null,
475
362
  abortOnError = false,
@@ -498,10 +385,9 @@ function organize(dirPath, options = {}) {
498
385
  }
499
386
 
500
387
  // Execute
501
- const { results, journal, summary: execSummary } = executePlan(plan, {
388
+ const { results, journal, summary: execSummary } = await executePlan(plan, {
502
389
  onProgress,
503
390
  onError,
504
- journalPath: journalPath || path.join(dirPath, '.filemayor-journal.json'),
505
391
  abortOnError
506
392
  });
507
393
 
package/core/reporter.js CHANGED
@@ -163,11 +163,12 @@ function formatTable(data, options = {}) {
163
163
  * @returns {string}
164
164
  */
165
165
  function progressBar(percent, width = 30) {
166
- const filled = Math.round((percent / 100) * width);
166
+ const clamped = Math.max(0, Math.min(100, percent || 0));
167
+ const filled = Math.round((clamped / 100) * width);
167
168
  const empty = width - filled;
168
169
  const bar = c('green', SYMBOLS.bar.repeat(filled)) +
169
170
  c('dim', SYMBOLS.lightBar.repeat(empty));
170
- return `${bar} ${c('bold', `${percent}%`)}`;
171
+ return `${bar} ${c('bold', `${clamped}%`)}`;
171
172
  }
172
173
 
173
174
  // ─── Spinner ──────────────────────────────────────────────────────
@@ -313,6 +314,13 @@ function formatScanCSV(result) {
313
314
  // ─── Organize Report ──────────────────────────────────────────────
314
315
 
315
316
  function formatOrganizeReport(result, format = 'table') {
317
+ // Defensive: handle empty/incomplete results
318
+ if (!result || (!result.plan && !result.planSummary)) {
319
+ return `\n ${c('green', SYMBOLS.check)} Nothing to organize — folder is already clean.\n`;
320
+ }
321
+ if (!result.plan) result.plan = [];
322
+ if (!result.planSummary) result.planSummary = { categories: {}, totalFiles: 0, totalSizeHuman: '0 B' };
323
+
316
324
  switch (format) {
317
325
  case 'json':
318
326
  return JSON.stringify(result, null, 2);
@@ -633,6 +641,125 @@ function formatAnalyzeReport(result, format = 'table') {
633
641
  return lines.join('\n');
634
642
  }
635
643
 
644
+ // ─── Diagnostic (Explain) Report ───────────────────────────────────
645
+
646
+ /**
647
+ * Format an explain/diagnostic result
648
+ * @param {Object} result - Result from ExplainEngine
649
+ * @returns {string}
650
+ */
651
+ function formatExplainReport(result) {
652
+ const lines = [];
653
+ const health = result.health;
654
+
655
+ lines.push('');
656
+ lines.push(c('bold', ` ${health.score > 70 ? SYMBOLS.sparkle : SYMBOLS.warning} ${result.title}`));
657
+ lines.push(c('dim', ` ${'─'.repeat(40)}`));
658
+
659
+ lines.push(` ${c('bold', 'Target:')} ${c('cyan', result.path)}`);
660
+
661
+ // Health Gauge
662
+ const gauge = progressBar(health.score, 20);
663
+ lines.push(` ${c('bold', 'Health:')} ${c('bold', health.label)} (${gauge})`);
664
+
665
+ lines.push('');
666
+ lines.push(c('bold', ' Issues Detected:'));
667
+ if (result.insights.length === 0) {
668
+ lines.push(` ${c('green', SYMBOLS.check)} No major issues identified. Clean as a whistle.`);
669
+ } else {
670
+ for (const insight of result.insights) {
671
+ lines.push(` ${c('yellow', SYMBOLS.bullet)} ${insight}`);
672
+ }
673
+ }
674
+
675
+ lines.push('');
676
+ lines.push(c('bold', ' Suggested Treatment:'));
677
+ lines.push(` ${c('dim', 'Run')} ${c('yellow', 'filemayor cure')} ${c('dim', 'to generate an AI-powered curative plan.')}`);
678
+ lines.push('');
679
+
680
+ return lines.join('\n');
681
+ }
682
+
683
+ /**
684
+ * Format a curative plan for terminal display
685
+ */
686
+ function formatCureReport(plan) {
687
+ const lines = [];
688
+ lines.push('');
689
+ lines.push(c('bold', ` ${SYMBOLS.shield} CURATIVE PLAN GENERATED`));
690
+ lines.push(c('dim', ` ${'─'.repeat(40)}`));
691
+
692
+ lines.push(` ${c('bold', 'Strategy:')} ${plan.narrative}`);
693
+ lines.push(` ${c('bold', 'Confidence:')} ${plan.confidence}%`);
694
+
695
+ lines.push('');
696
+ lines.push(c('bold', ' Proposed Actions:'));
697
+ const preview = plan.plan.slice(0, 10).map(p => ({
698
+ file: p.source.split(/[\\/]/).pop(),
699
+ to: p.destination.split(/[\\/]/).pop(),
700
+ }));
701
+
702
+ lines.push(formatTable(preview, {
703
+ columns: [
704
+ { key: 'file', label: 'File', width: 25 },
705
+ { key: 'to', label: 'Move to', width: 25 },
706
+ ]
707
+ }));
708
+
709
+ if (plan.plan.length > 10) {
710
+ lines.push(` ${c('dim', `... and ${plan.plan.length - 10} more actions.`)}`);
711
+ }
712
+
713
+ lines.push('');
714
+ lines.push(c('yellow', ` Run 'filemayor apply' to execute this plan.`));
715
+ lines.push('');
716
+
717
+ return lines.join('\n');
718
+ }
719
+
720
+ /**
721
+ * Format a duplicate detection report
722
+ */
723
+ function formatDedupeReport(result) {
724
+ const lines = [];
725
+ lines.push('');
726
+ lines.push(c('bold', ` ${SYMBOLS.trash} DUPLICATE ANALYSIS: `) + c('cyan', result.path));
727
+ lines.push(c('dim', ` ${'─'.repeat(50)}`));
728
+
729
+ if (result.sets === 0) {
730
+ lines.push(` ${c('green', SYMBOLS.check)} No duplicates detected. Zero bloat.`);
731
+ lines.push('');
732
+ return lines.join('\n');
733
+ }
734
+
735
+ lines.push(` ${c('red', 'BLOAT DETECTED:')} ${result.sets} duplicate files found.`);
736
+ lines.push(` ${c('yellow', 'SAVINGS POTENTIAL:')} ${result.totalWastedHuman}`);
737
+ lines.push('');
738
+
739
+ lines.push(c('bold', ' Duplicate Items:'));
740
+ const preview = result.duplicates.slice(0, 10).map(d => ({
741
+ name: d.duplicate.name.length > 35 ? d.duplicate.name.slice(0, 32) + '...' : d.duplicate.name,
742
+ size: d.duplicate.sizeHuman,
743
+ }));
744
+
745
+ lines.push(formatTable(preview, {
746
+ columns: [
747
+ { key: 'name', label: 'File', width: 37 },
748
+ { key: 'size', label: 'Wasted', width: 10, align: 'right' }
749
+ ]
750
+ }));
751
+
752
+ if (result.duplicates.length > 10) {
753
+ lines.push(` ${c('dim', `... and ${result.duplicates.length - 10} more duplicates.`)}`);
754
+ }
755
+
756
+ lines.push('');
757
+ lines.push(c('yellow', ` Run 'filemayor dedupe' to remove these and reclaim space.`));
758
+ lines.push('');
759
+
760
+ return lines.join('\n');
761
+ }
762
+
636
763
  module.exports = {
637
764
  COLORS,
638
765
  SYMBOLS,
@@ -645,6 +772,9 @@ module.exports = {
645
772
  formatOrganizeReport,
646
773
  formatCleanReport,
647
774
  formatAnalyzeReport,
775
+ formatExplainReport,
776
+ formatCureReport,
777
+ formatDedupeReport,
648
778
  banner,
649
779
  success,
650
780
  error,
package/core/scanner.js CHANGED
@@ -130,38 +130,33 @@ class Scanner {
130
130
  * @returns {{ files: Object[], stats: Object }}
131
131
  */
132
132
  scan(dirPath) {
133
- // Validate the path
134
- const validation = validatePath(dirPath);
135
- if (!validation.valid) {
136
- throw new Error(`Invalid path: ${validation.error}`);
137
- }
133
+ const validation = this._validate(dirPath);
134
+ this.stats.startTime = Date.now();
135
+ const files = [];
138
136
 
139
- const safeCheck = isDirSafe(validation.resolved);
140
- if (!safeCheck.safe) {
141
- throw new Error(`Unsafe directory: ${safeCheck.reason}`);
142
- }
137
+ this._scanRecursive(validation.resolved, validation.resolved, 0, files);
143
138
 
144
- if (!canRead(validation.resolved)) {
145
- throw new Error(`Permission denied: cannot read "${validation.resolved}"`);
146
- }
139
+ this.stats.endTime = Date.now();
140
+ this.stats.duration = this.stats.endTime - this.stats.startTime;
141
+ this.stats.durationHuman = this._formatDuration(this.stats.duration);
147
142
 
148
- // Verify it's actually a directory
149
- try {
150
- const stat = fs.statSync(validation.resolved);
151
- if (!stat.isDirectory()) {
152
- throw new Error(`Not a directory: "${validation.resolved}"`);
153
- }
154
- } catch (err) {
155
- if (err.code === 'ENOENT') {
156
- throw new Error(`Directory not found: "${validation.resolved}"`);
157
- }
158
- throw err;
159
- }
143
+ return {
144
+ root: validation.resolved,
145
+ files,
146
+ stats: { ...this.stats }
147
+ };
148
+ }
160
149
 
150
+ /**
151
+ * Asynchronous scan for higher performance and non-blocking operation
152
+ * @param {string} dirPath
153
+ */
154
+ async scanAsync(dirPath) {
155
+ const validation = this._validate(dirPath);
161
156
  this.stats.startTime = Date.now();
162
157
  const files = [];
163
158
 
164
- this._scanRecursive(validation.resolved, validation.resolved, 0, files);
159
+ await this._scanRecursiveAsync(validation.resolved, validation.resolved, 0, files);
165
160
 
166
161
  this.stats.endTime = Date.now();
167
162
  this.stats.duration = this.stats.endTime - this.stats.startTime;
@@ -174,22 +169,35 @@ class Scanner {
174
169
  };
175
170
  }
176
171
 
172
+ /**
173
+ * Shared validation logic
174
+ */
175
+ _validate(dirPath) {
176
+ const validation = validatePath(dirPath);
177
+ if (!validation.valid) throw new Error(`Invalid path: ${validation.error}`);
178
+
179
+ const safeCheck = isDirSafe(validation.resolved);
180
+ if (!safeCheck.safe) throw new Error(`Unsafe directory: ${safeCheck.reason}`);
181
+
182
+ if (!canRead(validation.resolved)) {
183
+ throw new Error(`Permission denied: cannot read "${validation.resolved}"`);
184
+ }
185
+
186
+ return validation;
187
+ }
188
+
177
189
  /**
178
190
  * Internal recursive scan implementation
179
191
  */
180
192
  _scanRecursive(dir, rootPath, depth, results) {
181
- // Check abort signal
182
193
  if (this._aborted || this.options.abortSignal?.aborted) {
183
194
  this._aborted = true;
184
195
  return;
185
196
  }
186
197
 
187
- // Depth limit
188
198
  if (depth > this.options.maxDepth) return;
189
-
190
199
  this.stats.dirsScanned++;
191
200
 
192
- // Progress callback
193
201
  if (this.options.onProgress) {
194
202
  this.options.onProgress({
195
203
  phase: 'scanning',
@@ -204,39 +212,25 @@ class Scanner {
204
212
  try {
205
213
  items = fs.readdirSync(dir, { withFileTypes: true });
206
214
  } catch (err) {
207
- const errorInfo = {
208
- path: dir,
209
- code: err.code,
210
- message: err.message,
211
- depth
212
- };
213
- this.stats.errors.push(errorInfo);
214
- if (this.options.onError) {
215
- this.options.onError(errorInfo);
216
- }
215
+ this.stats.errors.push({ path: dir, code: err.code, message: err.message });
216
+ if (this.options.onError) this.options.onError({ path: dir, error: err.message });
217
217
  return;
218
218
  }
219
219
 
220
220
  for (const item of items) {
221
221
  if (this._aborted) return;
222
-
223
222
  const fullPath = path.join(dir, item.name);
224
223
 
225
- // ─── Filtering ────────────────────────────────
226
-
227
- // Skip hidden files/dirs
228
224
  if (!this.options.includeHidden && item.name.startsWith('.')) {
229
225
  this.stats.skipped++;
230
226
  continue;
231
227
  }
232
228
 
233
- // Skip ignored directories
234
229
  if (item.isDirectory() && this.options.ignore.includes(item.name)) {
235
230
  this.stats.skipped++;
236
231
  continue;
237
232
  }
238
233
 
239
- // Skip by glob patterns
240
234
  if (this.options.ignorePatterns.length > 0) {
241
235
  const shouldSkip = this.options.ignorePatterns.some(p => matchGlob(p, item.name));
242
236
  if (shouldSkip) {
@@ -245,38 +239,25 @@ class Scanner {
245
239
  }
246
240
  }
247
241
 
248
- // Handle symlinks
249
242
  if (item.isSymbolicLink && item.isSymbolicLink() && !this.options.followSymlinks) {
250
243
  this.stats.skipped++;
251
244
  continue;
252
245
  }
253
246
 
254
- // ─── Process Entry ────────────────────────────
255
-
256
247
  let stats;
257
248
  try {
258
- stats = this.options.followSymlinks
259
- ? fs.statSync(fullPath)
260
- : fs.lstatSync(fullPath);
249
+ stats = this.options.followSymlinks ? fs.statSync(fullPath) : fs.lstatSync(fullPath);
261
250
  } catch (err) {
262
- this.stats.errors.push({
263
- path: fullPath,
264
- code: err.code,
265
- message: err.message,
266
- depth
267
- });
251
+ this.stats.errors.push({ path: fullPath, code: err.code, message: err.message });
268
252
  continue;
269
253
  }
270
254
 
271
255
  if (stats.isFile()) {
272
- // Apply file filters
273
256
  if (this._shouldIncludeFile(fullPath, stats)) {
274
257
  const fileInfo = buildFileInfo(fullPath, stats, rootPath);
275
258
  results.push(fileInfo);
276
259
  this.stats.filesFound++;
277
260
  this.stats.totalSize += stats.size;
278
-
279
- // Track category counts
280
261
  const cat = fileInfo.category;
281
262
  this.stats.categories[cat] = (this.stats.categories[cat] || 0) + 1;
282
263
  } else {
@@ -284,14 +265,63 @@ class Scanner {
284
265
  }
285
266
  } else if (stats.isDirectory()) {
286
267
  if (this.options.includeDirectories) {
287
- const dirInfo = buildFileInfo(fullPath, stats, rootPath);
288
- results.push(dirInfo);
268
+ results.push(buildFileInfo(fullPath, stats, rootPath));
289
269
  }
290
270
  this._scanRecursive(fullPath, rootPath, depth + 1, results);
291
271
  }
292
272
  }
293
273
  }
294
274
 
275
+ /**
276
+ * Async recursive scan using promises
277
+ */
278
+ async _scanRecursiveAsync(dir, rootPath, depth, results) {
279
+ if (this._aborted || this.options.abortSignal?.aborted) {
280
+ this._aborted = true;
281
+ return;
282
+ }
283
+
284
+ if (depth > this.options.maxDepth) return;
285
+ this.stats.dirsScanned++;
286
+
287
+ let items;
288
+ try {
289
+ items = await fs.promises.readdir(dir, { withFileTypes: true });
290
+ } catch (err) {
291
+ this.stats.errors.push({ path: dir, code: err.code, message: err.message });
292
+ return;
293
+ }
294
+
295
+ const promises = items.map(async (item) => {
296
+ if (this._aborted) return;
297
+ const fullPath = path.join(dir, item.name);
298
+
299
+ // Filtering
300
+ if (!this.options.includeHidden && item.name.startsWith('.')) return;
301
+ if (item.isDirectory() && this.options.ignore.includes(item.name)) return;
302
+
303
+ let stats;
304
+ try {
305
+ stats = await fs.promises.lstat(fullPath);
306
+ } catch (err) { return; }
307
+
308
+ if (stats.isFile()) {
309
+ if (this._shouldIncludeFile(fullPath, stats)) {
310
+ const fileInfo = buildFileInfo(fullPath, stats, rootPath);
311
+ results.push(fileInfo);
312
+ this.stats.filesFound++;
313
+ this.stats.totalSize += stats.size;
314
+ const cat = fileInfo.category;
315
+ this.stats.categories[cat] = (this.stats.categories[cat] || 0) + 1;
316
+ }
317
+ } else if (stats.isDirectory()) {
318
+ await this._scanRecursiveAsync(fullPath, rootPath, depth + 1, results);
319
+ }
320
+ });
321
+
322
+ await Promise.all(promises);
323
+ }
324
+
295
325
  /**
296
326
  * Check if a file passes all configured filters
297
327
  */