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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +84 -1
  2. package/README.md +8 -1
  3. package/dist/bin/cli.js +62 -12
  4. package/dist/commands/audit/fetcher.d.ts +6 -0
  5. package/dist/commands/audit/fetcher.js +79 -0
  6. package/dist/commands/audit/mapper.d.ts +16 -0
  7. package/dist/commands/audit/mapper.js +61 -0
  8. package/dist/commands/audit/parser.d.ts +3 -0
  9. package/dist/commands/audit/parser.js +87 -0
  10. package/dist/commands/audit/runner.d.ts +7 -0
  11. package/dist/commands/audit/runner.js +64 -0
  12. package/dist/commands/bisect/engine.d.ts +12 -0
  13. package/dist/commands/bisect/engine.js +89 -0
  14. package/dist/commands/bisect/oracle.d.ts +7 -0
  15. package/dist/commands/bisect/oracle.js +36 -0
  16. package/dist/commands/bisect/parser.d.ts +2 -0
  17. package/dist/commands/bisect/parser.js +73 -0
  18. package/dist/commands/bisect/runner.d.ts +6 -0
  19. package/dist/commands/bisect/runner.js +27 -0
  20. package/dist/commands/health/parser.d.ts +2 -0
  21. package/dist/commands/health/parser.js +90 -0
  22. package/dist/commands/health/runner.d.ts +7 -0
  23. package/dist/commands/health/runner.js +130 -0
  24. package/dist/config/loader.d.ts +5 -1
  25. package/dist/config/policy.d.ts +4 -0
  26. package/dist/config/policy.js +2 -0
  27. package/dist/core/check.js +56 -3
  28. package/dist/core/fix-pr-batch.js +3 -2
  29. package/dist/core/fix-pr.js +19 -4
  30. package/dist/core/init-ci.js +3 -3
  31. package/dist/core/options.d.ts +10 -1
  32. package/dist/core/options.js +129 -13
  33. package/dist/core/summary.d.ts +1 -0
  34. package/dist/core/summary.js +11 -1
  35. package/dist/core/upgrade.js +10 -0
  36. package/dist/core/warm-cache.js +19 -1
  37. package/dist/output/format.js +4 -0
  38. package/dist/output/github.js +3 -0
  39. package/dist/registry/npm.d.ts +9 -2
  40. package/dist/registry/npm.js +87 -17
  41. package/dist/types/index.d.ts +83 -0
  42. package/dist/utils/lockfile.d.ts +5 -0
  43. package/dist/utils/lockfile.js +44 -0
  44. package/package.json +13 -4
