@rainy-updates/cli 0.5.2 → 0.5.3

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/CHANGELOG.md CHANGED
@@ -2,6 +2,61 @@
2
2
 
3
3
  All notable changes to this project are documented in this file.
4
4
 
5
+ ## [0.5.3] - 2026-03-01
6
+
7
+ GA stabilization and review-centered workflow refinement.
8
+
9
+ ### Added
10
+
11
+ - **Dedicated risk engine layer** under `src/risk/`:
12
+ - formal risk scoring with `riskScore`, `riskLevel`, `riskReasons`, `riskCategories`, and `recommendedAction`,
13
+ - deterministic scoring for:
14
+ - known vulnerabilities,
15
+ - install lifecycle scripts,
16
+ - typosquatting heuristic,
17
+ - newly published packages,
18
+ - suspicious metadata,
19
+ - mutable git/http dependencies,
20
+ - maintainer stability heuristic,
21
+ - peer conflicts,
22
+ - license violations,
23
+ - stale/deprecated health signals,
24
+ - major version jumps.
25
+ - **Benchmark tooling and methodology**:
26
+ - `scripts/generate-benchmark-fixtures.mjs`,
27
+ - `scripts/benchmark.mjs`,
28
+ - generated fixtures under `benchmarks/fixtures/`,
29
+ - benchmark methodology doc: `docs/benchmarks.md`.
30
+ - **New workflow docs**:
31
+ - `docs/command-model.md`
32
+ - `docs/review-workflow.md`
33
+ - `docs/risk-engine.md`
34
+
35
+ ### Changed
36
+
37
+ - `review` is now the explicit product center:
38
+ - `check` detects,
39
+ - `doctor` summarizes,
40
+ - `review` decides,
41
+ - `upgrade` applies.
42
+ - CLI help, README, and docs now reflect the review-centered workflow instead of a flat command surface.
43
+ - Review outputs now carry formal risk engine results instead of ad hoc composite signals.
44
+ - TUI language has been upgraded to decision-oriented semantics:
45
+ - review queue,
46
+ - decision panel,
47
+ - explicit state labels,
48
+ - recommended action per candidate.
49
+ - GitHub annotations, SARIF, and human-readable output now expose formal risk score/action metadata additively.
50
+
51
+ ### Benchmarks
52
+
53
+ - Added package scripts for reproducible benchmark runs:
54
+ - `bench:fixtures`
55
+ - `bench:check`
56
+ - `bench:review`
57
+ - `bench:resolve`
58
+ - `bench:ci`
59
+
5
60
  ## [0.5.2] - 2026-03-01
6
61
 
7
62
  ### Added
package/README.md CHANGED
@@ -1,19 +1,65 @@
1
1
  # @rainy-updates/cli
2
2
 
3
- The fastest DevOps-first dependency CLI. Checks, audits, upgrades, bisects, and automates npm/pnpm dependencies in CI.
3
+ Rainy Updates is a deterministic dependency review and upgrade operator for Node monorepos and CI.
4
4
 
5
- `@rainy-updates/cli` is built for teams that need fast dependency intelligence, security auditing, policy-aware upgrades, and automation-ready output for CI/CD and pull request workflows.
5
+ `@rainy-updates/cli` is built for teams that need fast dependency detection, trustworthy review, controlled upgrades, and automation-ready outputs for CI/CD.
6
6
 
7
7
  Comparison:
8
8
  [Why Rainy vs Dependabot and Renovate](./docs/why-rainy-vs-dependabot-renovate.md)
9
9
 
10
- ## Why this package
10
+ Command model:
11
+ [Check → Doctor → Review → Upgrade](./docs/command-model.md)
12
+
13
+ Review workflow:
14
+ [Review workflow guide](./docs/review-workflow.md)
15
+
16
+ Risk engine:
17
+ [Risk engine guide](./docs/risk-engine.md)
18
+
19
+ Benchmarks:
20
+ [Benchmark methodology](./docs/benchmarks.md)
21
+
22
+ ## What it is
23
+
24
+ Rainy Updates gives teams one dependency lifecycle:
25
+
26
+ - `check` detects candidate updates.
27
+ - `doctor` summarizes the current situation.
28
+ - `review` decides what should happen.
29
+ - `upgrade` applies the approved change set.
30
+
31
+ Everything else supports that lifecycle: CI orchestration, advisory lookup, peer resolution, licenses, snapshots, baselines, and fix-PR automation.
32
+
33
+ ## Who it is for
34
+
35
+ - Node monorepo teams that want deterministic CI artifacts.
36
+ - Engineers who want to review dependency risk locally before applying changes.
37
+ - Teams that need fewer, better upgrade decisions instead of noisy automated PR churn.
38
+
39
+ ## 60-second workflow
40
+
41
+ ```bash
42
+ # 1) Detect what changed
43
+ npx @rainy-updates/cli check --workspace --show-impact
44
+
45
+ # 2) Summarize what matters
46
+ npx @rainy-updates/cli doctor --workspace
47
+
48
+ # 3) Decide in the review surface
49
+ npx @rainy-updates/cli review --interactive
50
+
51
+ # 4) Apply the approved set
52
+ npx @rainy-updates/cli upgrade --interactive
53
+ ```
54
+
55
+ ## Why teams use it
11
56
 
12
57
  - Detects updates quickly across single-package repos and workspaces.
58
+ - Centralizes security, peer, license, health, and behavioral risk review.
13
59
  - Applies updates safely with configurable targets (`patch`, `minor`, `major`, `latest`).
14
- - Enforces policy rules per package (ignore rules and max upgrade level).
60
+ - Enforces policy rules per package.
15
61
  - Supports offline and cache-warmed execution for deterministic CI runs.
16
- - Produces machine-readable artifacts (JSON, SARIF, GitHub outputs, PR markdown report).
62
+ - Produces machine-readable artifacts: JSON, SARIF, GitHub outputs, and PR reports.
17
63
 
18
64
  ## Install
19
65
 
@@ -52,15 +98,18 @@ npx @rainy-updates/cli ci --workspace --mode strict
52
98
 
53
99
  ## Commands
54
100
 
55
- ### Dependency management
101
+ ### Primary workflow
102
+
103
+ - `check` — detect candidate dependency updates
104
+ - `doctor` — summarize the current dependency situation
105
+ - `review` — decide what to do with security, risk, peer, and policy context
106
+ - `upgrade` — apply the approved change set
107
+
108
+ ### Supporting workflow
56
109
 
57
- - `check` — analyze dependencies and report available updates
58
- - `upgrade` — rewrite dependency ranges in manifests, optionally install lockfile updates
59
110
  - `ci` — run CI-focused dependency automation (warm cache, check/upgrade, policy gates)
60
111
  - `warm-cache` — prefetch package metadata for fast and offline checks
61
112
  - `baseline` — save and compare dependency baseline snapshots
62
- - `review` — guided review across updates, security, peer conflicts, licenses, and risk
63
- - `doctor` — fast verdict command for local triage and CI summaries
64
113
 
65
114
  ### Security & health (_new in v0.5.1_)
66
115
 
@@ -77,31 +126,35 @@ npx @rainy-updates/cli ci --workspace --mode strict
77
126
  npx @rainy-updates/cli check --format table
78
127
  rup check --format table # if installed
79
128
 
80
- # 2) Strict CI mode (non-zero when updates exist)
81
- npx @rainy-updates/cli check --workspace --ci --format json --json-file .artifacts/updates.json
82
- rup check --workspace --ci --format json --json-file .artifacts/updates.json
129
+ # 2) Summarize the state
130
+ npx @rainy-updates/cli doctor --workspace
131
+ rup doctor --workspace
132
+
133
+ # 3) Review and decide
134
+ npx @rainy-updates/cli review --security-only
135
+ rup review --interactive
136
+
137
+ # 4) Apply upgrades with workspace sync
138
+ npx @rainy-updates/cli upgrade --target latest --workspace --sync --install
139
+ rup upgrade --target latest --workspace --sync --install
83
140
 
84
- # 3) CI orchestration with policy gates
141
+ # 5) CI orchestration with policy gates
85
142
  npx @rainy-updates/cli ci --workspace --mode strict --format github
