@rainy-updates/cli 0.5.0-rc.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,35 @@
2
2
 
3
3
  All notable changes to this project are documented in this file.
4
4
 
5
+ ## [0.5.0] - 2026-02-27
6
+
7
+ ### Changed
8
+
9
+ - Promoted `0.5.0-rc.4` to General Availability.
10
+ - Stabilized deterministic CI artifact behavior for JSON, SARIF, and GitHub outputs.
11
+ - Finalized fix-PR summary metadata contract defaults for automation pipelines.
12
+
13
+ ### Added
14
+
15
+ - GA release gate includes `perf:smoke` CI check for regression protection.
16
+
17
+ ## [0.5.0-rc.4] - 2026-02-27
18
+
19
+ ### Changed
20
+
21
+ - Hardened deterministic CI artifacts:
22
+ - stable key ordering for JSON and SARIF files,
23
+ - deterministic sorting for updates, warnings, and errors in output pipelines.
24
+ - Improved fail-reason classification consistency for registry/runtime failures across commands.
25
+ - Fix-PR metadata in summary now has stable defaults (`fixPrApplied`, `fixBranchName`, `fixCommitSha`) to reduce contract drift.
26
+ - Fix-PR staging now includes only updated manifests plus explicit report files, with deterministic file ordering.
27
+ - Added warning when Bun runtime falls back from SQLite cache backend to file cache backend.
28
+
29
+ ### Added
30
+
31
+ - Added `perf:smoke` script and CI gate to enforce a basic performance regression threshold.
32
+ - Added deterministic output and summary regression tests.
33
+
5
34
  ## [0.5.0-rc.2] - 2026-02-27
6
35
 
7
36
  ### Added
package/README.md CHANGED
@@ -205,6 +205,7 @@ rainy-updates --version
205
205
  This package ships with production CI/CD pipelines in the repository:
206
206
 
207
207
  - Continuous integration pipeline for typecheck, tests, build, and production smoke checks.
208
+ - Performance smoke gate (`perf:smoke`) to catch startup/runtime regressions in CI.
208
209
  - Tag-driven release pipeline for npm publishing with provenance.
209
210
  - Release preflight validation for npm auth/scope checks before publishing.
210
211
 
package/dist/bin/cli.js CHANGED
@@ -14,6 +14,9 @@ import { renderResult } from "../output/format.js";
14
14
  import { writeGitHubOutput } from "../output/github.js";
15
15
  import { createSarifReport } from "../output/sarif.js";
16
16
  import { renderPrReport } from "../output/pr-report.js";