@@ -0,0 +1,89 @@
1
+ import { NpmRegistryClient } from "../../registry/npm.js";
2
+ import { VersionCache } from "../../cache/cache.js";
3
+ import { bisectOracle } from "./oracle.js";
4
+ /**
5
+ * Binary search engine for dependency bisecting.
6
+ * Given a sorted list of versions, finds the exact version that causes
7
+ * a test oracle to switch from "good" → "bad".
8
+ */
9
+ export async function bisectVersions(versions, options) {
10
+ const result = {
11
+ packageName: options.packageName,
12
+ breakingVersion: null,
13
+ lastGoodVersion: null,
14
+ totalVersionsTested: 0,
15
+ iterations: 0,
16
+ };
17
+ if (versions.length === 0) {
18
+ return result;
19
+ }
20
+ let lo = 0;
21
+ let hi = versions.length - 1;
22
+ // Verify boundaries: hi must be "bad", lo must be "good"
23
+ const hiOutcome = await bisectOracle(options.packageName, versions[hi], options);
24
+ result.totalVersionsTested += 1;
25
+ result.iterations += 1;
26
+ if (hiOutcome !== "bad") {
27
+ // Latest version is good — nothing to bisect
28
+ return result;
29
+ }
30
+ const loOutcome = await bisectOracle(options.packageName, versions[lo], options);
31
+ result.totalVersionsTested += 1;
32
+ result.iterations += 1;
33
+ if (loOutcome === "bad") {
34
+ // Even the first version fails — can't determine boundary
35
+ result.breakingVersion = versions[lo];
36
+ return result;
37
+ }
38
+ // Binary search
39
+ while (lo + 1 < hi) {
40
+ const mid = Math.floor((lo + hi) / 2);
41
+ const outcome = await bisectOracle(options.packageName, versions[mid], options);
42
+ result.totalVersionsTested += 1;
43
+ result.iterations += 1;
44
+ if (outcome === "bad") {
45
+ hi = mid;
46
+ }
47
+ else if (outcome === "good") {
48
+ lo = mid;
49
+ }
50
+ else {
51
+ // "skip" — skip this version and go toward bad side
52
+ hi = mid - 1;
53
+ }
54
+ }
55
+ result.lastGoodVersion = versions[lo];
56
+ result.breakingVersion = versions[hi];
57
+ return result;
58
+ }
59
+ /**
60
+ * Fetches available versions for a package from registry/cache,
61
+ * optionally filtered to a user-specified range, sorted ascending.
62
+ */
63
+ export async function fetchBisectVersions(options) {
64
+ const cache = await VersionCache.create();
65
+ const registry = new NpmRegistryClient(options.cwd, {
66
+ timeoutMs: options.registryTimeoutMs,
67
+ retries: 2,
68
+ });
69
+ const cached = await cache.getAny(options.packageName, "latest");
70
+ let allVersions = cached?.availableVersions ?? [];
71
+ if (allVersions.length === 0) {
72
+ const result = await registry.resolveManyPackageMetadata([options.packageName], {
73
+ concurrency: 1,
74
+ retries: 2,
75
+ timeoutMs: options.registryTimeoutMs,
76
+ });
77
+ const meta = result.metadata.get(options.packageName);
78
+ allVersions = meta?.versions ?? [];
79
+ }
80
+ if (options.versionRange) {
81
+ const [rangeStart, rangeEnd] = options.versionRange.split("..");
82
+ allVersions = allVersions.filter((v) => {
83
+ const afterStart = !rangeStart || v >= rangeStart;
84
+ const beforeEnd = !rangeEnd || v <= rangeEnd;
85
+ return afterStart && beforeEnd;
86
+ });
87
+ }
88
+ return allVersions.sort((a, b) => a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" }));
89
+ }
@@ -0,0 +1,7 @@
1
+ import type { BisectOptions, BisectOutcome } from "../../types/index.js";
2
+ /**
3
+ * The "oracle" for bisect: installs a specific version of a package
4
+ * into the project's node_modules (via the shell), then runs --cmd.
5
+ * Returns "good" (exit 0), "bad" (non-zero exit), or "skip" on install error.
6
+ */
7
+ export declare function bisectOracle(packageName: string, version: string, options: BisectOptions): Promise<BisectOutcome>;
@@ -0,0 +1,36 @@
1
+ import { spawn } from "node:child_process";
2
+ import path from "node:path";
3
+ /**
4
+ * The "oracle" for bisect: installs a specific version of a package
5
+ * into the project's node_modules (via the shell), then runs --cmd.
6
+ * Returns "good" (exit 0), "bad" (non-zero exit), or "skip" on install error.
7
+ */
8
+ export async function bisectOracle(packageName, version, options) {
9
+ if (options.dryRun) {
10
+ // In dry-run mode, simulate the oracle without side effects
11
+ process.stderr.write(`[bisect:dry-run] Would test ${packageName}@${version}\n`);
12
+ return "skip";
13
+ }
14
+ const installResult = await runShell(`npm install --no-save ${packageName}@${version}`, options.cwd);
15
+ if (installResult !== 0) {
16
+ process.stderr.write(`[bisect] Failed to install ${packageName}@${version}, skipping.\n`);
17
+ return "skip";
18
+ }
19
+ process.stderr.write(`[bisect] Testing ${packageName}@${version}...\n`);
20
+ const testResult = await runShell(options.testCommand, options.cwd);
21
+ const outcome = testResult === 0 ? "good" : "bad";
22
+ process.stderr.write(`[bisect] ${packageName}@${version} → ${outcome}\n`);
23
+ return outcome;
24
+ }
25
+ function runShell(command, cwd) {
26
+ return new Promise((resolve) => {
27
+ const [bin, ...args] = command.split(" ");
28
+ const child = spawn(bin, args, {
29
+ cwd: path.resolve(cwd),
30
+ shell: true,
31
+ stdio: "pipe",
32
+ });
33
+ child.on("close", (code) => resolve(code ?? 1));
34
+ child.on("error", () => resolve(1));
35
+ });
36
+ }
@@ -0,0 +1,2 @@
1
+ import type { BisectOptions } from "../../types/index.js";
2
+ export declare function parseBisectArgs(args: string[]): BisectOptions;
@@ -0,0 +1,73 @@
1
+ import path from "node:path";
2
+ import process from "node:process";
3
+ export function parseBisectArgs(args) {
4
+ const options = {
5
+ cwd: process.cwd(),
6
+ packageName: "",
7
+ versionRange: undefined,
8
+ testCommand: "npm test",
9
+ concurrency: 4,
10
+ registryTimeoutMs: 8000,
11
+ cacheTtlSeconds: 3600,
12
+ dryRun: false,
13
+ };
14
+ let index = 0;
15
+ while (index < args.length) {
16
+ const current = args[index];
17
+ const next = args[index + 1];
18
+ if (!current.startsWith("-") && !options.packageName) {
19
+ options.packageName = current;
20
+ index += 1;
21
+ continue;
22
+ }
23
+ if (current === "--cmd" && next) {
24
+ options.testCommand = next;
25
+ index += 2;
26
+ continue;
27
+ }
28
+ if (current === "--cmd") {
29
+ throw new Error("Missing value for --cmd");
30
+ }
31
+ if (current === "--range" && next) {
32
+ options.versionRange = next;
33
+ index += 2;
34
+ continue;
35
+ }
36
+ if (current === "--range") {
37
+ throw new Error("Missing value for --range");
38
+ }
39
+ if (current === "--cwd" && next) {
40
+ options.cwd = path.resolve(next);
41
+ index += 2;
42
+ continue;
43
+ }
44
+ if (current === "--cwd") {
45
+ throw new Error("Missing value for --cwd");
46
+ }
47
+ if (current === "--dry-run") {
48
+ options.dryRun = true;
49
+ index += 1;
50
+ continue;
51
+ }
52
+ if (current === "--registry-timeout-ms" && next) {
53
+ const parsed = Number(next);
54
+ if (!Number.isInteger(parsed) || parsed <= 0) {
55
+ throw new Error("--registry-timeout-ms must be a positive integer");
56
+ }
57
+ options.registryTimeoutMs = parsed;
58
+ index += 2;
59
+ continue;
60
+ }
61
+ if (current === "--registry-timeout-ms") {
62
+ throw new Error("Missing value for --registry-timeout-ms");
63
+ }
64
+ if (current.startsWith("-")) {
65
+ throw new Error(`Unknown bisect option: ${current}`);
66
+ }
67
+ throw new Error(`Unexpected bisect argument: ${current}`);
68
+ }
69
+ if (!options.packageName) {
70
+ throw new Error('bisect requires a package name: rup bisect <package> --cmd "<test command>"');
71
+ }
72
+ return options;
73
+ }
@@ -0,0 +1,6 @@
1
+ import type { BisectOptions, BisectResult } from "../../types/index.js";
2
+ /**
3
+ * Entry point for the `bisect` command. Lazy-loaded by cli.ts.
4
+ * Fully isolated: does NOT import anything from core/options.ts.
5
+ */
6
+ export declare function runBisect(options: BisectOptions): Promise<BisectResult>;
@@ -0,0 +1,27 @@
1
+ import { fetchBisectVersions, bisectVersions } from "./engine.js";
2
+ /**
3
+ * Entry point for the `bisect` command. Lazy-loaded by cli.ts.
4
+ * Fully isolated: does NOT import anything from core/options.ts.
5
+ */
6
+ export async function runBisect(options) {
7
+ process.stderr.write(`\n[bisect] Fetching available versions for ${options.packageName}...\n`);
8
+ const versions = await fetchBisectVersions(options);
9
+ if (versions.length === 0) {
10
+ throw new Error(`No versions found for package "${options.packageName}".`);
11
+ }
12
+ process.stderr.write(`[bisect] Found ${versions.length} versions. Starting binary search...\n`);
13
+ if (options.versionRange) {
14
+ process.stderr.write(`[bisect] Range: ${options.versionRange}\n`);
15
+ }
16
+ const result = await bisectVersions(versions, options);
17
+ if (result.breakingVersion) {
18
+ process.stdout.write(`\n✖ Break introduced in ${options.packageName}@${result.breakingVersion}\n` +
19
+ ` Last good version: ${result.lastGoodVersion ?? "none"}\n` +
20
+ ` Tested: ${result.totalVersionsTested} versions in ${result.iterations} iterations\n`);
21
+ }
22
+ else {
23
+ process.stdout.write(`\n✔ No breaking version found for ${options.packageName} (all versions passed).\n` +
24
+ ` Tested: ${result.totalVersionsTested} versions\n`);
25
+ }
26
+ return result;
27
+ }
@@ -0,0 +1,2 @@
1
+ import type { HealthOptions } from "../../types/index.js";
2
+ export declare function parseHealthArgs(args: string[]): HealthOptions;
@@ -0,0 +1,90 @@
1
+ import path from "node:path";
2
+ import process from "node:process";
3
+ export function parseHealthArgs(args) {
4
+ const options = {
5
+ cwd: process.cwd(),
6
+ workspace: false,
7
+ staleDays: 365,
8
+ includeDeprecated: true,
9
+ includeAlternatives: false,
10
+ reportFormat: "table",
11
+ jsonFile: undefined,
12
+ concurrency: 16,
13
+ registryTimeoutMs: 8000,
14
+ };
15
+ let index = 0;
16
+ while (index < args.length) {
17
+ const current = args[index];
18
+ const next = args[index + 1];
19
+ if (current === "--cwd" && next) {
20
+ options.cwd = path.resolve(next);
21
+ index += 2;
22
+ continue;
23
+ }
24
+ if (current === "--cwd")
25
+ throw new Error("Missing value for --cwd");
26
+ if (current === "--workspace") {
27
+ options.workspace = true;
28
+ index += 1;
29
+ continue;
30
+ }
31
+ if (current === "--stale" && next) {
32
+ // Accept "12m" → 365, "6m" → 180, "365d" → 365, or plain number
33
+ const match = next.match(/^(\d+)(m|d)?$/);
34
+ if (!match)
35
+ throw new Error("--stale must be a number of days or a duration like 12m or 180d");
36
+ const value = parseInt(match[1], 10);
37
+ const unit = match[2] ?? "d";
38
+ options.staleDays = unit === "m" ? value * 30 : value;
39
+ index += 2;
40
+ continue;
41
+ }
42
+ if (current === "--stale")
43
+ throw new Error("Missing value for --stale");
44
+ if (current === "--deprecated") {
45
+ options.includeDeprecated = true;
46
+ index += 1;
47
+ continue;
48
+ }
49
+ if (current === "--no-deprecated") {
50
+ options.includeDeprecated = false;
51
+ index += 1;
52
+ continue;
53
+ }
54
+ if (current === "--alternatives") {
55
+ options.includeAlternatives = true;
56
+ index += 1;
57
+ continue;
58
+ }
59
+ if (current === "--report" && next) {
60
+ if (next !== "table" && next !== "json")
61
+ throw new Error("--report must be table or json");
62
+ options.reportFormat = next;
63
+ index += 2;
64
+ continue;
65
+ }
66
+ if (current === "--report")
67
+ throw new Error("Missing value for --report");
68
+ if (current === "--json-file" && next) {
69
+ options.jsonFile = path.resolve(options.cwd, next);
70
+ index += 2;
71
+ continue;
72
+ }
73
+ if (current === "--json-file")
74
+ throw new Error("Missing value for --json-file");
75
+ if (current === "--concurrency" && next) {
76
+ const parsed = Number(next);
77
+ if (!Number.isInteger(parsed) || parsed <= 0)
78
+ throw new Error("--concurrency must be positive integer");
79
+ options.concurrency = parsed;
80
+ index += 2;
81
+ continue;
82
+ }
83
+ if (current === "--concurrency")
84
+ throw new Error("Missing value for --concurrency");
85
+ if (current.startsWith("-"))
86
+ throw new Error(`Unknown health option: ${current}`);
87
+ throw new Error(`Unexpected health argument: ${current}`);
88
+ }
89
+ return options;
90
+ }
@@ -0,0 +1,7 @@
1
+ import type { HealthOptions, HealthResult } from "../../types/index.js";
2
+ /**
3
+ * Lazy-loaded entry point for `rup health`.
4
+ * Discovers packages, queries npm registry for publication metadata,
5
+ * detects stale and deprecated packages, and renders a health report.
6
+ */
7
+ export declare function runHealth(options: HealthOptions): Promise<HealthResult>;
@@ -0,0 +1,130 @@
1
+ import { collectDependencies, readManifest, } from "../../parsers/package-json.js";
2
+ import { discoverPackageDirs } from "../../workspace/discover.js";
3
+ import { asyncPool } from "../../utils/async-pool.js";
4
+ import { writeFileAtomic } from "../../utils/io.js";
5
+ import { stableStringify } from "../../utils/stable-json.js";
6
+ async function fetchNpmMeta(packageName, timeoutMs) {
7
+ try {
8
+ const controller = new AbortController();
9
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
10
+ const response = await fetch(`https://registry.npmjs.org/${encodeURIComponent(packageName)}`, {
11
+ signal: controller.signal,
12
+ headers: { Accept: "application/vnd.npm.install-v1+json" },
13
+ });
14
+ clearTimeout(timer);
15
+ if (!response.ok)
16
+ return {};
17
+ return (await response.json());
18
+ }
19
+ catch {
20
+ return {};
21
+ }
22
+ }
23
+ function analyzePackage(name, currentVersion, meta, options) {
24
+ const flags = [];
25
+ const isDeprecated = typeof meta.deprecated === "string" && meta.deprecated.length > 0;
26
+ const now = Date.now();
27
+ let lastPublished = null;
28
+ let daysSinceLastRelease = null;
29
+ if (meta.time) {
30
+ const releaseTimes = Object.entries(meta.time)
31
+ .filter(([k]) => k !== "created" && k !== "modified")
32
+ .map(([, v]) => new Date(v).getTime())
33
+ .filter((t) => !Number.isNaN(t))
34
+ .sort((a, b) => b - a);
35
+ if (releaseTimes[0]) {
36
+ lastPublished = new Date(releaseTimes[0]).toISOString().slice(0, 10);
37
+ daysSinceLastRelease = Math.floor((now - releaseTimes[0]) / (1000 * 60 * 60 * 24));
38
+ }
39
+ }
40
+ if (isDeprecated && options.includeDeprecated)
41
+ flags.push("deprecated");
42
+ if (daysSinceLastRelease !== null && daysSinceLastRelease > options.staleDays)
43
+ flags.push("stale");
44
+ if (daysSinceLastRelease !== null &&
45
+ daysSinceLastRelease > options.staleDays * 2)
46
+ flags.push("unmaintained");
47
+ return {
48
+ name,
49
+ currentVersion,
50
+ lastPublished,
51
+ isDeprecated,
52
+ deprecatedMessage: isDeprecated ? meta.deprecated : undefined,
53
+ isArchived: false, // Would require GitHub API; deferred
54
+ daysSinceLastRelease,
55
+ flags,
56
+ };
57
+ }
58
+ function renderHealthTable(metrics) {
59
+ const flagged = metrics.filter((m) => m.flags.length > 0);
60
+ if (flagged.length === 0)
61
+ return "✔ All packages appear healthy.\n";
62
+ const lines = [
63
+ `Found ${flagged.length} package${flagged.length === 1 ? "" : "s"} with health concerns:\n`,
64
+ "Package".padEnd(30) +
65
+ "Flags".padEnd(25) +
66
+ "Last Release".padEnd(15) +
67
+ "Message",
68
+ "─".repeat(90),
69
+ ];
70
+ for (const m of flagged) {
71
+ const name = m.name.slice(0, 28).padEnd(30);
72
+ const flags = m.flags.join(", ").padEnd(25);
73
+ const lastRel = (m.lastPublished ?? "unknown").padEnd(15);
74
+ const msg = m.deprecatedMessage ??
75
+ (m.daysSinceLastRelease !== null ? `${m.daysSinceLastRelease}d ago` : "");
76
+ lines.push(`${name}${flags}${lastRel}${msg}`);
77
+ }
78
+ return lines.join("\n");
79
+ }
80
+ /**
81
+ * Lazy-loaded entry point for `rup health`.
82
+ * Discovers packages, queries npm registry for publication metadata,
83
+ * detects stale and deprecated packages, and renders a health report.
84
+ */
85
+ export async function runHealth(options) {
86
+ const result = {
87
+ metrics: [],
88
+ totalFlagged: 0,
89
+ errors: [],
90
+ warnings: [],
91
+ };
92
+ const packageDirs = await discoverPackageDirs(options.cwd, options.workspace);
93
+ const versionMap = new Map();
94
+ for (const dir of packageDirs) {
95
+ let manifest;
96
+ try {
97
+ manifest = await readManifest(dir);
98
+ }
99
+ catch (error) {
100
+ result.errors.push(`Failed to read package.json in ${dir}: ${String(error)}`);
101
+ continue;
102
+ }
103
+ const deps = collectDependencies(manifest, [
104
+ "dependencies",
105
+ "devDependencies",
106
+ "optionalDependencies",
107
+ ]);
108
+ for (const dep of deps) {
109
+ if (!versionMap.has(dep.name)) {
110
+ versionMap.set(dep.name, dep.range.replace(/^[\^~>=<]/, ""));
111
+ }
112
+ }
113
+ }
114
+ const entries = [...versionMap.entries()];
115
+ process.stderr.write(`[health] Analyzing ${entries.length} packages...\n`);
116
+ const tasks = entries.map(([name, version]) => async () => {
117
+ const meta = await fetchNpmMeta(name, options.registryTimeoutMs);
118
+ return analyzePackage(name, version, meta, options);
119
+ });
120
+ const rawResults = await asyncPool(options.concurrency, tasks);
121
+ const metrics = rawResults.filter((r) => !(r instanceof Error));
122
+ result.metrics = metrics.sort((a, b) => (b.daysSinceLastRelease ?? 0) - (a.daysSinceLastRelease ?? 0));
123
+ result.totalFlagged = result.metrics.filter((m) => m.flags.length > 0).length;
124
+ process.stdout.write(renderHealthTable(result.metrics) + "\n");
125
+ if (options.jsonFile) {
126
+ await writeFileAtomic(options.jsonFile, stableStringify(result, 2) + "\n");
127
+ process.stderr.write(`[health] JSON report written to ${options.jsonFile}\n`);
128
+ }
129
+ return result;
130
+ }
@@ -1,4 +1,4 @@
1
- import type { CiProfile, DependencyKind, FailOnLevel, GroupBy, LogLevel, OutputFormat, TargetLevel } from "../types/index.js";
1
+ import type { CiProfile, DependencyKind, FailOnLevel, GroupBy, LockfileMode, LogLevel, OutputFormat, TargetLevel } from "../types/index.js";
2
2
  export interface FileConfig {
3
3
  target?: TargetLevel;
4
4
  filter?: string;
@@ -12,7 +12,10 @@ export interface FileConfig {
12
12
  githubOutputFile?: string;
13
13
  sarifFile?: string;
14
14
  concurrency?: number;
15
+ registryTimeoutMs?: number;
16
+ registryRetries?: number;
15
17
  offline?: boolean;
18
+ stream?: boolean;
16
19
  policyFile?: string;
17
20
  prReportFile?: string;
18
21
  failOn?: FailOnLevel;
@@ -31,6 +34,7 @@ export interface FileConfig {
31
34
  prLimit?: number;
32
35
  onlyChanged?: boolean;
33
36
  ciProfile?: CiProfile;
37
+ lockfileMode?: LockfileMode;
34
38
  install?: boolean;
35
39
  packageManager?: "auto" | "npm" | "pnpm";
36
40
  sync?: boolean;
@@ -11,6 +11,8 @@ export interface PolicyConfig {
11
11
  allowPrerelease?: boolean;
12
12
  group?: string;
13
13
  priority?: number;
14
+ target?: TargetLevel;
15
+ autofix?: boolean;
14
16
  }>;
15
17
  }
16
18
  export interface PolicyRule {
@@ -22,6 +24,8 @@ export interface PolicyRule {
22
24
  allowPrerelease?: boolean;
23
25
  group?: string;
24
26
  priority?: number;
27
+ target?: TargetLevel;
28
+ autofix?: boolean;
25
29
  }
26
30
  export interface ResolvedPolicy {
27
31
  ignorePatterns: string[];
@@ -46,6 +46,8 @@ function normalizeRule(rule) {
46
46
  allowPrerelease: rule.allowPrerelease === true,
47
47
  group: typeof rule.group === "string" && rule.group.trim().length > 0 ? rule.group.trim() : undefined,
48
48
  priority: asNonNegativeInt(rule.priority),
49
+ target: rule.target,
50
+ autofix: rule.autofix !== false,
49
51
  };
50
52
  }
51
53
  function asNonNegativeInt(value) {
@@ -1,4 +1,5 @@
1
1
  import path from "node:path";
2
+ import process from "node:process";
2
3
  import { collectDependencies, readManifest } from "../parsers/package-json.js";
3
4
  import { matchesPattern } from "../utils/pattern.js";
4
5
  import { applyRangeStyle, classifyDiff, clampTarget, pickTargetVersionFromAvailable } from "../utils/semver.js";
@@ -18,7 +19,10 @@ export async function check(options) {
18
19
  const packageDirs = await discoverPackageDirs(options.cwd, options.workspace);
19
20
  discoveryMs += Date.now() - discoveryStartedAt;
20
21
  const cache = await VersionCache.create();
21
- const registryClient = new NpmRegistryClient(options.cwd);
22
+ const registryClient = new NpmRegistryClient(options.cwd, {
23
+ timeoutMs: options.registryTimeoutMs,
24
+ retries: options.registryRetries,
25
+ });
22
26
  const policy = await loadPolicy(options.cwd, options.policyFile);
23
27
  const updates = [];
24
28
  const errors = [];
@@ -30,6 +34,14 @@ export async function check(options) {
30
34
  const tasks = [];
31
35
  let skipped = 0;
32
36
  let cooldownSkipped = 0;
37
+ let streamedEvents = 0;
38
+ let policyOverridesApplied = 0;
39
+ const emitStream = (message) => {
40
+ if (!options.stream)
41
+ return;
42
+ streamedEvents += 1;
43
+ process.stdout.write(`${message}\n`);
44
+ };
33
45
  for (const packageDir of packageDirs) {
34
46
  let manifest;
35
47
  try {
@@ -49,10 +61,14 @@ export async function check(options) {
49
61
  const rule = resolvePolicyRule(dep.name, policy);
50
62
  if (rule?.ignore === true) {
51
63
  skipped += 1;
64
+ policyOverridesApplied += 1;
65
+ emitStream(`[policy-ignore] ${dep.name}`);
52
66
  continue;
53
67
  }
54
68
  if (policy.ignorePatterns.some((pattern) => matchesPattern(dep.name, pattern))) {
55
69
  skipped += 1;
70
+ policyOverridesApplied += 1;
71
+ emitStream(`[policy-ignore-pattern] ${dep.name}`);
56
72
  continue;
57
73
  }
58
74
  tasks.push({ packageDir, dependency: dep });
@@ -99,6 +115,8 @@ export async function check(options) {
99
115
  const registryStartedAt = Date.now();
100
116
  const fetched = await registryClient.resolveManyPackageMetadata(unresolvedPackages, {
101
117
  concurrency: options.concurrency,
118
+ retries: options.registryRetries,
119
+ timeoutMs: options.registryTimeoutMs,
102
120
  });
103
121
  registryMs += Date.now() - registryStartedAt;
104
122
  const cacheWriteStartedAt = Date.now();
@@ -126,6 +144,7 @@ export async function check(options) {
126
144
  }
127
145
  else {
128
146
  errors.push(`Unable to resolve ${packageName}: ${error}`);
147
+ emitStream(`[error] Unable to resolve ${packageName}: ${error}`);
129
148
  }
130
149
  }
131
150
  cacheMs += Date.now() - cacheStaleStartedAt;
@@ -136,12 +155,17 @@ export async function check(options) {
136
155
  if (!metadata?.latestVersion)
137
156
  continue;
138
157
  const rule = resolvePolicyRule(task.dependency.name, policy);
139
- const effectiveTarget = clampTarget(options.target, rule?.maxTarget);
158
+ const baseTarget = rule?.target ?? options.target;
159
+ const effectiveTarget = clampTarget(baseTarget, rule?.maxTarget);
160
+ if (rule?.target || rule?.maxTarget || rule?.autofix === false) {
161
+ policyOverridesApplied += 1;
162
+ }
140
163
  const picked = pickTargetVersionFromAvailable(task.dependency.range, metadata.availableVersions, metadata.latestVersion, effectiveTarget);
141
164
  if (!picked)
142
165
  continue;
143
166
  if (shouldSkipByCooldown(picked, metadata.publishedAtByVersion, options.cooldownDays, policy.cooldownDays, rule?.cooldownDays)) {
144
167
  cooldownSkipped += 1;
168
+ emitStream(`[cooldown-skip] ${task.dependency.name}@${picked}`);
145
169
  continue;
146
170
  }
147
171
  const nextRange = applyRangeStyle(task.dependency.range, picked);
@@ -156,14 +180,17 @@ export async function check(options) {
156
180
  toVersionResolved: picked,
157
181
  diffType: classifyDiff(task.dependency.range, picked),
158
182
  filtered: false,
183
+ autofix: rule?.autofix !== false,
159
184
  reason: rule?.maxTarget ? `policy maxTarget=${rule.maxTarget}` : undefined,
160
185
  });
186
+ emitStream(`[update] ${task.dependency.name} ${task.dependency.range} -> ${nextRange} (${classifyDiff(task.dependency.range, picked)})`);
161
187
  }
162
188
  const grouped = groupUpdates(updates, options.groupBy);
163
189
  const groupedUpdates = grouped.length;
164
190
  const groupedSorted = sortUpdates(grouped.flatMap((group) => group.items));
165
191
  const groupedCapped = typeof options.groupMax === "number" ? groupedSorted.slice(0, options.groupMax) : groupedSorted;
166
- const ruleLimited = applyRuleUpdateCaps(groupedCapped, policy);
192
+ const prioritized = applyPolicyPrioritySort(groupedCapped, policy);
193
+ const ruleLimited = applyRuleUpdateCaps(prioritized, policy);
167
194
  const prLimited = typeof options.prLimit === "number" ? ruleLimited.slice(0, options.prLimit) : ruleLimited;
168
195
  const limitedUpdates = sortUpdates(prLimited);
169
196
  const prLimitHit = typeof options.prLimit === "number" && groupedSorted.length > options.prLimit;
@@ -189,7 +216,9 @@ export async function check(options) {
189
216
  cooldownSkipped,
190
217
  ciProfile: options.ciProfile,
191
218
  prLimitHit,
219
+ policyOverridesApplied,
192
220
  }));
221
+ summary.streamedEvents = streamedEvents;
193
222
  return {
194
223
  projectPath: options.cwd,
195
224
  packagePaths: packageDirs,
@@ -266,6 +295,30 @@ function applyRuleUpdateCaps(updates, policy) {
266
295
  }
267
296
  return limited;
268
297
  }
298
+ function applyPolicyPrioritySort(updates, policy) {
299
+ return [...updates].sort((left, right) => {
300
+ const leftPriority = resolvePolicyRule(left.name, policy)?.priority ?? 0;
301
+ const rightPriority = resolvePolicyRule(right.name, policy)?.priority ?? 0;
302
+ if (leftPriority !== rightPriority)
303
+ return rightPriority - leftPriority;
304
+ const byRisk = riskRank(left.diffType) - riskRank(right.diffType);
305
+ if (byRisk !== 0)
306
+ return byRisk;
307
+ const byName = left.name.localeCompare(right.name);
308
+ if (byName !== 0)
309
+ return byName;
310
+ return left.packagePath.localeCompare(right.packagePath);
311
+ });
312
+ }
313
+ function riskRank(value) {
314
+ if (value === "patch")
315
+ return 0;
316
+ if (value === "minor")
317
+ return 1;
318
+ if (value === "major")
319
+ return 2;
320
+ return 3;
321
+ }
269
322
  function sortUpdates(updates) {
270
323
  return [...updates].sort((left, right) => {
271
324
  const byPath = left.packagePath.localeCompare(right.packagePath);