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

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 (61) hide show
  1. package/CHANGELOG.md +93 -1
  2. package/README.md +88 -25
  3. package/dist/bin/cli.js +50 -1
  4. package/dist/commands/audit/fetcher.d.ts +2 -6
  5. package/dist/commands/audit/fetcher.js +2 -79
  6. package/dist/commands/audit/mapper.d.ts +8 -1
  7. package/dist/commands/audit/mapper.js +106 -10
  8. package/dist/commands/audit/parser.js +36 -2
  9. package/dist/commands/audit/runner.js +179 -15
  10. package/dist/commands/audit/sources/github.d.ts +2 -0
  11. package/dist/commands/audit/sources/github.js +125 -0
  12. package/dist/commands/audit/sources/index.d.ts +6 -0
  13. package/dist/commands/audit/sources/index.js +92 -0
  14. package/dist/commands/audit/sources/osv.d.ts +2 -0
  15. package/dist/commands/audit/sources/osv.js +131 -0
  16. package/dist/commands/audit/sources/types.d.ts +21 -0
  17. package/dist/commands/audit/sources/types.js +1 -0
  18. package/dist/commands/audit/targets.d.ts +20 -0
  19. package/dist/commands/audit/targets.js +314 -0
  20. package/dist/commands/changelog/fetcher.d.ts +9 -0
  21. package/dist/commands/changelog/fetcher.js +130 -0
  22. package/dist/commands/licenses/parser.d.ts +2 -0
  23. package/dist/commands/licenses/parser.js +116 -0
  24. package/dist/commands/licenses/runner.d.ts +9 -0
  25. package/dist/commands/licenses/runner.js +163 -0
  26. package/dist/commands/licenses/sbom.d.ts +10 -0
  27. package/dist/commands/licenses/sbom.js +70 -0
  28. package/dist/commands/resolve/graph/builder.d.ts +20 -0
  29. package/dist/commands/resolve/graph/builder.js +183 -0
  30. package/dist/commands/resolve/graph/conflict.d.ts +20 -0
  31. package/dist/commands/resolve/graph/conflict.js +52 -0
  32. package/dist/commands/resolve/graph/resolver.d.ts +17 -0
  33. package/dist/commands/resolve/graph/resolver.js +71 -0
  34. package/dist/commands/resolve/parser.d.ts +2 -0
  35. package/dist/commands/resolve/parser.js +89 -0
  36. package/dist/commands/resolve/runner.d.ts +13 -0
  37. package/dist/commands/resolve/runner.js +136 -0
  38. package/dist/commands/snapshot/parser.d.ts +2 -0
  39. package/dist/commands/snapshot/parser.js +80 -0
  40. package/dist/commands/snapshot/runner.d.ts +11 -0
  41. package/dist/commands/snapshot/runner.js +115 -0
  42. package/dist/commands/snapshot/store.d.ts +35 -0
  43. package/dist/commands/snapshot/store.js +158 -0
  44. package/dist/commands/unused/matcher.d.ts +22 -0
  45. package/dist/commands/unused/matcher.js +95 -0
  46. package/dist/commands/unused/parser.d.ts +2 -0
  47. package/dist/commands/unused/parser.js +95 -0
  48. package/dist/commands/unused/runner.d.ts +11 -0
  49. package/dist/commands/unused/runner.js +113 -0
  50. package/dist/commands/unused/scanner.d.ts +18 -0
  51. package/dist/commands/unused/scanner.js +129 -0
  52. package/dist/core/impact.d.ts +36 -0
  53. package/dist/core/impact.js +82 -0
  54. package/dist/core/options.d.ts +13 -1
  55. package/dist/core/options.js +35 -13
  56. package/dist/types/index.d.ts +187 -1
  57. package/dist/ui/tui.d.ts +6 -0
  58. package/dist/ui/tui.js +50 -0
  59. package/dist/utils/semver.d.ts +18 -0
  60. package/dist/utils/semver.js +88 -3
  61. package/package.json +8 -1