17
+ import { writeFileAtomic } from "../utils/io.js";
18
+ import { resolveFailReason } from "../core/summary.js";
19
+ import { stableStringify } from "../utils/stable-json.js";
17
20
  async function main() {
18
21
  try {
19
22
  const argv = process.argv.slice(2);
@@ -64,30 +67,36 @@ async function main() {
64
67
  const result = await runCommand(parsed);
65
68
  if (parsed.options.prReportFile) {
66
69
  const markdown = renderPrReport(result);
67
- await fs.mkdir(path.dirname(parsed.options.prReportFile), { recursive: true });
68
- await fs.writeFile(parsed.options.prReportFile, markdown + "\n", "utf8");
70
+ await writeFileAtomic(parsed.options.prReportFile, markdown + "\n");
69
71
  }
70
72
  if (parsed.options.fixPr && (parsed.command === "check" || parsed.command === "upgrade")) {
73
+ result.summary.fixPrApplied = false;
74
+ result.summary.fixBranchName = parsed.options.fixBranch ?? "chore/rainy-updates";
75
+ result.summary.fixCommitSha = "";
71
76
  const fixResult = await applyFixPr(parsed.options, result, parsed.options.prReportFile ? [parsed.options.prReportFile] : []);
72
77
  result.summary.fixPrApplied = fixResult.applied;
73
- result.summary.fixBranchName = fixResult.branchName;
74
- result.summary.fixCommitSha = fixResult.commitSha;
78
+ result.summary.fixBranchName = fixResult.branchName ?? "";
79
+ result.summary.fixCommitSha = fixResult.commitSha ?? "";
80
+ }
81
+ result.summary.failReason = resolveFailReason(result.updates, result.errors, parsed.options.failOn, parsed.options.maxUpdates, parsed.options.ci);
82
+ const renderStartedAt = Date.now();
83
+ let rendered = renderResult(result, parsed.options.format);
84
+ result.summary.durationMs.render = Math.max(0, Date.now() - renderStartedAt);
85
+ if (parsed.options.format === "json" || parsed.options.format === "metrics") {
86
+ rendered = renderResult(result, parsed.options.format);
75
87
  }
76
88
  if (parsed.options.jsonFile) {
77
- await fs.mkdir(path.dirname(parsed.options.jsonFile), { recursive: true });
78
- await fs.writeFile(parsed.options.jsonFile, JSON.stringify(result, null, 2) + "\n", "utf8");
89
+ await writeFileAtomic(parsed.options.jsonFile, stableStringify(result, 2) + "\n");
79
90
  }
80
91
  if (parsed.options.githubOutputFile) {
81
92
  await writeGitHubOutput(parsed.options.githubOutputFile, result);
82
93
  }
83
94
  if (parsed.options.sarifFile) {
84
95
  const sarif = createSarifReport(result);
85
- await fs.mkdir(path.dirname(parsed.options.sarifFile), { recursive: true });
86
- await fs.writeFile(parsed.options.sarifFile, JSON.stringify(sarif, null, 2) + "\n", "utf8");
96
+ await writeFileAtomic(parsed.options.sarifFile, stableStringify(sarif, 2) + "\n");
87
97
  }
88
- const rendered = renderResult(result, parsed.options.format);
89
98
  process.stdout.write(rendered + "\n");
90
- process.exitCode = resolveExitCode(result, parsed.options.failOn, parsed.options.maxUpdates, parsed.options.ci);
99
+ process.exitCode = resolveExitCode(result, result.summary.failReason);
91
100
  }
92
101
  catch (error) {
93
102
  process.stderr.write(`rainy-updates: ${String(error)}\n`);
@@ -116,6 +125,7 @@ Options:
116
125
  --fix-branch <name>
117
126
  --fix-commit-message <text>
118
127
  --fix-dry-run
128
+ --fix-pr-no-checkout
119
129
  --no-pr-report
120
130
  --json-file <path>
121
131
  --github-output <path>
@@ -123,6 +133,7 @@ Options:
123
133
  --pr-report-file <path>
124
134
  --fail-on none|patch|minor|major|any
125
135
  --max-updates <n>
136
+ --log-level error|warn|info|debug
126
137
  --ci`;
127
138
  }
128
139
  if (isCommand && command === "warm-cache") {
@@ -161,6 +172,7 @@ Options:
161
172
  --fix-branch <name>
162
173
  --fix-commit-message <text>
163
174
  --fix-dry-run
175
+ --fix-pr-no-checkout
164
176
  --no-pr-report
165
177
  --json-file <path>
166
178
  --pr-report-file <path>`;
@@ -202,7 +214,7 @@ Global options:
202
214
  --cwd <path>
203
215
  --workspace
204
216
  --target patch|minor|major|latest
205
- --format table|json|minimal|github
217
+ --format table|json|minimal|github|metrics
206
218
  --json-file <path>
207
219
  --github-output <path>
208
220
  --sarif-file <path>
@@ -214,7 +226,9 @@ Global options:
214
226
  --fix-branch <name>
215
227
  --fix-commit-message <text>
216
228
  --fix-dry-run
229
+ --fix-pr-no-checkout
217
230
  --no-pr-report
231
+ --log-level error|warn|info|debug
218
232
  --concurrency <n>
219
233
  --cache-ttl <seconds>
220
234
  --offline
@@ -247,22 +261,10 @@ async function readPackageVersion() {
247
261
  const parsed = JSON.parse(content);
248
262
  return parsed.version ?? "0.0.0";
249
263
  }
250
- function resolveExitCode(result, failOn, maxUpdates, ciMode) {
264
+ function resolveExitCode(result, failReason) {
251
265
  if (result.errors.length > 0)
252
266
  return 2;
253
- if (typeof maxUpdates === "number" && result.updates.length > maxUpdates)
267
+ if (failReason !== "none")
254
268
  return 1;
255
- const effectiveFailOn = failOn && failOn !== "none" ? failOn : ciMode ? "any" : "none";
256
- if (!shouldFailForUpdates(result.updates, effectiveFailOn))
257
- return 0;
258
- return 1;
259
- }
260
- function shouldFailForUpdates(updates, failOn) {
261
- if (failOn === "none")
262
- return false;
263
- if (failOn === "any" || failOn === "patch")
264
- return updates.length > 0;
265
- if (failOn === "minor")
266
- return updates.some((update) => update.diffType === "minor" || update.diffType === "major");
267
- return updates.some((update) => update.diffType === "major");
269
+ return 0;
268
270
  }
@@ -1,6 +1,8 @@
1
1
  import type { CachedVersion, TargetLevel } from "../types/index.js";
2
2
  export declare class VersionCache {
3
3
  private readonly store;
4
+ readonly backend: "sqlite" | "file";
5
+ readonly degraded: boolean;
4
6
  private constructor();
5
7
  static create(customPath?: string): Promise<VersionCache>;
6
8
  getValid(packageName: string, target: TargetLevel): Promise<CachedVersion | null>;
@@ -106,17 +106,22 @@ class SqliteCacheStore {
106
106
  }
107
107
  export class VersionCache {
108
108
  store;
109
- constructor(store) {
109
+ backend;
110
+ degraded;
111
+ constructor(store, backend, degraded) {
110
112
  this.store = store;
113
+ this.backend = backend;
114
+ this.degraded = degraded;
111
115
  }
112
116
  static async create(customPath) {
113
117
  const basePath = customPath ?? path.join(os.homedir(), ".cache", "rainy-updates");
114
118
  const sqlitePath = path.join(basePath, "cache.db");
115
119
  const sqliteStore = await tryCreateSqliteStore(sqlitePath);
116
120
  if (sqliteStore)
117
- return new VersionCache(sqliteStore);
121
+ return new VersionCache(sqliteStore, "sqlite", false);
118
122
  const jsonPath = path.join(basePath, "cache.json");
119
- return new VersionCache(new FileCacheStore(jsonPath));
123
+ const degraded = typeof Bun !== "undefined";
124
+ return new VersionCache(new FileCacheStore(jsonPath), "file", degraded);
120
125
  }
121
126
  async getValid(packageName, target) {
122
127
  const entry = await this.store.get(packageName, target);
@@ -1,4 +1,4 @@
1
- import type { DependencyKind, FailOnLevel, OutputFormat, TargetLevel } from "../types/index.js";
1
+ import type { DependencyKind, FailOnLevel, LogLevel, OutputFormat, TargetLevel } from "../types/index.js";
2
2
  export interface FileConfig {
3
3
  target?: TargetLevel;
4
4
  filter?: string;
@@ -21,7 +21,9 @@ export interface FileConfig {
21
21
  fixBranch?: string;
22
22
  fixCommitMessage?: string;
23
23
  fixDryRun?: boolean;
24
+ fixPrNoCheckout?: boolean;
24
25
  noPrReport?: boolean;
26
+ logLevel?: LogLevel;
25
27
  install?: boolean;
26
28
  packageManager?: "auto" | "npm" | "pnpm";
27
29
  sync?: boolean;
@@ -2,15 +2,26 @@ import type { TargetLevel } from "../types/index.js";
2
2
  export interface PolicyConfig {
3
3
  ignore?: string[];
4
4
  packageRules?: Record<string, {
5
+ match?: string;
5
6
  maxTarget?: TargetLevel;
6
7
  ignore?: boolean;
8
+ maxUpdatesPerRun?: number;
9
+ cooldownDays?: number;
10
+ allowPrerelease?: boolean;
7
11
  }>;
8
12
  }
13
+ export interface PolicyRule {
14
+ match?: string;
15
+ maxTarget?: TargetLevel;
16
+ ignore: boolean;
17
+ maxUpdatesPerRun?: number;
18
+ cooldownDays?: number;
19
+ allowPrerelease?: boolean;
20
+ }
9
21
  export interface ResolvedPolicy {
10
22
  ignorePatterns: string[];
11
- packageRules: Map<string, {
12
- maxTarget?: TargetLevel;
13
- ignore: boolean;
14
- }>;
23
+ packageRules: Map<string, PolicyRule>;
24
+ matchRules: PolicyRule[];
15
25
  }
16
26
  export declare function loadPolicy(cwd: string, policyFile?: string): Promise<ResolvedPolicy>;
27
+ export declare function resolvePolicyRule(packageName: string, policy: ResolvedPolicy): PolicyRule | undefined;
@@ -12,13 +12,10 @@ export async function loadPolicy(cwd, policyFile) {
12
12
  const parsed = JSON.parse(content);
13
13
  return {
14
14
  ignorePatterns: parsed.ignore ?? [],
15
- packageRules: new Map(Object.entries(parsed.packageRules ?? {}).map(([pkg, rule]) => [
16
- pkg,
17
- {
18
- maxTarget: rule.maxTarget,
19
- ignore: rule.ignore === true,
20
- },
21
- ])),
15
+ packageRules: new Map(Object.entries(parsed.packageRules ?? {}).map(([pkg, rule]) => [pkg, normalizeRule(rule)])),
16
+ matchRules: Object.values(parsed.packageRules ?? {})
17
+ .map((rule) => normalizeRule(rule))
18
+ .filter((rule) => typeof rule.match === "string" && rule.match.length > 0),
22
19
  };
23
20
  }
24
21
  catch {
@@ -28,5 +25,36 @@ export async function loadPolicy(cwd, policyFile) {
28
25
  return {
29
26
  ignorePatterns: [],
30
27
  packageRules: new Map(),
28
+ matchRules: [],
31
29
  };
32
30
  }
31
+ export function resolvePolicyRule(packageName, policy) {
32
+ const exact = policy.packageRules.get(packageName);
33
+ if (exact)
34
+ return exact;
35
+ return policy.matchRules.find((rule) => matchesPattern(packageName, rule.match));
36
+ }
37
+ function normalizeRule(rule) {
38
+ return {
39
+ match: typeof rule.match === "string" ? rule.match : undefined,
40
+ maxTarget: rule.maxTarget,
41
+ ignore: rule.ignore === true,
42
+ maxUpdatesPerRun: asNonNegativeInt(rule.maxUpdatesPerRun),
43
+ cooldownDays: asNonNegativeInt(rule.cooldownDays),
44
+ allowPrerelease: rule.allowPrerelease === true,
45
+ };
46
+ }
47
+ function asNonNegativeInt(value) {
48
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 0)
49
+ return undefined;
50
+ return value;
51
+ }
52
+ function matchesPattern(value, pattern) {
53
+ if (!pattern || pattern.length === 0)
54
+ return false;
55
+ if (pattern === "*")
56
+ return true;
57
+ const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
58
+ const regex = new RegExp(`^${escaped}$`);
59
+ return regex.test(value);
60
+ }
@@ -6,16 +6,26 @@ import { VersionCache } from "../cache/cache.js";
6
6
  import { NpmRegistryClient } from "../registry/npm.js";
7
7
  import { detectPackageManager } from "../pm/detect.js";
8
8
  import { discoverPackageDirs } from "../workspace/discover.js";
9
- import { loadPolicy } from "../config/policy.js";
9
+ import { loadPolicy, resolvePolicyRule } from "../config/policy.js";
10
+ import { createSummary, finalizeSummary } from "./summary.js";
10
11
  export async function check(options) {
12
+ const startedAt = Date.now();
13
+ let discoveryMs = 0;
14
+ let cacheMs = 0;
15
+ let registryMs = 0;
16
+ const discoveryStartedAt = Date.now();
11
17
  const packageManager = await detectPackageManager(options.cwd);
12
18
  const packageDirs = await discoverPackageDirs(options.cwd, options.workspace);
19
+ discoveryMs += Date.now() - discoveryStartedAt;
13
20
  const cache = await VersionCache.create();
14
21
  const registryClient = new NpmRegistryClient(options.cwd);
15
22
  const policy = await loadPolicy(options.cwd, options.policyFile);
16
23
  const updates = [];
17
24
  const errors = [];
18
25
  const warnings = [];
26
+ if (cache.degraded) {
27
+ warnings.push("SQLite cache backend unavailable in Bun runtime. Falling back to file cache backend.");
28
+ }
19
29
  let totalDependencies = 0;
20
30
  const tasks = [];
21
31
  let skipped = 0;
@@ -35,7 +45,7 @@ export async function check(options) {
35
45
  continue;
36
46
  if (options.reject && matchesPattern(dep.name, options.reject))
37
47
  continue;
38
- const rule = policy.packageRules.get(dep.name);
48
+ const rule = resolvePolicyRule(dep.name, policy);
39
49
  if (rule?.ignore === true) {
40
50
  skipped += 1;
41
51
  continue;
@@ -47,9 +57,10 @@ export async function check(options) {
47
57
  tasks.push({ packageDir, dependency: dep });
48
58
  }
49
59
  }
50
- const uniquePackageNames = Array.from(new Set(tasks.map((task) => task.dependency.name)));
60
+ const uniquePackageNames = Array.from(new Set(tasks.map((task) => task.dependency.name))).sort((a, b) => a.localeCompare(b));
51
61
  const resolvedVersions = new Map();
52
62
  const unresolvedPackages = [];
63
+ const cacheLookupStartedAt = Date.now();
53
64
  for (const packageName of uniquePackageNames) {
54
65
  const cached = await cache.getValid(packageName, options.target);
55
66
  if (cached) {
@@ -62,8 +73,10 @@ export async function check(options) {
62
73
  unresolvedPackages.push(packageName);
63
74
  }
64
75
  }
76
+ cacheMs += Date.now() - cacheLookupStartedAt;
65
77
  if (unresolvedPackages.length > 0) {
66
78
  if (options.offline) {
79
+ const cacheFallbackStartedAt = Date.now();
67
80
  for (const packageName of unresolvedPackages) {
68
81
  const stale = await cache.getAny(packageName, options.target);
69
82
  if (stale) {
@@ -77,11 +90,15 @@ export async function check(options) {
77
90
  errors.push(`Offline cache miss for ${packageName}. Run once without --offline to warm cache.`);
78
91
  }
79
92
  }
93
+ cacheMs += Date.now() - cacheFallbackStartedAt;
80
94
  }
81
95
  else {
96
+ const registryStartedAt = Date.now();
82
97
  const fetched = await registryClient.resolveManyPackageMetadata(unresolvedPackages, {
83
98
  concurrency: options.concurrency,
84
99
  });
100
+ registryMs += Date.now() - registryStartedAt;
101
+ const cacheWriteStartedAt = Date.now();
85
102
  for (const [packageName, metadata] of fetched.metadata) {
86
103
  resolvedVersions.set(packageName, {
87
104
  latestVersion: metadata.latestVersion,
@@ -91,6 +108,8 @@ export async function check(options) {
91
108
  await cache.set(packageName, options.target, metadata.latestVersion, metadata.versions, options.cacheTtlSeconds);
92
109
  }
93
110
  }
111
+ cacheMs += Date.now() - cacheWriteStartedAt;
112
+ const cacheStaleStartedAt = Date.now();
94
113
  for (const [packageName, error] of fetched.errors) {
95
114
  const stale = await cache.getAny(packageName, options.target);
96
115
  if (stale) {
@@ -104,13 +123,14 @@ export async function check(options) {
104
123
  errors.push(`Unable to resolve ${packageName}: ${error}`);
105
124
  }
106
125
  }
126
+ cacheMs += Date.now() - cacheStaleStartedAt;
107
127
  }
108
128
  }
109
129
  for (const task of tasks) {
110
130
  const metadata = resolvedVersions.get(task.dependency.name);
111
131
  if (!metadata?.latestVersion)
112
132
  continue;
113
- const rule = policy.packageRules.get(task.dependency.name);
133
+ const rule = resolvePolicyRule(task.dependency.name, policy);
114
134
  const effectiveTarget = clampTarget(options.target, rule?.maxTarget);
115
135
  const picked = pickTargetVersionFromAvailable(task.dependency.range, metadata.availableVersions, metadata.latestVersion, effectiveTarget);
116
136
  if (!picked)
@@ -130,15 +150,26 @@ export async function check(options) {
130
150
  reason: rule?.maxTarget ? `policy maxTarget=${rule.maxTarget}` : undefined,
131
151
  });
132
152
  }
133
- const summary = {
153
+ const limitedUpdates = sortUpdates(applyRuleUpdateCaps(updates, policy));
154
+ const sortedErrors = [...errors].sort((a, b) => a.localeCompare(b));
155
+ const sortedWarnings = [...warnings].sort((a, b) => a.localeCompare(b));
156
+ const summary = finalizeSummary(createSummary({
134
157
  scannedPackages: packageDirs.length,
135
158
  totalDependencies,
136
159
  checkedDependencies: tasks.length,
137
- updatesFound: updates.length,
160
+ updatesFound: limitedUpdates.length,
138
161
  upgraded: 0,
139
162
  skipped,
140
163
  warmedPackages: 0,
141
- };
164
+ errors: sortedErrors,
165
+ warnings: sortedWarnings,
166
+ durations: {
167
+ totalMs: Date.now() - startedAt,
168
+ discoveryMs,
169
+ registryMs,
170
+ cacheMs,
171
+ },
172
+ }));
142
173
  return {
143
174
  projectPath: options.cwd,
144
175
  packagePaths: packageDirs,
@@ -146,8 +177,44 @@ export async function check(options) {
146
177
  target: options.target,
147
178
  timestamp: new Date().toISOString(),
148
179
  summary,
149
- updates,
150
- errors,
151
- warnings,
180
+ updates: limitedUpdates,
181
+ errors: sortedErrors,
182
+ warnings: sortedWarnings,
152
183
  };
153
184
  }
185
+ function applyRuleUpdateCaps(updates, policy) {
186
+ const limited = [];
187
+ const seenPerPackage = new Map();
188
+ for (const update of updates) {
189
+ const rule = resolvePolicyRule(update.name, policy);
190
+ const cap = rule?.maxUpdatesPerRun;
191
+ if (typeof cap !== "number") {
192
+ limited.push(update);
193
+ continue;
194
+ }
195
+ const seen = seenPerPackage.get(update.name) ?? 0;
196
+ if (seen >= cap) {
197
+ continue;
198
+ }
199
+ seenPerPackage.set(update.name, seen + 1);
200
+ limited.push(update);
201
+ }
202
+ return limited;
203
+ }
204
+ function sortUpdates(updates) {
205
+ return [...updates].sort((left, right) => {
206
+ const byPath = left.packagePath.localeCompare(right.packagePath);
207
+ if (byPath !== 0)
208
+ return byPath;
209
+ const byName = left.name.localeCompare(right.name);
210
+ if (byName !== 0)
211
+ return byName;
212
+ const byKind = left.kind.localeCompare(right.kind);
213
+ if (byKind !== 0)
214
+ return byKind;
215
+ const byFrom = left.fromRange.localeCompare(right.fromRange);
216
+ if (byFrom !== 0)
217
+ return byFrom;
218
+ return left.toRange.localeCompare(right.toRange);
219
+ });
220
+ }
@@ -3,28 +3,42 @@ import path from "node:path";
3
3
  export async function applyFixPr(options, result, extraFiles) {
4
4
  if (!options.fixPr)
5
5
  return { applied: false };
6
- if (result.updates.length === 0)
7
- return { applied: false };
6
+ if (result.updates.length === 0) {
7
+ return {
8
+ applied: false,
9
+ branchName: options.fixBranch ?? "chore/rainy-updates",
10
+ commitSha: "",
11
+ };
12
+ }
8
13
  const status = await runGit(options.cwd, ["status", "--porcelain"]);
9
14
  if (status.stdout.trim().length > 0) {
10
15
  throw new Error("Cannot run --fix-pr with a dirty git working tree.");
11
16
  }
12
17
  const branch = options.fixBranch ?? "chore/rainy-updates";
13
- const branchCheck = await runGit(options.cwd, ["rev-parse", "--verify", "--quiet", branch], true);
14
- if (branchCheck.code === 0) {
15
- await runGit(options.cwd, ["checkout", branch]);
18
+ const headRef = await runGit(options.cwd, ["symbolic-ref", "--quiet", "--short", "HEAD"], true);
19
+ if (headRef.code !== 0 && !options.fixPrNoCheckout) {
20
+ throw new Error("Cannot run --fix-pr in detached HEAD state without --fix-pr-no-checkout.");
16
21
  }
17
- else {
18
- await runGit(options.cwd, ["checkout", "-b", branch]);
22
+ if (!options.fixPrNoCheckout) {
23
+ const branchCheck = await runGit(options.cwd, ["rev-parse", "--verify", "--quiet", branch], true);
24
+ if (branchCheck.code === 0) {
25
+ await runGit(options.cwd, ["checkout", branch]);
26
+ }
27
+ else {
28
+ await runGit(options.cwd, ["checkout", "-b", branch]);
29
+ }
19
30
  }
20
31
  if (options.fixDryRun) {
21
32
  return {
22
33
  applied: false,
23
34
  branchName: branch,
35
+ commitSha: "",
24
36
  };
25
37
  }
26
- const manifestFiles = Array.from(new Set(result.packagePaths.map((pkgPath) => path.join(pkgPath, "package.json"))));
27
- const filesToStage = Array.from(new Set([...manifestFiles, ...extraFiles]));
38
+ const manifestFiles = Array.from(new Set(result.updates.map((update) => path.resolve(update.packagePath, "package.json"))));
39
+ const filesToStage = Array.from(new Set([...manifestFiles, ...extraFiles]
40
+ .map((entry) => path.resolve(options.cwd, entry))
41
+ .filter((entry) => entry.startsWith(path.resolve(options.cwd) + path.sep) || entry === path.resolve(options.cwd)))).sort((a, b) => a.localeCompare(b));
28
42
  if (filesToStage.length > 0) {
29
43
  await runGit(options.cwd, ["add", "--", ...filesToStage]);
30
44
  }
@@ -33,6 +47,7 @@ export async function applyFixPr(options, result, extraFiles) {
33
47
  return {
34
48
  applied: false,
35
49
  branchName: branch,
50
+ commitSha: "",
36
51
  };
37
52
  }
38
53
  const message = options.fixCommitMessage ?? `chore(deps): apply rainy-updates (${result.updates.length} updates)`;
@@ -40,7 +40,9 @@ export async function parseCliArgs(argv) {
40
40
  fixBranch: "chore/rainy-updates",
41
41
  fixCommitMessage: undefined,
42
42
  fixDryRun: false,
43
+ fixPrNoCheckout: false,
43
44
  noPrReport: false,
45
+ logLevel: "info",
44
46
  };
45
47
  let force = false;
46
48
  let initCiMode = "enterprise";
@@ -208,6 +210,18 @@ export async function parseCliArgs(argv) {
208
210
  base.noPrReport = true;
209
211
  continue;
210
212
  }
213
+ if (current === "--fix-pr-no-checkout") {
214
+ base.fixPrNoCheckout = true;
215
+ continue;
216
+ }
217
+ if (current === "--log-level" && next) {
218
+ base.logLevel = ensureLogLevel(next);
219
+ index += 1;
220
+ continue;
221
+ }
222
+ if (current === "--log-level") {
223
+ throw new Error("Missing value for --log-level");
224
+ }
211
225
  if (current === "--install" && command === "upgrade") {
212
226
  continue;
213
227
  }
@@ -406,9 +420,15 @@ function applyConfig(base, config) {
406
420
  if (typeof config.fixDryRun === "boolean") {
407
421
  base.fixDryRun = config.fixDryRun;
408
422
  }
423
+ if (typeof config.fixPrNoCheckout === "boolean") {
424
+ base.fixPrNoCheckout = config.fixPrNoCheckout;
425
+ }
409
426
  if (typeof config.noPrReport === "boolean") {
410
427
  base.noPrReport = config.noPrReport;
411
428
  }
429
+ if (typeof config.logLevel === "string") {
430
+ base.logLevel = ensureLogLevel(config.logLevel);
431
+ }
412
432
  }
413
433
  function parsePackageManager(args) {
414
434
  const index = args.indexOf("--pm");
@@ -427,10 +447,16 @@ function ensureTarget(value) {
427
447
  throw new Error("--target must be patch, minor, major, latest");
428
448
  }
429
449
  function ensureFormat(value) {
430
- if (value === "table" || value === "json" || value === "minimal" || value === "github") {
450
+ if (value === "table" || value === "json" || value === "minimal" || value === "github" || value === "metrics") {
451
+ return value;
452
+ }
453
+ throw new Error("--format must be table, json, minimal, github or metrics");
454
+ }
455
+ function ensureLogLevel(value) {
456
+ if (value === "error" || value === "warn" || value === "info" || value === "debug") {
431
457
  return value;
432
458
  }
433
- throw new Error("--format must be table, json, minimal or github");
459
+ throw new Error("--log-level must be error, warn, info or debug");
434
460
  }
435
461
  function parseDependencyKinds(value) {
436
462
  const mapped = value
@@ -0,0 +1,22 @@
1
+ import type { FailOnLevel, FailReason, PackageUpdate, Summary } from "../types/index.js";
2
+ export interface DurationInput {
3
+ totalMs: number;
4
+ discoveryMs: number;
5
+ registryMs: number;
6
+ cacheMs: number;
7
+ }
8
+ export declare function createSummary(input: {
9
+ scannedPackages: number;
10
+ totalDependencies: number;
11
+ checkedDependencies: number;
12
+ updatesFound: number;
13
+ upgraded: number;
14
+ skipped: number;
15
+ warmedPackages: number;
16
+ errors: string[];
17
+ warnings: string[];
18
+ durations: DurationInput;
19
+ }): Summary;
20
+ export declare function finalizeSummary(summary: Summary): Summary;
21
+ export declare function resolveFailReason(updates: PackageUpdate[], errors: string[], failOn: FailOnLevel | undefined, maxUpdates: number | undefined, ciMode: boolean): FailReason;
22
+ export declare function shouldFailForUpdates(updates: PackageUpdate[], failOn: FailOnLevel): boolean;
@@ -0,0 +1,78 @@
1
+ export function createSummary(input) {
2
+ const offlineCacheMiss = input.errors.filter((error) => isOfflineCacheMissError(error)).length;
3
+ const registryFailure = input.errors.filter((error) => isRegistryFailureError(error)).length;
4
+ const staleCache = input.warnings.filter((warning) => warning.includes("Using stale cache")).length;
5
+ return {
6
+ contractVersion: "2",
7
+ scannedPackages: input.scannedPackages,
8
+ totalDependencies: input.totalDependencies,
9
+ checkedDependencies: input.checkedDependencies,
10
+ updatesFound: input.updatesFound,
11
+ upgraded: input.upgraded,
12
+ skipped: input.skipped,
13
+ warmedPackages: input.warmedPackages,
14
+ failReason: "none",
15
+ errorCounts: {
16
+ total: input.errors.length,
17
+ offlineCacheMiss,
18
+ registryFailure,
19
+ other: 0,
20
+ },
21
+ warningCounts: {
22
+ total: input.warnings.length,
23
+ staleCache,
24
+ other: 0,
25
+ },
26
+ durationMs: {
27
+ total: Math.max(0, Math.round(input.durations.totalMs)),
28
+ discovery: Math.max(0, Math.round(input.durations.discoveryMs)),
29
+ registry: Math.max(0, Math.round(input.durations.registryMs)),
30
+ cache: Math.max(0, Math.round(input.durations.cacheMs)),
31
+ render: 0,
32
+ },
33
+ fixPrApplied: false,
34
+ fixBranchName: "",
35
+ fixCommitSha: "",
36
+ };
37
+ }
38
+ export function finalizeSummary(summary) {
39
+ const errorOther = summary.errorCounts.total - summary.errorCounts.offlineCacheMiss - summary.errorCounts.registryFailure;
40
+ const warningOther = summary.warningCounts.total - summary.warningCounts.staleCache;
41
+ summary.errorCounts.other = Math.max(0, errorOther);
42
+ summary.warningCounts.other = Math.max(0, warningOther);
43
+ return summary;
44
+ }
45
+ export function resolveFailReason(updates, errors, failOn, maxUpdates, ciMode) {
46
+ if (errors.some((error) => isOfflineCacheMissError(error))) {
47
+ return "offline-cache-miss";
48
+ }
49
+ if (errors.some((error) => isRegistryFailureError(error))) {
50
+ return "registry-failure";
51
+ }
52
+ if (typeof maxUpdates === "number" && updates.length > maxUpdates) {
53
+ return "updates-threshold";
54
+ }
55
+ const effectiveFailOn = failOn && failOn !== "none" ? failOn : ciMode ? "any" : "none";
56
+ if (shouldFailForUpdates(updates, effectiveFailOn)) {
57
+ return "severity-threshold";
58
+ }
59
+ return "none";
60
+ }
61
+ export function shouldFailForUpdates(updates, failOn) {
62
+ if (failOn === "none")
63
+ return false;
64
+ if (failOn === "any" || failOn === "patch")
65
+ return updates.length > 0;
66
+ if (failOn === "minor")
67
+ return updates.some((update) => update.diffType === "minor" || update.diffType === "major");
68
+ return updates.some((update) => update.diffType === "major");
69
+ }
70
+ function isOfflineCacheMissError(value) {
71
+ return value.includes("Offline cache miss");
72
+ }
73
+ function isRegistryFailureError(value) {
74
+ return (value.includes("Unable to resolve") ||
75
+ value.includes("Unable to warm") ||
76
+ value.includes("Registry request failed") ||
77
+ value.includes("Registry temporary error"));
78
+ }
@@ -4,13 +4,23 @@ import { VersionCache } from "../cache/cache.js";
4
4
  import { NpmRegistryClient } from "../registry/npm.js";
5
5
  import { detectPackageManager } from "../pm/detect.js";
6
6
  import { discoverPackageDirs } from "../workspace/discover.js";
7
+ import { createSummary, finalizeSummary } from "./summary.js";
7
8
  export async function warmCache(options) {
9
+ const startedAt = Date.now();
10
+ let discoveryMs = 0;
11
+ let cacheMs = 0;
12
+ let registryMs = 0;
13
+ const discoveryStartedAt = Date.now();
8
14
  const packageManager = await detectPackageManager(options.cwd);
9
15
  const packageDirs = await discoverPackageDirs(options.cwd, options.workspace);
16
+ discoveryMs += Date.now() - discoveryStartedAt;
10
17
  const cache = await VersionCache.create();
11
18
  const registryClient = new NpmRegistryClient(options.cwd);
12
19
  const errors = [];
13
20
  const warnings = [];
21
+ if (cache.degraded) {
22
+ warnings.push("SQLite cache backend unavailable in Bun runtime. Falling back to file cache backend.");
23
+ }
14
24
  let totalDependencies = 0;
15
25
  const packageNames = new Set();
16
26
  for (const packageDir of packageDirs) {
@@ -30,16 +40,19 @@ export async function warmCache(options) {
30
40
  errors.push(`Failed to read package.json in ${packageDir}: ${String(error)}`);
31
41
  }
32
42
  }
33
- const names = Array.from(packageNames);
43
+ const names = Array.from(packageNames).sort((a, b) => a.localeCompare(b));
34
44
  const needsFetch = [];
45
+ const cacheLookupStartedAt = Date.now();
35
46
  for (const pkg of names) {
36
47
  const valid = await cache.getValid(pkg, options.target);
37
48
  if (!valid)
38
49
  needsFetch.push(pkg);
39
50
  }
51
+ cacheMs += Date.now() - cacheLookupStartedAt;
40
52
  let warmed = 0;
41
53
  if (needsFetch.length > 0) {
42
54
  if (options.offline) {
55
+ const cacheFallbackStartedAt = Date.now();
43
56
  for (const pkg of needsFetch) {
44
57
  const stale = await cache.getAny(pkg, options.target);
45
58
  if (stale) {
@@ -50,23 +63,30 @@ export async function warmCache(options) {
50
63
  errors.push(`Offline cache miss for ${pkg}. Cannot warm cache in --offline mode.`);
51
64
  }
52
65
  }
66
+ cacheMs += Date.now() - cacheFallbackStartedAt;
53
67
  }
54
68
  else {
69
+ const registryStartedAt = Date.now();
55
70
  const fetched = await registryClient.resolveManyPackageMetadata(needsFetch, {
56
71
  concurrency: options.concurrency,
57
72
  });
73
+ registryMs += Date.now() - registryStartedAt;
74
+ const cacheWriteStartedAt = Date.now();
58
75
  for (const [pkg, metadata] of fetched.metadata) {
59
76
  if (metadata.latestVersion) {
60
77
  await cache.set(pkg, options.target, metadata.latestVersion, metadata.versions, options.cacheTtlSeconds);
61
78
  warmed += 1;
62
79
  }
63
80
  }
81
+ cacheMs += Date.now() - cacheWriteStartedAt;
64
82
  for (const [pkg, error] of fetched.errors) {
65
83
  errors.push(`Unable to warm ${pkg}: ${error}`);
66
84
  }
67
85
  }
68
86
  }
69
- const summary = {
87
+ const sortedErrors = [...errors].sort((a, b) => a.localeCompare(b));
88
+ const sortedWarnings = [...warnings].sort((a, b) => a.localeCompare(b));
89
+ const summary = finalizeSummary(createSummary({
70
90
  scannedPackages: packageDirs.length,
71
91
  totalDependencies,
72
92
  checkedDependencies: names.length,
@@ -74,7 +94,15 @@ export async function warmCache(options) {
74
94
  upgraded: 0,
75
95
  skipped: 0,
76
96
  warmedPackages: warmed,
77
- };
97
+ errors: sortedErrors,
98
+ warnings: sortedWarnings,
99
+ durations: {
100
+ totalMs: Date.now() - startedAt,
101
+ discoveryMs,
102
+ registryMs,
103
+ cacheMs,
104
+ },
105
+ }));
78
106
  return {
79
107
  projectPath: options.cwd,
80
108
  packagePaths: packageDirs,
@@ -83,7 +111,7 @@ export async function warmCache(options) {
83
111
  timestamp: new Date().toISOString(),
84
112
  summary,
85
113
  updates: [],
86
- errors,
87
- warnings,
114
+ errors: sortedErrors,
115
+ warnings: sortedWarnings,
88
116
  };
89
117
  }
@@ -1,7 +1,8 @@
1
1
  import { renderGitHubAnnotations } from "./github.js";
2
+ import { stableStringify } from "../utils/stable-json.js";
2
3
  export function renderResult(result, format) {
3
4
  if (format === "json") {
4
- return JSON.stringify(result, null, 2);
5
+ return stableStringify(result, 2);
5
6
  }
6
7
  if (format === "minimal") {
7
8
  if (result.updates.length === 0 && result.summary.warmedPackages > 0) {
@@ -16,6 +17,23 @@ export function renderResult(result, format) {
16
17
  if (format === "github") {
17
18
  return renderGitHubAnnotations(result);
18
19
  }
20
+ if (format === "metrics") {
21
+ return [
22
+ `contract_version=${result.summary.contractVersion}`,
23
+ `updates_found=${result.summary.updatesFound}`,
24
+ `errors_count=${result.summary.errorCounts.total}`,
25
+ `warnings_count=${result.summary.warningCounts.total}`,
26
+ `checked_dependencies=${result.summary.checkedDependencies}`,
27
+ `scanned_packages=${result.summary.scannedPackages}`,
28
+ `warmed_packages=${result.summary.warmedPackages}`,
29
+ `fail_reason=${result.summary.failReason}`,
30
+ `duration_total_ms=${result.summary.durationMs.total}`,
31
+ `duration_discovery_ms=${result.summary.durationMs.discovery}`,
32
+ `duration_registry_ms=${result.summary.durationMs.registry}`,
33
+ `duration_cache_ms=${result.summary.durationMs.cache}`,
34
+ `duration_render_ms=${result.summary.durationMs.render}`,
35
+ ].join("\n");
36
+ }
19
37
  const lines = [];
20
38
  lines.push(`Project: ${result.projectPath}`);
21
39
  lines.push(`Scanned packages: ${result.summary.scannedPackages}`);
@@ -52,6 +70,7 @@ export function renderResult(result, format) {
52
70
  }
53
71
  lines.push("");
54
72
  lines.push(`Summary: ${result.summary.updatesFound} updates, ${result.summary.checkedDependencies}/${result.summary.totalDependencies} checked, ${result.summary.warmedPackages} warmed`);
73
+ lines.push(`Contract v${result.summary.contractVersion}, failReason=${result.summary.failReason}, duration=${result.summary.durationMs.total}ms`);
55
74
  if (result.summary.fixPrApplied) {
56
75
  lines.push(`Fix PR: applied on branch ${result.summary.fixBranchName ?? "unknown"} (${result.summary.fixCommitSha ?? "no-commit"})`);
57
76
  }
@@ -1,29 +1,40 @@
1
- import { promises as fs } from "node:fs";
2
- import path from "node:path";
1
+ import { writeFileAtomic } from "../utils/io.js";
3
2
  export async function writeGitHubOutput(filePath, result) {
4
3
  const lines = [
4
+ `contract_version=${result.summary.contractVersion}`,
5
5
  `updates_found=${result.summary.updatesFound}`,
6
- `errors_count=${result.errors.length}`,
7
- `warnings_count=${result.warnings.length}`,
6
+ `errors_count=${result.summary.errorCounts.total}`,
7
+ `warnings_count=${result.summary.warningCounts.total}`,
8
8
  `checked_dependencies=${result.summary.checkedDependencies}`,
9
9
  `scanned_packages=${result.summary.scannedPackages}`,
10
10
  `warmed_packages=${result.summary.warmedPackages}`,
11
+ `fail_reason=${result.summary.failReason}`,
12
+ `duration_total_ms=${result.summary.durationMs.total}`,
13
+ `duration_discovery_ms=${result.summary.durationMs.discovery}`,
14
+ `duration_registry_ms=${result.summary.durationMs.registry}`,
15
+ `duration_cache_ms=${result.summary.durationMs.cache}`,
16
+ `duration_render_ms=${result.summary.durationMs.render}`,
11
17
  `fix_pr_applied=${result.summary.fixPrApplied === true ? "1" : "0"}`,
12
18
  `fix_pr_branch=${result.summary.fixBranchName ?? ""}`,
13
19
  `fix_pr_commit=${result.summary.fixCommitSha ?? ""}`,
14
20
  ];
15
- await fs.mkdir(path.dirname(filePath), { recursive: true });
16
- await fs.writeFile(filePath, lines.join("\n") + "\n", "utf8");
21
+ await writeFileAtomic(filePath, lines.join("\n") + "\n");
17
22
  }
18
23
  export function renderGitHubAnnotations(result) {
19
24
  const lines = [];
20
- for (const update of result.updates) {
25
+ const sortedUpdates = [...result.updates].sort((left, right) => {
26
+ const byName = left.name.localeCompare(right.name);
27
+ if (byName !== 0)
28
+ return byName;
29
+ return left.packagePath.localeCompare(right.packagePath);
30
+ });
31
+ for (const update of sortedUpdates) {
21
32
  lines.push(`::notice title=Dependency Update::${update.name} ${update.fromRange} -> ${update.toRange} (${update.packagePath})`);
22
33
  }
23
- for (const warning of result.warnings) {
34
+ for (const warning of [...result.warnings].sort((a, b) => a.localeCompare(b))) {
24
35
  lines.push(`::warning title=Rainy Updates::${warning}`);
25
36
  }
26
- for (const error of result.errors) {
37
+ for (const error of [...result.errors].sort((a, b) => a.localeCompare(b))) {
27
38
  lines.push(`::error title=Rainy Updates::${error}`);
28
39
  }
29
40
  if (lines.length === 0) {
@@ -4,7 +4,13 @@ import { fileURLToPath } from "node:url";
4
4
  export function createSarifReport(result) {
5
5
  const dependencyRuleId = "rainy-updates/dependency-update";
6
6
  const runtimeRuleId = "rainy-updates/runtime-error";
7
- const updateResults = result.updates.map((update) => ({
7
+ const sortedUpdates = [...result.updates].sort((left, right) => {
8
+ const byName = left.name.localeCompare(right.name);
9
+ if (byName !== 0)
10
+ return byName;
11
+ return left.packagePath.localeCompare(right.packagePath);
12
+ });
13
+ const updateResults = sortedUpdates.map((update) => ({
8
14
  ruleId: dependencyRuleId,
9
15
  level: "warning",
10
16
  message: {
@@ -26,7 +32,7 @@ export function createSarifReport(result) {
26
32
  resolvedVersion: update.toVersionResolved,
27
33
  },
28
34
  }));
29
- const errorResults = result.errors.map((error) => ({
35
+ const errorResults = [...result.errors].sort((a, b) => a.localeCompare(b)).map((error) => ({
30
36
  ruleId: runtimeRuleId,
31
37
  level: "error",
32
38
  message: {
@@ -57,6 +63,14 @@ export function createSarifReport(result) {
57
63
  },
58
64
  },
59
65
  results: [...updateResults, ...errorResults],
66
+ properties: {
67
+ contractVersion: result.summary.contractVersion,
68
+ failReason: result.summary.failReason,
69
+ updatesFound: result.summary.updatesFound,
70
+ errorsCount: result.summary.errorCounts.total,
71
+ warningsCount: result.summary.warningCounts.total,
72
+ durationMs: result.summary.durationMs,
73
+ },
60
74
  },
61
75
  ],
62
76
  };
@@ -21,7 +21,7 @@ export class NpmRegistryClient {
21
21
  return { latestVersion: null, versions: [] };
22
22
  }
23
23
  if (response.status === 429 || response.status >= 500) {
24
- throw new Error(`Registry temporary error: ${response.status}`);
24
+ throw new RetryableRegistryError(`Registry temporary error: ${response.status}`, response.retryAfterMs ?? computeBackoffMs(attempt));
25
25
  }
26
26
  if (response.status < 200 || response.status >= 300) {
27
27
  throw new Error(`Registry request failed: ${response.status}`);
@@ -32,7 +32,8 @@ export class NpmRegistryClient {
32
32
  catch (error) {
33
33
  lastError = String(error);
34
34
  if (attempt < 3) {
35
- await sleep(120 * attempt);
35
+ const backoffMs = error instanceof RetryableRegistryError ? error.waitMs : computeBackoffMs(attempt);
36
+ await sleep(backoffMs);
36
37
  }
37
38
  }
38
39
  }
@@ -84,6 +85,18 @@ export class NpmRegistryClient {
84
85
  function sleep(ms) {
85
86
  return new Promise((resolve) => setTimeout(resolve, ms));
86
87
  }
88
+ function computeBackoffMs(attempt) {
89
+ const baseMs = Math.max(120, attempt * 180);
90
+ const jitterMs = Math.floor(Math.random() * 120);
91
+ return baseMs + jitterMs;
92
+ }
93
+ class RetryableRegistryError extends Error {
94
+ waitMs;
95
+ constructor(message, waitMs) {
96
+ super(message);
97
+ this.waitMs = waitMs;
98
+ }
99
+ }
87
100
  async function createRequester(cwd) {
88
101
  const registryConfig = await loadRegistryConfig(cwd ?? process.cwd());
89
102
  const undiciRequester = await tryCreateUndiciRequester(registryConfig);
@@ -103,7 +116,11 @@ async function createRequester(cwd) {
103
116
  signal: controller.signal,
104
117
  });
105
118
  const data = (await response.json().catch(() => null));
106
- return { status: response.status, data };
119
+ return {
120
+ status: response.status,
121
+ data,
122
+ retryAfterMs: parseRetryAfterHeader(response.headers.get("retry-after")),
123
+ };
107
124
  }
108
125
  finally {
109
126
  clearTimeout(timeout);
@@ -136,7 +153,19 @@ async function tryCreateUndiciRequester(registryConfig) {
136
153
  catch {
137
154
  data = null;
138
155
  }
139
- return { status: res.statusCode, data };
156
+ const retryAfter = (() => {
157
+ const header = res.headers["retry-after"];
158
+ if (Array.isArray(header))
159
+ return header[0] ?? null;
160
+ if (typeof header === "string")
161
+ return header;
162
+ return null;
163
+ })();
164
+ return {
165
+ status: res.statusCode,
166
+ data,
167
+ retryAfterMs: parseRetryAfterHeader(retryAfter),
168
+ };
140
169
  }
141
170
  finally {
142
171
  clearTimeout(timeout);
@@ -218,3 +247,18 @@ function buildRegistryUrl(registry, packageName) {
218
247
  const base = normalizeRegistryUrl(registry);
219
248
  return new URL(encodeURIComponent(packageName), base).toString();
220
249
  }
250
+ function parseRetryAfterHeader(value) {
251
+ if (!value)
252
+ return null;
253
+ const parsedSeconds = Number(value);
254
+ if (Number.isFinite(parsedSeconds) && parsedSeconds >= 0) {
255
+ return Math.round(parsedSeconds * 1000);
256
+ }
257
+ const untilMs = Date.parse(value);
258
+ if (!Number.isFinite(untilMs))
259
+ return null;
260
+ const delta = untilMs - Date.now();
261
+ if (delta <= 0)
262
+ return 0;
263
+ return delta;
264
+ }
@@ -1,7 +1,9 @@
1
1
  export type DependencyKind = "dependencies" | "devDependencies" | "optionalDependencies" | "peerDependencies";
2
2
  export type TargetLevel = "patch" | "minor" | "major" | "latest";
3
- export type OutputFormat = "table" | "json" | "minimal" | "github";
3
+ export type OutputFormat = "table" | "json" | "minimal" | "github" | "metrics";
4
4
  export type FailOnLevel = "none" | "patch" | "minor" | "major" | "any";
5
+ export type LogLevel = "error" | "warn" | "info" | "debug";
6
+ export type FailReason = "none" | "updates-threshold" | "severity-threshold" | "registry-failure" | "offline-cache-miss" | "policy-blocked";
5
7
  export interface RunOptions {
6
8
  cwd: string;
7
9
  target: TargetLevel;
@@ -25,7 +27,9 @@ export interface RunOptions {
25
27
  fixBranch?: string;
26
28
  fixCommitMessage?: string;
27
29
  fixDryRun?: boolean;
30
+ fixPrNoCheckout?: boolean;
28
31
  noPrReport?: boolean;
32
+ logLevel: LogLevel;
29
33
  }
30
34
  export interface CheckOptions extends RunOptions {
31
35
  }
@@ -58,6 +62,7 @@ export interface PackageUpdate {
58
62
  reason?: string;
59
63
  }
60
64
  export interface Summary {
65
+ contractVersion: "2";
61
66
  scannedPackages: number;
62
67
  totalDependencies: number;
63
68
  checkedDependencies: number;
@@ -65,9 +70,28 @@ export interface Summary {
65
70
  upgraded: number;
66
71
  skipped: number;
67
72
  warmedPackages: number;
68
- fixPrApplied?: boolean;
69
- fixBranchName?: string;
70
- fixCommitSha?: string;
73
+ failReason: FailReason;
74
+ errorCounts: {
75
+ total: number;
76
+ offlineCacheMiss: number;
77
+ registryFailure: number;
78
+ other: number;
79
+ };
80
+ warningCounts: {
81
+ total: number;
82
+ staleCache: number;
83
+ other: number;
84
+ };
85
+ durationMs: {
86
+ total: number;
87
+ discovery: number;
88
+ registry: number;
89
+ cache: number;
90
+ render: number;
91
+ };
92
+ fixPrApplied: boolean;
93
+ fixBranchName: string;
94
+ fixCommitSha: string;
71
95
  }
72
96
  export interface CheckResult {
73
97
  projectPath: string;
@@ -0,0 +1 @@
1
+ export declare function writeFileAtomic(filePath: string, content: string): Promise<void>;
@@ -0,0 +1,10 @@
1
+ import { promises as fs } from "node:fs";
2
+ import path from "node:path";
3
+ import crypto from "node:crypto";
4
+ export async function writeFileAtomic(filePath, content) {
5
+ const dir = path.dirname(filePath);
6
+ await fs.mkdir(dir, { recursive: true });
7
+ const tempPath = path.join(dir, `.tmp-${path.basename(filePath)}-${crypto.randomUUID()}`);
8
+ await fs.writeFile(tempPath, content, "utf8");
9
+ await fs.rename(tempPath, filePath);
10
+ }
@@ -0,0 +1 @@
1
+ export declare function stableStringify(value: unknown, indent?: number): string;
@@ -0,0 +1,20 @@
1
+ function isPlainObject(value) {
2
+ return typeof value === "object" && value !== null && !Array.isArray(value);
3
+ }
4
+ function sortValue(value) {
5
+ if (Array.isArray(value)) {
6
+ return value.map((item) => sortValue(item));
7
+ }
8
+ if (!isPlainObject(value)) {
9
+ return value;
10
+ }
11
+ const sorted = {};
12
+ const keys = Object.keys(value).sort((a, b) => a.localeCompare(b));
13
+ for (const key of keys) {
14
+ sorted[key] = sortValue(value[key]);
15
+ }
16
+ return sorted;
17
+ }
18
+ export function stableStringify(value, indent = 2) {
19
+ return JSON.stringify(sortValue(value), null, indent);
20
+ }
@@ -1,5 +1,7 @@
1
1
  import { promises as fs } from "node:fs";
2
2
  import path from "node:path";
3
+ const HARD_IGNORE_DIRS = new Set(["node_modules", ".git", ".turbo", ".next", "dist", "coverage"]);
4
+ const MAX_DISCOVERED_DIRS = 20000;
3
5
  export async function discoverPackageDirs(cwd, workspaceMode) {
4
6
  if (!workspaceMode) {
5
7
  return [cwd];
@@ -84,6 +86,9 @@ async function expandWorkspacePattern(cwd, pattern) {
84
86
  return Array.from(results);
85
87
  }
86
88
  async function collectMatches(baseDir, segments, index, out) {
89
+ if (out.size > MAX_DISCOVERED_DIRS) {
90
+ throw new Error(`Workspace discovery exceeded ${MAX_DISCOVERED_DIRS} directories. Refine workspace patterns.`);
91
+ }
87
92
  if (index >= segments.length) {
88
93
  out.add(baseDir);
89
94
  return;
@@ -111,7 +116,8 @@ async function readChildDirs(dir) {
111
116
  const entries = await fs.readdir(dir, { withFileTypes: true });
112
117
  return entries
113
118
  .filter((entry) => entry.isDirectory())
114
- .filter((entry) => entry.name !== "node_modules" && !entry.name.startsWith("."))
119
+ .filter((entry) => !HARD_IGNORE_DIRS.has(entry.name))
120
+ .filter((entry) => !entry.name.startsWith("."))
115
121
  .map((entry) => path.join(dir, entry.name));
116
122
  }
117
123
  catch {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rainy-updates/cli",
3
- "version": "0.5.0-rc.2",
3
+ "version": "0.5.0",
4
4
  "description": "Agentic CLI to check and upgrade npm/pnpm dependencies for CI workflows",
5
5
  "type": "module",
6
6
  "private": false,
@@ -47,6 +47,7 @@
47
47
  "test": "bun test",
48
48
  "lint": "bunx biome check src tests",
49
49
  "check": "bun run typecheck && bun test",
50
+ "perf:smoke": "node scripts/perf-smoke.mjs",
50
51
  "test:prod": "node dist/bin/cli.js --help && node dist/bin/cli.js --version",
51
52
  "prepublishOnly": "bun run check && bun run build && bun run test:prod"
52
53
  },