@vibgrate/cli 1.0.20 → 1.0.22

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",
@@ -60,6 +140,40 @@ var FileCache = class _FileCache {
60
140
  jsonCache = /* @__PURE__ */ new Map();
61
141
  /** pathExists keyed by absolute path */
62
142
  existsCache = /* @__PURE__ */ new Map();
143
+ /** User-configured exclude predicate (compiled from glob patterns) */
144
+ excludePredicate = null;
145
+ /** Directories that were auto-skipped because they were stuck (>60s) */
146
+ _stuckPaths = [];
147
+ /** Files skipped because they exceed maxFileSizeToScan */
148
+ _skippedLargeFiles = [];
149
+ /** Maximum file size (bytes) we will read. 0 = unlimited. */
150
+ _maxFileSize = 0;
151
+ /** Root dir for relative-path computation (set by the first walkDir call) */
152
+ _rootDir = null;
153
+ /** Set exclude patterns from config (call once before the walk) */
154
+ setExcludePatterns(patterns) {
155
+ this.excludePredicate = compileGlobs(patterns);
156
+ }
157
+ /** Set the maximum file size in bytes that readTextFile / readJsonFile will process */
158
+ setMaxFileSize(bytes) {
159
+ this._maxFileSize = bytes;
160
+ }
161
+ /** Record a path that timed out or was stuck during scanning */
162
+ addStuckPath(relPath) {
163
+ this._stuckPaths.push(relPath);
164
+ }
165
+ /** Get all paths that were auto-skipped due to being stuck (dirs + scanner files) */
166
+ get stuckPaths() {
167
+ return this._stuckPaths;
168
+ }
169
+ /** @deprecated Use stuckPaths instead */
170
+ get stuckDirs() {
171
+ return this._stuckPaths;
172
+ }
173
+ /** Get files that were skipped because they exceeded maxFileSizeToScan */
174
+ get skippedLargeFiles() {
175
+ return this._skippedLargeFiles;
176
+ }
63
177
  // ── Directory walking ──
64
178
  /**
65
179
  * Walk the directory tree from `rootDir` once, skipping SKIP_DIRS plus
@@ -69,43 +183,71 @@ var FileCache = class _FileCache {
69
183
  * Consumers that need additional filtering (e.g. SOURCE_EXTENSIONS,
70
184
  * SKIP_EXTENSIONS) do so on the returned entries — no separate walk.
71
185
  */
72
- walkDir(rootDir) {
186
+ walkDir(rootDir, onProgress) {
187
+ this._rootDir = rootDir;
73
188
  const cached = this.walkCache.get(rootDir);
74
189
  if (cached) return cached;
75
- const promise = this._doWalk(rootDir);
190
+ const promise = this._doWalk(rootDir, onProgress);
76
191
  this.walkCache.set(rootDir, promise);
77
192
  return promise;
78
193
  }
79
194
  /** Additional dirs skipped only by the cached walk (framework outputs) */
80
195
  static EXTRA_SKIP = /* @__PURE__ */ new Set([".nuxt", ".output", ".svelte-kit"]);
81
- async _doWalk(rootDir) {
196
+ async _doWalk(rootDir, onProgress) {
82
197
  const results = [];
83
198
  const cores = typeof os.availableParallelism === "function" ? os.availableParallelism() : os.cpus().length || 4;
84
199
  const maxConcurrentReads = Math.max(8, Math.min(64, cores * 4));
200
+ let foundCount = 0;
201
+ let lastReported = 0;
202
+ const REPORT_INTERVAL = 50;
85
203
  const sem = new Semaphore(maxConcurrentReads);
204
+ const STUCK_TIMEOUT_MS = 6e4;
86
205
  const extraSkip = _FileCache.EXTRA_SKIP;
206
+ const isExcluded = this.excludePredicate;
207
+ const stuckDirs = this._stuckPaths;
87
208
  async function walk(dir) {
209
+ const relDir = path2.relative(rootDir, dir);
88
210
  let entries;
89
211
  try {
90
- entries = await fs.readdir(dir, { withFileTypes: true });
212
+ const readPromise = fs.readdir(dir, { withFileTypes: true });
213
+ const result = await Promise.race([
214
+ readPromise.then((e) => ({ ok: true, entries: e })),
215
+ new Promise(
216
+ (resolve7) => setTimeout(() => resolve7({ ok: false }), STUCK_TIMEOUT_MS)
217
+ )
218
+ ]);
219
+ if (!result.ok) {
220
+ stuckDirs.push(relDir || dir);
221
+ return;
222
+ }
223
+ entries = result.entries;
91
224
  } catch {
92
225
  return;
93
226
  }
94
227
  const subWalks = [];
95
228
  for (const e of entries) {
96
- const absPath = path.join(dir, e.name);
97
- const relPath = path.relative(rootDir, absPath);
229
+ const absPath = path2.join(dir, e.name);
230
+ const relPath = path2.relative(rootDir, absPath);
231
+ if (isExcluded && isExcluded(relPath)) continue;
98
232
  if (e.isDirectory()) {
99
233
  if (SKIP_DIRS.has(e.name) || extraSkip.has(e.name)) continue;
100
234
  results.push({ absPath, relPath, name: e.name, isFile: false, isDirectory: true });
101
235
  subWalks.push(sem.run(() => walk(absPath)));
102
236
  } else if (e.isFile()) {
103
237
  results.push({ absPath, relPath, name: e.name, isFile: true, isDirectory: false });
238
+ foundCount++;
239
+ if (onProgress && foundCount - lastReported >= REPORT_INTERVAL) {
240
+ lastReported = foundCount;
241
+ onProgress(foundCount, relPath);
242
+ }
104
243
  }
105
244
  }
106
245
  await Promise.all(subWalks);
107
246
  }
108
247
  await sem.run(() => walk(rootDir));
248
+ if (onProgress && foundCount !== lastReported) {
249
+ onProgress(foundCount, "");
250
+ }
109
251
  return results;
110
252
  }
111
253
  /**
@@ -130,17 +272,36 @@ var FileCache = class _FileCache {
130
272
  * Read a text file. Files ≤ 1 MB are cached so subsequent calls from
131
273
  * different scanners return the same string. Files > 1 MB (lockfiles,
132
274
  * large generated files) are read directly and never retained.
275
+ *
276
+ * If maxFileSizeToScan is set and the file exceeds it, the file is
277
+ * recorded as skipped and an empty string is returned.
133
278
  */
134
279
  readTextFile(filePath) {
135
- const abs = path.resolve(filePath);
280
+ const abs = path2.resolve(filePath);
136
281
  const cached = this.textCache.get(abs);
137
282
  if (cached) return cached;
138
- const promise = fs.readFile(abs, "utf8").then((content) => {
283
+ const maxSize = this._maxFileSize;
284
+ const skippedLarge = this._skippedLargeFiles;
285
+ const rootDir = this._rootDir;
286
+ const promise = (async () => {
287
+ if (maxSize > 0) {
288
+ try {
289
+ const stat4 = await fs.stat(abs);
290
+ if (stat4.size > maxSize) {
291
+ const rel = rootDir ? path2.relative(rootDir, abs) : abs;
292
+ skippedLarge.push(rel);
293
+ this.textCache.delete(abs);
294
+ return "";
295
+ }
296
+ } catch {
297
+ }
298
+ }
299
+ const content = await fs.readFile(abs, "utf8");
139
300
  if (content.length > TEXT_CACHE_MAX_BYTES) {
140
301
  this.textCache.delete(abs);
141
302
  }
142
303
  return content;
143
- });
304
+ })();
144
305
  this.textCache.set(abs, promise);
145
306
  return promise;
146
307
  }
@@ -149,7 +310,7 @@ var FileCache = class _FileCache {
149
310
  * text is evicted immediately so we never hold both representations.
150
311
  */
151
312
  readJsonFile(filePath) {
152
- const abs = path.resolve(filePath);
313
+ const abs = path2.resolve(filePath);
153
314
  const cached = this.jsonCache.get(abs);
154
315
  if (cached) return cached;
155
316
  const promise = this.readTextFile(abs).then((txt) => {
@@ -161,7 +322,7 @@ var FileCache = class _FileCache {
161
322
  }
162
323
  // ── Existence checks ──
163
324
  pathExists(p) {
164
- const abs = path.resolve(p);
325
+ const abs = path2.resolve(p);
165
326
  const cached = this.existsCache.get(abs);
166
327
  if (cached) return cached;
167
328
  const promise = fs.access(abs).then(() => true, () => false);
@@ -185,6 +346,38 @@ var FileCache = class _FileCache {
185
346
  return this.jsonCache.size;
186
347
  }
187
348
  };
349
+ async function quickTreeCount(rootDir, excludePatterns) {
350
+ let totalFiles = 0;
351
+ let totalDirs = 0;
352
+ const cores = typeof os.availableParallelism === "function" ? os.availableParallelism() : os.cpus().length || 4;
353
+ const maxConcurrent = Math.max(8, Math.min(128, cores * 8));
354
+ const sem = new Semaphore(maxConcurrent);
355
+ const extraSkip = /* @__PURE__ */ new Set([".nuxt", ".output", ".svelte-kit"]);
356
+ const isExcluded = excludePatterns ? compileGlobs(excludePatterns) : null;
357
+ async function count(dir) {
358
+ let entries;
359
+ try {
360
+ entries = await fs.readdir(dir, { withFileTypes: true });
361
+ } catch {
362
+ return;
363
+ }
364
+ const subs = [];
365
+ for (const e of entries) {
366
+ const relPath = path2.relative(rootDir, path2.join(dir, e.name));
367
+ if (isExcluded && isExcluded(relPath)) continue;
368
+ if (e.isDirectory()) {
369
+ if (SKIP_DIRS.has(e.name) || extraSkip.has(e.name)) continue;
370
+ totalDirs++;
371
+ subs.push(sem.run(() => count(path2.join(dir, e.name))));
372
+ } else if (e.isFile()) {
373
+ totalFiles++;
374
+ }
375
+ }
376
+ await Promise.all(subs);
377
+ }
378
+ await sem.run(() => count(rootDir));
379
+ return { totalFiles, totalDirs };
380
+ }
188
381
  async function findFiles(rootDir, predicate) {
189
382
  const results = [];
190
383
  const cores = typeof os.availableParallelism === "function" ? os.availableParallelism() : os.cpus().length || 4;
@@ -201,9 +394,9 @@ async function findFiles(rootDir, predicate) {
201
394
  for (const e of entries) {
202
395
  if (e.isDirectory()) {
203
396
  if (SKIP_DIRS.has(e.name)) continue;
204
- subDirectoryWalks.push(readDirSemaphore.run(() => walk(path.join(dir, e.name))));
397
+ subDirectoryWalks.push(readDirSemaphore.run(() => walk(path2.join(dir, e.name))));
205
398
  } else if (e.isFile() && predicate(e.name)) {
206
- results.push(path.join(dir, e.name));
399
+ results.push(path2.join(dir, e.name));
207
400
  }
208
401
  }
209
402
  await Promise.all(subDirectoryWalks);
@@ -239,11 +432,11 @@ async function ensureDir(dir) {
239
432
  await fs.mkdir(dir, { recursive: true });
240
433
  }
241
434
  async function writeJsonFile(filePath, data) {
242
- await ensureDir(path.dirname(filePath));
435
+ await ensureDir(path2.dirname(filePath));
243
436
  await fs.writeFile(filePath, JSON.stringify(data, null, 2) + "\n", "utf8");
244
437
  }
245
438
  async function writeTextFile(filePath, content) {
246
- await ensureDir(path.dirname(filePath));
439
+ await ensureDir(path2.dirname(filePath));
247
440
  await fs.writeFile(filePath, content, "utf8");
248
441
  }
249
442
 
@@ -323,12 +516,12 @@ function eolScore(projects) {
323
516
  }
324
517
  function computeDriftScore(projects) {
325
518
  const rs = runtimeScore(projects);
326
- const fs6 = frameworkScore(projects);
519
+ const fs7 = frameworkScore(projects);
327
520
  const ds = dependencyScore(projects);
328
521
  const es = eolScore(projects);
329
522
  const components = [
330
523
  { score: rs, weight: 0.25 },
331
- { score: fs6, weight: 0.25 },
524
+ { score: fs7, weight: 0.25 },
332
525
  { score: ds, weight: 0.3 },
333
526
  { score: es, weight: 0.2 }
334
527
  ];
@@ -339,7 +532,7 @@ function computeDriftScore(projects) {
339
532
  riskLevel: "low",
340
533
  components: {
341
534
  runtimeScore: Math.round(rs ?? 100),
342
- frameworkScore: Math.round(fs6 ?? 100),
535
+ frameworkScore: Math.round(fs7 ?? 100),
343
536
  dependencyScore: Math.round(ds ?? 100),
344
537
  eolScore: Math.round(es ?? 100)
345
538
  }
@@ -357,7 +550,7 @@ function computeDriftScore(projects) {
357
550
  else riskLevel = "high";
358
551
  const measured = [];
359
552
  if (rs !== null) measured.push("runtime");
360
- if (fs6 !== null) measured.push("framework");
553
+ if (fs7 !== null) measured.push("framework");
361
554
  if (ds !== null) measured.push("dependency");
362
555
  if (es !== null) measured.push("eol");
363
556
  return {
@@ -365,7 +558,7 @@ function computeDriftScore(projects) {
365
558
  riskLevel,
366
559
  components: {
367
560
  runtimeScore: Math.round(rs ?? 100),
368
- frameworkScore: Math.round(fs6 ?? 100),
561
+ frameworkScore: Math.round(fs7 ?? 100),
369
562
  dependencyScore: Math.round(ds ?? 100),
370
563
  eolScore: Math.round(es ?? 100)
371
564
  },
@@ -565,6 +758,10 @@ function formatText(artifact) {
565
758
  if (artifact.filesScanned !== void 0) {
566
759
  scannedParts.push(`${artifact.filesScanned} file${artifact.filesScanned !== 1 ? "s" : ""} scanned`);
567
760
  }
761
+ if (artifact.treeSummary) {
762
+ scannedParts.push(`${artifact.treeSummary.totalFiles.toLocaleString()} workspace files`);
763
+ scannedParts.push(`${artifact.treeSummary.totalDirs.toLocaleString()} dirs`);
764
+ }
568
765
  lines.push(chalk.dim(` ${scannedParts.join(" \xB7 ")}`));
569
766
  lines.push("");
570
767
  return lines.join("\n");
@@ -1173,7 +1370,7 @@ function toSarifResult(finding) {
1173
1370
 
1174
1371
  // src/commands/dsn.ts
1175
1372
  import * as crypto2 from "crypto";
1176
- import * as path2 from "path";
1373
+ import * as path3 from "path";
1177
1374
  import { Command } from "commander";
1178
1375
  import chalk2 from "chalk";
1179
1376
  var REGION_HOSTS = {
@@ -1218,7 +1415,7 @@ dsnCommand.command("create").description("Create a new DSN token").option("--ing
1218
1415
  console.log(chalk2.dim("Set this as VIBGRATE_DSN in your CI environment."));
1219
1416
  console.log(chalk2.dim("The secret must be registered on your Vibgrate ingest API."));
1220
1417
  if (opts.write) {
1221
- const writePath = path2.resolve(opts.write);
1418
+ const writePath = path3.resolve(opts.write);
1222
1419
  await writeTextFile(writePath, dsn + "\n");
1223
1420
  console.log("");
1224
1421
  console.log(chalk2.green("\u2714") + ` DSN written to ${opts.write}`);
@@ -1228,7 +1425,7 @@ dsnCommand.command("create").description("Create a new DSN token").option("--ing
1228
1425
 
1229
1426
  // src/commands/push.ts
1230
1427
  import * as crypto3 from "crypto";
1231
- import * as path3 from "path";
1428
+ import * as path4 from "path";
1232
1429
  import { Command as Command2 } from "commander";
1233
1430
  import chalk3 from "chalk";
1234
1431
  function parseDsn(dsn) {
@@ -1257,7 +1454,7 @@ var pushCommand = new Command2("push").description("Push scan results to Vibgrat
1257
1454
  if (opts.strict) process.exit(1);
1258
1455
  return;
1259
1456
  }
1260
- const filePath = path3.resolve(opts.file);
1457
+ const filePath = path4.resolve(opts.file);
1261
1458
  if (!await pathExists(filePath)) {
1262
1459
  console.error(chalk3.red(`Scan artifact not found: ${filePath}`));
1263
1460
  console.error(chalk3.dim('Run "vibgrate scan" first.'));
@@ -1302,14 +1499,31 @@ var pushCommand = new Command2("push").description("Push scan results to Vibgrat
1302
1499
  });
1303
1500
 
1304
1501
  // src/commands/scan.ts
1305
- import * as path15 from "path";
1502
+ import * as path17 from "path";
1306
1503
  import { Command as Command3 } from "commander";
1307
1504
  import chalk5 from "chalk";
1308
1505
 
1309
1506
  // src/scanners/node-scanner.ts
1310
- import * as path4 from "path";
1507
+ import * as path5 from "path";
1311
1508
  import * as semver2 from "semver";
1312
1509
 
1510
+ // src/utils/timeout.ts
1511
+ async function withTimeout(promise, ms) {
1512
+ let timer;
1513
+ const timeout = new Promise((resolve7) => {
1514
+ timer = setTimeout(() => resolve7({ ok: false }), ms);
1515
+ });
1516
+ try {
1517
+ const result = await Promise.race([
1518
+ promise.then((value) => ({ ok: true, value })),
1519
+ timeout
1520
+ ]);
1521
+ return result;
1522
+ } finally {
1523
+ clearTimeout(timer);
1524
+ }
1525
+ }
1526
+
1313
1527
  // src/scanners/npm-cache.ts
1314
1528
  import { spawn } from "child_process";
1315
1529
  import * as semver from "semver";
@@ -1484,10 +1698,20 @@ var KNOWN_FRAMEWORKS = {
1484
1698
  async function scanNodeProjects(rootDir, npmCache, cache) {
1485
1699
  const packageJsonFiles = cache ? await cache.findPackageJsonFiles(rootDir) : await findPackageJsonFiles(rootDir);
1486
1700
  const results = [];
1701
+ const STUCK_TIMEOUT_MS = 6e4;
1487
1702
  for (const pjPath of packageJsonFiles) {
1488
1703
  try {
1489
- const scan = await scanOnePackageJson(pjPath, rootDir, npmCache, cache);
1490
- results.push(scan);
1704
+ const scanPromise = scanOnePackageJson(pjPath, rootDir, npmCache, cache);
1705
+ const result = await withTimeout(scanPromise, STUCK_TIMEOUT_MS);
1706
+ if (result.ok) {
1707
+ results.push(result.value);
1708
+ } else {
1709
+ const relPath = path5.relative(rootDir, path5.dirname(pjPath));
1710
+ if (cache) {
1711
+ cache.addStuckPath(relPath || ".");
1712
+ }
1713
+ console.error(`Timeout scanning ${pjPath} (>${STUCK_TIMEOUT_MS / 1e3}s) \u2014 skipped`);
1714
+ }
1491
1715
  } catch (e) {
1492
1716
  const msg = e instanceof Error ? e.message : String(e);
1493
1717
  console.error(`Error scanning ${pjPath}: ${msg}`);
@@ -1497,8 +1721,8 @@ async function scanNodeProjects(rootDir, npmCache, cache) {
1497
1721
  }
1498
1722
  async function scanOnePackageJson(packageJsonPath, rootDir, npmCache, cache) {
1499
1723
  const pj = cache ? await cache.readJsonFile(packageJsonPath) : await readJsonFile(packageJsonPath);
1500
- const absProjectPath = path4.dirname(packageJsonPath);
1501
- const projectPath = path4.relative(rootDir, absProjectPath) || ".";
1724
+ const absProjectPath = path5.dirname(packageJsonPath);
1725
+ const projectPath = path5.relative(rootDir, absProjectPath) || ".";
1502
1726
  const nodeEngine = pj.engines?.node ?? void 0;
1503
1727
  let runtimeLatest;
1504
1728
  let runtimeMajorsBehind;
@@ -1580,7 +1804,7 @@ async function scanOnePackageJson(packageJsonPath, rootDir, npmCache, cache) {
1580
1804
  return {
1581
1805
  type: "node",
1582
1806
  path: projectPath,
1583
- name: pj.name ?? path4.basename(absProjectPath),
1807
+ name: pj.name ?? path5.basename(absProjectPath),
1584
1808
  runtime: nodeEngine,
1585
1809
  runtimeLatest,
1586
1810
  runtimeMajorsBehind,
@@ -1591,7 +1815,7 @@ async function scanOnePackageJson(packageJsonPath, rootDir, npmCache, cache) {
1591
1815
  }
1592
1816
 
1593
1817
  // src/scanners/dotnet-scanner.ts
1594
- import * as path5 from "path";
1818
+ import * as path6 from "path";
1595
1819
  import { XMLParser } from "fast-xml-parser";
1596
1820
  var parser = new XMLParser({
1597
1821
  ignoreAttributes: false,
@@ -1792,7 +2016,7 @@ function parseCsproj(xml, filePath) {
1792
2016
  const parsed = parser.parse(xml);
1793
2017
  const project = parsed?.Project;
1794
2018
  if (!project) {
1795
- return { targetFrameworks: [], packageReferences: [], projectName: path5.basename(filePath, ".csproj") };
2019
+ return { targetFrameworks: [], packageReferences: [], projectName: path6.basename(filePath, ".csproj") };
1796
2020
  }
1797
2021
  const propertyGroups = Array.isArray(project.PropertyGroup) ? project.PropertyGroup : project.PropertyGroup ? [project.PropertyGroup] : [];
1798
2022
  const targetFrameworks = [];
@@ -1820,7 +2044,7 @@ function parseCsproj(xml, filePath) {
1820
2044
  return {
1821
2045
  targetFrameworks: [...new Set(targetFrameworks)],
1822
2046
  packageReferences,
1823
- projectName: path5.basename(filePath, ".csproj")
2047
+ projectName: path6.basename(filePath, ".csproj")
1824
2048
  };
1825
2049
  }
1826
2050
  async function scanDotnetProjects(rootDir, cache) {
@@ -1830,12 +2054,12 @@ async function scanDotnetProjects(rootDir, cache) {
1830
2054
  for (const slnPath of slnFiles) {
1831
2055
  try {
1832
2056
  const slnContent = cache ? await cache.readTextFile(slnPath) : await readTextFile(slnPath);
1833
- const slnDir = path5.dirname(slnPath);
2057
+ const slnDir = path6.dirname(slnPath);
1834
2058
  const projectRegex = /Project\("[^"]*"\)\s*=\s*"[^"]*",\s*"([^"]+\.csproj)"/g;
1835
2059
  let match;
1836
2060
  while ((match = projectRegex.exec(slnContent)) !== null) {
1837
2061
  if (match[1]) {
1838
- const csprojPath = path5.resolve(slnDir, match[1].replace(/\\/g, "/"));
2062
+ const csprojPath = path6.resolve(slnDir, match[1].replace(/\\/g, "/"));
1839
2063
  slnCsprojPaths.add(csprojPath);
1840
2064
  }
1841
2065
  }
@@ -1844,10 +2068,20 @@ async function scanDotnetProjects(rootDir, cache) {
1844
2068
  }
1845
2069
  const allCsprojFiles = /* @__PURE__ */ new Set([...csprojFiles, ...slnCsprojPaths]);
1846
2070
  const results = [];
2071
+ const STUCK_TIMEOUT_MS = 6e4;
1847
2072
  for (const csprojPath of allCsprojFiles) {
1848
2073
  try {
1849
- const scan = await scanOneCsproj(csprojPath, rootDir, cache);
1850
- results.push(scan);
2074
+ const scanPromise = scanOneCsproj(csprojPath, rootDir, cache);
2075
+ const result = await withTimeout(scanPromise, STUCK_TIMEOUT_MS);
2076
+ if (result.ok) {
2077
+ results.push(result.value);
2078
+ } else {
2079
+ const relPath = path6.relative(rootDir, path6.dirname(csprojPath));
2080
+ if (cache) {
2081
+ cache.addStuckPath(relPath || ".");
2082
+ }
2083
+ console.error(`Timeout scanning ${csprojPath} (>${STUCK_TIMEOUT_MS / 1e3}s) \u2014 skipped`);
2084
+ }
1851
2085
  } catch (e) {
1852
2086
  const msg = e instanceof Error ? e.message : String(e);
1853
2087
  console.error(`Error scanning ${csprojPath}: ${msg}`);
@@ -1891,7 +2125,7 @@ async function scanOneCsproj(csprojPath, rootDir, cache) {
1891
2125
  const buckets = { current: 0, oneBehind: 0, twoPlusBehind: 0, unknown: dependencies.length };
1892
2126
  return {
1893
2127
  type: "dotnet",
1894
- path: path5.relative(rootDir, path5.dirname(csprojPath)) || ".",
2128
+ path: path6.relative(rootDir, path6.dirname(csprojPath)) || ".",
1895
2129
  name: data.projectName,
1896
2130
  targetFramework,
1897
2131
  runtime: primaryTfm,
@@ -1904,15 +2138,17 @@ async function scanOneCsproj(csprojPath, rootDir, cache) {
1904
2138
  }
1905
2139
 
1906
2140
  // src/config.ts
1907
- import * as path6 from "path";
2141
+ import * as path7 from "path";
1908
2142
  import * as fs2 from "fs/promises";
1909
2143
  var CONFIG_FILES = [
1910
2144
  "vibgrate.config.ts",
1911
2145
  "vibgrate.config.js",
1912
2146
  "vibgrate.config.json"
1913
2147
  ];
2148
+ var DEFAULT_MAX_FILE_SIZE = 5242880;
1914
2149
  var DEFAULT_CONFIG = {
1915
2150
  exclude: [],
2151
+ maxFileSizeToScan: DEFAULT_MAX_FILE_SIZE,
1916
2152
  thresholds: {
1917
2153
  failOnError: {
1918
2154
  eolDays: 180,
@@ -1926,28 +2162,44 @@ var DEFAULT_CONFIG = {
1926
2162
  }
1927
2163
  };
1928
2164
  async function loadConfig(rootDir) {
2165
+ let config = DEFAULT_CONFIG;
1929
2166
  for (const file of CONFIG_FILES) {
1930
- const configPath = path6.join(rootDir, file);
2167
+ const configPath = path7.join(rootDir, file);
1931
2168
  if (await pathExists(configPath)) {
1932
2169
  if (file.endsWith(".json")) {
1933
2170
  const txt = await readTextFile(configPath);
1934
- return { ...DEFAULT_CONFIG, ...JSON.parse(txt) };
2171
+ config = { ...DEFAULT_CONFIG, ...JSON.parse(txt) };
2172
+ break;
1935
2173
  }
1936
2174
  try {
1937
2175
  const mod = await import(configPath);
1938
- return { ...DEFAULT_CONFIG, ...mod.default ?? mod };
2176
+ config = { ...DEFAULT_CONFIG, ...mod.default ?? mod };
2177
+ break;
1939
2178
  } catch {
1940
2179
  }
1941
2180
  }
1942
2181
  }
1943
- return DEFAULT_CONFIG;
2182
+ const sidecarPath = path7.join(rootDir, ".vibgrate", "auto-excludes.json");
2183
+ if (await pathExists(sidecarPath)) {
2184
+ try {
2185
+ const txt = await readTextFile(sidecarPath);
2186
+ const autoExcludes = JSON.parse(txt);
2187
+ if (Array.isArray(autoExcludes) && autoExcludes.length > 0) {
2188
+ const existing = config.exclude ?? [];
2189
+ config = { ...config, exclude: [.../* @__PURE__ */ new Set([...existing, ...autoExcludes])] };
2190
+ }
2191
+ } catch {
2192
+ }
2193
+ }
2194
+ return config;
1944
2195
  }
1945
2196
  async function writeDefaultConfig(rootDir) {
1946
- const configPath = path6.join(rootDir, "vibgrate.config.ts");
2197
+ const configPath = path7.join(rootDir, "vibgrate.config.ts");
1947
2198
  const content = `import type { VibgrateConfig } from '@vibgrate/cli';
1948
2199
 
1949
2200
  const config: VibgrateConfig = {
1950
2201
  // exclude: ['legacy/**'],
2202
+ // maxFileSizeToScan: 5_242_880, // 5 MB (default)
1951
2203
  thresholds: {
1952
2204
  failOnError: {
1953
2205
  eolDays: 180,
@@ -1966,9 +2218,44 @@ export default config;
1966
2218
  await fs2.writeFile(configPath, content, "utf8");
1967
2219
  return configPath;
1968
2220
  }
2221
+ async function appendExcludePatterns(rootDir, newPatterns) {
2222
+ if (newPatterns.length === 0) return false;
2223
+ const jsonPath = path7.join(rootDir, "vibgrate.config.json");
2224
+ if (await pathExists(jsonPath)) {
2225
+ try {
2226
+ const txt = await readTextFile(jsonPath);
2227
+ const cfg = JSON.parse(txt);
2228
+ const existing2 = Array.isArray(cfg.exclude) ? cfg.exclude : [];
2229
+ const merged2 = [.../* @__PURE__ */ new Set([...existing2, ...newPatterns])];
2230
+ cfg.exclude = merged2;
2231
+ await fs2.writeFile(jsonPath, JSON.stringify(cfg, null, 2) + "\n", "utf8");
2232
+ return true;
2233
+ } catch {
2234
+ }
2235
+ }
2236
+ const vibgrateDir = path7.join(rootDir, ".vibgrate");
2237
+ const sidecarPath = path7.join(vibgrateDir, "auto-excludes.json");
2238
+ let existing = [];
2239
+ if (await pathExists(sidecarPath)) {
2240
+ try {
2241
+ const txt = await readTextFile(sidecarPath);
2242
+ const parsed = JSON.parse(txt);
2243
+ if (Array.isArray(parsed)) existing = parsed;
2244
+ } catch {
2245
+ }
2246
+ }
2247
+ const merged = [.../* @__PURE__ */ new Set([...existing, ...newPatterns])];
2248
+ try {
2249
+ await fs2.mkdir(vibgrateDir, { recursive: true });
2250
+ await fs2.writeFile(sidecarPath, JSON.stringify(merged, null, 2) + "\n", "utf8");
2251
+ return true;
2252
+ } catch {
2253
+ return false;
2254
+ }
2255
+ }
1969
2256
 
1970
2257
  // src/utils/vcs.ts
1971
- import * as path7 from "path";
2258
+ import * as path8 from "path";
1972
2259
  import * as fs3 from "fs/promises";
1973
2260
  async function detectVcs(rootDir) {
1974
2261
  try {
@@ -1982,7 +2269,7 @@ async function detectGit(rootDir) {
1982
2269
  if (!gitDir) {
1983
2270
  return { type: "unknown" };
1984
2271
  }
1985
- const headPath = path7.join(gitDir, "HEAD");
2272
+ const headPath = path8.join(gitDir, "HEAD");
1986
2273
  let headContent;
1987
2274
  try {
1988
2275
  headContent = (await fs3.readFile(headPath, "utf8")).trim();
@@ -2006,30 +2293,30 @@ async function detectGit(rootDir) {
2006
2293
  };
2007
2294
  }
2008
2295
  async function findGitDir(startDir) {
2009
- let dir = path7.resolve(startDir);
2010
- const root = path7.parse(dir).root;
2296
+ let dir = path8.resolve(startDir);
2297
+ const root = path8.parse(dir).root;
2011
2298
  while (dir !== root) {
2012
- const gitPath = path7.join(dir, ".git");
2299
+ const gitPath = path8.join(dir, ".git");
2013
2300
  try {
2014
- const stat3 = await fs3.stat(gitPath);
2015
- if (stat3.isDirectory()) {
2301
+ const stat4 = await fs3.stat(gitPath);
2302
+ if (stat4.isDirectory()) {
2016
2303
  return gitPath;
2017
2304
  }
2018
- if (stat3.isFile()) {
2305
+ if (stat4.isFile()) {
2019
2306
  const content = (await fs3.readFile(gitPath, "utf8")).trim();
2020
2307
  if (content.startsWith("gitdir: ")) {
2021
- const resolved = path7.resolve(dir, content.slice(8));
2308
+ const resolved = path8.resolve(dir, content.slice(8));
2022
2309
  return resolved;
2023
2310
  }
2024
2311
  }
2025
2312
  } catch {
2026
2313
  }
2027
- dir = path7.dirname(dir);
2314
+ dir = path8.dirname(dir);
2028
2315
  }
2029
2316
  return null;
2030
2317
  }
2031
2318
  async function resolveRef(gitDir, refPath) {
2032
- const loosePath = path7.join(gitDir, refPath);
2319
+ const loosePath = path8.join(gitDir, refPath);
2033
2320
  try {
2034
2321
  const sha = (await fs3.readFile(loosePath, "utf8")).trim();
2035
2322
  if (/^[0-9a-f]{40}$/i.test(sha)) {
@@ -2037,7 +2324,7 @@ async function resolveRef(gitDir, refPath) {
2037
2324
  }
2038
2325
  } catch {
2039
2326
  }
2040
- const packedPath = path7.join(gitDir, "packed-refs");
2327
+ const packedPath = path8.join(gitDir, "packed-refs");
2041
2328
  try {
2042
2329
  const packed = await fs3.readFile(packedPath, "utf8");
2043
2330
  for (const line of packed.split("\n")) {
@@ -2079,26 +2366,70 @@ var ScanProgress = class {
2079
2366
  startTime = Date.now();
2080
2367
  isTTY;
2081
2368
  rootDir = "";
2369
+ /** Last rendered frame content (strip to compare for dirty-checking) */
2370
+ lastFrame = "";
2371
+ /** Whether we've hidden the cursor */
2372
+ cursorHidden = false;
2373
+ /** Estimated total scan duration in ms (from history or live calculation) */
2374
+ estimatedTotalMs = null;
2375
+ /** Per-step estimated durations from history */
2376
+ stepEstimates = /* @__PURE__ */ new Map();
2377
+ /** Per-step actual start times for timing */
2378
+ stepStartTimes = /* @__PURE__ */ new Map();
2379
+ /** Per-step recorded durations (completed steps) */
2380
+ stepTimings = [];
2082
2381
  constructor(rootDir) {
2083
2382
  this.isTTY = process.stderr.isTTY ?? false;
2084
2383
  this.rootDir = rootDir;
2384
+ if (this.isTTY) {
2385
+ const restore = () => {
2386
+ if (this.cursorHidden) {
2387
+ process.stderr.write("\x1B[?25h");
2388
+ this.cursorHidden = false;
2389
+ }
2390
+ };
2391
+ process.on("exit", restore);
2392
+ process.on("SIGINT", () => {
2393
+ restore();
2394
+ process.exit(130);
2395
+ });
2396
+ process.on("SIGTERM", () => {
2397
+ restore();
2398
+ process.exit(143);
2399
+ });
2400
+ }
2401
+ }
2402
+ /** Set the estimated total duration from scan history */
2403
+ setEstimatedTotal(estimatedMs) {
2404
+ this.estimatedTotalMs = estimatedMs;
2405
+ }
2406
+ /** Set per-step estimated durations from scan history */
2407
+ setStepEstimates(estimates) {
2408
+ this.stepEstimates = estimates;
2409
+ }
2410
+ /** Get completed step timings for persisting to history */
2411
+ getStepTimings() {
2412
+ return [...this.stepTimings];
2085
2413
  }
2086
- /** Register all steps up front */
2414
+ /** Register all steps up front, optionally with weights */
2087
2415
  setSteps(steps) {
2088
- this.steps = steps.map((s) => ({ ...s, status: "pending" }));
2416
+ this.steps = steps.map((s) => ({ ...s, status: "pending", weight: s.weight ?? 1 }));
2089
2417
  if (this.isTTY) {
2090
2418
  this.startSpinner();
2091
2419
  }
2092
2420
  this.render();
2093
2421
  }
2094
- /** Mark a step as active (currently running) */
2095
- startStep(id) {
2422
+ /** Mark a step as active (currently running), optionally with expected total */
2423
+ startStep(id, subTotal) {
2096
2424
  const step = this.steps.find((s) => s.id === id);
2097
2425
  if (step) {
2098
2426
  step.status = "active";
2099
2427
  step.detail = void 0;
2100
2428
  step.count = void 0;
2429
+ step.subProgress = 0;
2430
+ step.subTotal = subTotal;
2101
2431
  }
2432
+ this.stepStartTimes.set(id, Date.now());
2102
2433
  this.render();
2103
2434
  }
2104
2435
  /** Mark a step as completed */
@@ -2109,6 +2440,10 @@ var ScanProgress = class {
2109
2440
  step.detail = detail;
2110
2441
  step.count = count;
2111
2442
  }
2443
+ const started = this.stepStartTimes.get(id);
2444
+ if (started) {
2445
+ this.stepTimings.push({ id, durationMs: Date.now() - started });
2446
+ }
2112
2447
  this.render();
2113
2448
  }
2114
2449
  /** Mark a step as skipped */
@@ -2120,6 +2455,16 @@ var ScanProgress = class {
2120
2455
  }
2121
2456
  this.render();
2122
2457
  }
2458
+ /** Update sub-step progress for the active step (files processed, etc.) */
2459
+ updateStepProgress(id, current, total, label) {
2460
+ const step = this.steps.find((s) => s.id === id);
2461
+ if (step) {
2462
+ step.subProgress = current;
2463
+ if (total !== void 0) step.subTotal = total;
2464
+ if (label !== void 0) step.subLabel = label;
2465
+ }
2466
+ this.render();
2467
+ }
2123
2468
  /** Update live stats */
2124
2469
  updateStats(partial) {
2125
2470
  Object.assign(this.stats, partial);
@@ -2151,38 +2496,45 @@ var ScanProgress = class {
2151
2496
  this.timer = null;
2152
2497
  }
2153
2498
  if (this.isTTY) {
2154
- this.clearLines();
2499
+ let buf = "";
2500
+ if (this.lastLineCount > 0) {
2501
+ buf += `\x1B[${this.lastLineCount}A`;
2502
+ for (let i = 0; i < this.lastLineCount; i++) {
2503
+ buf += "\x1B[2K\n";
2504
+ }
2505
+ buf += `\x1B[${this.lastLineCount}A`;
2506
+ }
2507
+ buf += "\x1B[?25h";
2508
+ if (buf) process.stderr.write(buf);
2509
+ this.cursorHidden = false;
2510
+ this.lastLineCount = 0;
2155
2511
  }
2156
- const elapsed = ((Date.now() - this.startTime) / 1e3).toFixed(1);
2512
+ const elapsed = this.formatElapsed(Date.now() - this.startTime);
2157
2513
  const doneCount = this.steps.filter((s) => s.status === "done").length;
2158
2514
  process.stderr.write(
2159
- chalk4.dim(` \u2714 ${doneCount} scanners completed in ${elapsed}s
2515
+ chalk4.dim(` \u2714 ${doneCount} scanners completed in ${elapsed}
2160
2516
 
2161
2517
  `)
2162
2518
  );
2163
2519
  }
2164
2520
  // ── Internal rendering ──
2165
2521
  startSpinner() {
2522
+ if (!this.cursorHidden) {
2523
+ process.stderr.write("\x1B[?25l");
2524
+ this.cursorHidden = true;
2525
+ }
2166
2526
  this.timer = setInterval(() => {
2167
2527
  this.spinnerFrame = (this.spinnerFrame + 1) % SPINNER_FRAMES.length;
2168
2528
  this.render();
2169
- }, 80);
2529
+ }, 120);
2170
2530
  }
2171
2531
  clearLines() {
2172
- if (this.lastLineCount > 0) {
2173
- process.stderr.write(`\x1B[${this.lastLineCount}A`);
2174
- for (let i = 0; i < this.lastLineCount; i++) {
2175
- process.stderr.write("\x1B[2K\n");
2176
- }
2177
- process.stderr.write(`\x1B[${this.lastLineCount}A`);
2178
- }
2179
2532
  }
2180
2533
  render() {
2181
2534
  if (!this.isTTY) {
2182
2535
  this.renderCI();
2183
2536
  return;
2184
2537
  }
2185
- this.clearLines();
2186
2538
  const lines = [];
2187
2539
  lines.push("");
2188
2540
  lines.push(` ${ROBOT[0]} ${BRAND[0]}`);
@@ -2190,14 +2542,32 @@ var ScanProgress = class {
2190
2542
  lines.push(` ${ROBOT[2]}`);
2191
2543
  lines.push(` ${ROBOT[3]} ${chalk4.dim(this.rootDir)}`);
2192
2544
  lines.push("");
2193
- const totalSteps = this.steps.length;
2194
- const doneSteps = this.steps.filter((s) => s.status === "done" || s.status === "skipped").length;
2195
- const pct = totalSteps > 0 ? Math.round(doneSteps / totalSteps * 100) : 0;
2545
+ const totalWeight = this.steps.reduce((sum, s) => sum + (s.weight ?? 1), 0);
2546
+ let completedWeight = 0;
2547
+ for (const step of this.steps) {
2548
+ const w = step.weight ?? 1;
2549
+ if (step.status === "done" || step.status === "skipped") {
2550
+ completedWeight += w;
2551
+ } else if (step.status === "active" && step.subTotal && step.subTotal > 0 && step.subProgress !== void 0) {
2552
+ completedWeight += w * Math.min(step.subProgress / step.subTotal, 0.99);
2553
+ } else if (step.status === "active") {
2554
+ const stepStart = this.stepStartTimes.get(step.id);
2555
+ const estimate = this.stepEstimates.get(step.id);
2556
+ if (stepStart && estimate && estimate > 0) {
2557
+ const stepElapsed = Date.now() - stepStart;
2558
+ completedWeight += w * Math.min(stepElapsed / estimate, 0.95);
2559
+ }
2560
+ }
2561
+ }
2562
+ const pct = totalWeight > 0 ? Math.min(Math.round(completedWeight / totalWeight * 100), 99) : 0;
2196
2563
  const barWidth = 30;
2197
- const filled = Math.round(doneSteps / Math.max(totalSteps, 1) * barWidth);
2198
- const bar = chalk4.greenBright("\u2501".repeat(filled)) + chalk4.dim("\u254C".repeat(barWidth - filled));
2199
- const elapsed = ((Date.now() - this.startTime) / 1e3).toFixed(1);
2200
- lines.push(` ${bar} ${chalk4.bold.white(`${pct}%`)} ${chalk4.dim(`${elapsed}s`)}`);
2564
+ const filled = Math.round(completedWeight / Math.max(totalWeight, 1) * barWidth);
2565
+ const bar = chalk4.greenBright("\u2501".repeat(Math.min(filled, barWidth))) + chalk4.dim("\u254C".repeat(Math.max(barWidth - filled, 0)));
2566
+ const elapsedMs = Date.now() - this.startTime;
2567
+ const elapsedStr = this.formatElapsed(elapsedMs);
2568
+ const etaStr = this.computeEtaString(elapsedMs, completedWeight, totalWeight);
2569
+ const treePart = this.stats.treeSummary ? chalk4.dim(` \xB7 ${this.stats.treeSummary.totalFiles.toLocaleString()} files \xB7 ${this.stats.treeSummary.totalDirs.toLocaleString()} dirs`) : "";
2570
+ lines.push(` ${bar} ${chalk4.bold.white(`${pct}%`)} ${chalk4.dim(elapsedStr)}${etaStr}${treePart}`);
2201
2571
  lines.push("");
2202
2572
  for (const step of this.steps) {
2203
2573
  lines.push(this.renderStep(step));
@@ -2205,8 +2575,21 @@ var ScanProgress = class {
2205
2575
  lines.push("");
2206
2576
  lines.push(this.renderStats());
2207
2577
  lines.push("");
2208
- const output = lines.join("\n") + "\n";
2209
- process.stderr.write(output);
2578
+ const content = lines.join("\n") + "\n";
2579
+ if (content === this.lastFrame && this.lastLineCount === lines.length) {
2580
+ return;
2581
+ }
2582
+ this.lastFrame = content;
2583
+ let buf = "";
2584
+ if (this.lastLineCount > 0) {
2585
+ buf += `\x1B[${this.lastLineCount}A`;
2586
+ for (let i = 0; i < this.lastLineCount; i++) {
2587
+ buf += "\x1B[2K\n";
2588
+ }
2589
+ buf += `\x1B[${this.lastLineCount}A`;
2590
+ }
2591
+ buf += content;
2592
+ process.stderr.write(buf);
2210
2593
  this.lastLineCount = lines.length;
2211
2594
  }
2212
2595
  renderStep(step) {
@@ -2222,6 +2605,14 @@ var ScanProgress = class {
2222
2605
  case "active":
2223
2606
  icon = chalk4.cyan(spinner);
2224
2607
  label = chalk4.bold.white(step.label);
2608
+ if (step.subTotal && step.subTotal > 0 && step.subProgress !== void 0 && step.subProgress > 0) {
2609
+ detail = chalk4.dim(` \xB7 ${step.subProgress.toLocaleString()} / ${step.subTotal.toLocaleString()}`);
2610
+ }
2611
+ if (step.subLabel) {
2612
+ const maxLen = 50;
2613
+ const displayPath = step.subLabel.length > maxLen ? "\u2026" + step.subLabel.slice(-maxLen + 1) : step.subLabel;
2614
+ detail += chalk4.dim(` ${displayPath}`);
2615
+ }
2225
2616
  break;
2226
2617
  case "skipped":
2227
2618
  icon = chalk4.dim("\u25CC");
@@ -2275,10 +2666,141 @@ var ScanProgress = class {
2275
2666
  }
2276
2667
  }
2277
2668
  }
2669
+ // ── Time formatting helpers ──
2670
+ /**
2671
+ * Format elapsed time:
2672
+ * - Under 90s → "12.3s"
2673
+ * - 90s and above → "1m 30s"
2674
+ */
2675
+ formatElapsed(ms) {
2676
+ const totalSecs = ms / 1e3;
2677
+ if (totalSecs < 90) {
2678
+ return `${totalSecs.toFixed(1)}s`;
2679
+ }
2680
+ const mins = Math.floor(totalSecs / 60);
2681
+ const secs = Math.floor(totalSecs % 60);
2682
+ return `${mins}m ${secs.toString().padStart(2, "0")}s`;
2683
+ }
2684
+ /**
2685
+ * Compute an ETA string for the progress bar.
2686
+ *
2687
+ * Uses two sources blended together:
2688
+ * 1. **Historical estimate** from `estimatedTotalMs` (if available)
2689
+ * 2. **Live rate** — extrapolated from `elapsedMs` and `completedWeight`
2690
+ *
2691
+ * Returns empty string if not enough data yet (< 3% progress or < 2s elapsed).
2692
+ */
2693
+ computeEtaString(elapsedMs, completedWeight, totalWeight) {
2694
+ if (totalWeight === 0 || elapsedMs < 2e3) return "";
2695
+ const fraction = completedWeight / totalWeight;
2696
+ if (fraction < 0.03) {
2697
+ if (this.estimatedTotalMs !== null && this.estimatedTotalMs > 0) {
2698
+ const remaining = Math.max(0, this.estimatedTotalMs - elapsedMs);
2699
+ if (remaining > 1e3) {
2700
+ return chalk4.dim(` \xB7 ~${this.formatElapsed(remaining)} left`);
2701
+ }
2702
+ }
2703
+ return "";
2704
+ }
2705
+ const liveRemaining = elapsedMs / fraction * (1 - fraction);
2706
+ let remainingMs;
2707
+ if (this.estimatedTotalMs !== null && this.estimatedTotalMs > 0) {
2708
+ const histRemaining = Math.max(0, this.estimatedTotalMs - elapsedMs);
2709
+ const histWeight = Math.max(0.1, 1 - fraction);
2710
+ remainingMs = histRemaining * histWeight + liveRemaining * (1 - histWeight);
2711
+ } else {
2712
+ remainingMs = liveRemaining;
2713
+ }
2714
+ if (remainingMs < 1500) return "";
2715
+ return chalk4.dim(` \xB7 ~${this.formatElapsed(remainingMs)} left`);
2716
+ }
2278
2717
  };
2279
2718
 
2719
+ // src/ui/scan-history.ts
2720
+ import * as fs4 from "fs/promises";
2721
+ import * as path9 from "path";
2722
+ var HISTORY_FILENAME = "scan_history.json";
2723
+ var MAX_RECORDS = 10;
2724
+ async function loadScanHistory(rootDir) {
2725
+ const filePath = path9.join(rootDir, ".vibgrate", HISTORY_FILENAME);
2726
+ try {
2727
+ const txt = await fs4.readFile(filePath, "utf8");
2728
+ const data = JSON.parse(txt);
2729
+ if (data.version === 1 && Array.isArray(data.records)) {
2730
+ return data;
2731
+ }
2732
+ return null;
2733
+ } catch {
2734
+ return null;
2735
+ }
2736
+ }
2737
+ async function saveScanHistory(rootDir, record) {
2738
+ const dir = path9.join(rootDir, ".vibgrate");
2739
+ const filePath = path9.join(dir, HISTORY_FILENAME);
2740
+ let history;
2741
+ const existing = await loadScanHistory(rootDir);
2742
+ if (existing) {
2743
+ history = existing;
2744
+ history.records.push(record);
2745
+ if (history.records.length > MAX_RECORDS) {
2746
+ history.records = history.records.slice(-MAX_RECORDS);
2747
+ }
2748
+ } else {
2749
+ history = { version: 1, records: [record] };
2750
+ }
2751
+ try {
2752
+ await fs4.mkdir(dir, { recursive: true });
2753
+ await fs4.writeFile(filePath, JSON.stringify(history, null, 2) + "\n", "utf8");
2754
+ } catch {
2755
+ }
2756
+ }
2757
+ function estimateTotalDuration(history, currentFileCount) {
2758
+ if (!history || history.records.length === 0) return null;
2759
+ const similar = history.records.filter((r) => {
2760
+ if (r.totalFiles === 0 || currentFileCount === 0) return false;
2761
+ const ratio = currentFileCount / r.totalFiles;
2762
+ return ratio >= 0.33 && ratio <= 3;
2763
+ });
2764
+ if (similar.length > 0) {
2765
+ let weightedSum = 0;
2766
+ let weightTotal = 0;
2767
+ for (let i = 0; i < similar.length; i++) {
2768
+ const rec = similar[i];
2769
+ const weight = i + 1;
2770
+ const scale = currentFileCount / rec.totalFiles;
2771
+ weightedSum += rec.totalDurationMs * scale * weight;
2772
+ weightTotal += weight;
2773
+ }
2774
+ return Math.round(weightedSum / weightTotal);
2775
+ }
2776
+ const last = history.records[history.records.length - 1];
2777
+ if (last.totalFiles > 0 && currentFileCount > 0) {
2778
+ const scale = currentFileCount / last.totalFiles;
2779
+ return Math.round(last.totalDurationMs * scale);
2780
+ }
2781
+ return last.totalDurationMs;
2782
+ }
2783
+ function estimateStepDurations(history, currentFileCount) {
2784
+ const result = /* @__PURE__ */ new Map();
2785
+ if (!history || history.records.length === 0) return result;
2786
+ let best = null;
2787
+ for (let i = history.records.length - 1; i >= 0; i--) {
2788
+ const rec = history.records[i];
2789
+ if (rec.steps.length > 0) {
2790
+ best = rec;
2791
+ break;
2792
+ }
2793
+ }
2794
+ if (!best) return result;
2795
+ const scale = best.totalFiles > 0 && currentFileCount > 0 ? currentFileCount / best.totalFiles : 1;
2796
+ for (const step of best.steps) {
2797
+ result.set(step.id, Math.round(step.durationMs * scale));
2798
+ }
2799
+ return result;
2800
+ }
2801
+
2280
2802
  // src/scanners/platform-matrix.ts
2281
- import * as path8 from "path";
2803
+ import * as path10 from "path";
2282
2804
  var NATIVE_MODULE_PACKAGES = /* @__PURE__ */ new Set([
2283
2805
  // Image / media processing
2284
2806
  "sharp",
@@ -2558,7 +3080,7 @@ async function scanPlatformMatrix(rootDir, cache) {
2558
3080
  }
2559
3081
  result.dockerBaseImages = [...baseImages].sort();
2560
3082
  for (const file of [".nvmrc", ".node-version", ".tool-versions"]) {
2561
- const exists = cache ? await cache.pathExists(path8.join(rootDir, file)) : await pathExists(path8.join(rootDir, file));
3083
+ const exists = cache ? await cache.pathExists(path10.join(rootDir, file)) : await pathExists(path10.join(rootDir, file));
2562
3084
  if (exists) {
2563
3085
  result.nodeVersionFiles.push(file);
2564
3086
  }
@@ -2635,7 +3157,7 @@ function scanDependencyRisk(projects) {
2635
3157
  }
2636
3158
 
2637
3159
  // src/scanners/dependency-graph.ts
2638
- import * as path9 from "path";
3160
+ import * as path11 from "path";
2639
3161
  function parsePnpmLock(content) {
2640
3162
  const entries = [];
2641
3163
  const regex = /^\s+\/?(@?[^@\s][^@\s]*?)@(\d+\.\d+\.\d+[^:\s]*)\s*:/gm;
@@ -2694,9 +3216,9 @@ async function scanDependencyGraph(rootDir, cache) {
2694
3216
  phantomDependencies: []
2695
3217
  };
2696
3218
  let entries = [];
2697
- const pnpmLock = path9.join(rootDir, "pnpm-lock.yaml");
2698
- const npmLock = path9.join(rootDir, "package-lock.json");
2699
- const yarnLock = path9.join(rootDir, "yarn.lock");
3219
+ const pnpmLock = path11.join(rootDir, "pnpm-lock.yaml");
3220
+ const npmLock = path11.join(rootDir, "package-lock.json");
3221
+ const yarnLock = path11.join(rootDir, "yarn.lock");
2700
3222
  const _pathExists = cache ? (p) => cache.pathExists(p) : pathExists;
2701
3223
  const _readTextFile = cache ? (p) => cache.readTextFile(p) : readTextFile;
2702
3224
  if (await _pathExists(pnpmLock)) {
@@ -2743,7 +3265,7 @@ async function scanDependencyGraph(rootDir, cache) {
2743
3265
  for (const pjPath of pkgFiles) {
2744
3266
  try {
2745
3267
  const pj = cache ? await cache.readJsonFile(pjPath) : await readJsonFile(pjPath);
2746
- const relPath = path9.relative(rootDir, pjPath);
3268
+ const relPath = path11.relative(rootDir, pjPath);
2747
3269
  for (const section of ["dependencies", "devDependencies"]) {
2748
3270
  const deps = pj[section];
2749
3271
  if (!deps) continue;
@@ -3089,7 +3611,7 @@ function scanToolingInventory(projects) {
3089
3611
  }
3090
3612
 
3091
3613
  // src/scanners/build-deploy.ts
3092
- import * as path10 from "path";
3614
+ import * as path12 from "path";
3093
3615
  var CI_FILES = {
3094
3616
  ".github/workflows": "github-actions",
3095
3617
  ".gitlab-ci.yml": "gitlab-ci",
@@ -3142,17 +3664,17 @@ async function scanBuildDeploy(rootDir, cache) {
3142
3664
  const _readTextFile = cache ? (p) => cache.readTextFile(p) : readTextFile;
3143
3665
  const ciSystems = /* @__PURE__ */ new Set();
3144
3666
  for (const [file, system] of Object.entries(CI_FILES)) {
3145
- const fullPath = path10.join(rootDir, file);
3667
+ const fullPath = path12.join(rootDir, file);
3146
3668
  if (await _pathExists(fullPath)) {
3147
3669
  ciSystems.add(system);
3148
3670
  }
3149
3671
  }
3150
- const ghWorkflowDir = path10.join(rootDir, ".github", "workflows");
3672
+ const ghWorkflowDir = path12.join(rootDir, ".github", "workflows");
3151
3673
  if (await _pathExists(ghWorkflowDir)) {
3152
3674
  try {
3153
3675
  if (cache) {
3154
3676
  const entries = await cache.walkDir(rootDir);
3155
- const ghPrefix = path10.relative(rootDir, ghWorkflowDir) + path10.sep;
3677
+ const ghPrefix = path12.relative(rootDir, ghWorkflowDir) + path12.sep;
3156
3678
  result.ciWorkflowCount = entries.filter(
3157
3679
  (e) => e.isFile && e.relPath.startsWith(ghPrefix) && (e.name.endsWith(".yml") || e.name.endsWith(".yaml"))
3158
3680
  ).length;
@@ -3203,11 +3725,11 @@ async function scanBuildDeploy(rootDir, cache) {
3203
3725
  (name) => name.endsWith(".cfn.json") || name.endsWith(".cfn.yaml")
3204
3726
  );
3205
3727
  if (cfnFiles.length > 0) iacSystems.add("cloudformation");
3206
- if (await _pathExists(path10.join(rootDir, "Pulumi.yaml"))) iacSystems.add("pulumi");
3728
+ if (await _pathExists(path12.join(rootDir, "Pulumi.yaml"))) iacSystems.add("pulumi");
3207
3729
  result.iac = [...iacSystems].sort();
3208
3730
  const releaseTools = /* @__PURE__ */ new Set();
3209
3731
  for (const [file, tool] of Object.entries(RELEASE_FILES)) {
3210
- if (await _pathExists(path10.join(rootDir, file))) releaseTools.add(tool);
3732
+ if (await _pathExists(path12.join(rootDir, file))) releaseTools.add(tool);
3211
3733
  }
3212
3734
  const pkgFiles = cache ? await cache.findPackageJsonFiles(rootDir) : await findPackageJsonFiles(rootDir);
3213
3735
  for (const pjPath of pkgFiles) {
@@ -3232,19 +3754,19 @@ async function scanBuildDeploy(rootDir, cache) {
3232
3754
  };
3233
3755
  const managers = /* @__PURE__ */ new Set();
3234
3756
  for (const [file, manager] of Object.entries(lockfileMap)) {
3235
- if (await _pathExists(path10.join(rootDir, file))) managers.add(manager);
3757
+ if (await _pathExists(path12.join(rootDir, file))) managers.add(manager);
3236
3758
  }
3237
3759
  result.packageManagers = [...managers].sort();
3238
3760
  const monoTools = /* @__PURE__ */ new Set();
3239
3761
  for (const [file, tool] of Object.entries(MONOREPO_FILES)) {
3240
- if (await _pathExists(path10.join(rootDir, file))) monoTools.add(tool);
3762
+ if (await _pathExists(path12.join(rootDir, file))) monoTools.add(tool);
3241
3763
  }
3242
3764
  result.monorepoTools = [...monoTools].sort();
3243
3765
  return result;
3244
3766
  }
3245
3767
 
3246
3768
  // src/scanners/ts-modernity.ts
3247
- import * as path11 from "path";
3769
+ import * as path13 from "path";
3248
3770
  async function scanTsModernity(rootDir, cache) {
3249
3771
  const result = {
3250
3772
  typescriptVersion: null,
@@ -3282,7 +3804,7 @@ async function scanTsModernity(rootDir, cache) {
3282
3804
  if (hasEsm && hasCjs) result.moduleType = "mixed";
3283
3805
  else if (hasEsm) result.moduleType = "esm";
3284
3806
  else if (hasCjs) result.moduleType = "cjs";
3285
- let tsConfigPath = path11.join(rootDir, "tsconfig.json");
3807
+ let tsConfigPath = path13.join(rootDir, "tsconfig.json");
3286
3808
  const tsConfigExists = cache ? await cache.pathExists(tsConfigPath) : await pathExists(tsConfigPath);
3287
3809
  if (!tsConfigExists) {
3288
3810
  const tsConfigs = cache ? await cache.findFiles(rootDir, (name) => name === "tsconfig.json") : await findFiles(rootDir, (name) => name === "tsconfig.json");
@@ -3628,8 +4150,8 @@ function scanBreakingChangeExposure(projects) {
3628
4150
  }
3629
4151
 
3630
4152
  // src/scanners/file-hotspots.ts
3631
- import * as fs4 from "fs/promises";
3632
- import * as path12 from "path";
4153
+ import * as fs5 from "fs/promises";
4154
+ import * as path14 from "path";
3633
4155
  var SKIP_DIRS2 = /* @__PURE__ */ new Set([
3634
4156
  "node_modules",
3635
4157
  ".git",
@@ -3672,16 +4194,16 @@ async function scanFileHotspots(rootDir, cache) {
3672
4194
  const entries = await cache.walkDir(rootDir);
3673
4195
  for (const entry of entries) {
3674
4196
  if (!entry.isFile) continue;
3675
- const ext = path12.extname(entry.name).toLowerCase();
4197
+ const ext = path14.extname(entry.name).toLowerCase();
3676
4198
  if (SKIP_EXTENSIONS.has(ext)) continue;
3677
- const depth = entry.relPath.split(path12.sep).length - 1;
4199
+ const depth = entry.relPath.split(path14.sep).length - 1;
3678
4200
  if (depth > maxDepth) maxDepth = depth;
3679
4201
  extensionCounts[ext] = (extensionCounts[ext] ?? 0) + 1;
3680
4202
  try {
3681
- const stat3 = await fs4.stat(entry.absPath);
4203
+ const stat4 = await fs5.stat(entry.absPath);
3682
4204
  allFiles.push({
3683
4205
  path: entry.relPath,
3684
- bytes: stat3.size
4206
+ bytes: stat4.size
3685
4207
  });
3686
4208
  } catch {
3687
4209
  }
@@ -3691,7 +4213,7 @@ async function scanFileHotspots(rootDir, cache) {
3691
4213
  if (depth > maxDepth) maxDepth = depth;
3692
4214
  let entries;
3693
4215
  try {
3694
- const dirents = await fs4.readdir(dir, { withFileTypes: true });
4216
+ const dirents = await fs5.readdir(dir, { withFileTypes: true });
3695
4217
  entries = dirents.map((d) => ({
3696
4218
  name: d.name,
3697
4219
  isDirectory: d.isDirectory(),
@@ -3703,16 +4225,16 @@ async function scanFileHotspots(rootDir, cache) {
3703
4225
  for (const e of entries) {
3704
4226
  if (e.isDirectory) {
3705
4227
  if (SKIP_DIRS2.has(e.name)) continue;
3706
- await walk(path12.join(dir, e.name), depth + 1);
4228
+ await walk(path14.join(dir, e.name), depth + 1);
3707
4229
  } else if (e.isFile) {
3708
- const ext = path12.extname(e.name).toLowerCase();
4230
+ const ext = path14.extname(e.name).toLowerCase();
3709
4231
  if (SKIP_EXTENSIONS.has(ext)) continue;
3710
4232
  extensionCounts[ext] = (extensionCounts[ext] ?? 0) + 1;
3711
4233
  try {
3712
- const stat3 = await fs4.stat(path12.join(dir, e.name));
4234
+ const stat4 = await fs5.stat(path14.join(dir, e.name));
3713
4235
  allFiles.push({
3714
- path: path12.relative(rootDir, path12.join(dir, e.name)),
3715
- bytes: stat3.size
4236
+ path: path14.relative(rootDir, path14.join(dir, e.name)),
4237
+ bytes: stat4.size
3716
4238
  });
3717
4239
  } catch {
3718
4240
  }
@@ -3734,7 +4256,7 @@ async function scanFileHotspots(rootDir, cache) {
3734
4256
  }
3735
4257
 
3736
4258
  // src/scanners/security-posture.ts
3737
- import * as path13 from "path";
4259
+ import * as path15 from "path";
3738
4260
  var LOCKFILES = {
3739
4261
  "pnpm-lock.yaml": "pnpm",
3740
4262
  "package-lock.json": "npm",
@@ -3755,14 +4277,14 @@ async function scanSecurityPosture(rootDir, cache) {
3755
4277
  const _readTextFile = cache ? (p) => cache.readTextFile(p) : readTextFile;
3756
4278
  const foundLockfiles = [];
3757
4279
  for (const [file, type] of Object.entries(LOCKFILES)) {
3758
- if (await _pathExists(path13.join(rootDir, file))) {
4280
+ if (await _pathExists(path15.join(rootDir, file))) {
3759
4281
  foundLockfiles.push(type);
3760
4282
  }
3761
4283
  }
3762
4284
  result.lockfilePresent = foundLockfiles.length > 0;
3763
4285
  result.multipleLockfileTypes = foundLockfiles.length > 1;
3764
4286
  result.lockfileTypes = foundLockfiles.sort();
3765
- const gitignorePath = path13.join(rootDir, ".gitignore");
4287
+ const gitignorePath = path15.join(rootDir, ".gitignore");
3766
4288
  if (await _pathExists(gitignorePath)) {
3767
4289
  try {
3768
4290
  const content = await _readTextFile(gitignorePath);
@@ -3777,7 +4299,7 @@ async function scanSecurityPosture(rootDir, cache) {
3777
4299
  }
3778
4300
  }
3779
4301
  for (const envFile of [".env", ".env.local", ".env.development", ".env.production"]) {
3780
- if (await _pathExists(path13.join(rootDir, envFile))) {
4302
+ if (await _pathExists(path15.join(rootDir, envFile))) {
3781
4303
  if (!result.gitignoreCoversEnv) {
3782
4304
  result.envFilesTracked = true;
3783
4305
  break;
@@ -4202,8 +4724,8 @@ function scanServiceDependencies(projects) {
4202
4724
  }
4203
4725
 
4204
4726
  // src/scanners/architecture.ts
4205
- import * as path14 from "path";
4206
- import * as fs5 from "fs/promises";
4727
+ import * as path16 from "path";
4728
+ import * as fs6 from "fs/promises";
4207
4729
  var ARCHETYPE_SIGNALS = [
4208
4730
  // Meta-frameworks (highest priority — they imply routing patterns)
4209
4731
  { packages: ["next", "@next/core"], archetype: "nextjs", weight: 10 },
@@ -4501,9 +5023,9 @@ async function walkSourceFiles(rootDir, cache) {
4501
5023
  const entries = await cache.walkDir(rootDir);
4502
5024
  return entries.filter((e) => {
4503
5025
  if (!e.isFile) return false;
4504
- const name = path14.basename(e.absPath);
5026
+ const name = path16.basename(e.absPath);
4505
5027
  if (name.startsWith(".") && name !== ".") return false;
4506
- const ext = path14.extname(name);
5028
+ const ext = path16.extname(name);
4507
5029
  return SOURCE_EXTENSIONS.has(ext);
4508
5030
  }).map((e) => e.relPath);
4509
5031
  }
@@ -4511,21 +5033,21 @@ async function walkSourceFiles(rootDir, cache) {
4511
5033
  async function walk(dir) {
4512
5034
  let entries;
4513
5035
  try {
4514
- entries = await fs5.readdir(dir, { withFileTypes: true });
5036
+ entries = await fs6.readdir(dir, { withFileTypes: true });
4515
5037
  } catch {
4516
5038
  return;
4517
5039
  }
4518
5040
  for (const entry of entries) {
4519
5041
  if (entry.name.startsWith(".") && entry.name !== ".") continue;
4520
- const fullPath = path14.join(dir, entry.name);
5042
+ const fullPath = path16.join(dir, entry.name);
4521
5043
  if (entry.isDirectory()) {
4522
5044
  if (!IGNORE_DIRS.has(entry.name)) {
4523
5045
  await walk(fullPath);
4524
5046
  }
4525
5047
  } else if (entry.isFile()) {
4526
- const ext = path14.extname(entry.name);
5048
+ const ext = path16.extname(entry.name);
4527
5049
  if (SOURCE_EXTENSIONS.has(ext)) {
4528
- files.push(path14.relative(rootDir, fullPath));
5050
+ files.push(path16.relative(rootDir, fullPath));
4529
5051
  }
4530
5052
  }
4531
5053
  }
@@ -4549,7 +5071,7 @@ function classifyFile(filePath, archetype) {
4549
5071
  }
4550
5072
  }
4551
5073
  if (!bestMatch || bestMatch.confidence < 0.7) {
4552
- const baseName = path14.basename(filePath, path14.extname(filePath));
5074
+ const baseName = path16.basename(filePath, path16.extname(filePath));
4553
5075
  const cleanBase = baseName.replace(/\.(test|spec)$/, "");
4554
5076
  for (const rule of SUFFIX_RULES) {
4555
5077
  if (cleanBase.endsWith(rule.suffix)) {
@@ -4740,14 +5262,19 @@ async function runScan(rootDir, opts) {
4740
5262
  const sem = new Semaphore(opts.concurrency);
4741
5263
  const npmCache = new NpmCache(rootDir, sem);
4742
5264
  const fileCache = new FileCache();
5265
+ const excludePatterns = config.exclude ?? [];
5266
+ fileCache.setExcludePatterns(excludePatterns);
5267
+ fileCache.setMaxFileSize(config.maxFileSizeToScan ?? 5242880);
4743
5268
  const scanners = config.scanners;
4744
5269
  let filesScanned = 0;
4745
5270
  const progress = new ScanProgress(rootDir);
4746
5271
  const steps = [
4747
5272
  { id: "config", label: "Loading configuration" },
5273
+ { id: "discovery", label: "Discovering workspace", weight: 3 },
4748
5274
  { id: "vcs", label: "Detecting version control" },
4749
- { id: "node", label: "Scanning Node projects" },
4750
- { id: "dotnet", label: "Scanning .NET projects" },
5275
+ { id: "walk", label: "Indexing files", weight: 8 },
5276
+ { id: "node", label: "Scanning Node projects", weight: 4 },
5277
+ { id: "dotnet", label: "Scanning .NET projects", weight: 2 },
4751
5278
  ...scanners !== false ? [
4752
5279
  ...scanners?.platformMatrix?.enabled !== false ? [{ id: "platform", label: "Platform matrix" }] : [],
4753
5280
  ...scanners?.toolingInventory?.enabled !== false ? [{ id: "tooling", label: "Tooling inventory" }] : [],
@@ -4766,10 +5293,26 @@ async function runScan(rootDir, opts) {
4766
5293
  ];
4767
5294
  progress.setSteps(steps);
4768
5295
  progress.completeStep("config", "loaded");
5296
+ progress.startStep("discovery");
5297
+ const treeCount = await quickTreeCount(rootDir, excludePatterns);
5298
+ progress.updateStats({ treeSummary: treeCount });
5299
+ progress.completeStep(
5300
+ "discovery",
5301
+ `${treeCount.totalFiles.toLocaleString()} files \xB7 ${treeCount.totalDirs.toLocaleString()} dirs`
5302
+ );
5303
+ const scanHistory = await loadScanHistory(rootDir);
5304
+ const estimatedTotal = estimateTotalDuration(scanHistory, treeCount.totalFiles);
5305
+ progress.setEstimatedTotal(estimatedTotal);
5306
+ progress.setStepEstimates(estimateStepDurations(scanHistory, treeCount.totalFiles));
4769
5307
  progress.startStep("vcs");
4770
5308
  const vcs = await detectVcs(rootDir);
4771
5309
  const vcsDetail = vcs.type !== "unknown" ? `${vcs.type}${vcs.branch ? ` ${vcs.branch}` : ""}${vcs.shortSha ? ` @ ${vcs.shortSha}` : ""}` : "none detected";
4772
5310
  progress.completeStep("vcs", vcsDetail);
5311
+ progress.startStep("walk", treeCount.totalFiles);
5312
+ await fileCache.walkDir(rootDir, (found, currentPath) => {
5313
+ progress.updateStepProgress("walk", found, treeCount.totalFiles, currentPath);
5314
+ });
5315
+ progress.completeStep("walk", `${treeCount.totalFiles.toLocaleString()} files indexed`);
4773
5316
  progress.startStep("node");
4774
5317
  const nodeProjects = await scanNodeProjects(rootDir, npmCache, fileCache);
4775
5318
  for (const p of nodeProjects) {
@@ -4949,6 +5492,36 @@ async function runScan(rootDir, opts) {
4949
5492
  if (noteCount > 0) findingParts.push(`${noteCount} note${noteCount !== 1 ? "s" : ""}`);
4950
5493
  progress.completeStep("findings", findingParts.join(", ") || "none");
4951
5494
  progress.finish();
5495
+ const stuckPaths = fileCache.stuckPaths;
5496
+ const skippedLarge = fileCache.skippedLargeFiles;
5497
+ if (stuckPaths.length > 0) {
5498
+ console.log(
5499
+ chalk5.yellow(`
5500
+ \u26A0 ${stuckPaths.length} path${stuckPaths.length === 1 ? "" : "s"} timed out (>60s) and ${stuckPaths.length === 1 ? "was" : "were"} skipped:`)
5501
+ );
5502
+ for (const d of stuckPaths) {
5503
+ console.log(chalk5.dim(` \u2192 ${d}`));
5504
+ }
5505
+ const newExcludes = stuckPaths.map((d) => `${d}/**`);
5506
+ const updated = await appendExcludePatterns(rootDir, newExcludes);
5507
+ if (updated) {
5508
+ console.log(chalk5.green("\u2714") + ` Added ${newExcludes.length} pattern${newExcludes.length !== 1 ? "s" : ""} to exclude list in config`);
5509
+ }
5510
+ }
5511
+ if (skippedLarge.length > 0) {
5512
+ const sizeLimit = config.maxFileSizeToScan ?? 5242880;
5513
+ const sizeMB = (sizeLimit / 1048576).toFixed(0);
5514
+ console.log(
5515
+ chalk5.yellow(`
5516
+ \u26A0 ${skippedLarge.length} file${skippedLarge.length === 1 ? "" : "s"} skipped (>${sizeMB} MB):`)
5517
+ );
5518
+ for (const f of skippedLarge.slice(0, 10)) {
5519
+ console.log(chalk5.dim(` \u2192 ${f}`));
5520
+ }
5521
+ if (skippedLarge.length > 10) {
5522
+ console.log(chalk5.dim(` \u2026 and ${skippedLarge.length - 10} more`));
5523
+ }
5524
+ }
4952
5525
  fileCache.clear();
4953
5526
  if (allProjects.length === 0) {
4954
5527
  console.log(chalk5.yellow("No projects found."));
@@ -4966,17 +5539,18 @@ async function runScan(rootDir, opts) {
4966
5539
  schemaVersion: "1.0",
4967
5540
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
4968
5541
  vibgrateVersion: VERSION,
4969
- rootPath: path15.basename(rootDir),
5542
+ rootPath: path17.basename(rootDir),
4970
5543
  ...vcs.type !== "unknown" ? { vcs } : {},
4971
5544
  projects: allProjects,
4972
5545
  drift,
4973
5546
  findings,
4974
5547
  ...Object.keys(extended).length > 0 ? { extended } : {},
4975
5548
  durationMs,
4976
- filesScanned
5549
+ filesScanned,
5550
+ treeSummary: treeCount
4977
5551
  };
4978
5552
  if (opts.baseline) {
4979
- const baselinePath = path15.resolve(opts.baseline);
5553
+ const baselinePath = path17.resolve(opts.baseline);
4980
5554
  if (await pathExists(baselinePath)) {
4981
5555
  try {
4982
5556
  const baseline = await readJsonFile(baselinePath);
@@ -4987,15 +5561,22 @@ async function runScan(rootDir, opts) {
4987
5561
  }
4988
5562
  }
4989
5563
  }
4990
- const vibgrateDir = path15.join(rootDir, ".vibgrate");
5564
+ const vibgrateDir = path17.join(rootDir, ".vibgrate");
4991
5565
  await ensureDir(vibgrateDir);
4992
- await writeJsonFile(path15.join(vibgrateDir, "scan_result.json"), artifact);
5566
+ await writeJsonFile(path17.join(vibgrateDir, "scan_result.json"), artifact);
5567
+ await saveScanHistory(rootDir, {
5568
+ timestamp: artifact.timestamp,
5569
+ totalDurationMs: durationMs,
5570
+ totalFiles: treeCount.totalFiles,
5571
+ totalDirs: treeCount.totalDirs,
5572
+ steps: progress.getStepTimings()
5573
+ });
4993
5574
  for (const project of allProjects) {
4994
5575
  if (project.drift && project.path) {
4995
- const projectDir = path15.resolve(rootDir, project.path);
4996
- const projectVibgrateDir = path15.join(projectDir, ".vibgrate");
5576
+ const projectDir = path17.resolve(rootDir, project.path);
5577
+ const projectVibgrateDir = path17.join(projectDir, ".vibgrate");
4997
5578
  await ensureDir(projectVibgrateDir);
4998
- await writeJsonFile(path15.join(projectVibgrateDir, "project_score.json"), {
5579
+ await writeJsonFile(path17.join(projectVibgrateDir, "project_score.json"), {
4999
5580
  projectId: project.projectId,
5000
5581
  name: project.name,
5001
5582
  type: project.type,
@@ -5012,7 +5593,7 @@ async function runScan(rootDir, opts) {
5012
5593
  if (opts.format === "json") {
5013
5594
  const jsonStr = JSON.stringify(artifact, null, 2);
5014
5595
  if (opts.out) {
5015
- await writeTextFile(path15.resolve(opts.out), jsonStr);
5596
+ await writeTextFile(path17.resolve(opts.out), jsonStr);
5016
5597
  console.log(chalk5.green("\u2714") + ` JSON written to ${opts.out}`);
5017
5598
  } else {
5018
5599
  console.log(jsonStr);
@@ -5021,7 +5602,7 @@ async function runScan(rootDir, opts) {
5021
5602
  const sarif = formatSarif(artifact);
5022
5603
  const sarifStr = JSON.stringify(sarif, null, 2);
5023
5604
  if (opts.out) {
5024
- await writeTextFile(path15.resolve(opts.out), sarifStr);
5605
+ await writeTextFile(path17.resolve(opts.out), sarifStr);
5025
5606
  console.log(chalk5.green("\u2714") + ` SARIF written to ${opts.out}`);
5026
5607
  } else {
5027
5608
  console.log(sarifStr);
@@ -5030,7 +5611,7 @@ async function runScan(rootDir, opts) {
5030
5611
  const text = formatText(artifact);
5031
5612
  console.log(text);
5032
5613
  if (opts.out) {
5033
- await writeTextFile(path15.resolve(opts.out), text);
5614
+ await writeTextFile(path17.resolve(opts.out), text);
5034
5615
  }
5035
5616
  }
5036
5617
  return artifact;
@@ -5089,7 +5670,7 @@ async function autoPush(artifact, rootDir, opts) {
5089
5670
  }
5090
5671
  }
5091
5672
  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) => {
5092
- const rootDir = path15.resolve(targetPath);
5673
+ const rootDir = path17.resolve(targetPath);
5093
5674
  if (!await pathExists(rootDir)) {
5094
5675
  console.error(chalk5.red(`Path does not exist: ${rootDir}`));
5095
5676
  process.exit(1);