@openrewrite/recipes-nodejs 0.37.0-20260104-170507 → 0.37.0-20260106-083133

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.
@@ -6,3 +6,4 @@
6
6
 
7
7
  export * from "./vulnerability";
8
8
  export * from "./dependency-vulnerability-check";
9
+ export * from "./remove-redundant-overrides";
@@ -0,0 +1,414 @@
1
+ /*
2
+ * Copyright 2025 the original author or authors.
3
+ *
4
+ * Moderne Proprietary. Only for use by Moderne customers under the terms of a commercial contract.
5
+ */
6
+
7
+ import {PackageManager, runInstallInTempDir} from "@openrewrite/rewrite/javascript";
8
+ import * as semver from "semver";
9
+ import {spawnSync, SpawnSyncReturns} from "child_process";
10
+ import * as fs from "fs";
11
+ import * as path from "path";
12
+ import * as os from "os";
13
+
14
+ /**
15
+ * Runs an npm command with optional .npmrc configuration.
16
+ * Writes a temp .npmrc file and uses --userconfig flag when config is provided.
17
+ *
18
+ * Note: Auth tokens cannot be passed via npm_config_* environment variables due to
19
+ * npm's underscore-to-dash conversion bug. See: https://github.com/npm/config/issues/64
20
+ */
21
+ function runNpmWithConfig(
22
+ args: string[],
23
+ configFiles?: Record<string, string>,
24
+ options?: { timeout?: number }
25
+ ): SpawnSyncReturns<string> {
26
+ const timeout = options?.timeout ?? 30000;
27
+ let tempConfigPath: string | undefined;
28
+
29
+ try {
30
+ const npmrcContent = configFiles?.['.npmrc'];
31
+ if (npmrcContent) {
32
+ tempConfigPath = path.join(os.tmpdir(), `npmrc-${process.pid}-${Date.now()}`);
33
+ fs.writeFileSync(tempConfigPath, npmrcContent);
34
+ args = [...args, `--userconfig=${tempConfigPath}`];
35
+ }
36
+
37
+ return spawnSync('npm', args, {
38
+ encoding: 'utf-8',
39
+ timeout,
40
+ stdio: ['pipe', 'pipe', 'pipe']
41
+ });
42
+ } finally {
43
+ if (tempConfigPath) {
44
+ try {
45
+ fs.unlinkSync(tempConfigPath);
46
+ } catch {
47
+ // Ignore cleanup errors
48
+ }
49
+ }
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Response from npm registry for a package.
55
+ */
56
+ export interface NpmPackageInfo {
57
+ name: string;
58
+ versions: Record<string, {
59
+ version: string;
60
+ dependencies?: Record<string, string>;
61
+ }>;
62
+ 'dist-tags': Record<string, string>;
63
+ }
64
+
65
+ /**
66
+ * Fetches package information using `npm view` command.
67
+ * This leverages npm's local cache and respects .npmrc settings.
68
+ *
69
+ * @param packageName The package name to fetch info for
70
+ * @param configFiles Optional config files (e.g., {'.npmrc': '...'}) for registry/auth settings
71
+ */
72
+ export async function fetchNpmPackageInfo(
73
+ packageName: string,
74
+ configFiles?: Record<string, string>
75
+ ): Promise<NpmPackageInfo | undefined> {
76
+ try {
77
+ const result = runNpmWithConfig(
78
+ ['view', packageName, '--json', '--prefer-offline'],
79
+ configFiles
80
+ );
81
+
82
+ if (result.error || result.status !== 0) {
83
+ return undefined;
84
+ }
85
+
86
+ const data = JSON.parse(result.stdout);
87
+
88
+ // npm view returns different structures depending on the package
89
+ // For a single version it returns the version object directly
90
+ // For multiple versions (when using package name without version), it returns full info
91
+ if (data.versions) {
92
+ return data as NpmPackageInfo;
93
+ }
94
+
95
+ // If we got a single version object, we need to fetch all versions
96
+ const versionsResult = runNpmWithConfig(
97
+ ['view', packageName, 'versions', '--json', '--prefer-offline'],
98
+ configFiles
99
+ );
100
+
101
+ if (versionsResult.error || versionsResult.status !== 0) {
102
+ return undefined;
103
+ }
104
+
105
+ const versions = JSON.parse(versionsResult.stdout);
106
+ const versionMap: Record<string, { version: string }> = {};
107
+
108
+ if (Array.isArray(versions)) {
109
+ for (const v of versions) {
110
+ versionMap[v] = { version: v };
111
+ }
112
+ }
113
+
114
+ return {
115
+ name: packageName,
116
+ versions: versionMap,
117
+ 'dist-tags': data['dist-tags'] || {}
118
+ };
119
+ } catch {
120
+ return undefined;
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Gets available versions of a package using npm's cache, sorted by semver descending.
126
+ *
127
+ * @param packageName The package name to get versions for
128
+ * @param configFiles Optional config files (e.g., {'.npmrc': '...'}) for registry/auth settings
129
+ */
130
+ export async function getAvailableVersions(
131
+ packageName: string,
132
+ configFiles?: Record<string, string>
133
+ ): Promise<string[]> {
134
+ try {
135
+ // Use npm view versions which leverages npm's cache
136
+ const result = runNpmWithConfig(
137
+ ['view', packageName, 'versions', '--json', '--prefer-offline'],
138
+ configFiles
139
+ );
140
+
141
+ if (result.error || result.status !== 0) {
142
+ return [];
143
+ }
144
+
145
+ const versions = JSON.parse(result.stdout);
146
+
147
+ if (!Array.isArray(versions)) {
148
+ // Single version returned as string
149
+ return typeof versions === 'string' ? [versions] : [];
150
+ }
151
+
152
+ // Sort by semver descending (newest first), excluding prereleases
153
+ return versions
154
+ .filter((v: string) => !v.includes('-'))
155
+ .sort((a: string, b: string) => semver.rcompare(a, b));
156
+ } catch {
157
+ return [];
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Extracts the resolved version of a package from a lock file content.
163
+ */
164
+ export function extractVersionFromLockFile(
165
+ lockFileContent: string,
166
+ packageName: string,
167
+ pm: PackageManager
168
+ ): string | undefined {
169
+ try {
170
+ switch (pm) {
171
+ case PackageManager.Npm: {
172
+ const lockJson = JSON.parse(lockFileContent);
173
+ // npm v2+ format
174
+ if (lockJson.packages) {
175
+ for (const [key, value] of Object.entries(lockJson.packages)) {
176
+ if (key.endsWith(`node_modules/${packageName}`) || key === `node_modules/${packageName}`) {
177
+ return (value as any).version;
178
+ }
179
+ }
180
+ }
181
+ // npm v1 format
182
+ if (lockJson.dependencies?.[packageName]) {
183
+ return lockJson.dependencies[packageName].version;
184
+ }
185
+ return undefined;
186
+ }
187
+
188
+ case PackageManager.Pnpm: {
189
+ // Simple regex extraction for pnpm-lock.yaml
190
+ const regex = new RegExp(`['"]?${escapeRegExp(packageName)}@([\\d.]+)['"]?:`, 'g');
191
+ const matches: string[] = [];
192
+ let match;
193
+ while ((match = regex.exec(lockFileContent)) !== null) {
194
+ matches.push(match[1]);
195
+ }
196
+ // Return highest version found
197
+ return matches.sort((a, b) => semver.rcompare(a, b))[0];
198
+ }
199
+
200
+ case PackageManager.YarnClassic:
201
+ case PackageManager.YarnBerry: {
202
+ const regex = new RegExp(`"?${escapeRegExp(packageName)}@[^":\\n]+[":]*\\s*\\n\\s*version:?\\s*"?([^"\\n]+)"?`, 'g');
203
+ const match = regex.exec(lockFileContent);
204
+ return match?.[1]?.trim();
205
+ }
206
+
207
+ case PackageManager.Bun: {
208
+ const lockJson = JSON.parse(lockFileContent);
209
+ if (lockJson.packages) {
210
+ for (const [key, value] of Object.entries(lockJson.packages)) {
211
+ if (key === packageName || key.endsWith(`/${packageName}`)) {
212
+ if (Array.isArray(value) && value[0]) {
213
+ return String(value[0]);
214
+ }
215
+ }
216
+ }
217
+ }
218
+ return undefined;
219
+ }
220
+
221
+ default:
222
+ return undefined;
223
+ }
224
+ } catch {
225
+ return undefined;
226
+ }
227
+ }
228
+
229
+ /**
230
+ * Escapes special regex characters in a string.
231
+ */
232
+ function escapeRegExp(str: string): string {
233
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
234
+ }
235
+
236
+ /**
237
+ * Result of checking if a direct dependency upgrade fixes a transitive vulnerability.
238
+ */
239
+ export interface DirectUpgradeCheckResult {
240
+ /** Whether upgrading the direct dependency fixes the transitive vulnerability */
241
+ fixed: boolean;
242
+ /** The version of the direct dependency that was tested */
243
+ directDepVersion: string;
244
+ /** The resolved version of the transitive after upgrade (if found) */
245
+ transitiveVersion?: string;
246
+ }
247
+
248
+ /**
249
+ * Dependency scope type.
250
+ */
251
+ export type DependencyScope = 'dependencies' | 'devDependencies' | 'peerDependencies' | 'optionalDependencies';
252
+
253
+ /**
254
+ * Checks if upgrading a direct dependency to a specific version fixes a transitive vulnerability.
255
+ * Does this by running npm install in a temp directory and checking the resolved transitive version.
256
+ *
257
+ * @param pm The package manager to use
258
+ * @param directDepName The direct dependency to upgrade
259
+ * @param directDepVersion The version to upgrade to
260
+ * @param transitiveDepName The transitive dependency that has a vulnerability
261
+ * @param isVulnerable Function that checks if a version is vulnerable
262
+ * @param originalPackageJson The original package.json content
263
+ * @param scope The dependency scope of the direct dependency
264
+ * @param configFiles Optional config files (e.g., .npmrc)
265
+ * @returns Result indicating if the upgrade fixes the vulnerability
266
+ */
267
+ export async function checkDirectUpgradeFixesTransitive(
268
+ pm: PackageManager,
269
+ directDepName: string,
270
+ directDepVersion: string,
271
+ transitiveDepName: string,
272
+ isVulnerable: (version: string) => boolean,
273
+ originalPackageJson: string,
274
+ scope: DependencyScope,
275
+ configFiles?: Record<string, string>
276
+ ): Promise<DirectUpgradeCheckResult> {
277
+ try {
278
+ // Parse and modify package.json with upgraded direct dependency
279
+ const pkgJson = JSON.parse(originalPackageJson);
280
+ if (!pkgJson[scope]?.[directDepName]) {
281
+ return {fixed: false, directDepVersion};
282
+ }
283
+
284
+ // Upgrade the direct dependency
285
+ pkgJson[scope][directDepName] = directDepVersion;
286
+ const modifiedPackageJson = JSON.stringify(pkgJson, null, 2);
287
+
288
+ // Run install in temp directory
289
+ const result = await runInstallInTempDir(pm, modifiedPackageJson, {
290
+ configFiles,
291
+ lockOnly: true
292
+ });
293
+
294
+ if (!result.success || !result.lockFileContent) {
295
+ return {fixed: false, directDepVersion};
296
+ }
297
+
298
+ // Parse lock file to find transitive version
299
+ const transitiveVersion = extractVersionFromLockFile(
300
+ result.lockFileContent,
301
+ transitiveDepName,
302
+ pm
303
+ );
304
+
305
+ if (!transitiveVersion) {
306
+ // Transitive not found - might mean it's no longer a dependency
307
+ return {fixed: true, directDepVersion, transitiveVersion: undefined};
308
+ }
309
+
310
+ // Check if the resolved version is still vulnerable
311
+ const stillVulnerable = isVulnerable(transitiveVersion);
312
+
313
+ return {
314
+ fixed: !stillVulnerable,
315
+ directDepVersion,
316
+ transitiveVersion
317
+ };
318
+ } catch {
319
+ return {fixed: false, directDepVersion};
320
+ }
321
+ }
322
+
323
+ /**
324
+ * Finds a direct dependency upgrade that fixes a transitive vulnerability.
325
+ * Tries versions from newest to oldest within the allowed delta.
326
+ *
327
+ * @param pm The package manager to use
328
+ * @param directDepName The direct dependency to upgrade
329
+ * @param currentDirectVersion The current version of the direct dependency
330
+ * @param transitiveDepName The transitive dependency that has a vulnerability
331
+ * @param isVulnerable Function that checks if a version is vulnerable
332
+ * @param isWithinDelta Function that checks if a version is within the allowed upgrade delta
333
+ * @param originalPackageJson The original package.json content
334
+ * @param scope The dependency scope of the direct dependency
335
+ * @param configFiles Optional config files (e.g., .npmrc)
336
+ * @returns The direct dependency version that fixes the transitive, or undefined if none found
337
+ */
338
+ export async function findDirectUpgradeThatFixesTransitive(
339
+ pm: PackageManager,
340
+ directDepName: string,
341
+ currentDirectVersion: string,
342
+ transitiveDepName: string,
343
+ isVulnerable: (version: string) => boolean,
344
+ isWithinDelta: (fromVersion: string, toVersion: string) => boolean,
345
+ originalPackageJson: string,
346
+ scope: DependencyScope,
347
+ configFiles?: Record<string, string>
348
+ ): Promise<string | undefined> {
349
+ // Get available versions of the direct dependency
350
+ const availableVersions = await getAvailableVersions(directDepName, configFiles);
351
+ if (availableVersions.length === 0) {
352
+ return undefined;
353
+ }
354
+
355
+ const currentMajor = semver.major(currentDirectVersion);
356
+
357
+ // Filter to versions newer than current and within delta
358
+ // Note: getAvailableVersions already filters out prereleases
359
+ const candidateVersions = availableVersions.filter(v => {
360
+ try {
361
+ return semver.gt(v, currentDirectVersion) && isWithinDelta(currentDirectVersion, v);
362
+ } catch {
363
+ return false;
364
+ }
365
+ });
366
+
367
+ if (candidateVersions.length === 0) {
368
+ return undefined;
369
+ }
370
+
371
+ // Sort candidates: same major version first (descending), then other majors (descending)
372
+ // This prioritizes peer-compatible versions while still allowing major upgrades if needed
373
+ candidateVersions.sort((a, b) => {
374
+ const aMajor = semver.major(a);
375
+ const bMajor = semver.major(b);
376
+ const aIsSameMajor = aMajor === currentMajor;
377
+ const bIsSameMajor = bMajor === currentMajor;
378
+
379
+ // Same major versions come first
380
+ if (aIsSameMajor && !bIsSameMajor) return -1;
381
+ if (!aIsSameMajor && bIsSameMajor) return 1;
382
+
383
+ // Within the same priority group, sort by version descending
384
+ return semver.rcompare(a, b);
385
+ });
386
+
387
+ // Try candidates in order (same major first, then others)
388
+ for (const candidate of candidateVersions) {
389
+ const result = await checkDirectUpgradeFixesTransitive(
390
+ pm,
391
+ directDepName,
392
+ candidate,
393
+ transitiveDepName,
394
+ isVulnerable,
395
+ originalPackageJson,
396
+ scope,
397
+ configFiles
398
+ );
399
+
400
+ if (result.fixed) {
401
+ return candidate;
402
+ }
403
+
404
+ // Only try a few candidates to avoid excessive npm installs
405
+ // After trying the newest same-major and newest other-major, give up
406
+ const candidateMajor = semver.major(candidate);
407
+ if (candidateMajor !== currentMajor) {
408
+ // We've tried a different major version and it didn't work, give up
409
+ break;
410
+ }
411
+ }
412
+
413
+ return undefined;
414
+ }