@vibgrate/cli 1.0.21 → 1.0.23

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.
@@ -1,7 +1,7 @@
1
1
  // src/utils/fs.ts
2
2
  import * as fs from "fs/promises";
3
3
  import * as os from "os";
4
- import * as path from "path";
4
+ import * as path2 from "path";
5
5
 
6
6
  // src/utils/semaphore.ts
7
7
  var Semaphore = class {
@@ -32,6 +32,86 @@ var Semaphore = class {
32
32
  }
33
33
  };
34
34
 
35
+ // src/utils/glob.ts
36
+ import * as path from "path";
37
+ function compileGlobs(patterns) {
38
+ if (patterns.length === 0) return null;
39
+ const matchers = patterns.map((p) => compileOne(normalise(p)));
40
+ return (relPath) => {
41
+ const norm = normalise(relPath);
42
+ return matchers.some((m) => m(norm));
43
+ };
44
+ }
45
+ function normalise(p) {
46
+ return p.split(path.sep).join("/").replace(/\/+$/, "");
47
+ }
48
+ function compileOne(pattern) {
49
+ if (!pattern.includes("/") && !hasGlobChars(pattern)) {
50
+ const prefix = pattern + "/";
51
+ return (p) => p === pattern || p.startsWith(prefix);
52
+ }
53
+ const re = globToRegex(pattern);
54
+ return (p) => re.test(p);
55
+ }
56
+ function hasGlobChars(s) {
57
+ return /[*?[\]{}]/.test(s);
58
+ }
59
+ function globToRegex(pattern) {
60
+ let i = 0;
61
+ let re = "^";
62
+ const len = pattern.length;
63
+ while (i < len) {
64
+ const ch = pattern[i];
65
+ if (ch === "*") {
66
+ if (pattern[i + 1] === "*") {
67
+ i += 2;
68
+ if (pattern[i] === "/") {
69
+ i++;
70
+ re += "(?:.+/)?";
71
+ } else {
72
+ re += ".*";
73
+ }
74
+ } else {
75
+ i++;
76
+ re += "[^/]*";
77
+ }
78
+ } else if (ch === "?") {
79
+ i++;
80
+ re += "[^/]";
81
+ } else if (ch === "[") {
82
+ const start = i;
83
+ i++;
84
+ while (i < len && pattern[i] !== "]") i++;
85
+ i++;
86
+ re += pattern.slice(start, i);
87
+ } else if (ch === "{") {
88
+ i++;
89
+ const alternatives = [];
90
+ let current = "";
91
+ while (i < len && pattern[i] !== "}") {
92
+ if (pattern[i] === ",") {
93
+ alternatives.push(current);
94
+ current = "";
95
+ } else {
96
+ current += pattern[i];
97
+ }
98
+ i++;
99
+ }
100
+ alternatives.push(current);
101
+ i++;
102
+ re += "(?:" + alternatives.map(escapeRegex).join("|") + ")";
103
+ } else {
104
+ re += escapeRegex(ch);
105
+ i++;
106
+ }
107
+ }
108
+ re += "$";
109
+ return new RegExp(re);
110
+ }
111
+ function escapeRegex(s) {
112
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
113
+ }
114
+
35
115
  // src/utils/fs.ts