86
143
  rup ci --workspace --mode strict --format github
87
144
 
88
- # 4) Batch fix branches by scope (enterprise)
145
+ # 6) Batch fix branches by scope (enterprise)
89
146
  npx @rainy-updates/cli ci --workspace --mode enterprise --group-by scope --fix-pr --fix-pr-batch-size 2
90
147
  rup ci --workspace --mode enterprise --group-by scope --fix-pr --fix-pr-batch-size 2
91
148
 
92
- # 5) Apply upgrades with workspace sync
93
- npx @rainy-updates/cli upgrade --target latest --workspace --sync --install
94
- rup upgrade --target latest --workspace --sync --install
95
-
96
- # 6) Warm cache → deterministic offline CI check
149
+ # 7) Warm cache deterministic offline CI check
97
150
  npx @rainy-updates/cli warm-cache --workspace --concurrency 32
98
151
  npx @rainy-updates/cli check --workspace --offline --ci
99
152
 
100
- # 7) Save and compare baseline drift
153
+ # 8) Save and compare baseline drift
101
154
  npx @rainy-updates/cli baseline --save --file .artifacts/deps-baseline.json --workspace
102
155
  npx @rainy-updates/cli baseline --check --file .artifacts/deps-baseline.json --workspace --ci
103
156
 
104
- # 8) Scan for known CVEs ── NEW in v0.5.1
157
+ # 9) Scan for known CVEs
105
158
  npx @rainy-updates/cli audit
106
159
  npx @rainy-updates/cli audit --severity high
107
160
  npx @rainy-updates/cli audit --summary
@@ -111,26 +164,20 @@ rup audit --severity high # if installed
111
164
 
112
165
  `audit` prefers npm/pnpm lockfiles today for exact installed-version inference, and now also reads simple `bun.lock` workspace entries when available. It reports source-health warnings when OSV or GitHub returns only partial coverage.
113
166
 
114
- # 9) Check dependency maintenance health ── NEW in v0.5.1
167
+ # 10) Check dependency maintenance health
115
168
  npx @rainy-updates/cli health
116
169
  npx @rainy-updates/cli health --stale 6m # flag packages with no release in 6 months
117
170
  npx @rainy-updates/cli health --stale 180d # same but in days
118
171
  rup health --stale 6m # if installed
119
172
 
120
- # 10) Find which version introduced a breaking change ── NEW in v0.5.1
173
+ # 11) Find which version introduced a breaking change
121
174
  npx @rainy-updates/cli bisect axios --cmd "bun test"
122
175
  npx @rainy-updates/cli bisect react --range "18.0.0..19.0.0" --cmd "npm test"
123
176
  npx @rainy-updates/cli bisect lodash --cmd "npm run test:unit" --dry-run
124
177
  rup bisect axios --cmd "bun test" # if installed
125
178
 
126
- # 11) Review updates with risk and security context ── NEW in v0.5.2 GA
127
- npx @rainy-updates/cli review --security-only
128
- rup review --interactive
179
+ # 12) Focus review on high-risk changes
129
180
  rup review --risk high --diff major
130
-
131
- # 12) Get a fast dependency verdict for CI or local triage ── NEW in v0.5.2 GA
132
- npx @rainy-updates/cli doctor
133
- rup doctor --verdict-only
134
181
  ```
135
182
 
136
183
  ## What it does in production
@@ -185,6 +232,13 @@ npx @rainy-updates/cli check --policy-file .rainyupdates-policy.json
185
232
  - `--format table`
186
233
  - `--format minimal`
187
234
 
235
+ Review-centered outputs:
236
+
237
+ - `check` is optimized for detection.
238
+ - `doctor` is optimized for summary.
239
+ - `review` is optimized for decision-making.
240
+ - `upgrade` is optimized for safe application.
241
+
188
242
  ### Automation output
189
243
 
190
244
  - `--format json`
package/dist/bin/cli.js CHANGED
@@ -85,7 +85,7 @@ async function main() {
85
85
  process.exitCode = result.totalFlagged > 0 ? 1 : 0;
86
86
  return;
87
87
  }
88
- // ─── v0.5.2 commands ─────────────────────────────────────────────────────
88
+ // ─── v0.5.3 commands ─────────────────────────────────────────────────────
89
89
  if (parsed.command === "unused") {
90
90
  const { runUnused } = await import("../commands/unused/runner.js");
91
91
  const result = await runUnused(parsed.options);
@@ -226,7 +226,11 @@ function renderHelp(command) {
226
226
  if (isCommand && command === "check") {
227
227
  return `rainy-updates check [options]
228
228
 
229
- Detect available dependency updates.
229
+ Detect candidate dependency updates. This is the first step in the flow:
230
+ check detects
231
+ doctor summarizes
232
+ review decides
233
+ upgrade applies
230
234
 
231
235
  Options:
232
236
  --workspace
