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/README.md +1 -1
- package/core/ai/planner.js +42 -0
- package/core/ai/sentry.js +92 -0
- package/core/ai/strategist.js +186 -0
- package/core/ai/validator.js +116 -0
- package/core/analyzer.js +16 -5
- package/core/emergency-halt.js +104 -0
- package/core/engine/apply-engine.js +69 -0
- package/core/engine/cure-engine.js +70 -0
- package/core/engine/dedupe-engine.js +77 -0
- package/core/engine/explain-engine.js +114 -0
- package/core/engine/preview-engine.js +49 -0
- package/core/fs-abstraction.js +199 -0
- package/core/guardrail.js +115 -0
- package/core/index.js +56 -0
- package/core/intent-interpreter.js +158 -0
- package/core/jailer.js +151 -0
- package/core/license.js +6 -4
- package/core/metadata-store.js +104 -0
- package/core/organizer.js +28 -142
- package/core/reporter.js +132 -2
- package/core/scanner.js +92 -62
- package/core/security.js +65 -12
- package/core/vault.js +165 -0
- package/index.js +193 -1
- package/package.json +2 -2
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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
|
|
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', `${
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
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
|
-
|
|
145
|
-
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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.
|
|
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
|
-
|
|
208
|
-
|
|
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
|
-
|
|
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
|
*/
|