@vibgrate/cli 2026.610.1 → 2026.611.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/DOCS.md CHANGED
@@ -162,7 +162,7 @@ Creates:
162
162
  The primary command. Scans your project for upgrade drift.
163
163
 
164
164
  ```bash
165
- vibgrate scan [path] [--format text|json|sarif|md] [--out <file>] [--fail-on warn|error] [--offline] [--package-manifest <file>] [--no-local-artifacts] [--max-privacy] [--baseline <file>] [--drift-budget <score>] [--drift-worsening <percent>] [--changed-only] [--concurrency <n>]
165
+ vibgrate scan [path] [--format text|json|sarif|md] [--out <file>] [--fail-on warn|error] [--offline] [--package-manifest <file>] [--no-local-artifacts] [--max-privacy] [--baseline <file>] [--drift-budget <score>] [--drift-worsening <percent>] [--changed-only] [--exclude <glob>...] [--concurrency <n>]
166
166
  ```
167
167
 
168
168
  | Flag | Default | Description |
@@ -172,6 +172,7 @@ vibgrate scan [path] [--format text|json|sarif|md] [--out <file>] [--fail-on war
172
172
  | `--fail-on <level>` | — | Exit with code 2 if findings at this level exist |
173
173
  | `--baseline <file>` | — | Compare against a previous baseline |
174
174
  | `--changed-only` | — | Only scan changed files |
175
+ | `-e, --exclude <glob>` | — | Exclude paths matching a glob pattern. Repeatable, and a single value may list several patterns separated by commas or semicolons. Merged with `exclude` from the config file |
175
176
  | `--concurrency <n>` | `8` | Max concurrent npm registry calls |
176
177
  | `--drift-budget <score>` | — | Fitness gate: fail if drift score is above this budget |
177
178
  | `--drift-worsening <percent>` | — | Fitness gate: fail if drift worsens by more than % vs baseline |
@@ -189,6 +190,32 @@ By default, the scan writes `.vibgrate/scan_result.json`. Use `--no-local-artifa
189
190
 
190
191
  For offline drift scoring, pass `--package-manifest <file>` with a downloaded manifest bundle such as `https://github.com/vibgrate/manifests/latest-packages.zip`.
191
192
 
193
+ #### Excluding paths from a scan
194
+
195
+ Use `--exclude` (alias `-e`) to skip directories or files from the scan. Values are [glob patterns](#exclude-glob-syntax) matched against repository-relative, forward-slash paths. The flag is **repeatable**, and a single value may carry **several patterns separated by commas or semicolons** — use whichever reads best:
196
+
197
+ ```bash
198
+ # Repeat the flag
199
+ vibgrate scan . --exclude "legacy/**" --exclude "vendor/**"
200
+
201
+ # Or list multiple patterns in one flag (comma- or semicolon-separated)
202
+ vibgrate scan . --exclude "legacy/**,vendor/**;**/fixtures/**"
203
+ ```
204
+
205
+ CLI excludes are **additive**: they are merged (and de-duplicated) with any `exclude` patterns from your [config file](#configuration), so command-line excludes never replace your committed defaults.
206
+
207
+ <a id="exclude-glob-syntax"></a>
208
+ Supported glob syntax:
209
+
210
+ | Pattern | Matches |
211
+ |---------|---------|
212
+ | `*` | Any run of characters within a single path segment (no `/`) |
213
+ | `**` | Any number of path segments, including `/` (recursive) |
214
+ | `?` | Exactly one non-separator character |
215
+ | `{a,b}` | Alternation — either `a` or `b` |
216
+ | `[abc]` | A single character from the set |
217
+ | `legacy` | A bare name is treated as a directory prefix — equivalent to `legacy/**` |
218
+
192
219
  Examples:
193
220
 
194
221
  ```bash
@@ -198,6 +225,9 @@ vibgrate scan .
198
225
  # JSON output for automation
199
226
  vibgrate scan . --format json --out scan.json
200
227
 
228
+ # Skip vendored and generated code
229
+ vibgrate scan . --exclude "vendor/**,**/*.generated.ts;dist/**"
230
+
201
231
  # CI gate with baseline regression protection
202
232
  vibgrate scan . --baseline .vibgrate/baseline.json --drift-budget 40 --drift-worsening 5 --fail-on error
203
233
 
@@ -433,6 +463,18 @@ export default config;
433
463
 
434
464
  Also supports `vibgrate.config.js` and `vibgrate.config.json`.
435
465
 
466
+ ### Exclude patterns
467
+
468
+ `exclude` accepts an array of glob patterns describing paths the scan should skip (relative to the repository root, forward-slash separated). See [exclude glob syntax](#exclude-glob-syntax) for the supported patterns.
469
+
470
+ ```typescript
471
+ const config: VibgrateConfig = {
472
+ exclude: ["legacy/**", "vendor/**", "**/*.generated.ts"],
473
+ };
474
+ ```
475
+
476
+ The same patterns can be supplied per-run on the command line with [`--exclude`](#excluding-paths-from-a-scan). CLI excludes are merged with (not a replacement for) the config `exclude` list, so committed defaults always apply and ad-hoc excludes are additive.
477
+
436
478
  ### Thresholds
437
479
 
438
480
  Control when findings are raised and when the CLI should fail.
package/README.md CHANGED
@@ -193,7 +193,7 @@ When offline mode runs without a package manifest, package freshness is marked a
193
193
  ## Core commands
194
194
 
195
195
  ```bash
196
- vibgrate scan [path] [--format text|json|sarif|md] [--out <file>] [--fail-on warn|error] [--offline] [--package-manifest <file>] [--no-local-artifacts] [--max-privacy]
196
+ vibgrate scan [path] [--format text|json|sarif|md] [--out <file>] [--fail-on warn|error] [--exclude <glob>...] [--offline] [--package-manifest <file>] [--no-local-artifacts] [--max-privacy]
197
197
  vibgrate baseline [path]
198
198
  vibgrate report [--in <artifact.json>] [--format md|text|json]
199
199
  vibgrate push [--dsn <dsn>] [--file <artifact.json>] [--strict]
@@ -225,7 +225,19 @@ Expected result:
225
225
  - Exit code `2` when the configured gate is exceeded
226
226
 
227
227
  ```bash
228
- # 3) Offline scan using local package-version bundle
228
+ # 3) Scan while excluding vendored / generated paths
229
+ # --exclude is repeatable and accepts comma/semicolon-separated globs;
230
+ # patterns are merged with `exclude` from vibgrate.config.*
231
+ npx @vibgrate/cli scan . --exclude "legacy/**,vendor/**" --exclude "**/*.generated.ts"
232
+ ```
233
+
234
+ Expected result:
235
+
236
+ - Matching directories and files are skipped before scanning
237
+ - Excludes from the config file still apply (CLI patterns are additive)
238
+
239
+ ```bash
240
+ # 4) Offline scan using local package-version bundle
229
241
  npx @vibgrate/cli scan . --offline --package-manifest ./latest-packages.zip --format json --out scan.json
230
242
  ```
231
243
 
@@ -236,7 +248,7 @@ Expected result:
236
248
  - Package freshness may be marked unknown if manifest lacks entries
237
249
 
238
250
  ```bash
239
- # 4) Export SBOM and compare two runs
251
+ # 5) Export SBOM and compare two runs
240
252
  npx @vibgrate/cli sbom export --format cyclonedx --out sbom.cdx.json
241
253
  npx @vibgrate/cli sbom delta --from .vibgrate/baseline.json --to .vibgrate/scan_result.json --out sbom-delta.txt
242
254
  ```
@@ -1,10 +1,10 @@
1
1
  import {
2
2
  baselineCommand,
3
3
  runBaseline
4
- } from "./chunk-HQCB2BTS.js";
5
- import "./chunk-X2MPRPZ6.js";
4
+ } from "./chunk-4CDBCG4I.js";
5
+ import "./chunk-EIZZ6VC3.js";
6
6
  import "./chunk-74ZJFYEM.js";
7
- import "./chunk-RAQ76CZO.js";
7
+ import "./chunk-C7LU6YIL.js";
8
8
  import "./chunk-JSBRDJBE.js";
9
9
  export {
10
10
  baselineCommand,
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  runScan
3
- } from "./chunk-X2MPRPZ6.js";
3
+ } from "./chunk-EIZZ6VC3.js";
4
4
 
5
5
  // src/commands/baseline.ts
6
6
  import * as path3 from "path";
@@ -48,9 +48,12 @@ import * as path from "path";
48
48
 
49
49
  // src/utils/fs.ts
50
50
  var execFileAsync = promisify(execFile);
51
+ function stripBom(text) {
52
+ return text.charCodeAt(0) === 65279 ? text.slice(1) : text;
53
+ }
51
54
  async function readJsonFile(filePath) {
52
55
  const txt = await fs.readFile(filePath, "utf8");
53
- return JSON.parse(txt);
56
+ return JSON.parse(stripBom(txt));
54
57
  }
55
58
  async function pathExists(p) {
56
59
  try {
@@ -1,4 +1,4 @@
1
- // ../vibgrate-core/dist/chunk-6F5NUOIM.js
1
+ // ../vibgrate-core/dist/chunk-R3UFC4G6.js
2
2
  import { execFile } from "child_process";
3
3
  import * as fs from "fs/promises";
4
4
  import * as os from "os";
@@ -32,6 +32,18 @@ var Semaphore = class {
32
32
  else this.available++;
33
33
  }
34
34
  };
35
+ function parseExcludePatterns(input) {
36
+ if (input === void 0) return [];
37
+ const raw = Array.isArray(input) ? input : [input];
38
+ const out = [];
39
+ for (const entry of raw) {
40
+ for (const part of entry.split(/[,;]/)) {
41
+ const trimmed = part.trim();
42
+ if (trimmed) out.push(trimmed);
43
+ }
44
+ }
45
+ return [...new Set(out)];
46
+ }
35
47
  function compileGlobs(patterns) {
36
48
  if (patterns.length === 0) return null;
37
49
  const matchers = patterns.map((p) => compileOne(normalise(p)));
@@ -112,6 +124,10 @@ function escapeRegex(s) {
112
124
  var execFileAsync = promisify(execFile);
113
125
  var SKIP_DIRS = /* @__PURE__ */ new Set([
114
126
  "node_modules",
127
+ // Vendored third-party dependency trees (Go vendor/, PHP composer,
128
+ // Rails vendor/) — their manifests are not the repo's own projects and
129
+ // their runtimes/dependencies must not produce drift findings.
130
+ "vendor",
115
131
  ".git",
116
132
  ".vibgrate",
117
133
  ".wrangler",
@@ -550,7 +566,7 @@ var FileCache = class _FileCache {
550
566
  if (cached) return cached;
551
567
  const promise = this.readTextFile(abs).then((txt) => {
552
568
  this.textCache.delete(abs);
553
- return JSON.parse(txt);
569
+ return JSON.parse(stripBom(txt));
554
570
  });
555
571
  this.jsonCache.set(abs, promise);
556
572
  return promise;
@@ -745,9 +761,12 @@ async function findSolutionFiles(rootDir) {
745
761
  async function findCsprojFiles(rootDir) {
746
762
  return findFiles(rootDir, (name) => name.endsWith(".csproj"));
747
763
  }
764
+ function stripBom(text) {
765
+ return text.charCodeAt(0) === 65279 ? text.slice(1) : text;
766
+ }
748
767
  async function readJsonFile(filePath) {
749
768
  const txt = await fs.readFile(filePath, "utf8");
750
- return JSON.parse(txt);
769
+ return JSON.parse(stripBom(txt));
751
770
  }
752
771
  async function readTextFile(filePath) {
753
772
  return fs.readFile(filePath, "utf8");
@@ -774,6 +793,7 @@ async function writeTextFile(filePath, content) {
774
793
 
775
794
  export {
776
795
  Semaphore,
796
+ parseExcludePatterns,
777
797
  FileCache,
778
798
  quickTreeCount,
779
799
  normalizeGlobForRipgrep,
@@ -783,6 +803,7 @@ export {
783
803
  findPackageJsonFiles,
784
804
  findSolutionFiles,
785
805
  findCsprojFiles,
806
+ stripBom,
786
807
  readJsonFile,
787
808
  readTextFile,
788
809
  pathExists,
@@ -14,7 +14,7 @@ import {
14
14
  readTextFile,
15
15
  writeJsonFile,
16
16
  writeTextFile
17
- } from "./chunk-RAQ76CZO.js";
17
+ } from "./chunk-C7LU6YIL.js";
18
18
  import {
19
19
  __commonJS,
20
20
  __toESM
@@ -1802,6 +1802,8 @@ import * as path31 from "path";
1802
1802
  import * as path33 from "path";
1803
1803
  import chalk3 from "chalk";
1804
1804
  import chalk2 from "chalk";
1805
+ import { writeFileSync, mkdirSync } from "fs";
1806
+ import { dirname as dirname182, basename as basename192 } from "path";
1805
1807
  import * as fs7 from "fs/promises";
1806
1808
  import * as path32 from "path";
1807
1809
  var CONFIG_FILES = [
@@ -4938,7 +4940,7 @@ async function scanPythonProjects(rootDir, pypiCache, cache, projectScanTimeout)
4938
4940
  return results;
4939
4941
  }
4940
4942
  async function findPythonManifests(rootDir) {
4941
- const { findFiles: findFiles2 } = await import("./fs-CMSVYM7J-QRLLRZ2J.js");
4943
+ const { findFiles: findFiles2 } = await import("./fs-PXXYZATK-EW5LCUA7.js");
4942
4944
  return findFiles2(rootDir, (name) => PYTHON_MANIFEST_FILES.has(name) || /^requirements.*\.txt$/.test(name));
4943
4945
  }
4944
4946
  async function scanOnePythonProject(dir, manifestFiles, rootDir, pypiCache, cache) {
@@ -5335,7 +5337,7 @@ async function scanJavaProjects(rootDir, mavenCache, cache, projectScanTimeout)
5335
5337
  return results;
5336
5338
  }
5337
5339
  async function findJavaManifests(rootDir) {
5338
- const { findFiles: findFiles2 } = await import("./fs-CMSVYM7J-QRLLRZ2J.js");
5340
+ const { findFiles: findFiles2 } = await import("./fs-PXXYZATK-EW5LCUA7.js");
5339
5341
  return findFiles2(rootDir, (name) => JAVA_MANIFEST_FILES.has(name));
5340
5342
  }
5341
5343
  async function scanOneJavaProject(dir, manifestFiles, rootDir, mavenCache, cache) {
@@ -5700,7 +5702,7 @@ async function scanRubyProjects(rootDir, rubygemsCache, cache, projectScanTimeou
5700
5702
  return results;
5701
5703
  }
5702
5704
  async function findRubyManifests(rootDir) {
5703
- const { findFiles: findFiles2 } = await import("./fs-CMSVYM7J-QRLLRZ2J.js");
5705
+ const { findFiles: findFiles2 } = await import("./fs-PXXYZATK-EW5LCUA7.js");
5704
5706
  return findFiles2(rootDir, (name) => RUBY_MANIFEST_FILES.has(name) || isGemspec(name));
5705
5707
  }
5706
5708
  async function scanOneRubyProject(dir, manifestFiles, rootDir, rubygemsCache, cache) {
@@ -5924,7 +5926,7 @@ async function scanSwiftProjects(rootDir, swiftCache, cache, projectScanTimeout)
5924
5926
  return results;
5925
5927
  }
5926
5928
  async function findSwiftManifests(rootDir) {
5927
- const { findFiles: findFiles2 } = await import("./fs-CMSVYM7J-QRLLRZ2J.js");
5929
+ const { findFiles: findFiles2 } = await import("./fs-PXXYZATK-EW5LCUA7.js");
5928
5930
  return findFiles2(rootDir, (name) => SWIFT_MANIFEST_FILES.has(name));
5929
5931
  }
5930
5932
  async function scanOneSwiftProject(dir, manifestFile, rootDir, swiftCache, cache) {
@@ -6161,7 +6163,7 @@ async function scanGoProjects(rootDir, goCache, cache, projectScanTimeout) {
6161
6163
  return results;
6162
6164
  }
6163
6165
  async function findGoManifests(rootDir) {
6164
- const { findFiles: findFiles2 } = await import("./fs-CMSVYM7J-QRLLRZ2J.js");
6166
+ const { findFiles: findFiles2 } = await import("./fs-PXXYZATK-EW5LCUA7.js");
6165
6167
  return findFiles2(rootDir, (name) => GO_MANIFEST_FILES.has(name));
6166
6168
  }
6167
6169
  async function scanOneGoProject(dir, manifestFile, rootDir, goCache, cache) {
@@ -6395,7 +6397,7 @@ async function scanRustProjects(rootDir, cargoCache, cache, projectScanTimeout)
6395
6397
  return results;
6396
6398
  }
6397
6399
  async function findRustManifests(rootDir) {
6398
- const { findFiles: findFiles2 } = await import("./fs-CMSVYM7J-QRLLRZ2J.js");
6400
+ const { findFiles: findFiles2 } = await import("./fs-PXXYZATK-EW5LCUA7.js");
6399
6401
  return findFiles2(rootDir, (name) => RUST_MANIFEST_FILES.has(name));
6400
6402
  }
6401
6403
  async function scanOneRustProject(dir, manifestFile, rootDir, cargoCache, cache) {
@@ -6619,7 +6621,7 @@ async function scanPhpProjects(rootDir, composerCache, cache, projectScanTimeout
6619
6621
  return results;
6620
6622
  }
6621
6623
  async function findPhpManifests(rootDir) {
6622
- const { findFiles: findFiles2 } = await import("./fs-CMSVYM7J-QRLLRZ2J.js");
6624
+ const { findFiles: findFiles2 } = await import("./fs-PXXYZATK-EW5LCUA7.js");
6623
6625
  return findFiles2(rootDir, (name) => PHP_MANIFEST_FILES.has(name));
6624
6626
  }
6625
6627
  async function scanOnePhpProject(dir, manifestFile, rootDir, composerCache, cache) {
@@ -6795,7 +6797,7 @@ function parsePubspecYaml(content) {
6795
6797
  for (const line of content.split(/\r?\n/)) {
6796
6798
  const trimmed = line.trim();
6797
6799
  if (trimmed.startsWith("sdk:")) {
6798
- const match = trimmed.match(/sdk:\s*['"]?>=?([^<'"]+)/);
6800
+ const match = trimmed.match(/sdk:\s*['"]?>?=?\s*(\d[^<'"]*)/);
6799
6801
  if (match) dartVersion = match[1].trim();
6800
6802
  continue;
6801
6803
  }
@@ -6805,7 +6807,7 @@ function parsePubspecYaml(content) {
6805
6807
  } else if (trimmed === "dev_dependencies:") {
6806
6808
  currentSection = "dev_dependencies";
6807
6809
  continue;
6808
- } else if (/^\w+:/.test(trimmed) && !trimmed.startsWith(" ")) {
6810
+ } else if (/^\w+:/.test(line)) {
6809
6811
  currentSection = null;
6810
6812
  continue;
6811
6813
  }
@@ -6833,7 +6835,7 @@ async function parsePubspecLock(filePath, cache) {
6833
6835
  let currentPackage = null;
6834
6836
  for (const line of content.split(/\r?\n/)) {
6835
6837
  const trimmed = line.trim();
6836
- const pkgMatch = trimmed.match(/^(\w+):$/);
6838
+ const pkgMatch = line.match(/^ {2}(\w+):$/);
6837
6839
  if (pkgMatch) {
6838
6840
  currentPackage = pkgMatch[1];
6839
6841
  continue;
@@ -6884,7 +6886,7 @@ async function scanDartProjects(rootDir, pubCache, cache, projectScanTimeout) {
6884
6886
  return results;
6885
6887
  }
6886
6888
  async function findDartManifests(rootDir) {
6887
- const { findFiles: findFiles2 } = await import("./fs-CMSVYM7J-QRLLRZ2J.js");
6889
+ const { findFiles: findFiles2 } = await import("./fs-PXXYZATK-EW5LCUA7.js");
6888
6890
  return findFiles2(rootDir, (name) => DART_MANIFEST_FILES.has(name));
6889
6891
  }
6890
6892
  async function scanOneDartProject(dir, manifestFile, rootDir, pubCache, cache) {
@@ -7254,6 +7256,7 @@ async function scanElixirProjects(rootDir, manifest, cache, projectScanTimeout,
7254
7256
  try {
7255
7257
  const scan = await scanElixir(dir, cache, manifest, offline);
7256
7258
  if (scan) {
7259
+ scan.path = path13.relative(rootDir, dir) || ".";
7257
7260
  results.push(scan);
7258
7261
  }
7259
7262
  } catch (error) {
@@ -7511,6 +7514,7 @@ async function scanDockerProjects(rootDir, manifest, cache, projectScanTimeout,
7511
7514
  try {
7512
7515
  const scan = await scanDocker(dir, cache, manifest, offline);
7513
7516
  if (scan) {
7517
+ scan.path = path14.relative(rootDir, dir) || ".";
7514
7518
  results.push(scan);
7515
7519
  }
7516
7520
  } catch (error) {
@@ -7747,6 +7751,7 @@ async function scanHelmProjects(rootDir, manifest, cache, projectScanTimeout, of
7747
7751
  try {
7748
7752
  const scan = await scanHelm(dir, cache, manifest, offline);
7749
7753
  if (scan) {
7754
+ scan.path = path15.relative(rootDir, dir) || ".";
7750
7755
  results.push(scan);
7751
7756
  }
7752
7757
  } catch (error) {
@@ -8123,6 +8128,7 @@ async function scanTerraformProjects(rootDir, manifest, cache, projectScanTimeou
8123
8128
  try {
8124
8129
  const scan = await scanTerraform(dir, cache, manifest, offline);
8125
8130
  if (scan) {
8131
+ scan.path = path16.relative(rootDir, dir) || ".";
8126
8132
  results.push(scan);
8127
8133
  }
8128
8134
  } catch (error) {
@@ -8184,6 +8190,9 @@ function parseLineDependencies(content, regex, capture = 1) {
8184
8190
  function getProjectName(projectPath, rootDir) {
8185
8191
  return path17.basename(projectPath) || path17.basename(rootDir);
8186
8192
  }
8193
+ function makefileHasCSignals(content) {
8194
+ return /^\s*(CC|CFLAGS|LDFLAGS)\s*[:?+]?=/m.test(content) || /\b(gcc|clang)\b/.test(content) || /\.(c|o)\b/.test(content);
8195
+ }
8187
8196
  function addProject(projects, seen, type, projectPath, rootDir, dependencies = []) {
8188
8197
  const normalizedPath = projectPath || ".";
8189
8198
  const key = `${type}:${normalizedPath}`;
@@ -8254,6 +8263,10 @@ async function scanPolyglotProjects(rootDir, cache) {
8254
8263
  for (const file of candidateFiles) {
8255
8264
  const mapping = MANIFEST_TO_LANGUAGE.find((m) => m.name === file.name || m.name.startsWith("*.") && file.name.endsWith(m.name.slice(1)));
8256
8265
  if (!mapping) continue;
8266
+ if (file.name === "Makefile") {
8267
+ const text = cache ? await cache.readTextFile(file.absPath) : await readTextFile(file.absPath);
8268
+ if (!makefileHasCSignals(text)) continue;
8269
+ }
8257
8270
  const projectPath = path17.dirname(file.relPath) || ".";
8258
8271
  const dependencies = await parseDepsByManifest(file.absPath, cache);
8259
8272
  addProject(projects, seen, mapping.type, projectPath, rootDir, dependencies);
@@ -13406,6 +13419,54 @@ async function computeTreeMetadataHash(rootDir, options) {
13406
13419
  }
13407
13420
  return digest.digest("hex");
13408
13421
  }
13422
+ var ProgressTrace = class _ProgressTrace {
13423
+ constructor(outPath) {
13424
+ this.outPath = outPath;
13425
+ }
13426
+ events = [];
13427
+ start = Date.now();
13428
+ lastThrottled = /* @__PURE__ */ new Map();
13429
+ flushed = false;
13430
+ /** Returns a trace when VIBGRATE_TRACE_EVENTS is set, else null. */
13431
+ static fromEnv() {
13432
+ const path34 = process.env.VIBGRATE_TRACE_EVENTS;
13433
+ return path34 ? new _ProgressTrace(path34) : null;
13434
+ }
13435
+ record(op, data = {}) {
13436
+ if (this.flushed) return;
13437
+ this.events.push({ t: Date.now() - this.start, op, ...data });
13438
+ }
13439
+ /**
13440
+ * Record at most one event per key per interval. Used for high-frequency
13441
+ * updates (sub-step progress, live stats) so traces stay compact.
13442
+ */
13443
+ recordThrottled(key, op, data = {}, intervalMs = 120) {
13444
+ if (this.flushed) return;
13445
+ const now = Date.now();
13446
+ const last = this.lastThrottled.get(key) ?? 0;
13447
+ if (now - last < intervalMs) return;
13448
+ this.lastThrottled.set(key, now);
13449
+ this.record(op, data);
13450
+ }
13451
+ /** Write the trace document. Safe to call once; later calls are no-ops. */
13452
+ flush(meta) {
13453
+ if (this.flushed) return;
13454
+ this.flushed = true;
13455
+ const doc = {
13456
+ traceVersion: 1,
13457
+ cliVersion: meta.cliVersion,
13458
+ workspace: basename192(meta.rootDir) || meta.rootDir,
13459
+ recordedAt: (/* @__PURE__ */ new Date()).toISOString(),
13460
+ durationMs: Date.now() - this.start,
13461
+ events: this.events
13462
+ };
13463
+ try {
13464
+ mkdirSync(dirname182(this.outPath), { recursive: true });
13465
+ writeFileSync(this.outPath, JSON.stringify(doc));
13466
+ } catch {
13467
+ }
13468
+ }
13469
+ };
13409
13470
  var ROBOT = [
13410
13471
  chalk2.cyan(" \u256D\u2500\u2500\u2500\u256E") + chalk2.greenBright("\u279C"),
13411
13472
  chalk2.cyan(" \u256D\u2524") + chalk2.greenBright("\u25C9 \u25C9") + chalk2.cyan("\u251C\u256E"),
@@ -13449,8 +13510,11 @@ var ScanProgress = class {
13449
13510
  /** Last emitted step snapshot for append-only output modes */
13450
13511
  lastLoggedStates = /* @__PURE__ */ new Map();
13451
13512
  version;
13513
+ /** Optional semantic event recorder (VIBGRATE_TRACE_EVENTS) for the web simulator */
13514
+ trace;
13452
13515
  constructor(rootDir, version = "unknown") {
13453
13516
  this.version = version;
13517
+ this.trace = ProgressTrace.fromEnv();
13454
13518
  this.isTTY = process.stderr.isTTY ?? false;
13455
13519
  this.useLiveUpdates = this.isTTY && process.env.VIBGRATE_PROGRESS_MODE !== "plain";
13456
13520
  this.rootDir = rootDir;
@@ -13475,6 +13539,7 @@ var ScanProgress = class {
13475
13539
  /** Set the estimated total duration from scan history */
13476
13540
  setEstimatedTotal(estimatedMs) {
13477
13541
  this.estimatedTotalMs = estimatedMs;
13542
+ this.trace?.record("setEstimatedTotal", { estimatedMs });
13478
13543
  }
13479
13544
  /** Set per-step estimated durations from scan history */
13480
13545
  setStepEstimates(estimates) {
@@ -13487,6 +13552,7 @@ var ScanProgress = class {
13487
13552
  /** Register all steps up front, optionally with weights */
13488
13553
  setSteps(steps) {
13489
13554
  this.steps = steps.map((s) => ({ ...s, status: "pending", weight: s.weight ?? 1 }));
13555
+ this.trace?.record("setSteps", { steps: steps.map((s) => ({ id: s.id, label: s.label, weight: s.weight ?? 1 })) });
13490
13556
  if (this.isTTY) {
13491
13557
  const header = [
13492
13558
  "",
@@ -13512,6 +13578,7 @@ var ScanProgress = class {
13512
13578
  } else {
13513
13579
  this.steps.push(newStep);
13514
13580
  }
13581
+ this.trace?.record("insertStepBefore", { beforeId, step: { id: step.id, label: step.label, weight: step.weight ?? 1 } });
13515
13582
  }
13516
13583
  /** Mark a step as active (currently running), optionally with expected total */
13517
13584
  startStep(id, subTotal) {
@@ -13524,6 +13591,7 @@ var ScanProgress = class {
13524
13591
  step.subTotal = subTotal;
13525
13592
  }
13526
13593
  this.stepStartTimes.set(id, Date.now());
13594
+ this.trace?.record("startStep", { id, subTotal });
13527
13595
  this.render();
13528
13596
  }
13529
13597
  /** Mark a step as completed */
@@ -13538,6 +13606,7 @@ var ScanProgress = class {
13538
13606
  if (started) {
13539
13607
  this.stepTimings.push({ id, durationMs: Date.now() - started });
13540
13608
  }
13609
+ this.trace?.record("completeStep", { id, detail, count });
13541
13610
  this.render();
13542
13611
  }
13543
13612
  /** Mark a step as skipped */
@@ -13547,6 +13616,7 @@ var ScanProgress = class {
13547
13616
  step.status = "skipped";
13548
13617
  step.detail = "disabled";
13549
13618
  }
13619
+ this.trace?.record("skipStep", { id });
13550
13620
  this.render();
13551
13621
  }
13552
13622
  /** Update sub-step progress for the active step (files processed, etc.) */
@@ -13557,32 +13627,51 @@ var ScanProgress = class {
13557
13627
  if (total !== void 0) step.subTotal = total;
13558
13628
  if (label !== void 0) step.subLabel = label;
13559
13629
  }
13630
+ this.trace?.recordThrottled(`sub:${id}`, "updateStepProgress", { id, current, total, label });
13560
13631
  this.render();
13561
13632
  }
13562
13633
  /** Update live stats */
13563
13634
  updateStats(partial) {
13564
13635
  Object.assign(this.stats, partial);
13636
+ this.traceStats(true);
13565
13637
  this.render();
13566
13638
  }
13567
13639
  /** Increment stats */
13568
13640
  addProjects(n) {
13569
13641
  this.stats.projects += n;
13642
+ this.traceStats();
13570
13643
  this.render();
13571
13644
  }
13572
13645
  addDependencies(n) {
13573
13646
  this.stats.dependencies += n;
13647
+ this.traceStats();
13574
13648
  this.render();
13575
13649
  }
13576
13650
  addFrameworks(n) {
13577
13651
  this.stats.frameworks += n;
13652
+ this.traceStats();
13578
13653
  this.render();
13579
13654
  }
13580
13655
  addFindings(warnings, errors, notes) {
13581
13656
  this.stats.findings.warnings += warnings;
13582
13657
  this.stats.findings.errors += errors;
13583
13658
  this.stats.findings.notes += notes;
13659
+ this.traceStats();
13584
13660
  this.render();
13585
13661
  }
13662
+ /** Record a (throttled) snapshot of live stats into the trace */
13663
+ traceStats(force = false) {
13664
+ if (!this.trace) return;
13665
+ const snapshot = {
13666
+ stats: {
13667
+ ...this.stats,
13668
+ findings: { ...this.stats.findings },
13669
+ treeSummary: this.stats.treeSummary ? { ...this.stats.treeSummary } : void 0
13670
+ }
13671
+ };
13672
+ if (force) this.trace.record("stats", snapshot);
13673
+ else this.trace.recordThrottled("stats", "stats", snapshot);
13674
+ }
13586
13675
  /** Stop the progress display and clear it */
13587
13676
  finish() {
13588
13677
  if (this.timer) {
@@ -13607,6 +13696,8 @@ var ScanProgress = class {
13607
13696
 
13608
13697
  `)
13609
13698
  );
13699
+ this.trace?.record("finish", { doneCount, elapsedMs: Date.now() - this.startTime, summary: `${doneCount} scanners completed in ${elapsed}` });
13700
+ this.trace?.flush({ cliVersion: this.version, rootDir: this.rootDir });
13610
13701
  if (this.isTTY && process.platform === "win32") {
13611
13702
  process.stdout.write("\x1B[0G\x1B[K");
13612
13703
  }
@@ -13980,7 +14071,7 @@ async function runScan(rootDir, opts) {
13980
14071
  const composerCache = new ComposerCache(sem, packageManifest, offlineMode);
13981
14072
  const pubCache = new PubCache(sem, packageManifest, offlineMode);
13982
14073
  const fileCache = new FileCache();
13983
- const excludePatterns = config.exclude ?? [];
14074
+ const excludePatterns = [.../* @__PURE__ */ new Set([...config.exclude ?? [], ...opts.exclude ?? []])];
13984
14075
  fileCache.setExcludePatterns(excludePatterns);
13985
14076
  const projectScanTimeoutMs = (opts.projectScanTimeout ?? config.projectScanTimeout ?? 180) * 1e3;
13986
14077
  fileCache.setMaxFileSize(config.maxFileSizeToScan ?? 5242880);
@@ -14260,14 +14351,17 @@ async function runScan(rootDir, opts) {
14260
14351
  progress.addProjects(polyglotProjects.length);
14261
14352
  progress.completeStep("polyglot", `${polyglotProjects.length} project${polyglotProjects.length !== 1 ? "s" : ""}`, polyglotProjects.length);
14262
14353
  }
14354
+ const OVERLAY_PROJECT_TYPES = /* @__PURE__ */ new Set(["docker", "helm", "terraform"]);
14355
+ const dedupeKey = (p) => OVERLAY_PROJECT_TYPES.has(p.type) ? `${p.type}:${p.path}` : p.path;
14263
14356
  const rawProjects = [...nodeProjects, ...dotnetProjects, ...pythonProjects, ...javaProjects, ...rubyProjects, ...swiftProjects, ...goProjects, ...rustProjects, ...phpProjects, ...dartProjects, ...elixirProjects, ...dockerProjects, ...helmProjects, ...terraformProjects, ...polyglotProjects];
14264
14357
  const deduplicatedMap = /* @__PURE__ */ new Map();
14265
14358
  for (const project of rawProjects) {
14266
- const existing = deduplicatedMap.get(project.path);
14359
+ const existing = deduplicatedMap.get(dedupeKey(project));
14267
14360
  if (!existing) {
14268
- deduplicatedMap.set(project.path, project);
14361
+ deduplicatedMap.set(dedupeKey(project), project);
14269
14362
  } else {
14270
- const keepNew = project.dependencies.length > existing.dependencies.length;
14363
+ const resolvedCount = (p) => p.dependencies.filter((d) => d.resolvedVersion || d.latestStable).length;
14364
+ const keepNew = resolvedCount(project) > resolvedCount(existing) || resolvedCount(project) === resolvedCount(existing) && project.dependencies.length > existing.dependencies.length;
14271
14365
  const winner = keepNew ? project : existing;
14272
14366
  const loser = keepNew ? existing : project;
14273
14367
  if (loser.projectReferences?.length) {
@@ -14280,7 +14374,7 @@ async function runScan(rootDir, opts) {
14280
14374
  }
14281
14375
  }
14282
14376
  }
14283
- deduplicatedMap.set(project.path, winner);
14377
+ deduplicatedMap.set(dedupeKey(project), winner);
14284
14378
  }
14285
14379
  }
14286
14380
  const allProjects = [...deduplicatedMap.values()];
package/dist/cli.js CHANGED
@@ -6,7 +6,7 @@ import {
6
6
  pathExists,
7
7
  readJsonFile,
8
8
  writeTextFile
9
- } from "./chunk-HQCB2BTS.js";
9
+ } from "./chunk-4CDBCG4I.js";
10
10
  import {
11
11
  computeRepoFingerprint,
12
12
  detectVcs,
@@ -16,13 +16,14 @@ import {
16
16
  resolveRepositoryName,
17
17
  runScan,
18
18
  writeDefaultConfig
19
- } from "./chunk-X2MPRPZ6.js";
19
+ } from "./chunk-EIZZ6VC3.js";
20
20
  import {
21
21
  require_semver
22
22
  } from "./chunk-74ZJFYEM.js";
23
23
  import {
24
+ parseExcludePatterns,
24
25
  pathExists as pathExists2
25
- } from "./chunk-RAQ76CZO.js";
26
+ } from "./chunk-C7LU6YIL.js";
26
27
  import {
27
28
  __toESM
28
29
  } from "./chunk-JSBRDJBE.js";
@@ -48,7 +49,7 @@ var initCommand = new Command("init").description("Initialize vibgrate in a proj
48
49
  console.log(chalk.green("\u2714") + ` Created ${chalk.bold("vibgrate.config.ts")}`);
49
50
  }
50
51
  if (opts.baseline) {
51
- const { runBaseline } = await import("./baseline-Q3S7D7RQ.js");
52
+ const { runBaseline } = await import("./baseline-QRGGTCJD.js");
52
53
  await runBaseline(rootDir);
53
54
  }
54
55
  console.log("");
@@ -302,6 +303,9 @@ async function autoPush(artifact, rootDir, opts) {
302
303
  if (opts.strict) process.exit(1);
303
304
  }
304
305
  }
306
+ function collectExcludes(value, previous) {
307
+ return [...previous, ...parseExcludePatterns(value)];
308
+ }
305
309
  function parseNonNegativeNumber(value, label) {
306
310
  if (value === void 0) return void 0;
307
311
  const parsed = Number(value);
@@ -310,7 +314,12 @@ function parseNonNegativeNumber(value, label) {
310
314
  }
311
315
  return parsed;
312
316
  }
313
- 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|md)", "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").option("--ui-purpose", "Enable optional UI purpose evidence extraction (slower)").option("--no-local-artifacts", "Do not write .vibgrate JSON artifacts to disk").option("--max-privacy", "Enable strongest privacy mode (minimal scanners, no local artifacts)").option("--offline", "Run without network calls; do not upload results").option("--package-manifest <file>", "Use local package-version manifest JSON/ZIP (for offline mode)").option("--project-scan-timeout <seconds>", "Per-project scan timeout in seconds (default: 180)").option("--drift-budget <score>", "Fail if drift score is above budget (0-100)").option("--drift-worsening <percent>", "Fail if drift worsens by more than % since baseline").action(async (targetPath, opts) => {
317
+ 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|md)", "text").option("--fail-on <level>", "Fail on warn or error").option("--baseline <file>", "Compare against baseline").option("--changed-only", "Only scan changed files").option(
318
+ "-e, --exclude <glob>",
319
+ 'Exclude paths matching a glob pattern. Repeatable, and a single value may list several patterns separated by commas or semicolons (e.g. --exclude "legacy/**,vendor/**"). Merged with excludes from the config file.',
320
+ collectExcludes,
321
+ []
322
+ ).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").option("--ui-purpose", "Enable optional UI purpose evidence extraction (slower)").option("--no-local-artifacts", "Do not write .vibgrate JSON artifacts to disk").option("--max-privacy", "Enable strongest privacy mode (minimal scanners, no local artifacts)").option("--offline", "Run without network calls; do not upload results").option("--package-manifest <file>", "Use local package-version manifest JSON/ZIP (for offline mode)").option("--project-scan-timeout <seconds>", "Per-project scan timeout in seconds (default: 180)").option("--drift-budget <score>", "Fail if drift score is above budget (0-100)").option("--drift-worsening <percent>", "Fail if drift worsens by more than % since baseline").action(async (targetPath, opts) => {
314
323
  const rootDir = path3.resolve(targetPath);
315
324
  if (!await pathExists2(rootDir)) {
316
325
  console.error(chalk3.red(`Path does not exist: ${rootDir}`));
@@ -374,6 +383,7 @@ var scanCommand = new Command3("scan").description("Scan a project for upgrade d
374
383
  failOn: opts.failOn,
375
384
  baseline: opts.baseline,
376
385
  changedOnly: opts.changedOnly,
386
+ exclude: opts.exclude,
377
387
  concurrency: parseInt(opts.concurrency, 10) || 8,
378
388
  push: opts.push,
379
389
  dsn: opts.dsn,