@@ -0,0 +1,95 @@
1
+ export function matchDependencies(manifest, importedPackages, packageDir, options) {
2
+ const declared = buildDeclaredMap(manifest, options);
3
+ const unused = [];
4
+ const missing = [];
5
+ // Find declared deps not seen in source imports
6
+ for (const [name, field] of declared) {
7
+ if (!importedPackages.has(name)) {
8
+ unused.push({
9
+ name,
10
+ kind: "declared-not-imported",
11
+ declaredIn: field,
12
+ });
13
+ }
14
+ }
15
+ // Find imports not declared in package.json
16
+ for (const importedName of importedPackages) {
17
+ if (!declared.has(importedName)) {
18
+ missing.push({
19
+ name: importedName,
20
+ kind: "imported-not-declared",
21
+ importedFrom: packageDir,
22
+ });
23
+ }
24
+ }
25
+ return { unused, missing };
26
+ }
27
+ /**
28
+ * Build a map of { packageName → fieldName } from package.json.
29
+ * Only includes the fields requested (e.g. skip devDependencies when
30
+ * includeDevDependencies is false).
31
+ */
32
+ function buildDeclaredMap(manifest, options) {
33
+ const result = new Map();
34
+ const fields = [
35
+ [
36
+ manifest.dependencies,
37
+ "dependencies",
38
+ ],
39
+ [
40
+ manifest.optionalDependencies,
41
+ "optionalDependencies",
42
+ ],
43
+ ];
44
+ if (options.includeDevDependencies) {
45
+ fields.push([
46
+ manifest.devDependencies,
47
+ "devDependencies",
48
+ ]);
49
+ }
50
+ // peerDependencies are intentionally excluded — they are rarely directly imported
51
+ // and are a separate concern handled by `rup resolve`.
52
+ for (const [deps, fieldName] of fields) {
53
+ if (!deps)
54
+ continue;
55
+ for (const name of Object.keys(deps)) {
56
+ if (!result.has(name)) {
57
+ result.set(name, fieldName);
58
+ }
59
+ }
60
+ }
61
+ return result;
62
+ }
63
+ /**
64
+ * Applies the unused/missing result of `matchDependencies` to a package.json
65
+ * manifest in-memory, removing `unused` entries. Returns the modified manifest
66
+ * as a formatted JSON string ready to write back to disk.
67
+ */
68
+ export function removeUnusedFromManifest(manifestJson, unused) {
69
+ if (unused.length === 0)
70
+ return manifestJson;
71
+ let parsed;
72
+ try {
73
+ parsed = JSON.parse(manifestJson);
74
+ }
75
+ catch {
76
+ return manifestJson;
77
+ }
78
+ const unusedNames = new Set(unused.map((u) => u.name));
79
+ const fields = [
80
+ "dependencies",
81
+ "devDependencies",
82
+ "optionalDependencies",
83
+ ];
84
+ for (const field of fields) {
85
+ const deps = parsed[field];
86
+ if (!deps || typeof deps !== "object")
87
+ continue;
88
+ for (const name of Object.keys(deps)) {
89
+ if (unusedNames.has(name)) {
90
+ delete deps[name];
91
+ }
92
+ }
93
+ }
94
+ return JSON.stringify(parsed, null, 2) + "\n";
95
+ }
@@ -0,0 +1,2 @@
1
+ import type { UnusedOptions } from "../../types/index.js";
2
+ export declare function parseUnusedArgs(args: string[]): UnusedOptions;
@@ -0,0 +1,95 @@
1
+ const DEFAULT_SRC_DIRS = ["src"];
2
+ export function parseUnusedArgs(args) {
3
+ const options = {
4
+ cwd: process.cwd(),
5
+ workspace: false,
6
+ srcDirs: DEFAULT_SRC_DIRS,
7
+ includeDevDependencies: true,
8
+ fix: false,
9
+ dryRun: false,
10
+ jsonFile: undefined,
11
+ concurrency: 16,
12
+ };
13
+ for (let i = 0; i < args.length; i++) {
14
+ const current = args[i];
15
+ const next = args[i + 1];
16
+ if (current === "--cwd" && next) {
17
+ options.cwd = next;
18
+ i++;
19
+ continue;
20
+ }
21
+ if (current === "--cwd")
22
+ throw new Error("Missing value for --cwd");
23
+ if (current === "--workspace") {
24
+ options.workspace = true;
25
+ continue;
26
+ }
27
+ if (current === "--src" && next) {
28
+ options.srcDirs = next
29
+ .split(",")
30
+ .map((s) => s.trim())
31
+ .filter(Boolean);
32
+ i++;
33
+ continue;
34
+ }
35
+ if (current === "--src")
36
+ throw new Error("Missing value for --src");
37
+ if (current === "--no-dev") {
38
+ options.includeDevDependencies = false;
39
+ continue;
40
+ }
41
+ if (current === "--fix") {
42
+ options.fix = true;
43
+ continue;
44
+ }
45
+ if (current === "--dry-run") {
46
+ options.dryRun = true;
47
+ continue;
48
+ }
49
+ if (current === "--json-file" && next) {
50
+ options.jsonFile = next;
51
+ i++;
52
+ continue;
53
+ }
54
+ if (current === "--json-file")
55
+ throw new Error("Missing value for --json-file");
56
+ if (current === "--concurrency" && next) {
57
+ const parsed = Number(next);
58
+ if (!Number.isInteger(parsed) || parsed <= 0)
59
+ throw new Error("--concurrency must be a positive integer");
60
+ options.concurrency = parsed;
61
+ i++;
62
+ continue;
63
+ }
64
+ if (current === "--concurrency")
65
+ throw new Error("Missing value for --concurrency");
66
+ if (current === "--help" || current === "-h") {
67
+ process.stdout.write(UNUSED_HELP);
68
+ process.exit(0);
69
+ }
70
+ if (current.startsWith("-"))
71
+ throw new Error(`Unknown option: ${current}`);
72
+ }
73
+ return options;
74
+ }
75
+ const UNUSED_HELP = `
76
+ rup unused — Detect unused and missing npm dependencies
77
+
78
+ Usage:
79
+ rup unused [options]
80
+
81
+ Options:
82
+ --src <dirs> Comma-separated source directories to scan (default: src)
83
+ --workspace Scan all workspace packages
84
+ --no-dev Exclude devDependencies from unused detection
85
+ --fix Remove unused dependencies from package.json
86
+ --dry-run Preview changes without writing
87
+ --json-file <path> Write JSON report to file
88
+ --cwd <path> Working directory (default: cwd)
89
+ --concurrency <n> Parallel file scanning concurrency (default: 16)
90
+ --help Show this help
91
+
92
+ Exit codes:
93
+ 0 No unused dependencies found
94
+ 1 Unused or missing dependencies detected
95
+ `.trimStart();
@@ -0,0 +1,11 @@
1
+ import type { UnusedOptions, UnusedResult } from "../../types/index.js";
2
+ /**
3
+ * Entry point for `rup unused`. Lazy-loaded by cli.ts.
4
+ *
5
+ * Strategy:
6
+ * 1. Collect all source directories to scan
7
+ * 2. Scan them in parallel for imported package names
8
+ * 3. Cross-reference against package.json declarations
9
+ * 4. Optionally apply --fix (remove unused from package.json)
10
+ */
11
+ export declare function runUnused(options: UnusedOptions): Promise<UnusedResult>;
@@ -0,0 +1,113 @@
1
+ import path from "node:path";
2
+ import process from "node:process";
3
+ import { readManifest, } from "../../parsers/package-json.js";
4
+ import { discoverPackageDirs } from "../../workspace/discover.js";
5
+ import { writeFileAtomic } from "../../utils/io.js";
6
+ import { stableStringify } from "../../utils/stable-json.js";
7
+ import { scanDirectory } from "./scanner.js";
8
+ import { matchDependencies, removeUnusedFromManifest } from "./matcher.js";
9
+ /**
10
+ * Entry point for `rup unused`. Lazy-loaded by cli.ts.
11
+ *
12
+ * Strategy:
13
+ * 1. Collect all source directories to scan
14
+ * 2. Scan them in parallel for imported package names
15
+ * 3. Cross-reference against package.json declarations
16
+ * 4. Optionally apply --fix (remove unused from package.json)
17
+ */
18
+ export async function runUnused(options) {
19
+ const result = {
20
+ unused: [],
21
+ missing: [],
22
+ totalUnused: 0,
23
+ totalMissing: 0,
24
+ errors: [],
25
+ warnings: [],
26
+ };
27
+ const packageDirs = await discoverPackageDirs(options.cwd, options.workspace);
28
+ for (const packageDir of packageDirs) {
29
+ // ─ Read manifest ─────────────────────────────────────────────────────────
30
+ let manifest;
31
+ try {
32
+ manifest = await readManifest(packageDir);
33
+ }
34
+ catch (error) {
35
+ result.errors.push(`Failed to read package.json in ${packageDir}: ${String(error)}`);
36
+ continue;
37
+ }
38
+ // ─ Scan source directories for imports ───────────────────────────────────
39
+ const allImports = new Set();
40
+ for (const srcDir of options.srcDirs) {
41
+ const scanTarget = path.isAbsolute(srcDir)
42
+ ? srcDir
43
+ : path.join(packageDir, srcDir);
44
+ const found = await scanDirectory(scanTarget);
45
+ for (const name of found)
46
+ allImports.add(name);
47
+ }
48
+ // Fallback: if no src dir exists, scan the package root itself
49
+ if (allImports.size === 0) {
50
+ const rootImports = await scanDirectory(packageDir);
51
+ for (const name of rootImports)
52
+ allImports.add(name);
53
+ }
54
+ // ─ Match declared vs imported ─────────────────────────────────────────────
55
+ const { unused, missing } = matchDependencies(manifest, allImports, packageDir, {
56
+ includeDevDependencies: options.includeDevDependencies,
57
+ });
58
+ result.unused.push(...unused);
59
+ result.missing.push(...missing);
60
+ // ─ Apply fix ─────────────────────────────────────────────────────────────
61
+ if (options.fix && unused.length > 0) {
62
+ if (options.dryRun) {
63
+ process.stderr.write(`[unused] --dry-run: would remove ${unused.length} unused dep(s) from ${packageDir}/package.json\n`);
64
+ }
65
+ else {
66
+ try {
67
+ const { promises: fs } = await import("node:fs");
68
+ const manifestPath = path.join(packageDir, "package.json");
69
+ const originalJson = await fs.readFile(manifestPath, "utf8");
70
+ const updatedJson = removeUnusedFromManifest(originalJson, unused);
71
+ await writeFileAtomic(manifestPath, updatedJson);
72
+ process.stderr.write(`[unused] Removed ${unused.length} unused dep(s) from ${packageDir}/package.json\n`);
73
+ }
74
+ catch (error) {
75
+ result.errors.push(`Failed to update package.json in ${packageDir}: ${String(error)}`);
76
+ }
77
+ }
78
+ }
79
+ }
80
+ result.totalUnused = result.unused.length;
81
+ result.totalMissing = result.missing.length;
82
+ // ─ Render output ─────────────────────────────────────────────────────────
83
+ process.stdout.write(renderUnusedTable(result) + "\n");
84
+ // ─ JSON report ───────────────────────────────────────────────────────────
85
+ if (options.jsonFile) {
86
+ await writeFileAtomic(options.jsonFile, stableStringify(result, 2) + "\n");
87
+ process.stderr.write(`[unused] JSON report written to ${options.jsonFile}\n`);
88
+ }
89
+ return result;
90
+ }
91
+ function renderUnusedTable(result) {
92
+ const lines = [];
93
+ if (result.unused.length === 0 && result.missing.length === 0) {
94
+ return "✔ No unused or missing dependencies found.";
95
+ }
96
+ if (result.unused.length > 0) {
97
+ lines.push(`\n⚠ Unused dependencies (${result.unused.length}) — declared but never imported:\n`);
98
+ lines.push(" " + "Package".padEnd(35) + "Declared in");
99
+ lines.push(" " + "─".repeat(55));
100
+ for (const dep of result.unused) {
101
+ lines.push(" " + dep.name.padEnd(35) + (dep.declaredIn ?? ""));
102
+ }
103
+ }
104
+ if (result.missing.length > 0) {
105
+ lines.push(`\n✖ Missing dependencies (${result.missing.length}) — imported but not declared:\n`);
106
+ lines.push(" " + "Package".padEnd(35) + "Imported from");
107
+ lines.push(" " + "─".repeat(55));
108
+ for (const dep of result.missing) {
109
+ lines.push(" " + dep.name.padEnd(35) + (dep.importedFrom ?? ""));
110
+ }
111
+ }
112
+ return lines.join("\n");
113
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Extracts all imported package names from a single source file.
3
+ *
4
+ * Handles:
5
+ * - ESM static: import ... from "pkg"
6
+ * - ESM dynamic: import("pkg")
7
+ * - CJS: require("pkg")
8
+ *
9
+ * Strips subpath imports (e.g. "lodash/merge" → "lodash"),
10
+ * skips relative imports and node: builtins.
11
+ */
12
+ export declare function extractImportsFromSource(source: string): Set<string>;
13
+ export declare function extractPackageName(specifier: string): string | null;
14
+ /**
15
+ * Recursively scans a directory and returns all imported package names
16
+ * found across all source files.
17
+ */
18
+ export declare function scanDirectory(dir: string): Promise<Set<string>>;
@@ -0,0 +1,129 @@
1
+ import { promises as fs } from "node:fs";
2
+ import path from "node:path";
3
+ /**
4
+ * Extracts all imported package names from a single source file.
5
+ *
6
+ * Handles:
7
+ * - ESM static: import ... from "pkg"
8
+ * - ESM dynamic: import("pkg")
9
+ * - CJS: require("pkg")
10
+ *
11
+ * Strips subpath imports (e.g. "lodash/merge" → "lodash"),
12
+ * skips relative imports and node: builtins.
13
+ */
14
+ export function extractImportsFromSource(source) {
15
+ const names = new Set();
16
+ // ESM static import: from "pkg" or from 'pkg'
17
+ const staticImport = /from\s+['"]([^'"]+)['"]/g;
18
+ for (const match of source.matchAll(staticImport)) {
19
+ addPackageName(names, match[1]);
20
+ }
21
+ // ESM dynamic import: import("pkg") or import('pkg')
22
+ const dynamicImport = /\bimport\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
23
+ for (const match of source.matchAll(dynamicImport)) {
24
+ addPackageName(names, match[1]);
25
+ }
26
+ // CJS require: require("pkg") or require('pkg')
27
+ const cjsRequire = /\brequire\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
28
+ for (const match of source.matchAll(cjsRequire)) {
29
+ addPackageName(names, match[1]);
30
+ }
31
+ // export ... from "pkg"
32
+ const reExport = /\bexport\s+(?:\*|\{[^}]*\})\s+from\s+['"]([^'"]+)['"]/g;
33
+ for (const match of source.matchAll(reExport)) {
34
+ addPackageName(names, match[1]);
35
+ }
36
+ return names;
37
+ }
38
+ function addPackageName(names, specifier) {
39
+ // Skip relative imports, node: builtins, and bare file paths
40
+ if (specifier.startsWith(".") || specifier.startsWith("/"))
41
+ return;
42
+ if (specifier.startsWith("node:"))
43
+ return;
44
+ if (specifier.startsWith("bun:"))
45
+ return;
46
+ // Normalize package name (strip subpath): "lodash/merge" → "lodash"
47
+ // "@scope/pkg/subpath" → "@scope/pkg"
48
+ const name = extractPackageName(specifier);
49
+ if (name)
50
+ names.add(name);
51
+ }
52
+ export function extractPackageName(specifier) {
53
+ if (!specifier)
54
+ return null;
55
+ if (specifier.startsWith("@")) {
56
+ // Scoped: @scope/pkg or @scope/pkg/subpath
57
+ const parts = specifier.split("/");
58
+ if (parts.length < 2)
59
+ return null;
60
+ return `${parts[0]}/${parts[1]}`;
61
+ }
62
+ // Unscoped: pkg or pkg/subpath
63
+ return specifier.split("/")[0] ?? null;
64
+ }
65
+ const SOURCE_EXTENSIONS = new Set([
66
+ ".ts",
67
+ ".tsx",
68
+ ".js",
69
+ ".jsx",
70
+ ".mjs",
71
+ ".cjs",
72
+ ".mts",
73
+ ".cts",
74
+ ]);
75
+ const IGNORED_DIRS = new Set([
76
+ "node_modules",
77
+ ".git",
78
+ "dist",
79
+ "build",
80
+ "out",
81
+ ".next",
82
+ ".nuxt",
83
+ ]);
84
+ /**
85
+ * Recursively scans a directory and returns all imported package names
86
+ * found across all source files.
87
+ */
88
+ export async function scanDirectory(dir) {
89
+ const allImports = new Set();
90
+ await walkDirectory(dir, allImports);
91
+ return allImports;
92
+ }
93
+ async function walkDirectory(dir, collector) {
94
+ let entries;
95
+ try {
96
+ entries = await fs.readdir(dir);
97
+ }
98
+ catch {
99
+ return;
100
+ }
101
+ const tasks = [];
102
+ for (const entryName of entries) {
103
+ if (IGNORED_DIRS.has(entryName))
104
+ continue;
105
+ const fullPath = path.join(dir, entryName);
106
+ tasks.push(fs
107
+ .stat(fullPath)
108
+ .then((stat) => {
109
+ if (stat.isDirectory()) {
110
+ return walkDirectory(fullPath, collector);
111
+ }
112
+ if (stat.isFile()) {
113
+ const ext = path.extname(entryName).toLowerCase();
114
+ if (!SOURCE_EXTENSIONS.has(ext))
115
+ return;
116
+ return fs
117
+ .readFile(fullPath, "utf8")
118
+ .then((source) => {
119
+ for (const name of extractImportsFromSource(source)) {
120
+ collector.add(name);
121
+ }
122
+ })
123
+ .catch(() => undefined);
124
+ }
125
+ })
126
+ .catch(() => undefined));
127
+ }
128
+ await Promise.all(tasks);
129
+ }
@@ -0,0 +1,36 @@
1
+ import type { ImpactScore, PackageUpdate } from "../types/index.js";
2
+ /**
3
+ * Context fed into the impact scorer from the outer check/upgrade pipeline.
4
+ * All fields are optional — missing data degrades gracefully.
5
+ */
6
+ export interface ImpactContext {
7
+ /** Names of packages that have a known CVE advisory (from `rup audit` cache). */
8
+ advisoryPackages?: ReadonlySet<string>;
9
+ /** How many workspace packages depend on this package (reverse-dep count). */
10
+ workspaceDependentCount?: (name: string) => number;
11
+ }
12
+ /**
13
+ * Compute an `ImpactScore` for a single pending package update.
14
+ *
15
+ * Algorithm:
16
+ * score = diffWeight
17
+ * + (advisoryBonus if CVE exists)
18
+ * + min(workspaceCount × perPkg, cap)
19
+ *
20
+ * Clamped to [0, 100]. Rank thresholds:
21
+ * ≥ 70 → critical
22
+ * ≥ 45 → high
23
+ * ≥ 20 → medium
24
+ * < 20 → low
25
+ */
26
+ export declare function computeImpactScore(update: PackageUpdate, context?: ImpactContext): ImpactScore;
27
+ /**
28
+ * Batch-compute impact scores for all updates, returning a new array with
29
+ * `impactScore` populated on each entry. Non-mutating.
30
+ */
31
+ export declare function applyImpactScores(updates: PackageUpdate[], context?: ImpactContext): PackageUpdate[];
32
+ /**
33
+ * Returns the ANSI color badge string for terminal output.
34
+ * Used by the table renderer when --show-impact is active.
35
+ */
36
+ export declare function impactBadge(score: ImpactScore): string;
@@ -0,0 +1,82 @@
1
+ /** Weights for each semver diff type. */
2
+ const DIFF_WEIGHT = {
3
+ patch: 10,
4
+ minor: 25,
5
+ major: 55,
6
+ latest: 30,
7
+ };
8
+ /** Advisory presence bonus — lifts total score significantly. */
9
+ const ADVISORY_BONUS = 35;
10
+ /** Points added per additional workspace package that uses this dep. */
11
+ const WORKSPACE_SPREAD_PER_PKG = 5;
12
+ /** Maximum workspace spread contribution (cap). */
13
+ const WORKSPACE_SPREAD_CAP = 20;
14
+ /**
15
+ * Compute an `ImpactScore` for a single pending package update.
16
+ *
17
+ * Algorithm:
18
+ * score = diffWeight
19
+ * + (advisoryBonus if CVE exists)
20
+ * + min(workspaceCount × perPkg, cap)
21
+ *
22
+ * Clamped to [0, 100]. Rank thresholds:
23
+ * ≥ 70 → critical
24
+ * ≥ 45 → high
25
+ * ≥ 20 → medium
26
+ * < 20 → low
27
+ */
28
+ export function computeImpactScore(update, context = {}) {
29
+ const diffTypeWeight = DIFF_WEIGHT[update.diffType] ?? DIFF_WEIGHT.latest;
30
+ const hasAdvisory = context.advisoryPackages?.has(update.name) ?? false;
31
+ const rawWorkspaceCount = context.workspaceDependentCount?.(update.name) ?? 0;
32
+ const affectedWorkspaceCount = Math.max(0, rawWorkspaceCount);
33
+ const advisoryPoints = hasAdvisory ? ADVISORY_BONUS : 0;
34
+ const workspacePoints = Math.min(affectedWorkspaceCount * WORKSPACE_SPREAD_PER_PKG, WORKSPACE_SPREAD_CAP);
35
+ const rawScore = diffTypeWeight + advisoryPoints + workspacePoints;
36
+ const score = Math.min(100, Math.max(0, rawScore));
37
+ const rank = scoreToRank(score);
38
+ return {
39
+ rank,
40
+ score,
41
+ factors: {
42
+ diffTypeWeight,
43
+ hasAdvisory,
44
+ affectedWorkspaceCount,
45
+ },
46
+ };
47
+ }
48
+ /**
49
+ * Batch-compute impact scores for all updates, returning a new array with
50
+ * `impactScore` populated on each entry. Non-mutating.
51
+ */
52
+ export function applyImpactScores(updates, context = {}) {
53
+ return updates.map((u) => ({
54
+ ...u,
55
+ impactScore: computeImpactScore(u, context),
56
+ }));
57
+ }
58
+ function scoreToRank(score) {
59
+ if (score >= 70)
60
+ return "critical";
61
+ if (score >= 45)
62
+ return "high";
63
+ if (score >= 20)
64
+ return "medium";
65
+ return "low";
66
+ }
67
+ /**
68
+ * Returns the ANSI color badge string for terminal output.
69
+ * Used by the table renderer when --show-impact is active.
70
+ */
71
+ export function impactBadge(score) {
72
+ switch (score.rank) {
73
+ case "critical":
74
+ return "\x1b[41m\x1b[97m CRITICAL \x1b[0m";
75
+ case "high":
76
+ return "\x1b[31m HIGH \x1b[0m";
77
+ case "medium":
78
+ return "\x1b[33m MED \x1b[0m";
79
+ case "low":
80
+ return "\x1b[32m LOW \x1b[0m";
81
+ }
82
+ }
@@ -1,4 +1,4 @@
1
- import type { BaselineOptions, CheckOptions, UpgradeOptions, AuditOptions, BisectOptions, HealthOptions } from "../types/index.js";
1
+ import type { BaselineOptions, CheckOptions, UpgradeOptions, AuditOptions, BisectOptions, HealthOptions, UnusedOptions, ResolveOptions, LicenseOptions, SnapshotOptions } from "../types/index.js";
2
2
  import type { InitCiMode, InitCiSchedule } from "./init-ci.js";
