filemayor 2.0.5 → 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 +48 -43
- 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 +163 -0
- 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 +12 -13
- package/core/metadata-store.js +104 -0
- package/core/organizer.js +28 -142
- package/core/reporter.js +212 -2
- package/core/scanner.js +92 -62
- package/core/security.js +65 -12
- package/core/sop-parser.js +1 -2
- package/core/vault.js +165 -0
- package/index.js +230 -2
- package/package.json +55 -55
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 ──────────────────────────────────────────────────────
|
|
@@ -244,6 +245,7 @@ function formatScanTable(result) {
|
|
|
244
245
|
// Header
|
|
245
246
|
lines.push('');
|
|
246
247
|
lines.push(c('bold', ` ${SYMBOLS.folder} Scan Report: `) + c('cyan', result.root));
|
|
248
|
+
lines.push(c('dim', ` ${SYMBOLS.shield} Integrity Check: `) + c('green', 'PASSED'));
|
|
247
249
|
lines.push(c('dim', ` ${'─'.repeat(60)}`));
|
|
248
250
|
|
|
249
251
|
// Category summary
|
|
@@ -312,6 +314,13 @@ function formatScanCSV(result) {
|
|
|
312
314
|
// ─── Organize Report ──────────────────────────────────────────────
|
|
313
315
|
|
|
314
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
|
+
|
|
315
324
|
switch (format) {
|
|
316
325
|
case 'json':
|
|
317
326
|
return JSON.stringify(result, null, 2);
|
|
@@ -336,6 +345,7 @@ function formatOrganizeTable(result) {
|
|
|
336
345
|
} else {
|
|
337
346
|
lines.push(c('green', ` ${SYMBOLS.sparkle} Organization Complete`));
|
|
338
347
|
}
|
|
348
|
+
lines.push(c('dim', ` ${SYMBOLS.shield} Integrity Check: `) + c('green', 'PASSED'));
|
|
339
349
|
lines.push(c('dim', ` ${'─'.repeat(60)}`));
|
|
340
350
|
|
|
341
351
|
// Category breakdown
|
|
@@ -440,6 +450,7 @@ function formatCleanTable(result) {
|
|
|
440
450
|
} else {
|
|
441
451
|
lines.push(c('cyan', ` ${SYMBOLS.trash} Junk Scan Results`));
|
|
442
452
|
}
|
|
453
|
+
lines.push(c('dim', ` ${SYMBOLS.shield} Integrity Check: `) + c('green', 'PASSED'));
|
|
443
454
|
lines.push(c('dim', ` ${'─'.repeat(60)}`));
|
|
444
455
|
|
|
445
456
|
// Category breakdown
|
|
@@ -554,6 +565,201 @@ function info(msg) {
|
|
|
554
565
|
return `${c('cyan', SYMBOLS.info)} ${msg}`;
|
|
555
566
|
}
|
|
556
567
|
|
|
568
|
+
// ─── Analyze Report ────────────────────────────────────────────────
|
|
569
|
+
|
|
570
|
+
function formatAnalyzeReport(result, format = 'table') {
|
|
571
|
+
if (format === 'json') return JSON.stringify(result, null, 2);
|
|
572
|
+
|
|
573
|
+
const lines = [];
|
|
574
|
+
lines.push('');
|
|
575
|
+
lines.push(c('bold', ` ${SYMBOLS.eye} Deep Intelligence Report: `) + c('cyan', result.root));
|
|
576
|
+
lines.push(c('dim', ` ${'─'.repeat(60)}`));
|
|
577
|
+
|
|
578
|
+
// Summary Insights
|
|
579
|
+
lines.push('');
|
|
580
|
+
lines.push(` ${c('bold', 'Summary:')} ${result.summary.totalFiles} files detected. Total size: ${result.summary.totalSizeHuman}`);
|
|
581
|
+
lines.push(` ${c('green', '⚡ Insight:')} You can reclaim ${c('bold', result.summary.potentialSavingsHuman)} today.`);
|
|
582
|
+
|
|
583
|
+
// Duplicates Section
|
|
584
|
+
if (result.duplicates.sets > 0) {
|
|
585
|
+
lines.push('');
|
|
586
|
+
lines.push(c('bold', ' Duplicate Bloat'));
|
|
587
|
+
const dupeData = result.duplicates.details.map(d => ({
|
|
588
|
+
name: d.files[0].name.length > 35 ? d.files[0].name.slice(0, 32) + '...' : d.files[0].name,
|
|
589
|
+
count: String(d.files.length),
|
|
590
|
+
wasted: d.wastedSpaceHuman
|
|
591
|
+
}));
|
|
592
|
+
lines.push(formatTable(dupeData, {
|
|
593
|
+
columns: [
|
|
594
|
+
{ key: 'name', label: 'File Name', width: 37 },
|
|
595
|
+
{ key: 'count', label: 'Copies', width: 8, align: 'right' },
|
|
596
|
+
{ key: 'wasted', label: 'Wasted', width: 10, align: 'right' }
|
|
597
|
+
]
|
|
598
|
+
}));
|
|
599
|
+
lines.push(` ${c('dim', ` Found ${result.duplicates.sets} duplicate sets causing ${result.duplicates.totalWastedHuman} bloat.`)}`);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// Largest Folders Section
|
|
603
|
+
if (result.largestDirs.length > 0) {
|
|
604
|
+
lines.push('');
|
|
605
|
+
lines.push(c('bold', ' Top Space Consumers (Bloat Map)'));
|
|
606
|
+
const dirData = result.largestDirs.map(d => ({
|
|
607
|
+
name: d.name.length > 37 ? d.name.slice(0, 34) + '...' : d.name,
|
|
608
|
+
files: String(d.count),
|
|
609
|
+
size: d.sizeHuman
|
|
610
|
+
}));
|
|
611
|
+
lines.push(formatTable(dirData, {
|
|
612
|
+
columns: [
|
|
613
|
+
{ key: 'name', label: 'Directory', width: 40 },
|
|
614
|
+
{ key: 'files', label: 'Files', width: 8, align: 'right' },
|
|
615
|
+
{ key: 'size', label: 'Size', width: 10, align: 'right' }
|
|
616
|
+
]
|
|
617
|
+
}));
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// Junk Section
|
|
621
|
+
if (result.junk.count > 0) {
|
|
622
|
+
lines.push('');
|
|
623
|
+
lines.push(c('bold', ' Recoverable Junk'));
|
|
624
|
+
const junkData = Object.entries(result.junk.categories).map(([cat, info]) => ({
|
|
625
|
+
category: cat.charAt(0).toUpperCase() + cat.slice(1),
|
|
626
|
+
size: formatBytes(info.size)
|
|
627
|
+
}));
|
|
628
|
+
lines.push(formatTable(junkData, {
|
|
629
|
+
columns: [
|
|
630
|
+
{ key: 'category', label: 'Category', width: 40 },
|
|
631
|
+
{ key: 'size', label: 'Savings', width: 18, align: 'right' }
|
|
632
|
+
]
|
|
633
|
+
}));
|
|
634
|
+
lines.push(` ${c('dim', ` Total Junk: ${result.junk.count} items / ${result.junk.totalSizeHuman}`)}`);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
lines.push('');
|
|
638
|
+
lines.push(c('yellow', ' Run \'filemayor organize\' or \'filemayor clean\' to restore order and reclaim space.'));
|
|
639
|
+
lines.push('');
|
|
640
|
+
|
|
641
|
+
return lines.join('\n');
|
|
642
|
+
}
|
|
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
|
+
|
|
557
763
|
module.exports = {
|
|
558
764
|
COLORS,
|
|
559
765
|
SYMBOLS,
|
|
@@ -565,6 +771,10 @@ module.exports = {
|
|
|
565
771
|
formatScanReport,
|
|
566
772
|
formatOrganizeReport,
|
|
567
773
|
formatCleanReport,
|
|
774
|
+
formatAnalyzeReport,
|
|
775
|
+
formatExplainReport,
|
|
776
|
+
formatCureReport,
|
|
777
|
+
formatDedupeReport,
|
|
568
778
|
banner,
|
|
569
779
|
success,
|
|
570
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
|
*/
|
package/core/security.js
CHANGED
|
@@ -27,7 +27,8 @@ const PROTECTED_DIRECTORIES = new Set([
|
|
|
27
27
|
const PROTECTED_PREFIXES = [
|
|
28
28
|
'/System', '/Library/System',
|
|
29
29
|
'C:\\Windows', 'C:\\Program Files', 'C:\\Program Files (x86)',
|
|
30
|
-
'C:\\ProgramData', '
|
|
30
|
+
'C:\\ProgramData', 'C:\\Users\\Public',
|
|
31
|
+
'/usr/lib', '/usr/bin', '/usr/sbin',
|
|
31
32
|
'/var/lib', '/var/run', '/boot', '/dev', '/proc', '/sys'
|
|
32
33
|
];
|
|
33
34
|
|
|
@@ -36,6 +37,14 @@ const DANGEROUS_EXTENSIONS = new Set([
|
|
|
36
37
|
'.plist', '.reg', '.msc', '.cpl'
|
|
37
38
|
]);
|
|
38
39
|
|
|
40
|
+
// ─── Project Substrates (v3.5) ────────────────────────────────────
|
|
41
|
+
const SUBSTRATE_MARKERS = [
|
|
42
|
+
'.git', '.svn', 'node_modules', 'package.json',
|
|
43
|
+
'venv', '.venv', '__pycache__', 'env',
|
|
44
|
+
'pom.xml', 'build.gradle', 'CMakeLists.txt',
|
|
45
|
+
'Makefile', '.hg', 'composer.json', 'go.mod'
|
|
46
|
+
];
|
|
47
|
+
|
|
39
48
|
// ─── Path Validation ──────────────────────────────────────────────
|
|
40
49
|
|
|
41
50
|
/**
|
|
@@ -49,27 +58,32 @@ function validatePath(inputPath, basePath = null) {
|
|
|
49
58
|
return { valid: false, resolved: '', error: 'Path is empty or invalid' };
|
|
50
59
|
}
|
|
51
60
|
|
|
52
|
-
// Reject null bytes (path traversal vector)
|
|
61
|
+
// [Hacker-Proof] Reject null bytes (classic path traversal vector)
|
|
53
62
|
if (inputPath.includes('\0')) {
|
|
54
63
|
return { valid: false, resolved: '', error: 'Path contains null bytes (rejected)' };
|
|
55
64
|
}
|
|
56
65
|
|
|
57
|
-
// Resolve to absolute
|
|
66
|
+
// [Hacker-Proof] Resolve to absolute and normalize to handle '..' etc.
|
|
58
67
|
const resolved = path.resolve(inputPath);
|
|
68
|
+
const rLower = resolved.toLowerCase();
|
|
59
69
|
|
|
60
|
-
// If a basePath is given, ensure resolved path stays within it
|
|
70
|
+
// [Hacker-Proof] Jail Logic: If a basePath is given, ensure resolved path stays within it
|
|
61
71
|
if (basePath) {
|
|
62
|
-
const resolvedBase = path.resolve(basePath);
|
|
63
|
-
|
|
72
|
+
const resolvedBase = path.resolve(basePath).toLowerCase();
|
|
73
|
+
|
|
74
|
+
// [Elite Fix] Robust jailing: Target must start with base + separator or be the base itself
|
|
75
|
+
const isSafe = rLower === resolvedBase || rLower.startsWith(resolvedBase + path.sep);
|
|
76
|
+
|
|
77
|
+
if (!isSafe) {
|
|
64
78
|
return {
|
|
65
79
|
valid: false,
|
|
66
80
|
resolved,
|
|
67
|
-
error: `Path
|
|
81
|
+
error: `Security Alert: Path Traversal Attempted. "${resolved}" is outside scope.`
|
|
68
82
|
};
|
|
69
83
|
}
|
|
70
84
|
}
|
|
71
85
|
|
|
72
|
-
// Check against protected directories
|
|
86
|
+
// [Hacker-Proof] Check against protected directories
|
|
73
87
|
if (PROTECTED_DIRECTORIES.has(resolved)) {
|
|
74
88
|
return {
|
|
75
89
|
valid: false,
|
|
@@ -78,14 +92,17 @@ function validatePath(inputPath, basePath = null) {
|
|
|
78
92
|
};
|
|
79
93
|
}
|
|
80
94
|
|
|
81
|
-
// Check against protected prefixes
|
|
95
|
+
// [Hacker-Proof] Check against protected prefixes (System/Library/Program Files)
|
|
82
96
|
for (const prefix of PROTECTED_PREFIXES) {
|
|
83
|
-
const normalizedPrefix = path.resolve(prefix);
|
|
84
|
-
|
|
97
|
+
const normalizedPrefix = path.resolve(prefix).toLowerCase();
|
|
98
|
+
const rLower = resolved.toLowerCase();
|
|
99
|
+
|
|
100
|
+
// Block if exact match OR if it starts with prefix + separator
|
|
101
|
+
if (rLower === normalizedPrefix || rLower.startsWith(normalizedPrefix + path.sep)) {
|
|
85
102
|
return {
|
|
86
103
|
valid: false,
|
|
87
104
|
resolved,
|
|
88
|
-
error: `
|
|
105
|
+
error: `Refusal: Path is within a system-protected root ("${resolved}")`
|
|
89
106
|
};
|
|
90
107
|
}
|
|
91
108
|
}
|
|
@@ -99,6 +116,9 @@ function validatePath(inputPath, basePath = null) {
|
|
|
99
116
|
* @returns {{ safe: boolean, reason?: string }}
|
|
100
117
|
*/
|
|
101
118
|
function isFileSafe(filePath) {
|
|
119
|
+
if (!filePath || typeof filePath !== 'string') {
|
|
120
|
+
return { safe: false, reason: 'File path is empty or invalid' };
|
|
121
|
+
}
|
|
102
122
|
const ext = path.extname(filePath).toLowerCase();
|
|
103
123
|
const basename = path.basename(filePath);
|
|
104
124
|
|
|
@@ -122,6 +142,37 @@ function isFileSafe(filePath) {
|
|
|
122
142
|
return { safe: true };
|
|
123
143
|
}
|
|
124
144
|
|
|
145
|
+
/**
|
|
146
|
+
* Check if a directory is a "Project Substrate" (should not be scattered)
|
|
147
|
+
* @param {string} dirPath - Directory to check
|
|
148
|
+
* @returns {boolean}
|
|
149
|
+
*/
|
|
150
|
+
function isSubstrateCritical(dirPath) {
|
|
151
|
+
try {
|
|
152
|
+
const items = fs.readdirSync(dirPath);
|
|
153
|
+
return SUBSTRATE_MARKERS.some(marker => items.includes(marker));
|
|
154
|
+
} catch {
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Find the nearest substrate parent for a file
|
|
161
|
+
* @param {string} filePath
|
|
162
|
+
* @returns {string|null}
|
|
163
|
+
*/
|
|
164
|
+
function findNearestSubstrate(filePath) {
|
|
165
|
+
let current = path.dirname(path.resolve(filePath));
|
|
166
|
+
const root = path.parse(current).root;
|
|
167
|
+
|
|
168
|
+
while (current !== root) {
|
|
169
|
+
if (isSubstrateCritical(current)) return current;
|
|
170
|
+
current = path.dirname(current);
|
|
171
|
+
}
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
|
|
125
176
|
/**
|
|
126
177
|
* Check if a directory is safe to scan/organize
|
|
127
178
|
* @param {string} dirPath - Absolute directory path
|
|
@@ -303,6 +354,8 @@ module.exports = {
|
|
|
303
354
|
validatePath,
|
|
304
355
|
isFileSafe,
|
|
305
356
|
isDirSafe,
|
|
357
|
+
isSubstrateCritical,
|
|
358
|
+
findNearestSubstrate,
|
|
306
359
|
sanitizeFilename,
|
|
307
360
|
sanitizeDirname,
|
|
308
361
|
canRead,
|
package/core/sop-parser.js
CHANGED
|
@@ -530,8 +530,7 @@ async function parseSOP(filePath, options = {}) {
|
|
|
530
530
|
try {
|
|
531
531
|
rules = await parseWithGemini(text, apiKey);
|
|
532
532
|
} catch (err) {
|
|
533
|
-
|
|
534
|
-
console.warn('[SOP] Falling back to rule-based parser');
|
|
533
|
+
/* AI failed — silently fall back to rule-based parser */
|
|
535
534
|
rules = parseRuleBased(text);
|
|
536
535
|
fallbackUsed = true;
|
|
537
536
|
}
|