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/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 ──────────────────────────────────────────────────────
@@ -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
- // 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
  */
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', '/usr/lib', '/usr/bin', '/usr/sbin',
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
- if (!resolved.startsWith(resolvedBase + path.sep) && resolved !== resolvedBase) {
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 escapes base directory: "${resolved}" is outside "${resolvedBase}"`
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
- if (resolved.startsWith(normalizedPrefix + path.sep) || resolved === normalizedPrefix) {
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: `Refusing to operate on system directory: "${resolved}"`
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,
@@ -530,8 +530,7 @@ async function parseSOP(filePath, options = {}) {
530
530
  try {
531
531
  rules = await parseWithGemini(text, apiKey);
532
532
  } catch (err) {
533
- console.warn(`[SOP] Gemini AI failed: ${err.message}`);
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
  }