@@ -291,7 +295,7 @@ Options:
291
295
  if (isCommand && command === "upgrade") {
292
296
  return `rainy-updates upgrade [options]
293
297
 
294
- Apply dependency updates to package.json manifests.
298
+ Apply an approved change set to package.json manifests.
295
299
 
296
300
  Options:
297
301
  --workspace
@@ -318,7 +322,11 @@ Options:
318
322
  if (isCommand && command === "ci") {
319
323
  return `rainy-updates ci [options]
320
324
 
321
- Run CI-oriented dependency automation pipeline.
325
+ Run CI-oriented automation around the same lifecycle:
326
+ check detects
327
+ doctor summarizes
328
+ review decides
329
+ upgrade applies
322
330
 
323
331
  Options:
324
332
  --workspace
@@ -396,7 +404,8 @@ Options:
396
404
  if (isCommand && command === "review") {
397
405
  return `rainy-updates review [options]
398
406
 
399
- Review updates with risk, security, peer, and policy context.
407
+ Review is the decision center of Rainy Updates.
408
+ Use it to inspect risk, security, peer, license, and policy context before applying changes.
400
409
 
401
410
  Options:
402
411
  --workspace
@@ -414,7 +423,7 @@ Options:
414
423
  if (isCommand && command === "doctor") {
415
424
  return `rainy-updates doctor [options]
416
425
 
417
- Produce a fast overall dependency verdict.
426
+ Produce a fast summary verdict and point the operator to review when action is needed.
418
427
 
419
428
  Options:
420
429
  --workspace
@@ -424,9 +433,11 @@ Options:
424
433
  return `rainy-updates (rup / rainy-up) <command> [options]
425
434
 
426
435
  Commands:
427
- check Detect available updates
428
- upgrade Apply updates to manifests
429
- ci Run CI-focused update pipeline
436
+ check Detect candidate updates
437
+ doctor Summarize what matters
438
+ review Decide what to do
439
+ upgrade Apply the approved change set
440
+ ci Run CI-focused orchestration
430
441
  warm-cache Warm local cache for fast/offline checks
431
442
  init-ci Scaffold GitHub Actions workflow
432
443
  baseline Save/check dependency baseline snapshots
@@ -437,8 +448,6 @@ Commands:
437
448
  resolve Check peer dependency conflicts (pure-TS, no subprocess)
438
449
  licenses Scan dependency licenses and generate SPDX SBOM
439
450
  snapshot Save, list, restore, and diff dependency state snapshots
440
- review Guided dependency review with risk/security context
441
- doctor Fast dependency verdict for local or CI use
442
451
 
443
452
  Global options:
444
453
  --cwd <path>
@@ -85,7 +85,7 @@ Usage:
85
85
  rup doctor [options]
86
86
 
87
87
  Options:
88
- --verdict-only Print one-line verdict for CI summaries
88
+ --verdict-only Print the 3-line quick verdict without counts
89
89
  --workspace Scan all workspace packages
90
90
  --json-file <path> Write JSON doctor report to file
91
91
  --cwd <path>
@@ -93,7 +93,8 @@ export async function check(options) {
93
93
  latestVersion: cached.latestVersion,
94
94
  availableVersions: cached.availableVersions,
95
95
  publishedAtByVersion: {},
96
- hasInstallScript: false,
96
+ installScriptByVersion: {},
97
+ maintainerCount: null,
97
98
  });
98
99
  }
99
100
  else {
@@ -111,7 +112,8 @@ export async function check(options) {
111
112
  latestVersion: stale.latestVersion,
112
113
  availableVersions: stale.availableVersions,
113
114
  publishedAtByVersion: {},
114
- hasInstallScript: false,
115
+ installScriptByVersion: {},
116
+ maintainerCount: null,
115
117
  });
116
118
  warnings.push(`Using stale cache for ${packageName} because --offline is enabled.`);
117
119
  }
@@ -143,7 +145,8 @@ export async function check(options) {
143
145
  publishedAtByVersion: metadata.publishedAtByVersion,
144
146
  homepage: metadata.homepage,
145
147
  repository: metadata.repository,
146
- hasInstallScript: metadata.hasInstallScript,
148
+ installScriptByVersion: metadata.installScriptByVersion,
149
+ maintainerCount: metadata.maintainerCount,
147
150
  });
148
151
  if (metadata.latestVersion) {
149
152
  await cache.set(packageName, options.target, metadata.latestVersion, metadata.versions, options.cacheTtlSeconds);
@@ -158,7 +161,8 @@ export async function check(options) {
158
161
  latestVersion: stale.latestVersion,
159
162
  availableVersions: stale.availableVersions,
160
163
  publishedAtByVersion: {},
161
- hasInstallScript: false,
164
+ installScriptByVersion: {},
165
+ maintainerCount: null,
162
166
  });
163
167
  warnings.push(`Using stale cache for ${packageName} due to registry error: ${error}`);
164
168
  }
@@ -214,6 +218,17 @@ export async function check(options) {
214
218
  autofix: rule?.autofix !== false,
215
219
  reason: rule?.maxTarget ? `policy maxTarget=${rule.maxTarget}` : undefined,
216
220
  homepage: metadata.homepage,
221
+ repository: metadata.repository,
222
+ publishedAt: metadata.publishedAtByVersion[picked]
223
+ ? new Date(metadata.publishedAtByVersion[picked]).toISOString()
224
+ : undefined,
225
+ publishAgeDays: metadata.publishedAtByVersion[picked]
226
+ ? Math.max(0, Math.floor((Date.now() - metadata.publishedAtByVersion[picked]) /
227
+ (1000 * 60 * 60 * 24)))
228
+ : null,
229
+ hasInstallScript: metadata.installScriptByVersion[picked] ?? false,
230
+ maintainerCount: metadata.maintainerCount,
231
+ maintainerChurn: deriveMaintainerChurn(metadata.maintainerCount, metadata.publishedAtByVersion[picked]),
217
232
  });
218
233
  emitStream(`[update] ${task.dependency.name} ${task.dependency.range} -> ${nextRange} (${classifyDiff(task.dependency.range, picked)})`);
219
234
  }
@@ -265,6 +280,16 @@ export async function check(options) {
265
280
  warnings: sortedWarnings,
266
281
  };
267
282
  }
283
+ function deriveMaintainerChurn(maintainerCount, publishedAt) {
284
+ if (maintainerCount === null || publishedAt === undefined) {
285
+ return "unknown";
286
+ }
287
+ const ageDays = Math.max(0, Math.floor((Date.now() - publishedAt) / (1000 * 60 * 60 * 24)));
288
+ if (maintainerCount <= 1 && ageDays <= 30) {
289
+ return "elevated-change";
290
+ }
291
+ return "stable";
292
+ }
268
293
  function groupUpdates(updates, groupBy) {
269
294
  if (updates.length === 0) {
270
295
  return [];
@@ -3,6 +3,7 @@ import process from "node:process";
3
3
  import { check } from "./check.js";
4
4
  import { createSummary, finalizeSummary } from "./summary.js";
5
5
  import { applyImpactScores } from "./impact.js";
6
+ import { applyRiskAssessments } from "../risk/index.js";
6
7
  export async function buildReviewResult(options) {
7
8
  const baseCheckOptions = {
8
9
  ...options,
@@ -47,9 +48,12 @@ export async function buildReviewResult(options) {
47
48
  list.push(issue);
48
49
  unusedByName.set(issue.name, list);
49
50
  }
50
- const items = impactedUpdates
51
+ const baseItems = impactedUpdates
51
52
  .map((update) => enrichUpdate(update, advisoriesByName, conflictsByName, healthByName, licenseByName, licenseViolationNames, unusedByName))
52
53
  .filter((item) => matchesReviewFilters(item, options));
54
+ const items = applyRiskAssessments(baseItems, {
55
+ knownPackageNames: new Set(checkResult.updates.map((item) => item.name)),
56
+ }).filter((item) => matchesReviewFilters(item, options));
53
57
  const summary = createReviewSummary(checkResult.summary, items, [
54
58
  ...checkResult.errors,
55
59
  ...auditResult.errors,
@@ -76,7 +80,7 @@ export async function buildReviewResult(options) {
76
80
  };
77
81
  }
78
82
  export function createDoctorResult(review) {
79
- const verdict = review.summary.verdict ?? deriveVerdict(review.items);
83
+ const verdict = review.summary.verdict ?? deriveVerdict(review.items, review.errors);
80
84
  const primaryFindings = buildPrimaryFindings(review);
81
85
  return {
82
86
  verdict,
@@ -101,6 +105,9 @@ export function renderReviewResult(review) {
101
105
  const notes = [
102
106
  item.update.diffType,
103
107
  item.update.riskLevel ? `risk=${item.update.riskLevel}` : undefined,
108
+ typeof item.update.riskScore === "number"
109
+ ? `score=${item.update.riskScore}`
110
+ : undefined,
104
111
  item.update.advisoryCount ? `security=${item.update.advisoryCount}` : undefined,
105
112
  item.update.peerConflictSeverity && item.update.peerConflictSeverity !== "none"
106
113
  ? `peer=${item.update.peerConflictSeverity}`
@@ -113,6 +120,9 @@ export function renderReviewResult(review) {
113
120
  if (item.update.riskReasons && item.update.riskReasons.length > 0) {
114
121
  lines.push(` reasons: ${item.update.riskReasons.join("; ")}`);
115
122
  }
123
+ if (item.update.recommendedAction) {
124
+ lines.push(` action: ${item.update.recommendedAction}`);
125
+ }
116
126
  if (item.update.homepage) {
117
127
  lines.push(` homepage: ${item.update.homepage}`);
118
128
  }
@@ -153,16 +163,6 @@ function enrichUpdate(update, advisoriesByName, conflictsByName, healthByName, l
153
163
  const health = healthByName.get(update.name);
154
164
  const license = licenseByName.get(update.name);
155
165
  const unusedIssues = unusedByName.get(update.name) ?? [];
156
- const riskReasons = [
157
- advisories.length > 0 ? `${advisories.length} advisory finding(s)` : undefined,
158
- peerConflicts.some((item) => item.severity === "error") ? "peer conflict requires review" : undefined,
159
- health?.flags.includes("deprecated") ? "package is deprecated" : undefined,
160
- health?.flags.includes("stale") ? "package is stale" : undefined,
161
- licenseViolationNames.has(update.name) ? "license policy violation" : undefined,
162
- unusedIssues.length > 0 ? `${unusedIssues.length} unused/missing dependency signal(s)` : undefined,
163
- update.diffType === "major" ? "major version jump" : undefined,
164
- ].filter((value) => Boolean(value));
165
- const riskLevel = deriveRiskLevel(update, advisories.length, peerConflicts.length, licenseViolationNames.has(update.name), health?.flags ?? []);
166
166
  return {
167
167
  update: {
168
168
  ...update,
@@ -178,8 +178,6 @@ function enrichUpdate(update, advisoriesByName, conflictsByName, healthByName, l
178
178
  ? "allowed"
179
179
  : "review",
180
180
  healthStatus: health?.flags[0] ?? "healthy",
181
- riskLevel,
182
- riskReasons,
183
181
  },
184
182
  advisories,
185
183
  health,
@@ -244,10 +242,10 @@ function createReviewSummary(base, items, errors, warnings, interactiveSession)
244
242
  summary.riskPackages = items.filter((item) => item.update.riskLevel === "critical" || item.update.riskLevel === "high").length;
245
243
  summary.peerConflictPackages = items.filter((item) => item.update.peerConflictSeverity && item.update.peerConflictSeverity !== "none").length;
246
244
  summary.licenseViolationPackages = items.filter((item) => item.update.licenseStatus === "denied").length;
247
- summary.verdict = deriveVerdict(items);
245
+ summary.verdict = deriveVerdict(items, errors);
248
246
  return summary;
249
247
  }
250
- function deriveVerdict(items) {
248
+ function deriveVerdict(items, errors) {
251
249
  if (items.some((item) => item.update.peerConflictSeverity === "error" ||
252
250
  item.update.licenseStatus === "denied")) {
253
251
  return "blocked";
@@ -255,23 +253,12 @@ function deriveVerdict(items) {
255
253
  if (items.some((item) => item.advisories.length > 0 || item.update.riskLevel === "critical")) {
256
254
  return "actionable";
257
255
  }
258
- if (items.some((item) => item.update.riskLevel === "high" || item.update.diffType === "major")) {
256
+ if (errors.length > 0 ||
257
+ items.some((item) => item.update.riskLevel === "high" || item.update.diffType === "major")) {
259
258
  return "review";
260
259
  }
261
260
  return "safe";
262
261
  }
263
- function deriveRiskLevel(update, advisories, conflicts, hasLicenseViolation, healthFlags) {
264
- if (hasLicenseViolation || conflicts > 0 || advisories > 0) {
265
- return update.diffType === "major" || advisories > 0 ? "critical" : "high";
266
- }
267
- if (healthFlags.includes("deprecated") || update.diffType === "major") {
268
- return "high";
269
- }
270
- if (healthFlags.length > 0 || update.diffType === "minor") {
271
- return "medium";
272
- }
273
- return update.impactScore?.rank ?? "low";
274
- }
275
262
  function buildPrimaryFindings(review) {
276
263
  const findings = [];
277
264
  if ((review.summary.peerConflictPackages ?? 0) > 0) {
@@ -286,6 +273,9 @@ function buildPrimaryFindings(review) {
286
273
  if ((review.summary.riskPackages ?? 0) > 0) {
287
274
  findings.push(`${review.summary.riskPackages} package(s) are high risk.`);
288
275
  }
276
+ if (review.errors.length > 0) {
277
+ findings.push(`${review.errors.length} execution error(s) need review before treating the result as clean.`);
278
+ }
289
279
  if (findings.length === 0) {
290
280
  findings.push("No blocking findings; remaining updates are low-risk.");
291
281
  }
@@ -293,10 +283,10 @@ function buildPrimaryFindings(review) {
293
283
  }
294
284
  function recommendCommand(review, verdict) {
295
285
  if (verdict === "blocked")
296
- return "rup resolve --after-update";
286
+ return "rup review --interactive";
297
287
  if ((review.summary.securityPackages ?? 0) > 0)
298
- return "rup audit --fix";
299
- if (review.items.length > 0)
288
+ return "rup review --security-only";
289
+ if (review.errors.length > 0 || review.items.length > 0)
300
290
  return "rup review --interactive";
301
291
  return "rup check";
302
292
  }
package/dist/index.d.ts CHANGED
@@ -8,4 +8,5 @@ export { createSarifReport } from "./output/sarif.js";
8
8
  export { writeGitHubOutput, renderGitHubAnnotations } from "./output/github.js";
9
9
  export { renderPrReport } from "./output/pr-report.js";
10
10
  export { buildReviewResult, createDoctorResult } from "./core/review-model.js";
11
- export type { CheckOptions, CheckResult, CiProfile, DependencyKind, FailOnLevel, GroupBy, OutputFormat, PackageUpdate, RunOptions, TargetLevel, UpgradeOptions, UpgradeResult, ReviewOptions, ReviewResult, DoctorOptions, DoctorResult, Verdict, RiskLevel, } from "./types/index.js";
11
+ export { applyRiskAssessments } from "./risk/index.js";
12
+ export type { CheckOptions, CheckResult, CiProfile, DependencyKind, FailOnLevel, GroupBy, OutputFormat, PackageUpdate, RunOptions, TargetLevel, UpgradeOptions, UpgradeResult, ReviewOptions, ReviewResult, DoctorOptions, DoctorResult, Verdict, RiskLevel, RiskCategory, RiskAssessment, RiskFactor, MaintainerChurnStatus, } from "./types/index.js";
package/dist/index.js CHANGED
@@ -8,3 +8,4 @@ export { createSarifReport } from "./output/sarif.js";
8
8
  export { writeGitHubOutput, renderGitHubAnnotations } from "./output/github.js";
9
9
  export { renderPrReport } from "./output/pr-report.js";
10
10
  export { buildReviewResult, createDoctorResult } from "./core/review-model.js";
11
+ export { applyRiskAssessments } from "./risk/index.js";
@@ -8,6 +8,11 @@ export function renderResult(result, format, display = {}) {
8
8
  if (result.updates.length === 0 && result.summary.warmedPackages > 0) {
9
9
  return `Cache warmed for ${result.summary.warmedPackages} package(s).`;
10
10
  }
11
+ if (result.updates.length === 0 && result.errors.length > 0) {
12
+ const [firstError] = result.errors;
13
+ const suffix = result.errors.length > 1 ? ` (+${result.errors.length - 1} more errors)` : "";
14
+ return `${firstError}${suffix}`;
15
+ }
11
16
  if (result.updates.length === 0)
12
17
  return "No updates found.";
13
18
  return result.updates
@@ -16,6 +21,9 @@ export function renderResult(result, format, display = {}) {
16
21
  if (display.showImpact && item.impactScore) {
17
22
  parts.push(`impact=${item.impactScore.rank}:${item.impactScore.score}`);
18
23
  }
24
+ if (typeof item.riskScore === "number") {
25
+ parts.push(`risk=${item.riskLevel}:${item.riskScore}`);
26
+ }
19
27
  if (display.showHomepage && item.homepage) {
20
28
  parts.push(item.homepage);
21
29
  }
@@ -80,9 +88,13 @@ export function renderResult(result, format, display = {}) {
80
88
  : undefined,
81
89
  display.showHomepage && update.homepage ? update.homepage : undefined,
82
90
  update.riskLevel ? `risk=${update.riskLevel}` : undefined,
91
+ typeof update.riskScore === "number" ? `score=${update.riskScore}` : undefined,
83
92
  ]
84
93
  .filter(Boolean)
85
94
  .join(", ")})`);
95
+ if (update.recommendedAction) {
96
+ lines.push(` action: ${update.recommendedAction}`);
97
+ }
86
98
  }