36
116
  var SKIP_DIRS = /* @__PURE__ */ new Set([
37
117
  "node_modules",
@@ -50,6 +130,80 @@ var SKIP_DIRS = /* @__PURE__ */ new Set([
50
130
  "packages",
51
131
  "TestResults"
52
132
  ]);
133
+ var SKIP_EXTENSIONS = /* @__PURE__ */ new Set([
134
+ // Fonts
135
+ ".woff",
136
+ ".woff2",
137
+ ".ttf",
138
+ ".otf",
139
+ ".eot",
140
+ // Images & vector
141
+ ".png",
142
+ ".jpg",
143
+ ".jpeg",
144
+ ".gif",
145
+ ".ico",
146
+ ".bmp",
147
+ ".tiff",
148
+ ".tif",
149
+ ".webp",
150
+ ".avif",
151
+ ".svg",
152
+ ".heic",
153
+ ".heif",
154
+ ".jfif",
155
+ ".psd",
156
+ ".ai",
157
+ ".eps",
158
+ ".raw",
159
+ ".cr2",
160
+ ".nef",
161
+ ".dng",
162
+ // Video
163
+ ".mp4",
164
+ ".webm",
165
+ ".avi",
166
+ ".mov",
167
+ ".mkv",
168
+ ".wmv",
169
+ ".flv",
170
+ ".m4v",
171
+ ".mpg",
172
+ ".mpeg",
173
+ ".3gp",
174
+ ".ogv",
175
+ // Audio
176
+ ".mp3",
177
+ ".wav",
178
+ ".ogg",
179
+ ".flac",
180
+ ".aac",
181
+ ".wma",
182
+ ".m4a",
183
+ ".opus",
184
+ ".aiff",
185
+ ".mid",
186
+ ".midi",
187
+ // Archives
188
+ ".zip",
189
+ ".tar",
190
+ ".gz",
191
+ ".bz2",
192
+ ".7z",
193
+ ".rar",
194
+ // Compiled / binary
195
+ ".exe",
196
+ ".dll",
197
+ ".so",
198
+ ".dylib",
199
+ ".o",
200
+ ".a",
201
+ ".class",
202
+ ".pyc",
203
+ ".pdb",
204
+ // Source maps & lockfiles (large, not useful for drift analysis)
205
+ ".map"
206
+ ]);
53
207
  var TEXT_CACHE_MAX_BYTES = 1048576;
54
208
  var FileCache = class _FileCache {
55
209
  /** Directory walk results keyed by rootDir */
@@ -60,6 +214,40 @@ var FileCache = class _FileCache {
60
214
  jsonCache = /* @__PURE__ */ new Map();
61
215
  /** pathExists keyed by absolute path */
62
216
  existsCache = /* @__PURE__ */ new Map();
217
+ /** User-configured exclude predicate (compiled from glob patterns) */
218
+ excludePredicate = null;
219
+ /** Directories that were auto-skipped because they were stuck (>60s) */
220
+ _stuckPaths = [];
221
+ /** Files skipped because they exceed maxFileSizeToScan */
222
+ _skippedLargeFiles = [];
223
+ /** Maximum file size (bytes) we will read. 0 = unlimited. */
224
+ _maxFileSize = 0;
225
+ /** Root dir for relative-path computation (set by the first walkDir call) */
226
+ _rootDir = null;
227
+ /** Set exclude patterns from config (call once before the walk) */
228
+ setExcludePatterns(patterns) {
229
+ this.excludePredicate = compileGlobs(patterns);
230
+ }
231
+ /** Set the maximum file size in bytes that readTextFile / readJsonFile will process */
232
+ setMaxFileSize(bytes) {
233
+ this._maxFileSize = bytes;
234
+ }
235
+ /** Record a path that timed out or was stuck during scanning */
236
+ addStuckPath(relPath) {
237
+ this._stuckPaths.push(relPath);
238
+ }
239
+ /** Get all paths that were auto-skipped due to being stuck (dirs + scanner files) */
240
+ get stuckPaths() {
241
+ return this._stuckPaths;
242
+ }
243
+ /** @deprecated Use stuckPaths instead */
244
+ get stuckDirs() {
245
+ return this._stuckPaths;
246
+ }
247
+ /** Get files that were skipped because they exceeded maxFileSizeToScan */
248
+ get skippedLargeFiles() {
249
+ return this._skippedLargeFiles;
250
+ }
63
251
  // ── Directory walking ──
64
252
  /**
65
253
  * Walk the directory tree from `rootDir` once, skipping SKIP_DIRS plus
@@ -70,6 +258,7 @@ var FileCache = class _FileCache {
70
258
  * SKIP_EXTENSIONS) do so on the returned entries — no separate walk.
71
259
  */
72
260
  walkDir(rootDir, onProgress) {
261
+ this._rootDir = rootDir;
73
262
  const cached = this.walkCache.get(rootDir);
74
263
  if (cached) return cached;
75
264
  const promise = this._doWalk(rootDir, onProgress);
@@ -86,28 +275,49 @@ var FileCache = class _FileCache {
86
275
  let lastReported = 0;
87
276
  const REPORT_INTERVAL = 50;
88
277
  const sem = new Semaphore(maxConcurrentReads);
278
+ const STUCK_TIMEOUT_MS = 6e4;
89
279
  const extraSkip = _FileCache.EXTRA_SKIP;
280
+ const isExcluded = this.excludePredicate;
281
+ const stuckDirs = this._stuckPaths;
90
282
  async function walk(dir) {
283
+ const relDir = path2.relative(rootDir, dir);
284
+ if (onProgress) {
285
+ onProgress(foundCount, relDir || ".");
286
+ }
91
287
  let entries;
92
288
  try {
93
- entries = await fs.readdir(dir, { withFileTypes: true });
289
+ const readPromise = fs.readdir(dir, { withFileTypes: true });
290
+ const result = await Promise.race([
291
+ readPromise.then((e) => ({ ok: true, entries: e })),
292
+ new Promise(
293
+ (resolve7) => setTimeout(() => resolve7({ ok: false }), STUCK_TIMEOUT_MS)
294
+ )
295
+ ]);
296
+ if (!result.ok) {
297
+ stuckDirs.push(relDir || dir);
298
+ return;
299
+ }
300
+ entries = result.entries;
94
301
  } catch {
95
302
  return;
96
303
  }
97
304
  const subWalks = [];
98
305
  for (const e of entries) {
99
- const absPath = path.join(dir, e.name);
100
- const relPath = path.relative(rootDir, absPath);
306
+ const absPath = path2.join(dir, e.name);
307
+ const relPath = path2.relative(rootDir, absPath);
308
+ if (isExcluded && isExcluded(relPath)) continue;
101
309
  if (e.isDirectory()) {
102
310
  if (SKIP_DIRS.has(e.name) || extraSkip.has(e.name)) continue;
103
311
  results.push({ absPath, relPath, name: e.name, isFile: false, isDirectory: true });
104
312
  subWalks.push(sem.run(() => walk(absPath)));
105
313
  } else if (e.isFile()) {
314
+ const ext = path2.extname(e.name).toLowerCase();
315
+ if (SKIP_EXTENSIONS.has(ext)) continue;
106
316
  results.push({ absPath, relPath, name: e.name, isFile: true, isDirectory: false });
107
317
  foundCount++;
108
318
  if (onProgress && foundCount - lastReported >= REPORT_INTERVAL) {
109
319
  lastReported = foundCount;
110
- onProgress(foundCount);
320
+ onProgress(foundCount, relPath);
111
321
  }
112
322
  }
113
323
  }
@@ -115,7 +325,7 @@ var FileCache = class _FileCache {
115
325
  }
116
326
  await sem.run(() => walk(rootDir));
117
327
  if (onProgress && foundCount !== lastReported) {
118
- onProgress(foundCount);
328
+ onProgress(foundCount, "");
119
329
  }
120
330
  return results;
121
331
  }
@@ -141,17 +351,36 @@ var FileCache = class _FileCache {
141
351
  * Read a text file. Files ≤ 1 MB are cached so subsequent calls from
142
352
  * different scanners return the same string. Files > 1 MB (lockfiles,
143
353
  * large generated files) are read directly and never retained.
354
+ *
355
+ * If maxFileSizeToScan is set and the file exceeds it, the file is
356
+ * recorded as skipped and an empty string is returned.
144
357
  */
145
358
  readTextFile(filePath) {
146
- const abs = path.resolve(filePath);
359
+ const abs = path2.resolve(filePath);
147
360
  const cached = this.textCache.get(abs);
148
361
  if (cached) return cached;
149
- const promise = fs.readFile(abs, "utf8").then((content) => {
362
+ const maxSize = this._maxFileSize;
363
+ const skippedLarge = this._skippedLargeFiles;
364
+ const rootDir = this._rootDir;
365
+ const promise = (async () => {
366
+ if (maxSize > 0) {
367
+ try {
368
+ const stat4 = await fs.stat(abs);
369
+ if (stat4.size > maxSize) {
370
+ const rel = rootDir ? path2.relative(rootDir, abs) : abs;
371
+ skippedLarge.push(rel);
372
+ this.textCache.delete(abs);
373
+ return "";
374
+ }
375
+ } catch {
376
+ }
377
+ }
378
+ const content = await fs.readFile(abs, "utf8");
150
379
  if (content.length > TEXT_CACHE_MAX_BYTES) {
151
380
  this.textCache.delete(abs);
152
381
  }
153
382
  return content;
154
- });
383
+ })();
155
384
  this.textCache.set(abs, promise);
156
385
  return promise;
157
386
  }
@@ -160,7 +389,7 @@ var FileCache = class _FileCache {
160
389
  * text is evicted immediately so we never hold both representations.
161
390
  */
162
391
  readJsonFile(filePath) {
163
- const abs = path.resolve(filePath);
392
+ const abs = path2.resolve(filePath);
164
393
  const cached = this.jsonCache.get(abs);
165
394
  if (cached) return cached;
166
395
  const promise = this.readTextFile(abs).then((txt) => {
@@ -172,7 +401,7 @@ var FileCache = class _FileCache {
172
401
  }
173
402
  // ── Existence checks ──
174
403
  pathExists(p) {
175
- const abs = path.resolve(p);
404
+ const abs = path2.resolve(p);
176
405
  const cached = this.existsCache.get(abs);
177
406
  if (cached) return cached;
178
407
  const promise = fs.access(abs).then(() => true, () => false);
@@ -196,13 +425,14 @@ var FileCache = class _FileCache {
196
425
  return this.jsonCache.size;
197
426
  }
198
427
  };
199
- async function quickTreeCount(rootDir) {
428
+ async function quickTreeCount(rootDir, excludePatterns) {
200
429
  let totalFiles = 0;
201
430
  let totalDirs = 0;
202
431
  const cores = typeof os.availableParallelism === "function" ? os.availableParallelism() : os.cpus().length || 4;
203
432
  const maxConcurrent = Math.max(8, Math.min(128, cores * 8));
204
433
  const sem = new Semaphore(maxConcurrent);
205
434
  const extraSkip = /* @__PURE__ */ new Set([".nuxt", ".output", ".svelte-kit"]);
435
+ const isExcluded = excludePatterns ? compileGlobs(excludePatterns) : null;
206
436
  async function count(dir) {
207
437
  let entries;
208
438
  try {
@@ -212,12 +442,15 @@ async function quickTreeCount(rootDir) {
212
442
  }
213
443
  const subs = [];
214
444
  for (const e of entries) {
445
+ const relPath = path2.relative(rootDir, path2.join(dir, e.name));
446
+ if (isExcluded && isExcluded(relPath)) continue;
215
447
  if (e.isDirectory()) {
216
448
  if (SKIP_DIRS.has(e.name) || extraSkip.has(e.name)) continue;
217
449
  totalDirs++;
218
- subs.push(sem.run(() => count(path.join(dir, e.name))));
450
+ subs.push(sem.run(() => count(path2.join(dir, e.name))));
219
451
  } else if (e.isFile()) {
220
- totalFiles++;
452
+ const ext = path2.extname(e.name).toLowerCase();
453
+ if (!SKIP_EXTENSIONS.has(ext)) totalFiles++;
221
454
  }
222
455
  }
223
456
  await Promise.all(subs);
@@ -241,9 +474,10 @@ async function findFiles(rootDir, predicate) {
241
474
  for (const e of entries) {
242
475
  if (e.isDirectory()) {
243
476
  if (SKIP_DIRS.has(e.name)) continue;
244
- subDirectoryWalks.push(readDirSemaphore.run(() => walk(path.join(dir, e.name))));
477
+ subDirectoryWalks.push(readDirSemaphore.run(() => walk(path2.join(dir, e.name))));
245
478
  } else if (e.isFile() && predicate(e.name)) {
246
- results.push(path.join(dir, e.name));
479
+ const ext = path2.extname(e.name).toLowerCase();
480
+ if (!SKIP_EXTENSIONS.has(ext)) results.push(path2.join(dir, e.name));
247
481
  }
248
482
  }
249
483
  await Promise.all(subDirectoryWalks);
@@ -279,11 +513,11 @@ async function ensureDir(dir) {
279
513
  await fs.mkdir(dir, { recursive: true });
280
514
  }
281
515
  async function writeJsonFile(filePath, data) {
282
- await ensureDir(path.dirname(filePath));
516
+ await ensureDir(path2.dirname(filePath));
283
517
  await fs.writeFile(filePath, JSON.stringify(data, null, 2) + "\n", "utf8");
284
518
  }
285
519
  async function writeTextFile(filePath, content) {
286
- await ensureDir(path.dirname(filePath));
520
+ await ensureDir(path2.dirname(filePath));
287
521
  await fs.writeFile(filePath, content, "utf8");
288
522
  }
289
523
 
@@ -1217,7 +1451,7 @@ function toSarifResult(finding) {
1217
1451
 
1218
1452
  // src/commands/dsn.ts
1219
1453
  import * as crypto2 from "crypto";
1220
- import * as path2 from "path";
1454
+ import * as path3 from "path";
1221
1455
  import { Command } from "commander";
1222
1456
  import chalk2 from "chalk";
1223
1457
  var REGION_HOSTS = {
@@ -1262,7 +1496,7 @@ dsnCommand.command("create").description("Create a new DSN token").option("--ing
1262
1496
  console.log(chalk2.dim("Set this as VIBGRATE_DSN in your CI environment."));
1263
1497
  console.log(chalk2.dim("The secret must be registered on your Vibgrate ingest API."));
1264
1498
  if (opts.write) {
1265
- const writePath = path2.resolve(opts.write);
1499
+ const writePath = path3.resolve(opts.write);
1266
1500
  await writeTextFile(writePath, dsn + "\n");
1267
1501
  console.log("");
1268
1502
  console.log(chalk2.green("\u2714") + ` DSN written to ${opts.write}`);
@@ -1272,7 +1506,7 @@ dsnCommand.command("create").description("Create a new DSN token").option("--ing
1272
1506
 
1273
1507
  // src/commands/push.ts
1274
1508
  import * as crypto3 from "crypto";
1275
- import * as path3 from "path";
1509
+ import * as path4 from "path";
1276
1510
  import { Command as Command2 } from "commander";
1277
1511
  import chalk3 from "chalk";
1278
1512
  function parseDsn(dsn) {
@@ -1301,7 +1535,7 @@ var pushCommand = new Command2("push").description("Push scan results to Vibgrat
1301
1535
  if (opts.strict) process.exit(1);
1302
1536
  return;
1303
1537
  }
1304
- const filePath = path3.resolve(opts.file);
1538
+ const filePath = path4.resolve(opts.file);
1305
1539
  if (!await pathExists(filePath)) {
1306
1540
  console.error(chalk3.red(`Scan artifact not found: ${filePath}`));
1307
1541
  console.error(chalk3.dim('Run "vibgrate scan" first.'));
@@ -1346,14 +1580,31 @@ var pushCommand = new Command2("push").description("Push scan results to Vibgrat
1346
1580
  });
1347
1581
 
1348
1582
  // src/commands/scan.ts
1349
- import * as path16 from "path";
1583
+ import * as path17 from "path";
1350
1584
  import { Command as Command3 } from "commander";
1351
1585
  import chalk5 from "chalk";
1352
1586
 
1353
1587
  // src/scanners/node-scanner.ts
1354
- import * as path4 from "path";
1588
+ import * as path5 from "path";
1355
1589
  import * as semver2 from "semver";
1356
1590
 
1591
+ // src/utils/timeout.ts
1592
+ async function withTimeout(promise, ms) {
1593
+ let timer;
1594
+ const timeout = new Promise((resolve7) => {
1595
+ timer = setTimeout(() => resolve7({ ok: false }), ms);
1596
+ });
1597
+ try {
1598
+ const result = await Promise.race([
1599
+ promise.then((value) => ({ ok: true, value })),
1600
+ timeout
1601
+ ]);
1602
+ return result;
1603
+ } finally {
1604
+ clearTimeout(timer);
1605
+ }
1606
+ }
1607
+
1357
1608
  // src/scanners/npm-cache.ts
1358
1609
  import { spawn } from "child_process";
1359
1610
  import * as semver from "semver";
@@ -1528,10 +1779,20 @@ var KNOWN_FRAMEWORKS = {
1528
1779
  async function scanNodeProjects(rootDir, npmCache, cache) {
1529
1780
  const packageJsonFiles = cache ? await cache.findPackageJsonFiles(rootDir) : await findPackageJsonFiles(rootDir);
1530
1781
  const results = [];
1782
+ const STUCK_TIMEOUT_MS = 6e4;
1531
1783
  for (const pjPath of packageJsonFiles) {
1532
1784
  try {
1533
- const scan = await scanOnePackageJson(pjPath, rootDir, npmCache, cache);
1534
- results.push(scan);
1785
+ const scanPromise = scanOnePackageJson(pjPath, rootDir, npmCache, cache);
1786
+ const result = await withTimeout(scanPromise, STUCK_TIMEOUT_MS);
1787
+ if (result.ok) {
1788
+ results.push(result.value);
1789
+ } else {
1790
+ const relPath = path5.relative(rootDir, path5.dirname(pjPath));
1791
+ if (cache) {
1792
+ cache.addStuckPath(relPath || ".");
1793
+ }
1794
+ console.error(`Timeout scanning ${pjPath} (>${STUCK_TIMEOUT_MS / 1e3}s) \u2014 skipped`);
1795
+ }
1535
1796
  } catch (e) {
1536
1797
  const msg = e instanceof Error ? e.message : String(e);
1537
1798
  console.error(`Error scanning ${pjPath}: ${msg}`);
@@ -1541,8 +1802,8 @@ async function scanNodeProjects(rootDir, npmCache, cache) {
1541
1802
  }
1542
1803
  async function scanOnePackageJson(packageJsonPath, rootDir, npmCache, cache) {
1543
1804
  const pj = cache ? await cache.readJsonFile(packageJsonPath) : await readJsonFile(packageJsonPath);
1544
- const absProjectPath = path4.dirname(packageJsonPath);
1545
- const projectPath = path4.relative(rootDir, absProjectPath) || ".";
1805
+ const absProjectPath = path5.dirname(packageJsonPath);
1806
+ const projectPath = path5.relative(rootDir, absProjectPath) || ".";
1546
1807
  const nodeEngine = pj.engines?.node ?? void 0;
1547
1808
  let runtimeLatest;
1548
1809
  let runtimeMajorsBehind;
@@ -1624,7 +1885,7 @@ async function scanOnePackageJson(packageJsonPath, rootDir, npmCache, cache) {
1624
1885
  return {
1625
1886
  type: "node",
1626
1887
  path: projectPath,
1627
- name: pj.name ?? path4.basename(absProjectPath),
1888
+ name: pj.name ?? path5.basename(absProjectPath),
1628
1889
  runtime: nodeEngine,
1629
1890
  runtimeLatest,
1630
1891
  runtimeMajorsBehind,
@@ -1635,7 +1896,7 @@ async function scanOnePackageJson(packageJsonPath, rootDir, npmCache, cache) {
1635
1896
  }
1636
1897
 
1637
1898
  // src/scanners/dotnet-scanner.ts
1638
- import * as path5 from "path";
1899
+ import * as path6 from "path";
1639
1900
  import { XMLParser } from "fast-xml-parser";
1640
1901
  var parser = new XMLParser({
1641
1902
  ignoreAttributes: false,
@@ -1836,7 +2097,7 @@ function parseCsproj(xml, filePath) {
1836
2097
  const parsed = parser.parse(xml);
1837
2098
  const project = parsed?.Project;
1838
2099
  if (!project) {
1839
- return { targetFrameworks: [], packageReferences: [], projectName: path5.basename(filePath, ".csproj") };
2100
+ return { targetFrameworks: [], packageReferences: [], projectName: path6.basename(filePath, ".csproj") };
1840
2101
  }
1841
2102
  const propertyGroups = Array.isArray(project.PropertyGroup) ? project.PropertyGroup : project.PropertyGroup ? [project.PropertyGroup] : [];
1842
2103
  const targetFrameworks = [];
@@ -1864,7 +2125,7 @@ function parseCsproj(xml, filePath) {
1864
2125
  return {
1865
2126
  targetFrameworks: [...new Set(targetFrameworks)],
1866
2127
  packageReferences,
1867
- projectName: path5.basename(filePath, ".csproj")
2128
+ projectName: path6.basename(filePath, ".csproj")
1868
2129
  };
1869
2130
  }
1870
2131
  async function scanDotnetProjects(rootDir, cache) {
@@ -1874,12 +2135,12 @@ async function scanDotnetProjects(rootDir, cache) {
1874
2135
  for (const slnPath of slnFiles) {
1875
2136
  try {
1876
2137
  const slnContent = cache ? await cache.readTextFile(slnPath) : await readTextFile(slnPath);
1877
- const slnDir = path5.dirname(slnPath);
2138
+ const slnDir = path6.dirname(slnPath);
1878
2139
  const projectRegex = /Project\("[^"]*"\)\s*=\s*"[^"]*",\s*"([^"]+\.csproj)"/g;
1879
2140
  let match;
1880
2141
  while ((match = projectRegex.exec(slnContent)) !== null) {
1881
2142
  if (match[1]) {
1882
- const csprojPath = path5.resolve(slnDir, match[1].replace(/\\/g, "/"));
2143
+ const csprojPath = path6.resolve(slnDir, match[1].replace(/\\/g, "/"));
1883
2144
  slnCsprojPaths.add(csprojPath);
1884
2145
  }
1885
2146
  }
@@ -1888,10 +2149,20 @@ async function scanDotnetProjects(rootDir, cache) {
1888
2149
  }
1889
2150
  const allCsprojFiles = /* @__PURE__ */ new Set([...csprojFiles, ...slnCsprojPaths]);
1890
2151
  const results = [];
2152
+ const STUCK_TIMEOUT_MS = 6e4;
1891
2153
  for (const csprojPath of allCsprojFiles) {
1892
2154
  try {
1893
- const scan = await scanOneCsproj(csprojPath, rootDir, cache);
1894
- results.push(scan);
2155
+ const scanPromise = scanOneCsproj(csprojPath, rootDir, cache);
2156
+ const result = await withTimeout(scanPromise, STUCK_TIMEOUT_MS);
2157
+ if (result.ok) {
2158
+ results.push(result.value);
2159
+ } else {
2160
+ const relPath = path6.relative(rootDir, path6.dirname(csprojPath));
2161
+ if (cache) {
2162
+ cache.addStuckPath(relPath || ".");
2163
+ }
2164
+ console.error(`Timeout scanning ${csprojPath} (>${STUCK_TIMEOUT_MS / 1e3}s) \u2014 skipped`);
2165
+ }
1895
2166
  } catch (e) {
1896
2167
  const msg = e instanceof Error ? e.message : String(e);
1897
2168
  console.error(`Error scanning ${csprojPath}: ${msg}`);
@@ -1935,7 +2206,7 @@ async function scanOneCsproj(csprojPath, rootDir, cache) {
1935
2206
  const buckets = { current: 0, oneBehind: 0, twoPlusBehind: 0, unknown: dependencies.length };
1936
2207
  return {
1937
2208
  type: "dotnet",
1938
- path: path5.relative(rootDir, path5.dirname(csprojPath)) || ".",
2209
+ path: path6.relative(rootDir, path6.dirname(csprojPath)) || ".",
1939
2210
  name: data.projectName,
1940
2211
  targetFramework,
1941
2212
  runtime: primaryTfm,
@@ -1948,15 +2219,17 @@ async function scanOneCsproj(csprojPath, rootDir, cache) {
1948
2219
  }
1949
2220
 
1950
2221
  // src/config.ts
1951
- import * as path6 from "path";
2222
+ import * as path7 from "path";
1952
2223
  import * as fs2 from "fs/promises";
1953
2224
  var CONFIG_FILES = [
1954
2225
  "vibgrate.config.ts",
1955
2226
  "vibgrate.config.js",
1956
2227
  "vibgrate.config.json"
1957
2228
  ];
2229
+ var DEFAULT_MAX_FILE_SIZE = 5242880;
1958
2230
  var DEFAULT_CONFIG = {
1959
2231
  exclude: [],
2232
+ maxFileSizeToScan: DEFAULT_MAX_FILE_SIZE,
1960
2233
  thresholds: {
1961
2234
  failOnError: {
1962
2235
  eolDays: 180,
@@ -1970,28 +2243,44 @@ var DEFAULT_CONFIG = {
1970
2243
  }
1971
2244
  };
1972
2245
  async function loadConfig(rootDir) {
2246
+ let config = DEFAULT_CONFIG;
1973
2247
  for (const file of CONFIG_FILES) {
1974
- const configPath = path6.join(rootDir, file);
2248
+ const configPath = path7.join(rootDir, file);
1975
2249
  if (await pathExists(configPath)) {
1976
2250
  if (file.endsWith(".json")) {
1977
2251
  const txt = await readTextFile(configPath);
1978
- return { ...DEFAULT_CONFIG, ...JSON.parse(txt) };
2252
+ config = { ...DEFAULT_CONFIG, ...JSON.parse(txt) };
2253
+ break;
1979
2254
  }
1980
2255
  try {
1981
2256
  const mod = await import(configPath);
1982
- return { ...DEFAULT_CONFIG, ...mod.default ?? mod };
2257
+ config = { ...DEFAULT_CONFIG, ...mod.default ?? mod };
2258
+ break;
1983
2259
  } catch {
1984
2260
  }
1985
2261
  }
1986
2262
  }
1987
- return DEFAULT_CONFIG;
2263
+ const sidecarPath = path7.join(rootDir, ".vibgrate", "auto-excludes.json");
2264
+ if (await pathExists(sidecarPath)) {
2265
+ try {
2266
+ const txt = await readTextFile(sidecarPath);
2267
+ const autoExcludes = JSON.parse(txt);
2268
+ if (Array.isArray(autoExcludes) && autoExcludes.length > 0) {
2269
+ const existing = config.exclude ?? [];
2270
+ config = { ...config, exclude: [.../* @__PURE__ */ new Set([...existing, ...autoExcludes])] };
2271
+ }
2272
+ } catch {
2273
+ }
2274
+ }
2275
+ return config;
1988
2276
  }
1989
2277
  async function writeDefaultConfig(rootDir) {
1990
- const configPath = path6.join(rootDir, "vibgrate.config.ts");
2278
+ const configPath = path7.join(rootDir, "vibgrate.config.ts");
1991
2279
  const content = `import type { VibgrateConfig } from '@vibgrate/cli';
1992
2280
 
1993
2281
  const config: VibgrateConfig = {
1994
2282
  // exclude: ['legacy/**'],
2283
+ // maxFileSizeToScan: 5_242_880, // 5 MB (default)
1995
2284
  thresholds: {
1996
2285
  failOnError: {
1997
2286
  eolDays: 180,
@@ -2010,9 +2299,44 @@ export default config;
2010
2299
  await fs2.writeFile(configPath, content, "utf8");
2011
2300
  return configPath;
2012
2301
  }
2302
+ async function appendExcludePatterns(rootDir, newPatterns) {
2303
+ if (newPatterns.length === 0) return false;
2304
+ const jsonPath = path7.join(rootDir, "vibgrate.config.json");
2305
+ if (await pathExists(jsonPath)) {
2306
+ try {
2307
+ const txt = await readTextFile(jsonPath);
2308
+ const cfg = JSON.parse(txt);
2309
+ const existing2 = Array.isArray(cfg.exclude) ? cfg.exclude : [];
2310
+ const merged2 = [.../* @__PURE__ */ new Set([...existing2, ...newPatterns])];
2311
+ cfg.exclude = merged2;
2312
+ await fs2.writeFile(jsonPath, JSON.stringify(cfg, null, 2) + "\n", "utf8");
2313
+ return true;
2314
+ } catch {
2315
+ }
2316
+ }
2317
+ const vibgrateDir = path7.join(rootDir, ".vibgrate");
2318
+ const sidecarPath = path7.join(vibgrateDir, "auto-excludes.json");
2319
+ let existing = [];
2320
+ if (await pathExists(sidecarPath)) {
2321
+ try {
2322
+ const txt = await readTextFile(sidecarPath);
2323
+ const parsed = JSON.parse(txt);
2324
+ if (Array.isArray(parsed)) existing = parsed;
2325
+ } catch {
2326
+ }
2327
+ }
2328
+ const merged = [.../* @__PURE__ */ new Set([...existing, ...newPatterns])];
2329
+ try {
2330
+ await fs2.mkdir(vibgrateDir, { recursive: true });
2331
+ await fs2.writeFile(sidecarPath, JSON.stringify(merged, null, 2) + "\n", "utf8");
2332
+ return true;
2333
+ } catch {
2334
+ return false;
2335
+ }
2336
+ }
2013
2337
 
2014
2338
  // src/utils/vcs.ts
2015
- import * as path7 from "path";
2339
+ import * as path8 from "path";
2016
2340
  import * as fs3 from "fs/promises";
2017
2341
  async function detectVcs(rootDir) {
2018
2342
  try {
@@ -2026,7 +2350,7 @@ async function detectGit(rootDir) {
2026
2350
  if (!gitDir) {
2027
2351
  return { type: "unknown" };
2028
2352
  }
2029
- const headPath = path7.join(gitDir, "HEAD");
2353
+ const headPath = path8.join(gitDir, "HEAD");
2030
2354
  let headContent;
2031
2355
  try {
2032
2356
  headContent = (await fs3.readFile(headPath, "utf8")).trim();
@@ -2050,30 +2374,30 @@ async function detectGit(rootDir) {
2050
2374
  };
2051
2375
  }
2052
2376
  async function findGitDir(startDir) {
2053
- let dir = path7.resolve(startDir);
2054
- const root = path7.parse(dir).root;
2377
+ let dir = path8.resolve(startDir);
2378
+ const root = path8.parse(dir).root;
2055
2379
  while (dir !== root) {
2056
- const gitPath = path7.join(dir, ".git");
2380
+ const gitPath = path8.join(dir, ".git");
2057
2381
  try {
2058
- const stat3 = await fs3.stat(gitPath);
2059
- if (stat3.isDirectory()) {
2382
+ const stat4 = await fs3.stat(gitPath);
2383
+ if (stat4.isDirectory()) {
2060
2384
  return gitPath;
2061
2385
  }
2062
- if (stat3.isFile()) {
2386
+ if (stat4.isFile()) {
2063
2387
  const content = (await fs3.readFile(gitPath, "utf8")).trim();
2064
2388
  if (content.startsWith("gitdir: ")) {
2065
- const resolved = path7.resolve(dir, content.slice(8));
2389
+ const resolved = path8.resolve(dir, content.slice(8));
2066
2390
  return resolved;
2067
2391
  }
2068
2392
  }
2069
2393
  } catch {
2070
2394
  }
2071
- dir = path7.dirname(dir);
2395
+ dir = path8.dirname(dir);
2072
2396
  }
2073
2397
  return null;
2074
2398
  }
2075
2399
  async function resolveRef(gitDir, refPath) {
2076
- const loosePath = path7.join(gitDir, refPath);
2400
+ const loosePath = path8.join(gitDir, refPath);
2077
2401
  try {
2078
2402
  const sha = (await fs3.readFile(loosePath, "utf8")).trim();
2079
2403
  if (/^[0-9a-f]{40}$/i.test(sha)) {
@@ -2081,7 +2405,7 @@ async function resolveRef(gitDir, refPath) {
2081
2405
  }
2082
2406
  } catch {
2083
2407
  }
2084
- const packedPath = path7.join(gitDir, "packed-refs");
2408
+ const packedPath = path8.join(gitDir, "packed-refs");
2085
2409
  try {
2086
2410
  const packed = await fs3.readFile(packedPath, "utf8");
2087
2411
  for (const line of packed.split("\n")) {
@@ -2123,6 +2447,10 @@ var ScanProgress = class {
2123
2447
  startTime = Date.now();
2124
2448
  isTTY;
2125
2449
  rootDir = "";
2450
+ /** Last rendered frame content (strip to compare for dirty-checking) */
2451
+ lastFrame = "";
2452
+ /** Whether we've hidden the cursor */
2453
+ cursorHidden = false;
2126
2454
  /** Estimated total scan duration in ms (from history or live calculation) */
2127
2455
  estimatedTotalMs = null;
2128
2456
  /** Per-step estimated durations from history */
@@ -2134,6 +2462,23 @@ var ScanProgress = class {
2134
2462
  constructor(rootDir) {
2135
2463
  this.isTTY = process.stderr.isTTY ?? false;
2136
2464
  this.rootDir = rootDir;
2465
+ if (this.isTTY) {
2466
+ const restore = () => {
2467
+ if (this.cursorHidden) {
2468
+ process.stderr.write("\x1B[?25h");
2469
+ this.cursorHidden = false;
2470
+ }
2471
+ };
2472
+ process.on("exit", restore);
2473
+ process.on("SIGINT", () => {
2474
+ restore();
2475
+ process.exit(130);
2476
+ });
2477
+ process.on("SIGTERM", () => {
2478
+ restore();
2479
+ process.exit(143);
2480
+ });
2481
+ }
2137
2482
  }
2138
2483
  /** Set the estimated total duration from scan history */
2139
2484
  setEstimatedTotal(estimatedMs) {
@@ -2192,11 +2537,12 @@ var ScanProgress = class {
2192
2537
  this.render();
2193
2538
  }
2194
2539
  /** Update sub-step progress for the active step (files processed, etc.) */
2195
- updateStepProgress(id, current, total) {
2540
+ updateStepProgress(id, current, total, label) {
2196
2541
  const step = this.steps.find((s) => s.id === id);
2197
2542
  if (step) {
2198
2543
  step.subProgress = current;
2199
2544
  if (total !== void 0) step.subTotal = total;
2545
+ if (label !== void 0) step.subLabel = label;
2200
2546
  }
2201
2547
  this.render();
2202
2548
  }
@@ -2231,7 +2577,18 @@ var ScanProgress = class {
2231
2577
  this.timer = null;
2232
2578
  }
2233
2579
  if (this.isTTY) {
2234
- this.clearLines();
2580
+ let buf = "";
2581
+ if (this.lastLineCount > 0) {
2582
+ buf += `\x1B[${this.lastLineCount}A`;
2583
+ for (let i = 0; i < this.lastLineCount; i++) {
2584
+ buf += "\x1B[2K\n";
2585
+ }
2586
+ buf += `\x1B[${this.lastLineCount}A`;
2587
+ }
2588
+ buf += "\x1B[?25h";
2589
+ if (buf) process.stderr.write(buf);
2590
+ this.cursorHidden = false;
2591
+ this.lastLineCount = 0;
2235
2592
  }
2236
2593
  const elapsed = this.formatElapsed(Date.now() - this.startTime);
2237
2594
  const doneCount = this.steps.filter((s) => s.status === "done").length;
@@ -2243,26 +2600,22 @@ var ScanProgress = class {
2243
2600
  }
2244
2601
  // ── Internal rendering ──
2245
2602
  startSpinner() {
2603
+ if (!this.cursorHidden) {
2604
+ process.stderr.write("\x1B[?25l");
2605
+ this.cursorHidden = true;
2606
+ }
2246
2607
  this.timer = setInterval(() => {
2247
2608
  this.spinnerFrame = (this.spinnerFrame + 1) % SPINNER_FRAMES.length;
2248
2609
  this.render();
2249
- }, 80);
2610
+ }, 120);
2250
2611
  }
2251
2612
  clearLines() {
2252
- if (this.lastLineCount > 0) {
2253
- process.stderr.write(`\x1B[${this.lastLineCount}A`);
2254
- for (let i = 0; i < this.lastLineCount; i++) {
2255
- process.stderr.write("\x1B[2K\n");
2256
- }
2257
- process.stderr.write(`\x1B[${this.lastLineCount}A`);
2258
- }
2259
2613
  }
2260
2614
  render() {
2261
2615
  if (!this.isTTY) {
2262
2616
  this.renderCI();
2263
2617
  return;
2264
2618
  }
2265
- this.clearLines();
2266
2619
  const lines = [];
2267
2620
  lines.push("");
2268
2621
  lines.push(` ${ROBOT[0]} ${BRAND[0]}`);
@@ -2303,8 +2656,21 @@ var ScanProgress = class {
2303
2656
  lines.push("");
2304
2657
  lines.push(this.renderStats());
2305
2658
  lines.push("");
2306
- const output = lines.join("\n") + "\n";
2307
- process.stderr.write(output);
2659
+ const content = lines.join("\n") + "\n";
2660
+ if (content === this.lastFrame && this.lastLineCount === lines.length) {
2661
+ return;
2662
+ }
2663
+ this.lastFrame = content;
2664
+ let buf = "";
2665
+ if (this.lastLineCount > 0) {
2666
+ buf += `\x1B[${this.lastLineCount}A`;
2667
+ for (let i = 0; i < this.lastLineCount; i++) {
2668
+ buf += "\x1B[2K\n";
2669
+ }
2670
+ buf += `\x1B[${this.lastLineCount}A`;
2671
+ }
2672
+ buf += content;
2673
+ process.stderr.write(buf);
2308
2674
  this.lastLineCount = lines.length;
2309
2675
  }
2310
2676
  renderStep(step) {
@@ -2323,6 +2689,11 @@ var ScanProgress = class {
2323
2689
  if (step.subTotal && step.subTotal > 0 && step.subProgress !== void 0 && step.subProgress > 0) {
2324
2690
  detail = chalk4.dim(` \xB7 ${step.subProgress.toLocaleString()} / ${step.subTotal.toLocaleString()}`);
2325
2691
  }
2692
+ if (step.subLabel) {
2693
+ const maxLen = 50;
2694
+ const displayPath = step.subLabel.length > maxLen ? "\u2026" + step.subLabel.slice(-maxLen + 1) : step.subLabel;
2695
+ detail += chalk4.dim(` ${displayPath}`);
2696
+ }
2326
2697
  break;
2327
2698
  case "skipped":
2328
2699
  icon = chalk4.dim("\u25CC");
@@ -2428,11 +2799,11 @@ var ScanProgress = class {
2428
2799
 
2429
2800
  // src/ui/scan-history.ts
2430
2801
  import * as fs4 from "fs/promises";
2431
- import * as path8 from "path";
2802
+ import * as path9 from "path";
2432
2803
  var HISTORY_FILENAME = "scan_history.json";
2433
2804
  var MAX_RECORDS = 10;
2434
2805
  async function loadScanHistory(rootDir) {
2435
- const filePath = path8.join(rootDir, ".vibgrate", HISTORY_FILENAME);
2806
+ const filePath = path9.join(rootDir, ".vibgrate", HISTORY_FILENAME);
2436
2807
  try {
2437
2808
  const txt = await fs4.readFile(filePath, "utf8");
2438
2809
  const data = JSON.parse(txt);
@@ -2445,8 +2816,8 @@ async function loadScanHistory(rootDir) {
2445
2816
  }
2446
2817
  }
2447
2818
  async function saveScanHistory(rootDir, record) {
2448
- const dir = path8.join(rootDir, ".vibgrate");
2449
- const filePath = path8.join(dir, HISTORY_FILENAME);
2819
+ const dir = path9.join(rootDir, ".vibgrate");
2820
+ const filePath = path9.join(dir, HISTORY_FILENAME);
2450
2821
  let history;
2451
2822
  const existing = await loadScanHistory(rootDir);
2452
2823
  if (existing) {
@@ -2510,7 +2881,7 @@ function estimateStepDurations(history, currentFileCount) {
2510
2881
  }
2511
2882
 
2512
2883
  // src/scanners/platform-matrix.ts
2513
- import * as path9 from "path";
2884
+ import * as path10 from "path";
2514
2885
  var NATIVE_MODULE_PACKAGES = /* @__PURE__ */ new Set([
2515
2886
  // Image / media processing
2516
2887
  "sharp",
@@ -2790,7 +3161,7 @@ async function scanPlatformMatrix(rootDir, cache) {
2790
3161
  }
2791
3162
  result.dockerBaseImages = [...baseImages].sort();
2792
3163
  for (const file of [".nvmrc", ".node-version", ".tool-versions"]) {
2793
- const exists = cache ? await cache.pathExists(path9.join(rootDir, file)) : await pathExists(path9.join(rootDir, file));
3164
+ const exists = cache ? await cache.pathExists(path10.join(rootDir, file)) : await pathExists(path10.join(rootDir, file));
2794
3165
  if (exists) {
2795
3166
  result.nodeVersionFiles.push(file);
2796
3167
  }
@@ -2867,7 +3238,7 @@ function scanDependencyRisk(projects) {
2867
3238
  }
2868
3239
 
2869
3240
  // src/scanners/dependency-graph.ts
2870
- import * as path10 from "path";
3241
+ import * as path11 from "path";
2871
3242
  function parsePnpmLock(content) {
2872
3243
  const entries = [];
2873
3244
  const regex = /^\s+\/?(@?[^@\s][^@\s]*?)@(\d+\.\d+\.\d+[^:\s]*)\s*:/gm;
@@ -2926,9 +3297,9 @@ async function scanDependencyGraph(rootDir, cache) {
2926
3297
  phantomDependencies: []
2927
3298
  };
2928
3299
  let entries = [];
2929
- const pnpmLock = path10.join(rootDir, "pnpm-lock.yaml");
2930
- const npmLock = path10.join(rootDir, "package-lock.json");
2931
- const yarnLock = path10.join(rootDir, "yarn.lock");
3300
+ const pnpmLock = path11.join(rootDir, "pnpm-lock.yaml");
3301
+ const npmLock = path11.join(rootDir, "package-lock.json");
3302
+ const yarnLock = path11.join(rootDir, "yarn.lock");
2932
3303
  const _pathExists = cache ? (p) => cache.pathExists(p) : pathExists;
2933
3304
  const _readTextFile = cache ? (p) => cache.readTextFile(p) : readTextFile;
2934
3305
  if (await _pathExists(pnpmLock)) {
@@ -2975,7 +3346,7 @@ async function scanDependencyGraph(rootDir, cache) {
2975
3346
  for (const pjPath of pkgFiles) {
2976
3347
  try {
2977
3348
  const pj = cache ? await cache.readJsonFile(pjPath) : await readJsonFile(pjPath);
2978
- const relPath = path10.relative(rootDir, pjPath);
3349
+ const relPath = path11.relative(rootDir, pjPath);
2979
3350
  for (const section of ["dependencies", "devDependencies"]) {
2980
3351
  const deps = pj[section];
2981
3352
  if (!deps) continue;
@@ -3321,7 +3692,7 @@ function scanToolingInventory(projects) {
3321
3692
  }
3322
3693
 
3323
3694
  // src/scanners/build-deploy.ts
3324
- import * as path11 from "path";
3695
+ import * as path12 from "path";
3325
3696
  var CI_FILES = {
3326
3697
  ".github/workflows": "github-actions",
3327
3698
  ".gitlab-ci.yml": "gitlab-ci",
@@ -3374,17 +3745,17 @@ async function scanBuildDeploy(rootDir, cache) {
3374
3745
  const _readTextFile = cache ? (p) => cache.readTextFile(p) : readTextFile;
3375
3746
  const ciSystems = /* @__PURE__ */ new Set();
3376
3747
  for (const [file, system] of Object.entries(CI_FILES)) {
3377
- const fullPath = path11.join(rootDir, file);
3748
+ const fullPath = path12.join(rootDir, file);
3378
3749
  if (await _pathExists(fullPath)) {
3379
3750
  ciSystems.add(system);
3380
3751
  }
3381
3752
  }
3382
- const ghWorkflowDir = path11.join(rootDir, ".github", "workflows");
3753
+ const ghWorkflowDir = path12.join(rootDir, ".github", "workflows");
3383
3754
  if (await _pathExists(ghWorkflowDir)) {
3384
3755
  try {
3385
3756
  if (cache) {
3386
3757
  const entries = await cache.walkDir(rootDir);
3387
- const ghPrefix = path11.relative(rootDir, ghWorkflowDir) + path11.sep;
3758
+ const ghPrefix = path12.relative(rootDir, ghWorkflowDir) + path12.sep;
3388
3759
  result.ciWorkflowCount = entries.filter(
3389
3760
  (e) => e.isFile && e.relPath.startsWith(ghPrefix) && (e.name.endsWith(".yml") || e.name.endsWith(".yaml"))
3390
3761
  ).length;
@@ -3435,11 +3806,11 @@ async function scanBuildDeploy(rootDir, cache) {
3435
3806
  (name) => name.endsWith(".cfn.json") || name.endsWith(".cfn.yaml")
3436
3807
  );
3437
3808
  if (cfnFiles.length > 0) iacSystems.add("cloudformation");
3438
- if (await _pathExists(path11.join(rootDir, "Pulumi.yaml"))) iacSystems.add("pulumi");
3809
+ if (await _pathExists(path12.join(rootDir, "Pulumi.yaml"))) iacSystems.add("pulumi");
3439
3810
  result.iac = [...iacSystems].sort();
3440
3811
  const releaseTools = /* @__PURE__ */ new Set();
3441
3812
  for (const [file, tool] of Object.entries(RELEASE_FILES)) {
3442
- if (await _pathExists(path11.join(rootDir, file))) releaseTools.add(tool);
3813
+ if (await _pathExists(path12.join(rootDir, file))) releaseTools.add(tool);
3443
3814
  }
3444
3815
  const pkgFiles = cache ? await cache.findPackageJsonFiles(rootDir) : await findPackageJsonFiles(rootDir);
3445
3816
  for (const pjPath of pkgFiles) {
@@ -3464,19 +3835,19 @@ async function scanBuildDeploy(rootDir, cache) {
3464
3835
  };
3465
3836
  const managers = /* @__PURE__ */ new Set();
3466
3837
  for (const [file, manager] of Object.entries(lockfileMap)) {
3467
- if (await _pathExists(path11.join(rootDir, file))) managers.add(manager);
3838
+ if (await _pathExists(path12.join(rootDir, file))) managers.add(manager);
3468
3839
  }
3469
3840
  result.packageManagers = [...managers].sort();
3470
3841
  const monoTools = /* @__PURE__ */ new Set();
3471
3842
  for (const [file, tool] of Object.entries(MONOREPO_FILES)) {
3472
- if (await _pathExists(path11.join(rootDir, file))) monoTools.add(tool);
3843
+ if (await _pathExists(path12.join(rootDir, file))) monoTools.add(tool);
3473
3844
  }
3474
3845
  result.monorepoTools = [...monoTools].sort();
3475
3846
  return result;
3476
3847
  }
3477
3848
 
3478
3849
  // src/scanners/ts-modernity.ts
3479
- import * as path12 from "path";
3850
+ import * as path13 from "path";
3480
3851
  async function scanTsModernity(rootDir, cache) {
3481
3852
  const result = {
3482
3853
  typescriptVersion: null,
@@ -3514,7 +3885,7 @@ async function scanTsModernity(rootDir, cache) {
3514
3885
  if (hasEsm && hasCjs) result.moduleType = "mixed";
3515
3886
  else if (hasEsm) result.moduleType = "esm";
3516
3887
  else if (hasCjs) result.moduleType = "cjs";
3517
- let tsConfigPath = path12.join(rootDir, "tsconfig.json");
3888
+ let tsConfigPath = path13.join(rootDir, "tsconfig.json");
3518
3889
  const tsConfigExists = cache ? await cache.pathExists(tsConfigPath) : await pathExists(tsConfigPath);
3519
3890
  if (!tsConfigExists) {
3520
3891
  const tsConfigs = cache ? await cache.findFiles(rootDir, (name) => name === "tsconfig.json") : await findFiles(rootDir, (name) => name === "tsconfig.json");
@@ -3861,7 +4232,7 @@ function scanBreakingChangeExposure(projects) {
3861
4232
 
3862
4233
  // src/scanners/file-hotspots.ts
3863
4234
  import * as fs5 from "fs/promises";
3864
- import * as path13 from "path";
4235
+ import * as path14 from "path";
3865
4236
  var SKIP_DIRS2 = /* @__PURE__ */ new Set([
3866
4237
  "node_modules",
3867
4238
  ".git",
@@ -3880,7 +4251,7 @@ var SKIP_DIRS2 = /* @__PURE__ */ new Set([
3880
4251
  ".output",
3881
4252
  ".svelte-kit"
3882
4253
  ]);
3883
- var SKIP_EXTENSIONS = /* @__PURE__ */ new Set([
4254
+ var SKIP_EXTENSIONS2 = /* @__PURE__ */ new Set([
3884
4255
  ".map",
3885
4256
  ".lock",
3886
4257
  ".png",
@@ -3892,6 +4263,7 @@ var SKIP_EXTENSIONS = /* @__PURE__ */ new Set([
3892
4263
  ".woff",
3893
4264
  ".woff2",
3894
4265
  ".ttf",
4266
+ ".otf",
3895
4267
  ".eot",
3896
4268
  ".mp4",
3897
4269
  ".webm"
@@ -3904,16 +4276,16 @@ async function scanFileHotspots(rootDir, cache) {
3904
4276
  const entries = await cache.walkDir(rootDir);
3905
4277
  for (const entry of entries) {
3906
4278
  if (!entry.isFile) continue;
3907
- const ext = path13.extname(entry.name).toLowerCase();
3908
- if (SKIP_EXTENSIONS.has(ext)) continue;
3909
- const depth = entry.relPath.split(path13.sep).length - 1;
4279
+ const ext = path14.extname(entry.name).toLowerCase();
4280
+ if (SKIP_EXTENSIONS2.has(ext)) continue;
4281
+ const depth = entry.relPath.split(path14.sep).length - 1;
3910
4282
  if (depth > maxDepth) maxDepth = depth;
3911
4283
  extensionCounts[ext] = (extensionCounts[ext] ?? 0) + 1;
3912
4284
  try {
3913
- const stat3 = await fs5.stat(entry.absPath);
4285
+ const stat4 = await fs5.stat(entry.absPath);
3914
4286
  allFiles.push({
3915
4287
  path: entry.relPath,
3916
- bytes: stat3.size
4288
+ bytes: stat4.size
3917
4289
  });
3918
4290
  } catch {
3919
4291
  }
@@ -3935,16 +4307,16 @@ async function scanFileHotspots(rootDir, cache) {
3935
4307
  for (const e of entries) {
3936
4308
  if (e.isDirectory) {
3937
4309
  if (SKIP_DIRS2.has(e.name)) continue;
3938
- await walk(path13.join(dir, e.name), depth + 1);
4310
+ await walk(path14.join(dir, e.name), depth + 1);
3939
4311
  } else if (e.isFile) {
3940
- const ext = path13.extname(e.name).toLowerCase();
3941
- if (SKIP_EXTENSIONS.has(ext)) continue;
4312
+ const ext = path14.extname(e.name).toLowerCase();
4313
+ if (SKIP_EXTENSIONS2.has(ext)) continue;
3942
4314
  extensionCounts[ext] = (extensionCounts[ext] ?? 0) + 1;
3943
4315
  try {
3944
- const stat3 = await fs5.stat(path13.join(dir, e.name));
4316
+ const stat4 = await fs5.stat(path14.join(dir, e.name));
3945
4317
  allFiles.push({
3946
- path: path13.relative(rootDir, path13.join(dir, e.name)),
3947
- bytes: stat3.size
4318
+ path: path14.relative(rootDir, path14.join(dir, e.name)),
4319
+ bytes: stat4.size
3948
4320
  });
3949
4321
  } catch {
3950
4322
  }
@@ -3966,7 +4338,7 @@ async function scanFileHotspots(rootDir, cache) {
3966
4338
  }
3967
4339
 
3968
4340
  // src/scanners/security-posture.ts
3969
- import * as path14 from "path";
4341
+ import * as path15 from "path";
3970
4342
  var LOCKFILES = {
3971
4343
  "pnpm-lock.yaml": "pnpm",
3972
4344
  "package-lock.json": "npm",
@@ -3987,14 +4359,14 @@ async function scanSecurityPosture(rootDir, cache) {
3987
4359
  const _readTextFile = cache ? (p) => cache.readTextFile(p) : readTextFile;
3988
4360
  const foundLockfiles = [];
3989
4361
  for (const [file, type] of Object.entries(LOCKFILES)) {
3990
- if (await _pathExists(path14.join(rootDir, file))) {
4362
+ if (await _pathExists(path15.join(rootDir, file))) {
3991
4363
  foundLockfiles.push(type);
3992
4364
  }
3993
4365
  }
3994
4366
  result.lockfilePresent = foundLockfiles.length > 0;
3995
4367
  result.multipleLockfileTypes = foundLockfiles.length > 1;
3996
4368
  result.lockfileTypes = foundLockfiles.sort();
3997
- const gitignorePath = path14.join(rootDir, ".gitignore");
4369
+ const gitignorePath = path15.join(rootDir, ".gitignore");
3998
4370
  if (await _pathExists(gitignorePath)) {
3999
4371
  try {
4000
4372
  const content = await _readTextFile(gitignorePath);
@@ -4009,7 +4381,7 @@ async function scanSecurityPosture(rootDir, cache) {
4009
4381
  }
4010
4382
  }
4011
4383
  for (const envFile of [".env", ".env.local", ".env.development", ".env.production"]) {
4012
- if (await _pathExists(path14.join(rootDir, envFile))) {
4384
+ if (await _pathExists(path15.join(rootDir, envFile))) {
4013
4385
  if (!result.gitignoreCoversEnv) {
4014
4386
  result.envFilesTracked = true;
4015
4387
  break;
@@ -4434,7 +4806,7 @@ function scanServiceDependencies(projects) {
4434
4806
  }
4435
4807
 
4436
4808
  // src/scanners/architecture.ts
4437
- import * as path15 from "path";
4809
+ import * as path16 from "path";
4438
4810
  import * as fs6 from "fs/promises";
4439
4811
  var ARCHETYPE_SIGNALS = [
4440
4812
  // Meta-frameworks (highest priority — they imply routing patterns)
@@ -4733,9 +5105,9 @@ async function walkSourceFiles(rootDir, cache) {
4733
5105
  const entries = await cache.walkDir(rootDir);
4734
5106
  return entries.filter((e) => {
4735
5107
  if (!e.isFile) return false;
4736
- const name = path15.basename(e.absPath);
5108
+ const name = path16.basename(e.absPath);
4737
5109
  if (name.startsWith(".") && name !== ".") return false;
4738
- const ext = path15.extname(name);
5110
+ const ext = path16.extname(name);
4739
5111
  return SOURCE_EXTENSIONS.has(ext);
4740
5112
  }).map((e) => e.relPath);
4741
5113
  }
@@ -4749,15 +5121,15 @@ async function walkSourceFiles(rootDir, cache) {
4749
5121
  }
4750
5122
  for (const entry of entries) {
4751
5123
  if (entry.name.startsWith(".") && entry.name !== ".") continue;
4752
- const fullPath = path15.join(dir, entry.name);
5124
+ const fullPath = path16.join(dir, entry.name);
4753
5125
  if (entry.isDirectory()) {
4754
5126
  if (!IGNORE_DIRS.has(entry.name)) {
4755
5127
  await walk(fullPath);
4756
5128
  }
4757
5129
  } else if (entry.isFile()) {
4758
- const ext = path15.extname(entry.name);
5130
+ const ext = path16.extname(entry.name);
4759
5131
  if (SOURCE_EXTENSIONS.has(ext)) {
4760
- files.push(path15.relative(rootDir, fullPath));
5132
+ files.push(path16.relative(rootDir, fullPath));
4761
5133
  }
4762
5134
  }
4763
5135
  }
@@ -4781,7 +5153,7 @@ function classifyFile(filePath, archetype) {
4781
5153
  }
4782
5154
  }
4783
5155
  if (!bestMatch || bestMatch.confidence < 0.7) {
4784
- const baseName = path15.basename(filePath, path15.extname(filePath));
5156
+ const baseName = path16.basename(filePath, path16.extname(filePath));
4785
5157
  const cleanBase = baseName.replace(/\.(test|spec)$/, "");
4786
5158
  for (const rule of SUFFIX_RULES) {
4787
5159
  if (cleanBase.endsWith(rule.suffix)) {
@@ -4972,6 +5344,9 @@ async function runScan(rootDir, opts) {
4972
5344
  const sem = new Semaphore(opts.concurrency);
4973
5345
  const npmCache = new NpmCache(rootDir, sem);
4974
5346
  const fileCache = new FileCache();
5347
+ const excludePatterns = config.exclude ?? [];
5348
+ fileCache.setExcludePatterns(excludePatterns);
5349
+ fileCache.setMaxFileSize(config.maxFileSizeToScan ?? 5242880);
4975
5350
  const scanners = config.scanners;
4976
5351
  let filesScanned = 0;
4977
5352
  const progress = new ScanProgress(rootDir);
@@ -5001,7 +5376,7 @@ async function runScan(rootDir, opts) {
5001
5376
  progress.setSteps(steps);
5002
5377
  progress.completeStep("config", "loaded");
5003
5378
  progress.startStep("discovery");
5004
- const treeCount = await quickTreeCount(rootDir);
5379
+ const treeCount = await quickTreeCount(rootDir, excludePatterns);
5005
5380
  progress.updateStats({ treeSummary: treeCount });
5006
5381
  progress.completeStep(
5007
5382
  "discovery",
@@ -5016,8 +5391,8 @@ async function runScan(rootDir, opts) {
5016
5391
  const vcsDetail = vcs.type !== "unknown" ? `${vcs.type}${vcs.branch ? ` ${vcs.branch}` : ""}${vcs.shortSha ? ` @ ${vcs.shortSha}` : ""}` : "none detected";
5017
5392
  progress.completeStep("vcs", vcsDetail);
5018
5393
  progress.startStep("walk", treeCount.totalFiles);
5019
- await fileCache.walkDir(rootDir, (found) => {
5020
- progress.updateStepProgress("walk", found, treeCount.totalFiles);
5394
+ await fileCache.walkDir(rootDir, (found, currentPath) => {
5395
+ progress.updateStepProgress("walk", found, treeCount.totalFiles, currentPath);
5021
5396
  });
5022
5397
  progress.completeStep("walk", `${treeCount.totalFiles.toLocaleString()} files indexed`);
5023
5398
  progress.startStep("node");
@@ -5199,6 +5574,36 @@ async function runScan(rootDir, opts) {
5199
5574
  if (noteCount > 0) findingParts.push(`${noteCount} note${noteCount !== 1 ? "s" : ""}`);
5200
5575
  progress.completeStep("findings", findingParts.join(", ") || "none");
5201
5576
  progress.finish();
5577
+ const stuckPaths = fileCache.stuckPaths;
5578
+ const skippedLarge = fileCache.skippedLargeFiles;
5579
+ if (stuckPaths.length > 0) {
5580
+ console.log(
5581
+ chalk5.yellow(`
5582
+ \u26A0 ${stuckPaths.length} path${stuckPaths.length === 1 ? "" : "s"} timed out (>60s) and ${stuckPaths.length === 1 ? "was" : "were"} skipped:`)
5583
+ );
5584
+ for (const d of stuckPaths) {
5585
+ console.log(chalk5.dim(` \u2192 ${d}`));
5586
+ }
5587
+ const newExcludes = stuckPaths.map((d) => `${d}/**`);
5588
+ const updated = await appendExcludePatterns(rootDir, newExcludes);
5589
+ if (updated) {
5590
+ console.log(chalk5.green("\u2714") + ` Added ${newExcludes.length} pattern${newExcludes.length !== 1 ? "s" : ""} to exclude list in config`);
5591
+ }
5592
+ }
5593
+ if (skippedLarge.length > 0) {
5594
+ const sizeLimit = config.maxFileSizeToScan ?? 5242880;
5595
+ const sizeMB = (sizeLimit / 1048576).toFixed(0);
5596
+ console.log(
5597
+ chalk5.yellow(`
5598
+ \u26A0 ${skippedLarge.length} file${skippedLarge.length === 1 ? "" : "s"} skipped (>${sizeMB} MB):`)
5599
+ );
5600
+ for (const f of skippedLarge.slice(0, 10)) {
5601
+ console.log(chalk5.dim(` \u2192 ${f}`));
5602
+ }
5603
+ if (skippedLarge.length > 10) {
5604
+ console.log(chalk5.dim(` \u2026 and ${skippedLarge.length - 10} more`));
5605
+ }
5606
+ }
5202
5607
  fileCache.clear();
5203
5608
  if (allProjects.length === 0) {
5204
5609
  console.log(chalk5.yellow("No projects found."));
@@ -5216,7 +5621,7 @@ async function runScan(rootDir, opts) {
5216
5621
  schemaVersion: "1.0",
5217
5622
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
5218
5623
  vibgrateVersion: VERSION,
5219
- rootPath: path16.basename(rootDir),
5624
+ rootPath: path17.basename(rootDir),
5220
5625
  ...vcs.type !== "unknown" ? { vcs } : {},
5221
5626
  projects: allProjects,
5222
5627
  drift,
@@ -5227,7 +5632,7 @@ async function runScan(rootDir, opts) {
5227
5632
  treeSummary: treeCount
5228
5633
  };
5229
5634
  if (opts.baseline) {
5230
- const baselinePath = path16.resolve(opts.baseline);
5635
+ const baselinePath = path17.resolve(opts.baseline);
5231
5636
  if (await pathExists(baselinePath)) {
5232
5637
  try {
5233
5638
  const baseline = await readJsonFile(baselinePath);
@@ -5238,9 +5643,9 @@ async function runScan(rootDir, opts) {
5238
5643
  }
5239
5644
  }
5240
5645
  }
5241
- const vibgrateDir = path16.join(rootDir, ".vibgrate");
5646
+ const vibgrateDir = path17.join(rootDir, ".vibgrate");
5242
5647
  await ensureDir(vibgrateDir);
5243
- await writeJsonFile(path16.join(vibgrateDir, "scan_result.json"), artifact);
5648
+ await writeJsonFile(path17.join(vibgrateDir, "scan_result.json"), artifact);
5244
5649
  await saveScanHistory(rootDir, {
5245
5650
  timestamp: artifact.timestamp,
5246
5651
  totalDurationMs: durationMs,
@@ -5250,10 +5655,10 @@ async function runScan(rootDir, opts) {
5250
5655
  });
5251
5656
  for (const project of allProjects) {
5252
5657
  if (project.drift && project.path) {
5253
- const projectDir = path16.resolve(rootDir, project.path);
5254
- const projectVibgrateDir = path16.join(projectDir, ".vibgrate");
5658
+ const projectDir = path17.resolve(rootDir, project.path);
5659
+ const projectVibgrateDir = path17.join(projectDir, ".vibgrate");
5255
5660
  await ensureDir(projectVibgrateDir);
5256
- await writeJsonFile(path16.join(projectVibgrateDir, "project_score.json"), {
5661
+ await writeJsonFile(path17.join(projectVibgrateDir, "project_score.json"), {
5257
5662
  projectId: project.projectId,
5258
5663
  name: project.name,
5259
5664
  type: project.type,
@@ -5270,7 +5675,7 @@ async function runScan(rootDir, opts) {
5270
5675
  if (opts.format === "json") {
5271
5676
  const jsonStr = JSON.stringify(artifact, null, 2);
5272
5677
  if (opts.out) {
5273
- await writeTextFile(path16.resolve(opts.out), jsonStr);
5678
+ await writeTextFile(path17.resolve(opts.out), jsonStr);
5274
5679
  console.log(chalk5.green("\u2714") + ` JSON written to ${opts.out}`);
5275
5680
  } else {
5276
5681
  console.log(jsonStr);
@@ -5279,7 +5684,7 @@ async function runScan(rootDir, opts) {
5279
5684
  const sarif = formatSarif(artifact);
5280
5685
  const sarifStr = JSON.stringify(sarif, null, 2);
5281
5686
  if (opts.out) {
5282
- await writeTextFile(path16.resolve(opts.out), sarifStr);
5687
+ await writeTextFile(path17.resolve(opts.out), sarifStr);
5283
5688
  console.log(chalk5.green("\u2714") + ` SARIF written to ${opts.out}`);
5284
5689
  } else {
5285
5690
  console.log(sarifStr);
@@ -5288,7 +5693,7 @@ async function runScan(rootDir, opts) {
5288
5693
  const text = formatText(artifact);
5289
5694
  console.log(text);
5290
5695
  if (opts.out) {
5291
- await writeTextFile(path16.resolve(opts.out), text);
5696
+ await writeTextFile(path17.resolve(opts.out), text);
5292
5697
  }
5293
5698
  }
5294
5699
  return artifact;
@@ -5347,7 +5752,7 @@ async function autoPush(artifact, rootDir, opts) {
5347
5752
  }
5348
5753
  }
5349
5754
  var scanCommand = new Command3("scan").description("Scan a project for upgrade drift").argument("[path]", "Path to scan", ".").option("--out <file>", "Output file path").option("--format <format>", "Output format (text|json|sarif)", "text").option("--fail-on <level>", "Fail on warn or error").option("--baseline <file>", "Compare against baseline").option("--changed-only", "Only scan changed files").option("--concurrency <n>", "Max concurrent npm calls", "8").option("--push", "Auto-push results to Vibgrate API after scan").option("--dsn <dsn>", "DSN token for push (or use VIBGRATE_DSN env)").option("--region <region>", "Override data residency region for push (us, eu)").option("--strict", "Fail on push errors").action(async (targetPath, opts) => {
5350
- const rootDir = path16.resolve(targetPath);
5755
+ const rootDir = path17.resolve(targetPath);
5351
5756
  if (!await pathExists(rootDir)) {
5352
5757
  console.error(chalk5.red(`Path does not exist: ${rootDir}`));
5353
5758
  process.exit(1);