@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.
- package/CHANGELOG.md +93 -1
- package/README.md +88 -25
- package/dist/bin/cli.js +50 -1
- package/dist/commands/audit/fetcher.d.ts +2 -6
- package/dist/commands/audit/fetcher.js +2 -79
- package/dist/commands/audit/mapper.d.ts +8 -1
- package/dist/commands/audit/mapper.js +106 -10
- package/dist/commands/audit/parser.js +36 -2
- package/dist/commands/audit/runner.js +179 -15
- package/dist/commands/audit/sources/github.d.ts +2 -0
- package/dist/commands/audit/sources/github.js +125 -0
- package/dist/commands/audit/sources/index.d.ts +6 -0
- package/dist/commands/audit/sources/index.js +92 -0
- package/dist/commands/audit/sources/osv.d.ts +2 -0
- package/dist/commands/audit/sources/osv.js +131 -0
- package/dist/commands/audit/sources/types.d.ts +21 -0
- package/dist/commands/audit/sources/types.js +1 -0
- package/dist/commands/audit/targets.d.ts +20 -0
- package/dist/commands/audit/targets.js +314 -0
- package/dist/commands/changelog/fetcher.d.ts +9 -0
- package/dist/commands/changelog/fetcher.js +130 -0
- package/dist/commands/licenses/parser.d.ts +2 -0
- package/dist/commands/licenses/parser.js +116 -0
- package/dist/commands/licenses/runner.d.ts +9 -0
- package/dist/commands/licenses/runner.js +163 -0
- package/dist/commands/licenses/sbom.d.ts +10 -0
- package/dist/commands/licenses/sbom.js +70 -0
- package/dist/commands/resolve/graph/builder.d.ts +20 -0
- package/dist/commands/resolve/graph/builder.js +183 -0
- package/dist/commands/resolve/graph/conflict.d.ts +20 -0
- package/dist/commands/resolve/graph/conflict.js +52 -0
- package/dist/commands/resolve/graph/resolver.d.ts +17 -0
- package/dist/commands/resolve/graph/resolver.js +71 -0
- package/dist/commands/resolve/parser.d.ts +2 -0
- package/dist/commands/resolve/parser.js +89 -0
- package/dist/commands/resolve/runner.d.ts +13 -0
- package/dist/commands/resolve/runner.js +136 -0
- package/dist/commands/snapshot/parser.d.ts +2 -0
- package/dist/commands/snapshot/parser.js +80 -0
- package/dist/commands/snapshot/runner.d.ts +11 -0
- package/dist/commands/snapshot/runner.js +115 -0
- package/dist/commands/snapshot/store.d.ts +35 -0
- package/dist/commands/snapshot/store.js +158 -0
- package/dist/commands/unused/matcher.d.ts +22 -0
- package/dist/commands/unused/matcher.js +95 -0
- package/dist/commands/unused/parser.d.ts +2 -0
- package/dist/commands/unused/parser.js +95 -0
- package/dist/commands/unused/runner.d.ts +11 -0
- package/dist/commands/unused/runner.js +113 -0
- package/dist/commands/unused/scanner.d.ts +18 -0
- package/dist/commands/unused/scanner.js +129 -0
- package/dist/core/impact.d.ts +36 -0
- package/dist/core/impact.js +82 -0
- package/dist/core/options.d.ts +13 -1
- package/dist/core/options.js +35 -13
- package/dist/types/index.d.ts +187 -1
- package/dist/ui/tui.d.ts +6 -0
- package/dist/ui/tui.js +50 -0
- package/dist/utils/semver.d.ts +18 -0
- package/dist/utils/semver.js +88 -3
- 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,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
|
+
}
|
package/dist/core/options.d.ts
CHANGED
|
@@ -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>;
|
package/dist/core/options.js
CHANGED
|
@@ -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,
|