87
99
  }
88
100
  if (result.errors.length > 0) {
@@ -42,7 +42,7 @@ export function renderGitHubAnnotations(result) {
42
42
  return left.packagePath.localeCompare(right.packagePath);
43
43
  });
44
44
  for (const update of sortedUpdates) {
45
- lines.push(`::notice title=Dependency Update::${update.name} ${update.fromRange} -> ${update.toRange} (${update.packagePath})`);
45
+ lines.push(`::notice title=Dependency Update::${update.name} ${update.fromRange} -> ${update.toRange} (${update.packagePath})${typeof update.riskScore === "number" ? ` [risk=${update.riskLevel}:${update.riskScore}]` : ""}`);
46
46
  }
47
47
  for (const warning of [...result.warnings].sort((a, b) => a.localeCompare(b))) {
48
48
  lines.push(`::warning title=Rainy Updates::${warning}`);
@@ -33,6 +33,9 @@ export function createSarifReport(result) {
33
33
  impactRank: update.impactScore?.rank,
34
34
  impactScore: update.impactScore?.score,
35
35
  riskLevel: update.riskLevel,
36
+ riskScore: update.riskScore,
37
+ riskCategories: update.riskCategories ?? [],
38
+ recommendedAction: update.recommendedAction,
36
39
  advisoryCount: update.advisoryCount ?? 0,
37
40
  peerConflictSeverity: update.peerConflictSeverity ?? "none",
38
41
  licenseStatus: update.licenseStatus ?? "allowed",
@@ -24,7 +24,8 @@ export interface ResolveManyResult {
24
24
  publishedAtByVersion: Record<string, number>;
25
25
  homepage?: string;
26
26
  repository?: string;
27
- hasInstallScript: boolean;
27
+ installScriptByVersion: Record<string, boolean>;
28
+ maintainerCount: number | null;
28
29
  }>;
29
30
  errors: Map<string, string>;
30
31
  }
@@ -39,7 +40,8 @@ export declare class NpmRegistryClient {
39
40
  publishedAtByVersion: Record<string, number>;
40
41
  homepage?: string;
41
42
  repository?: string;
42
- hasInstallScript: boolean;
43
+ installScriptByVersion: Record<string, boolean>;
44
+ maintainerCount: number | null;
43
45
  }>;
44
46
  resolveLatestVersion(packageName: string, timeoutMs?: number): Promise<string | null>;
45
47
  resolveManyPackageMetadata(packageNames: string[], options: ResolveManyOptions): Promise<ResolveManyResult>;
@@ -22,7 +22,13 @@ export class NpmRegistryClient {
22
22
  try {
23
23
  const response = await requester(packageName, timeoutMs);
24
24
  if (response.status === 404) {
25
- return { latestVersion: null, versions: [], publishedAtByVersion: {}, hasInstallScript: false };
25
+ return {
26
+ latestVersion: null,
27
+ versions: [],
28
+ publishedAtByVersion: {},
29
+ installScriptByVersion: {},
30
+ maintainerCount: null,
31
+ };
26
32
  }
27
33
  if (response.status === 429 || response.status >= 500) {
28
34
  throw new RetryableRegistryError(`Registry temporary error: ${response.status}`, response.retryAfterMs ?? computeBackoffMs(attempt));
@@ -37,7 +43,10 @@ export class NpmRegistryClient {
37
43
  publishedAtByVersion: extractPublishTimes(response.data?.time),
38
44
  homepage: response.data?.homepage,
39
45
  repository: normalizeRepository(response.data?.repository),
40
- hasInstallScript: detectInstallScript(response.data?.versions),
46
+ installScriptByVersion: detectInstallScriptsByVersion(response.data?.versions),
47
+ maintainerCount: Array.isArray(response.data?.maintainers)
48
+ ? response.data?.maintainers.length
49
+ : null,
41
50
  };
42
51
  }
43
52
  catch (error) {
@@ -104,18 +113,15 @@ function normalizeRepository(value) {
104
113
  return value;
105
114
  return value.url;
106
115
  }
107
- function detectInstallScript(versions) {
116
+ function detectInstallScriptsByVersion(versions) {
108
117
  if (!versions)
109
- return false;
110
- for (const metadata of Object.values(versions)) {
118
+ return {};
119
+ const results = {};
120
+ for (const [version, metadata] of Object.entries(versions)) {
111
121
  const scripts = metadata?.scripts;
112
- if (!scripts)
113
- continue;
114
- if (scripts.preinstall || scripts.install || scripts.postinstall) {
115
- return true;
116
- }
122
+ results[version] = Boolean(scripts?.preinstall || scripts?.install || scripts?.postinstall);
117
123
  }
118
- return false;
124
+ return results;
119
125
  }
120
126
  function computeBackoffMs(attempt) {
121
127
  const baseMs = Math.max(120, attempt * 180);
@@ -0,0 +1,3 @@
1
+ import type { ReviewItem } from "../types/index.js";
2
+ import type { RiskContext } from "./types.js";
3
+ export declare function applyRiskAssessments(items: ReviewItem[], context: RiskContext): ReviewItem[];
@@ -0,0 +1,24 @@
1
+ import { assessRisk } from "./scorer.js";
2
+ export function applyRiskAssessments(items, context) {
3
+ return items.map((item) => {
4
+ const assessment = assessRisk({
5
+ update: item.update,
6
+ advisories: item.advisories,
7
+ health: item.health,
8
+ peerConflicts: item.peerConflicts,
9
+ licenseViolation: item.update.licenseStatus === "denied",
10
+ unusedIssues: item.unusedIssues,
11
+ }, context);
12
+ return {
13
+ ...item,
14
+ update: {
15
+ ...item.update,
16
+ riskLevel: assessment.level,
17
+ riskScore: assessment.score,
18
+ riskReasons: assessment.reasons,
19
+ riskCategories: assessment.categories,
20
+ recommendedAction: assessment.recommendedAction,
21
+ },
22
+ };
23
+ });
24
+ }
@@ -0,0 +1,3 @@
1
+ import type { RiskAssessment } from "../types/index.js";
2
+ import type { RiskContext, RiskInput } from "./types.js";
3
+ export declare function assessRisk(input: RiskInput, context: RiskContext): RiskAssessment;
@@ -0,0 +1,114 @@
1
+ import { detectInstallScriptsRisk } from "./signals/install-scripts.js";
2
+ import { detectTyposquatRisk } from "./signals/typosquat.js";
3
+ import { detectFreshPackageRisk } from "./signals/fresh-package.js";
4
+ import { detectSuspiciousMetadataRisk } from "./signals/metadata.js";
5
+ import { detectMutableSourceRisk } from "./signals/mutable-source.js";
6
+ import { detectMaintainerChurnRisk } from "./signals/maintainer-churn.js";
7
+ export function assessRisk(input, context) {
8
+ // Base factors model direct supply-chain behavior; modifiers adjust operational severity.
9
+ const baseFactors = [];
10
+ const modifierFactors = [];
11
+ if (input.advisories.length > 0) {
12
+ baseFactors.push({
13
+ code: "known-vulnerability",
14
+ weight: 35,
15
+ category: "known-vulnerability",
16
+ message: `${input.advisories.length} known vulnerability finding(s) affect this package.`,
17
+ });
18
+ }
19
+ const installScripts = detectInstallScriptsRisk(input);
20
+ if (installScripts)
21
+ baseFactors.push(installScripts);
22
+ const typosquat = detectTyposquatRisk(input, context);
23
+ if (typosquat)
24
+ baseFactors.push(typosquat);
25
+ const freshPackage = detectFreshPackageRisk(input);
26
+ if (freshPackage)
27
+ baseFactors.push(freshPackage);
28
+ const metadata = detectSuspiciousMetadataRisk(input);
29
+ if (metadata)
30
+ baseFactors.push(metadata);
31
+ const mutableSource = detectMutableSourceRisk(input);
32
+ if (mutableSource)
33
+ baseFactors.push(mutableSource);
34
+ const maintainerChurn = detectMaintainerChurnRisk(input);
35
+ if (maintainerChurn)
36
+ baseFactors.push(maintainerChurn);
37
+ if (input.peerConflicts.some((conflict) => conflict.severity === "error")) {
38
+ modifierFactors.push({
39
+ code: "peer-conflict",
40
+ weight: 20,
41
+ category: "operational-health",
42
+ message: "Peer dependency conflicts block safe application.",
43
+ });
44
+ }
45
+ if (input.licenseViolation) {
46
+ modifierFactors.push({
47
+ code: "license-violation",
48
+ weight: 20,
49
+ category: "operational-health",
50
+ message: "License policy would block or require review for this update.",
51
+ });
52
+ }
53
+ if (input.health?.flags.includes("deprecated")) {
54
+ modifierFactors.push({
55
+ code: "deprecated-package",
56
+ weight: 10,
57
+ category: "operational-health",
58
+ message: "Package is deprecated.",
59
+ });
60
+ }
61
+ else if (input.health?.flags.includes("stale") ||
62
+ input.health?.flags.includes("unmaintained")) {
63
+ modifierFactors.push({
64
+ code: "stale-package",
65
+ weight: 5,
66
+ category: "operational-health",
67
+ message: "Package has stale operational health signals.",
68
+ });
69
+ }
70
+ if (input.update.diffType === "major") {
71
+ modifierFactors.push({
72
+ code: "major-version",
73
+ weight: 10,
74
+ category: "operational-health",
75
+ message: "Update crosses a major version boundary.",
76
+ });
77
+ }
78
+ const baseScore = baseFactors.reduce((sum, factor) => sum + factor.weight, 0);
79
+ const modifierScore = modifierFactors.reduce((sum, factor) => sum + factor.weight, 0);
80
+ const factors = [...baseFactors, ...modifierFactors];
81
+ const score = Math.min(100, baseScore + modifierScore);
82
+ const level = scoreToLevel(score);
83
+ const categories = Array.from(new Set(factors.map((factor) => factor.category)));
84
+ const reasons = factors.map((factor) => factor.message);
85
+ return {
86
+ score,
87
+ level,
88
+ reasons,
89
+ categories,
90
+ recommendedAction: recommendAction(level, input),
91
+ factors,
92
+ };
93
+ }
94
+ function scoreToLevel(score) {
95
+ if (score >= 70)
96
+ return "critical";
97
+ if (score >= 45)
98
+ return "high";
99
+ if (score >= 20)
100
+ return "medium";
101
+ return "low";
102
+ }
103
+ function recommendAction(level, input) {
104
+ if (input.peerConflicts.some((conflict) => conflict.severity === "error")) {
105
+ return "Run `rup resolve --after-update` before applying this update.";
106
+ }
107
+ if (input.advisories.length > 0) {
108
+ return "Review in `rup review` and consider `rup audit --fix` for the secure minimum patch.";
109
+ }
110
+ if (level === "critical" || level === "high") {
111
+ return "Keep this update in review until the risk reasons are cleared.";
112
+ }
113
+ return "Safe to keep in the review queue and apply after normal verification.";
114
+ }
@@ -0,0 +1,3 @@
1
+ import type { RiskFactor } from "../../types/index.js";
2
+ import type { RiskInput } from "../types.js";
3
+ export declare function detectFreshPackageRisk(input: RiskInput): RiskFactor | null;
@@ -0,0 +1,22 @@
1
+ export function detectFreshPackageRisk(input) {
2
+ const age = input.update.publishAgeDays;
3
+ if (typeof age !== "number")
4
+ return null;
5
+ if (age <= 7) {
6
+ return {
7
+ code: "fresh-package-7d",
8
+ weight: 20,
9
+ category: "behavioral-risk",
10
+ message: `Resolved version was published ${age} day(s) ago.`,
11
+ };
12
+ }
13
+ if (age <= 30) {
14
+ return {
15
+ code: "fresh-package-30d",
16
+ weight: 10,
17
+ category: "behavioral-risk",
18
+ message: `Resolved version was published ${age} day(s) ago.`,
19
+ };
20
+ }
21
+ return null;
22
+ }
@@ -0,0 +1,3 @@
1
+ import type { RiskFactor } from "../../types/index.js";
2
+ import type { RiskInput } from "../types.js";
3
+ export declare function detectInstallScriptsRisk(input: RiskInput): RiskFactor | null;
@@ -0,0 +1,10 @@
1
+ export function detectInstallScriptsRisk(input) {
2
+ if (!input.update.hasInstallScript)
3
+ return null;
4
+ return {
5
+ code: "install-scripts",
6
+ weight: 20,
7
+ category: "behavioral-risk",
8
+ message: "Resolved package includes install lifecycle scripts.",
9
+ };
10
+ }
@@ -0,0 +1,3 @@
1
+ import type { RiskFactor } from "../../types/index.js";
2
+ import type { RiskInput } from "../types.js";
3
+ export declare function detectMaintainerChurnRisk(input: RiskInput): RiskFactor | null;
@@ -0,0 +1,11 @@
1
+ export function detectMaintainerChurnRisk(input) {
2
+ if (input.update.maintainerChurn !== "elevated-change") {
3
+ return null;
4
+ }
5
+ return {
6
+ code: "maintainer-churn",
7
+ weight: 15,
8
+ category: "behavioral-risk",
9
+ message: "Maintainer profile looks unstable for a recent release based on available registry metadata.",
10
+ };
11
+ }
@@ -0,0 +1,3 @@
1
+ import type { RiskFactor } from "../../types/index.js";
2
+ import type { RiskInput } from "../types.js";
3
+ export declare function detectSuspiciousMetadataRisk(input: RiskInput): RiskFactor | null;
@@ -0,0 +1,18 @@
1
+ export function detectSuspiciousMetadataRisk(input) {
2
+ const { homepage, repository } = input.update;
3
+ const homepageMissing = !homepage;
4
+ const repositoryMissing = !repository;
5
+ const repositoryMalformed = typeof repository === "string" &&
6
+ !repository.startsWith("http://") &&
7
+ !repository.startsWith("https://") &&
8
+ !repository.startsWith("git+");
9
+ if ((homepageMissing && repositoryMissing) || repositoryMalformed) {
10
+ return {
11
+ code: "suspicious-metadata",
12
+ weight: 10,
13
+ category: "behavioral-risk",
14
+ message: "Package metadata is incomplete or uses a non-canonical repository reference.",
15
+ };
16
+ }
17
+ return null;
18
+ }
@@ -0,0 +1,3 @@
1
+ import type { RiskFactor } from "../../types/index.js";
2
+ import type { RiskInput } from "../types.js";
3
+ export declare function detectMutableSourceRisk(input: RiskInput): RiskFactor | null;
@@ -0,0 +1,24 @@
1
+ const MUTABLE_PATTERNS = [
2
+ "git+",
3
+ "github:",
4
+ "gitlab:",
5
+ "http://",
6
+ "https://",
7
+ "git://",
8
+ ];
9
+ export function detectMutableSourceRisk(input) {
10
+ const raw = input.update.fromRange;
11
+ if (!MUTABLE_PATTERNS.some((pattern) => raw.startsWith(pattern))) {
12
+ return null;
13
+ }
14
+ const immutableCommitPinned = /#[a-f0-9]{7,40}$/i.test(raw);
15
+ if (immutableCommitPinned) {
16
+ return null;
17
+ }
18
+ return {
19
+ code: "mutable-source",
20
+ weight: 25,
21
+ category: "behavioral-risk",
22
+ message: "Dependency uses a mutable git/http source without an immutable commit pin.",
23
+ };
24
+ }
@@ -0,0 +1,3 @@
1
+ import type { RiskFactor } from "../../types/index.js";
2
+ import type { RiskContext, RiskInput } from "../types.js";
3
+ export declare function detectTyposquatRisk(input: RiskInput, context: RiskContext): RiskFactor | null;
@@ -0,0 +1,70 @@
1
+ const HIGH_VALUE_PACKAGES = [
2
+ "react",
3
+ "react-dom",
4
+ "next",
5
+ "typescript",
6
+ "lodash",
7
+ "axios",
8
+ "zod",
9
+ "vite",
10
+ "eslint",
11
+ "express",
12
+ ];
13
+ export function detectTyposquatRisk(input, context) {
14
+ const target = normalizeName(input.update.name);
15
+ if (target.length < 4)
16
+ return null;
17
+ const candidates = new Set([
18
+ ...HIGH_VALUE_PACKAGES,
19
+ ...Array.from(context.knownPackageNames),
20
+ ]);
21
+ for (const candidate of candidates) {
22
+ const normalizedCandidate = normalizeName(candidate);
23
+ if (!normalizedCandidate || normalizedCandidate === target)
24
+ continue;
25
+ if (Math.abs(normalizedCandidate.length - target.length) > 1)
26
+ continue;
27
+ if (isTransposition(normalizedCandidate, target) || levenshtein(normalizedCandidate, target) === 1) {
28
+ return {
29
+ code: "typosquat-heuristic",
30
+ weight: 25,
31
+ category: "behavioral-risk",
32
+ message: `Package name is highly similar to "${candidate}", which may indicate typosquatting.`,
33
+ };
34
+ }
35
+ }
36
+ return null;
37
+ }
38
+ function normalizeName(value) {
39
+ const trimmed = value.startsWith("@") ? value.split("/")[1] ?? value : value;
40
+ return trimmed.replace(/[^a-z0-9]/gi, "").toLowerCase();
41
+ }
42
+ function isTransposition(left, right) {
43
+ if (left.length !== right.length || left === right)
44
+ return false;
45
+ const mismatches = [];
46
+ for (let index = 0; index < left.length; index += 1) {
47
+ if (left[index] !== right[index])
48
+ mismatches.push(index);
49
+ if (mismatches.length > 2)
50
+ return false;
51
+ }
52
+ if (mismatches.length !== 2)
53
+ return false;
54
+ const [first, second] = mismatches;
55
+ return left[first] === right[second] && left[second] === right[first];
56
+ }
57
+ function levenshtein(left, right) {
58
+ const matrix = Array.from({ length: left.length + 1 }, () => Array(right.length + 1).fill(0));
59
+ for (let i = 0; i <= left.length; i += 1)
60
+ matrix[i][0] = i;
61
+ for (let j = 0; j <= right.length; j += 1)
62
+ matrix[0][j] = j;
63
+ for (let i = 1; i <= left.length; i += 1) {
64
+ for (let j = 1; j <= right.length; j += 1) {
65
+ const cost = left[i - 1] === right[j - 1] ? 0 : 1;
66
+ matrix[i][j] = Math.min(matrix[i - 1][j] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j - 1] + cost);
67
+ }
68
+ }
69
+ return matrix[left.length][right.length];
70
+ }
@@ -0,0 +1,15 @@
1
+ import type { HealthResult, PackageUpdate, PeerConflict, RiskAssessment, UnusedDependency, CveAdvisory } from "../types/index.js";
2
+ export interface RiskInput {
3
+ update: PackageUpdate;
4
+ advisories: CveAdvisory[];
5
+ health?: HealthResult["metrics"][number];
6
+ peerConflicts: PeerConflict[];
7
+ licenseViolation: boolean;
8
+ unusedIssues: UnusedDependency[];
9
+ }
10
+ export interface RiskContext {
11
+ knownPackageNames: ReadonlySet<string>;
12
+ }
13
+ export interface RiskSignalResult {
14
+ assessment: RiskAssessment;
15
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -5,6 +5,8 @@ export type CiProfile = "minimal" | "strict" | "enterprise";
5
5
  export type LockfileMode = "preserve" | "update" | "error";
6
6
  export type Verdict = "safe" | "review" | "blocked" | "actionable";
7
7
  export type RiskLevel = "critical" | "high" | "medium" | "low";
8
+ export type RiskCategory = "known-vulnerability" | "behavioral-risk" | "operational-health";
9
+ export type MaintainerChurnStatus = "unknown" | "stable" | "elevated-change";
8
10
  export type OutputFormat = "table" | "json" | "minimal" | "github" | "metrics";
9
11
  export type FailOnLevel = "none" | "patch" | "minor" | "major" | "any";
10
12
  export type LogLevel = "error" | "warn" | "info" | "debug";
@@ -91,8 +93,17 @@ export interface PackageUpdate {
91
93
  reason?: string;
92
94
  impactScore?: ImpactScore;
93
95
  homepage?: string;
96
+ repository?: string;
97
+ publishedAt?: string;
98
+ publishAgeDays?: number | null;
99
+ hasInstallScript?: boolean;
100
+ maintainerCount?: number | null;
101
+ maintainerChurn?: MaintainerChurnStatus;
94
102
  riskLevel?: RiskLevel;
103
+ riskScore?: number;
95
104
  riskReasons?: string[];
105
+ riskCategories?: RiskCategory[];
106
+ recommendedAction?: string;
96
107
  advisoryCount?: number;
97
108
  peerConflictSeverity?: "none" | PeerConflictSeverity;
98
109
  licenseStatus?: "allowed" | "review" | "denied";
@@ -323,8 +334,25 @@ export interface ResolveResult {
323
334
  }
324
335
  export interface RiskSignal {
325
336
  packageName: string;
337
+ code: string;
338
+ weight: number;
339
+ category: RiskCategory;
340
+ level: RiskLevel;
341
+ reasons: string[];
342
+ }
343
+ export interface RiskFactor {
344
+ code: string;
345
+ weight: number;
346
+ category: RiskCategory;
347
+ message: string;
348
+ }
349
+ export interface RiskAssessment {
350
+ score: number;
326
351
  level: RiskLevel;
327
352
  reasons: string[];
353
+ categories: RiskCategory[];
354
+ recommendedAction: string;
355
+ factors: RiskFactor[];
328
356
  }
329
357
  export interface ReviewItem {
330
358
  update: PackageUpdate;
package/dist/ui/tui.js CHANGED
@@ -34,6 +34,30 @@ function diffColor(level) {
34
34
  return "cyan";
35
35
  }
36
36
  }
37
+ function decisionLabel(update) {
38
+ if (update.peerConflictSeverity === "error" || update.licenseStatus === "denied") {
39
+ return "blocked";
40
+ }
41
+ if (update.advisoryCount && update.advisoryCount > 0) {
42
+ return "actionable";
43
+ }
44
+ if (update.riskLevel === "critical" || update.riskLevel === "high") {
45
+ return "review";
46
+ }
47
+ return "safe";
48
+ }
49
+ function decisionColor(label) {
50
+ switch (label) {
51
+ case "blocked":
52
+ return "red";
53
+ case "actionable":
54
+ return "yellow";
55
+ case "review":
56
+ return "cyan";
57
+ default:
58
+ return "green";
59
+ }
60
+ }
37
61
  function TuiApp({ updates, onComplete }) {
38
62
  const [cursorIndex, setCursorIndex] = useState(0);
39
63
  const [filterIndex, setFilterIndex] = useState(0);
@@ -90,12 +114,13 @@ function TuiApp({ updates, onComplete }) {
90
114
  onComplete(updates.filter((_, index) => selectedIndices.has(index)));
91
115
  }
92
116
  });
93
- return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "Rainy Review TUI" }), _jsx(Text, { color: "gray", children: "Left/Right filter Up/Down move Space toggle A select all view N clear Enter confirm" }), _jsx(Box, { marginTop: 1, children: FILTER_ORDER.map((filter, index) => (_jsx(Box, { marginRight: 2, children: _jsxs(Text, { color: index === filterIndex ? "cyan" : "gray", children: ["[", filter, "]"] }) }, filter))) }), _jsxs(Box, { marginTop: 1, flexDirection: "row", children: [_jsxs(Box, { width: 72, flexDirection: "column", borderStyle: "round", borderColor: "gray", paddingX: 1, children: [_jsx(Text, { bold: true, children: "Updates" }), filteredIndices.length === 0 ? (_jsx(Text, { color: "gray", children: "No updates match this filter." })) : (filteredIndices.map((index, visibleIndex) => {
117
+ return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "Rainy Review Queue" }), _jsx(Text, { color: "gray", children: "Detect with check, summarize with doctor, decide here in review, then apply with upgrade." }), _jsx(Text, { color: "gray", children: "Left/Right filter Up/Down move Space toggle A select visible N clear Enter confirm" }), _jsx(Box, { marginTop: 1, children: FILTER_ORDER.map((filter, index) => (_jsx(Box, { marginRight: 2, children: _jsxs(Text, { color: index === filterIndex ? "cyan" : "gray", children: ["[", filter, "]"] }) }, filter))) }), _jsxs(Box, { marginTop: 1, flexDirection: "row", children: [_jsxs(Box, { width: 72, flexDirection: "column", borderStyle: "round", borderColor: "gray", paddingX: 1, children: [_jsx(Text, { bold: true, children: "Review Queue" }), filteredIndices.length === 0 ? (_jsx(Text, { color: "gray", children: "No review candidates match this filter." })) : (filteredIndices.map((index, visibleIndex) => {
94
118
  const update = updates[index];
95
119
  const isFocused = visibleIndex === boundedCursor;
96
120
  const isSelected = selectedIndices.has(index);
97
- return (_jsxs(Box, { flexDirection: "row", children: [_jsxs(Text, { color: isFocused ? "cyan" : "gray", children: [isFocused ? ">" : " ", " ", isSelected ? "[x]" : "[ ]", " "] }), _jsx(Box, { width: 22, children: _jsx(Text, { bold: isFocused, children: update.name }) }), _jsx(Box, { width: 10, children: _jsx(Text, { color: diffColor(update.diffType), children: update.diffType }) }), _jsx(Box, { width: 18, children: _jsx(Text, { color: riskColor(update.riskLevel), children: update.riskLevel ?? update.impactScore?.rank ?? "low" }) }), _jsx(VersionDiff, { from: update.fromRange, to: update.toVersionResolved })] }, `${update.packagePath}:${update.name}`));
98
- }))] }), _jsxs(Box, { marginLeft: 1, width: 46, flexDirection: "column", borderStyle: "round", borderColor: "gray", paddingX: 1, children: [_jsx(Text, { bold: true, children: "Details" }), focusedUpdate ? (_jsxs(_Fragment, { children: [_jsx(Text, { children: focusedUpdate.name }), _jsxs(Text, { color: "gray", children: ["package: ", focusedUpdate.packagePath] }), _jsxs(Text, { children: ["diff: ", _jsx(Text, { color: diffColor(focusedUpdate.diffType), children: focusedUpdate.diffType })] }), _jsxs(Text, { children: ["risk: ", _jsx(Text, { color: riskColor(focusedUpdate.riskLevel), children: focusedUpdate.riskLevel ?? focusedUpdate.impactScore?.rank ?? "low" })] }), _jsxs(Text, { children: ["impact: ", focusedUpdate.impactScore?.score ?? 0] }), _jsxs(Text, { children: ["advisories: ", focusedUpdate.advisoryCount ?? 0] }), _jsxs(Text, { children: ["peer: ", focusedUpdate.peerConflictSeverity ?? "none"] }), _jsxs(Text, { children: ["license: ", focusedUpdate.licenseStatus ?? "allowed"] }), _jsxs(Text, { children: ["health: ", focusedUpdate.healthStatus ?? "healthy"] }), focusedUpdate.homepage ? (_jsxs(Text, { color: "blue", children: ["homepage: ", focusedUpdate.homepage] })) : (_jsx(Text, { color: "gray", children: "homepage: unavailable" })), focusedUpdate.riskReasons && focusedUpdate.riskReasons.length > 0 ? (_jsxs(_Fragment, { children: [_jsx(Text, { bold: true, children: "Reasons" }), focusedUpdate.riskReasons.slice(0, 4).map((reason) => (_jsxs(Text, { color: "gray", children: ["- ", reason] }, reason)))] })) : (_jsx(Text, { color: "gray", children: "No elevated risk reasons." }))] })) : (_jsx(Text, { color: "gray", children: "No update selected." }))] })] }), _jsx(Box, { marginTop: 1, borderStyle: "round", borderColor: "gray", paddingX: 1, children: _jsxs(Text, { color: "gray", children: [selectedIndices.size, " selected of ", updates.length, ". Filter: ", activeFilter, "."] }) })] }));
121
+ const decision = decisionLabel(update);
122
+ return (_jsxs(Box, { flexDirection: "row", children: [_jsxs(Text, { color: isFocused ? "cyan" : "gray", children: [isFocused ? ">" : " ", " ", isSelected ? "[x]" : "[ ]", " "] }), _jsx(Box, { width: 22, children: _jsx(Text, { bold: isFocused, children: update.name }) }), _jsx(Box, { width: 10, children: _jsx(Text, { color: diffColor(update.diffType), children: update.diffType }) }), _jsx(Box, { width: 18, children: _jsx(Text, { color: riskColor(update.riskLevel), children: update.riskLevel ?? update.impactScore?.rank ?? "low" }) }), _jsx(Box, { width: 12, children: _jsx(Text, { color: decisionColor(decision), children: decision }) }), _jsx(Box, { width: 10, children: _jsx(Text, { color: decisionColor(decision), children: typeof update.riskScore === "number" ? update.riskScore : "--" }) }), _jsx(VersionDiff, { from: update.fromRange, to: update.toVersionResolved })] }, `${update.packagePath}:${update.name}`));
123
+ }))] }), _jsxs(Box, { marginLeft: 1, width: 46, flexDirection: "column", borderStyle: "round", borderColor: "gray", paddingX: 1, children: [_jsx(Text, { bold: true, children: "Decision Panel" }), focusedUpdate ? (_jsxs(_Fragment, { children: [_jsx(Text, { children: focusedUpdate.name }), _jsxs(Text, { color: "gray", children: ["package: ", focusedUpdate.packagePath] }), _jsxs(Text, { children: ["state:", " ", _jsx(Text, { color: decisionColor(decisionLabel(focusedUpdate)), children: decisionLabel(focusedUpdate) })] }), _jsxs(Text, { children: ["diff: ", _jsx(Text, { color: diffColor(focusedUpdate.diffType), children: focusedUpdate.diffType })] }), _jsxs(Text, { children: ["risk: ", _jsx(Text, { color: riskColor(focusedUpdate.riskLevel), children: focusedUpdate.riskLevel ?? focusedUpdate.impactScore?.rank ?? "low" })] }), _jsxs(Text, { children: ["risk score: ", focusedUpdate.riskScore ?? 0] }), _jsxs(Text, { children: ["impact score: ", focusedUpdate.impactScore?.score ?? 0] }), _jsxs(Text, { children: ["advisories: ", focusedUpdate.advisoryCount ?? 0] }), _jsxs(Text, { children: ["peer: ", focusedUpdate.peerConflictSeverity ?? "none"] }), _jsxs(Text, { children: ["license: ", focusedUpdate.licenseStatus ?? "allowed"] }), _jsxs(Text, { children: ["health: ", focusedUpdate.healthStatus ?? "healthy"] }), _jsxs(Text, { children: ["action:", " ", _jsx(Text, { color: decisionColor(decisionLabel(focusedUpdate)), children: focusedUpdate.recommendedAction ?? "Safe to keep in the review queue." })] }), focusedUpdate.homepage ? (_jsxs(Text, { color: "blue", children: ["homepage: ", focusedUpdate.homepage] })) : (_jsx(Text, { color: "gray", children: "homepage: unavailable" })), focusedUpdate.riskReasons && focusedUpdate.riskReasons.length > 0 ? (_jsxs(_Fragment, { children: [_jsx(Text, { bold: true, children: "Reasons" }), focusedUpdate.riskReasons.slice(0, 4).map((reason) => (_jsxs(Text, { color: "gray", children: ["- ", reason] }, reason)))] })) : (_jsx(Text, { color: "gray", children: "No elevated risk reasons." }))] })) : (_jsx(Text, { color: "gray", children: "No review candidate selected." }))] })] }), _jsx(Box, { marginTop: 1, borderStyle: "round", borderColor: "gray", paddingX: 1, children: _jsxs(Text, { color: "gray", children: [selectedIndices.size, " selected for apply of ", updates.length, ". Filter: ", activeFilter, ". Enter confirms the review decision set."] }) })] }));
99
124
  }