3
3
  export type ParsedCliArgs = {
4
4
  command: "check";
@@ -34,5 +34,17 @@ export type ParsedCliArgs = {
34
34
  } | {
35
35
  command: "health";
36
36
  options: HealthOptions;
37
+ } | {
38
+ command: "unused";
39
+ options: UnusedOptions;
40
+ } | {
41
+ command: "resolve";
42
+ options: ResolveOptions;
43
+ } | {
44
+ command: "licenses";
45
+ options: LicenseOptions;
46
+ } | {
47
+ command: "snapshot";
48
+ options: SnapshotOptions;
37
49
  };
38
50
  export declare function parseCliArgs(argv: string[]): Promise<ParsedCliArgs>;
@@ -17,6 +17,10 @@ const KNOWN_COMMANDS = [
17
17
  "bisect",
18
18
  "audit",
19
19
  "health",
20
+ "unused",
21
+ "resolve",
22
+ "licenses",
23
+ "snapshot",
20
24
  ];
21
25
  export async function parseCliArgs(argv) {
22
26
  const firstArg = argv[0];
@@ -27,6 +31,37 @@ export async function parseCliArgs(argv) {
27
31
  const command = isKnownCommand ? argv[0] : "check";
28
32
  const hasExplicitCommand = isKnownCommand;
29
33
  const args = hasExplicitCommand ? argv.slice(1) : argv.slice(0);
34
+ // ─── Early dispatch for isolated commands ─────────────────────────────────
35
+ // These commands have their own parsers and must NOT go through the base
36
+ // CheckOptions builder below, which would throw on their unique flags.
37
+ if (command === "bisect") {
38
+ const { parseBisectArgs } = await import("../commands/bisect/parser.js");
39
+ return { command, options: parseBisectArgs(args) };
40
+ }
41
+ if (command === "audit") {
42
+ const { parseAuditArgs } = await import("../commands/audit/parser.js");
43
+ return { command, options: parseAuditArgs(args) };
44
+ }
45
+ if (command === "health") {
46
+ const { parseHealthArgs } = await import("../commands/health/parser.js");
47
+ return { command, options: parseHealthArgs(args) };
48
+ }
49
+ if (command === "unused") {
50
+ const { parseUnusedArgs } = await import("../commands/unused/parser.js");
51
+ return { command, options: parseUnusedArgs(args) };
52
+ }
53
+ if (command === "resolve") {
54
+ const { parseResolveArgs } = await import("../commands/resolve/parser.js");
55
+ return { command, options: parseResolveArgs(args) };
56
+ }
57
+ if (command === "licenses") {
58
+ const { parseLicensesArgs } = await import("../commands/licenses/parser.js");
59
+ return { command, options: parseLicensesArgs(args) };
60
+ }
61
+ if (command === "snapshot") {
62
+ const { parseSnapshotArgs } = await import("../commands/snapshot/parser.js");
63
+ return { command, options: parseSnapshotArgs(args) };
64
+ }
30
65
  const base = {
31
66
  cwd: process.cwd(),
32
67
  target: "latest",
@@ -485,19 +520,6 @@ export async function parseCliArgs(argv) {
485
520
  },
486
521
  };
487
522
  }
488
- // ─── New v0.5.1 commands: lazy-parsed by isolated sub-parsers ────────────
489
- if (command === "bisect") {
490
- const { parseBisectArgs } = await import("../commands/bisect/parser.js");
491
- return { command, options: parseBisectArgs(args) };
492
- }
493
- if (command === "audit") {
494
- const { parseAuditArgs } = await import("../commands/audit/parser.js");
495
- return { command, options: parseAuditArgs(args) };
496
- }
497
- if (command === "health") {
498
- const { parseHealthArgs } = await import("../commands/health/parser.js");
499
- return { command, options: parseHealthArgs(args) };
500
- }
501
523
  return {
502
524
  command: "check",
503
525
  options: base,