@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.
- package/CHANGELOG.md +84 -1
- package/README.md +8 -1
- package/dist/bin/cli.js +62 -12
- package/dist/commands/audit/fetcher.d.ts +6 -0
- package/dist/commands/audit/fetcher.js +79 -0
- package/dist/commands/audit/mapper.d.ts +16 -0
- package/dist/commands/audit/mapper.js +61 -0
- package/dist/commands/audit/parser.d.ts +3 -0
- package/dist/commands/audit/parser.js +87 -0
- package/dist/commands/audit/runner.d.ts +7 -0
- package/dist/commands/audit/runner.js +64 -0
- package/dist/commands/bisect/engine.d.ts +12 -0
- package/dist/commands/bisect/engine.js +89 -0
- package/dist/commands/bisect/oracle.d.ts +7 -0
- package/dist/commands/bisect/oracle.js +36 -0
- package/dist/commands/bisect/parser.d.ts +2 -0
- package/dist/commands/bisect/parser.js +73 -0
- package/dist/commands/bisect/runner.d.ts +6 -0
- package/dist/commands/bisect/runner.js +27 -0
- package/dist/commands/health/parser.d.ts +2 -0
- package/dist/commands/health/parser.js +90 -0
- package/dist/commands/health/runner.d.ts +7 -0
- package/dist/commands/health/runner.js +130 -0
- package/dist/config/loader.d.ts +5 -1
- package/dist/config/policy.d.ts +4 -0
- package/dist/config/policy.js +2 -0
- package/dist/core/check.js +56 -3
- package/dist/core/fix-pr-batch.js +3 -2
- package/dist/core/fix-pr.js +19 -4
- package/dist/core/init-ci.js +3 -3
- package/dist/core/options.d.ts +10 -1
- package/dist/core/options.js +129 -13
- package/dist/core/summary.d.ts +1 -0
- package/dist/core/summary.js +11 -1
- package/dist/core/upgrade.js +10 -0
- package/dist/core/warm-cache.js +19 -1
- package/dist/output/format.js +4 -0
- package/dist/output/github.js +3 -0
- package/dist/registry/npm.d.ts +9 -2
- package/dist/registry/npm.js +87 -17
- package/dist/types/index.d.ts +83 -0
- package/dist/utils/lockfile.d.ts +5 -0
- package/dist/utils/lockfile.js +44 -0
- 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,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,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
|
+
}
|
package/dist/config/loader.d.ts
CHANGED
|
@@ -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;
|
package/dist/config/policy.d.ts
CHANGED
|
@@ -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[];
|
package/dist/config/policy.js
CHANGED
|
@@ -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) {
|
package/dist/core/check.js
CHANGED
|
@@ -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
|
|
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
|
|
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);
|