100
125
  export async function runTui(updates) {
101
126
  return new Promise((resolve) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rainy-updates/cli",
3
- "version": "0.5.2",
3
+ "version": "0.5.3",
4
4
  "description": "The fastest DevOps-first dependency CLI. Checks, audits, upgrades, bisects, and automates npm/pnpm dependencies in CI.",
5
5
  "type": "module",
6
6
  "private": false,
@@ -56,6 +56,11 @@
56
56
  "test": "bun test",
57
57
  "lint": "bunx biome check src tests",
58
58
  "check": "bun run typecheck && bun test",
59
+ "bench:fixtures": "node scripts/generate-benchmark-fixtures.mjs",
60
+ "bench:check": "bun run build && bun run bench:fixtures && node scripts/benchmark.mjs single-100 check cold 3 && node scripts/benchmark.mjs single-100 check warm 3",
61
+ "bench:review": "bun run build && bun run bench:fixtures && node scripts/benchmark.mjs single-100 review cold 3 && node scripts/benchmark.mjs single-100 review warm 3",
62
+ "bench:resolve": "bun run build && bun run bench:fixtures && node scripts/benchmark.mjs single-100 resolve cold 3 && node scripts/benchmark.mjs single-100 resolve warm 3",
63
+ "bench:ci": "bun run build && bun run bench:fixtures && node scripts/benchmark.mjs mono-1000 ci cold 3 && node scripts/benchmark.mjs mono-1000 ci warm 3",
59
64
  "perf:smoke": "node scripts/perf-smoke.mjs && RAINY_UPDATES_PERF_SCENARIO=resolve node scripts/perf-smoke.mjs && RAINY_UPDATES_PERF_SCENARIO=ci node scripts/perf-smoke.mjs",
60
65
  "perf:check": "node scripts/perf-smoke.mjs",
61
66
  "perf:resolve": "RAINY_UPDATES_PERF_SCENARIO=resolve node scripts/perf-smoke.mjs",