@openrewrite/recipes-nodejs 0.37.0-20260103-170432 → 0.37.0-20260106-082310

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.
@@ -0,0 +1,515 @@
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 {
8
+ ExecutionContext,
9
+ Option,
10
+ ScanningRecipe,
11
+ Tree,
12
+ TreePrinters,
13
+ TreeVisitor
14
+ } from "@openrewrite/rewrite";
15
+ import {isJson, Json, JsonParser, JsonVisitor} from "@openrewrite/rewrite/json";
16
+ import {
17
+ findNodeResolutionResult,
18
+ NpmrcScope,
19
+ PackageManager,
20
+ runInstallInTempDir
21
+ } from "@openrewrite/rewrite/javascript";
22
+ import * as semver from "semver";
23
+ import {extractVersionFromLockFile} from "./npm-utils";
24
+
25
+ /**
26
+ * Information about an override that might be redundant.
27
+ */
28
+ interface OverrideInfo {
29
+ /** The package name (or version-specific key like "package@^1") */
30
+ key: string;
31
+ /** The base package name (without version specifier) */
32
+ packageName: string;
33
+ /** The version the override pins to */
34
+ version: string;
35
+ /** Whether this is a version-specific override (e.g., "package@^1") */
36
+ isVersionSpecific: boolean;
37
+ /** The version range specifier if version-specific (e.g., "^1") */
38
+ versionRange?: string;
39
+ }
40
+
41
+ /**
42
+ * Project information for override analysis.
43
+ */
44
+ interface ProjectInfo {
45
+ /** Path to package.json */
46
+ packageJsonPath: string;
47
+ /** Original package.json content */
48
+ originalPackageJson: string;
49
+ /** Package manager used */
50
+ packageManager: PackageManager;
51
+ /** List of overrides found */
52
+ overrides: OverrideInfo[];
53
+ /** Config files (.npmrc, etc.) */
54
+ configFiles?: Record<string, string>;
55
+ }
56
+
57
+ /**
58
+ * Accumulator for the recipe.
59
+ */
60
+ interface Accumulator {
61
+ /** Projects to analyze */
62
+ projects: Map<string, ProjectInfo>;
63
+ /** Overrides confirmed as redundant (project path -> set of override keys) */
64
+ redundantOverrides: Map<string, Set<string>>;
65
+ /** Whether redundant override analysis has been completed */
66
+ analysisComplete: boolean;
67
+ }
68
+
69
+ /**
70
+ * Removes overrides from package.json that are redundant because the dependency tree
71
+ * already resolves to the overridden version (or higher) without the override.
72
+ *
73
+ * This recipe is useful for cleaning up package.json after dependency upgrades that
74
+ * may have made previously-necessary overrides redundant.
75
+ *
76
+ * For each override, the recipe:
77
+ * 1. Temporarily removes the override from package.json
78
+ * 2. Runs `npm install --package-lock-only` (or equivalent)
79
+ * 3. Checks if the resolved version satisfies the override constraint
80
+ * 4. If yes, marks the override as redundant and removes it
81
+ *
82
+ * Also removes associated comments (e.g., from `//overrides`).
83
+ */
84
+ export class RemoveRedundantOverrides extends ScanningRecipe<Accumulator> {
85
+ readonly name = "org.openrewrite.node.security.remove-redundant-overrides";
86
+ readonly displayName = "Remove redundant dependency overrides";
87
+ readonly description = "Removes overrides/resolutions from package.json that are redundant " +
88
+ "because the dependency tree already resolves to the overridden version or higher.";
89
+
90
+ @Option({
91
+ displayName: "Dry run",
92
+ description: "If true, only report which overrides are redundant without removing them.",
93
+ required: false,
94
+ example: "true"
95
+ })
96
+ dryRun?: boolean;
97
+
98
+ constructor(options?: { dryRun?: boolean }) {
99
+ super();
100
+ this.dryRun = options?.dryRun ?? false;
101
+ }
102
+
103
+ initialValue(_ctx: ExecutionContext): Accumulator {
104
+ return {
105
+ projects: new Map(),
106
+ redundantOverrides: new Map(),
107
+ analysisComplete: false
108
+ };
109
+ }
110
+
111
+ async scanner(acc: Accumulator): Promise<TreeVisitor<any, ExecutionContext>> {
112
+ const recipe = this;
113
+
114
+ return new class extends TreeVisitor<Tree, ExecutionContext> {
115
+ protected async accept(tree: Tree, _ctx: ExecutionContext): Promise<Tree | undefined> {
116
+ if (!isJson(tree) || tree.kind !== Json.Kind.Document) {
117
+ return tree;
118
+ }
119
+
120
+ const doc = tree as Json.Document;
121
+ if (!doc.sourcePath.endsWith('package.json')) {
122
+ return doc;
123
+ }
124
+
125
+ const marker = findNodeResolutionResult(doc);
126
+ if (!marker) {
127
+ return doc;
128
+ }
129
+
130
+ const pm = marker.packageManager ?? PackageManager.Npm;
131
+ const content = await TreePrinters.print(doc);
132
+ let packageJson: Record<string, any>;
133
+
134
+ try {
135
+ packageJson = JSON.parse(content);
136
+ } catch {
137
+ return doc;
138
+ }
139
+
140
+ // Extract overrides based on package manager
141
+ const overrides = recipe.extractOverrides(packageJson, pm);
142
+ if (overrides.length === 0) {
143
+ return doc;
144
+ }
145
+
146
+ // Extract config files
147
+ const configFiles: Record<string, string> = {};
148
+ const projectNpmrc = marker.npmrcConfigs?.find(c => c.scope === NpmrcScope.Project);
149
+ if (projectNpmrc) {
150
+ const lines = Object.entries(projectNpmrc.properties)
151
+ .map(([key, value]) => `${key}=${value}`);
152
+ configFiles['.npmrc'] = lines.join('\n');
153
+ }
154
+
155
+ acc.projects.set(doc.sourcePath, {
156
+ packageJsonPath: doc.sourcePath,
157
+ originalPackageJson: content,
158
+ packageManager: pm,
159
+ overrides,
160
+ configFiles: Object.keys(configFiles).length > 0 ? configFiles : undefined
161
+ });
162
+
163
+ return doc;
164
+ }
165
+ };
166
+ }
167
+
168
+ async editorWithData(acc: Accumulator): Promise<TreeVisitor<any, ExecutionContext>> {
169
+ const recipe = this;
170
+
171
+ // Only run the expensive analysis once (editorWithData is called per source file)
172
+ if (!acc.analysisComplete) {
173
+ for (const [projectPath, project] of acc.projects) {
174
+ const redundant = await recipe.findRedundantOverrides(project);
175
+ if (redundant.size > 0) {
176
+ acc.redundantOverrides.set(projectPath, redundant);
177
+ }
178
+ }
179
+ acc.analysisComplete = true;
180
+ }
181
+
182
+ // If dry run, don't modify files
183
+ if (recipe.dryRun) {
184
+ return new class extends TreeVisitor<Tree, ExecutionContext> {
185
+ protected async accept(tree: Tree, _ctx: ExecutionContext): Promise<Tree | undefined> {
186
+ return tree;
187
+ }
188
+ };
189
+ }
190
+
191
+ return new class extends JsonVisitor<ExecutionContext> {
192
+ protected async visitDocument(doc: Json.Document, _ctx: ExecutionContext): Promise<Json | undefined> {
193
+ if (!doc.sourcePath.endsWith('package.json')) {
194
+ return doc;
195
+ }
196
+
197
+ const redundant = acc.redundantOverrides.get(doc.sourcePath);
198
+ if (!redundant || redundant.size === 0) {
199
+ return doc;
200
+ }
201
+
202
+ const project = acc.projects.get(doc.sourcePath);
203
+ if (!project) {
204
+ return doc;
205
+ }
206
+
207
+ // Remove redundant overrides
208
+ const modifiedContent = recipe.removeOverrides(
209
+ project.originalPackageJson,
210
+ project.packageManager,
211
+ redundant
212
+ );
213
+
214
+ // Re-parse the modified content
215
+ const parsed = await new JsonParser({}).parseOne({
216
+ text: modifiedContent,
217
+ sourcePath: doc.sourcePath
218
+ }) as Json.Document;
219
+
220
+ return {
221
+ ...doc,
222
+ value: parsed.value,
223
+ eof: parsed.eof
224
+ } as Json.Document;
225
+ }
226
+ };
227
+ }
228
+
229
+ /**
230
+ * Extracts override information from package.json.
231
+ */
232
+ private extractOverrides(
233
+ packageJson: Record<string, any>,
234
+ pm: PackageManager
235
+ ): OverrideInfo[] {
236
+ const overrides: OverrideInfo[] = [];
237
+
238
+ let overrideObj: Record<string, any> | undefined;
239
+
240
+ switch (pm) {
241
+ case PackageManager.Npm:
242
+ case PackageManager.Bun:
243
+ overrideObj = packageJson.overrides;
244
+ break;
245
+ case PackageManager.Pnpm:
246
+ overrideObj = packageJson.pnpm?.overrides;
247
+ break;
248
+ case PackageManager.YarnClassic:
249
+ case PackageManager.YarnBerry:
250
+ overrideObj = packageJson.resolutions;
251
+ break;
252
+ }
253
+
254
+ if (!overrideObj) {
255
+ return overrides;
256
+ }
257
+
258
+ for (const [key, value] of Object.entries(overrideObj)) {
259
+ // Skip nested overrides (npm supports objects as values)
260
+ if (typeof value !== 'string') {
261
+ continue;
262
+ }
263
+
264
+ // Parse the key to extract package name and version range
265
+ const atIndex = key.lastIndexOf('@');
266
+ let packageName: string;
267
+ let versionRange: string | undefined;
268
+ let isVersionSpecific = false;
269
+
270
+ // Check if this is a version-specific override like "package@^1"
271
+ // But be careful with scoped packages like "@scope/package"
272
+ if (atIndex > 0 && !key.startsWith('@')) {
273
+ // Unscoped package with version specifier
274
+ packageName = key.substring(0, atIndex);
275
+ versionRange = key.substring(atIndex + 1);
276
+ isVersionSpecific = true;
277
+ } else if (atIndex > 0 && key.startsWith('@')) {
278
+ // Scoped package - check if there's another @ after the scope
279
+ const secondAtIndex = key.indexOf('@', 1);
280
+ if (secondAtIndex > 0 && secondAtIndex !== atIndex) {
281
+ // Has version specifier: @scope/package@^1
282
+ packageName = key.substring(0, secondAtIndex);
283
+ versionRange = key.substring(secondAtIndex + 1);
284
+ isVersionSpecific = true;
285
+ } else {
286
+ // Just @scope/package
287
+ packageName = key;
288
+ }
289
+ } else {
290
+ packageName = key;
291
+ }
292
+
293
+ overrides.push({
294
+ key,
295
+ packageName,
296
+ version: value,
297
+ isVersionSpecific,
298
+ versionRange
299
+ });
300
+ }
301
+
302
+ return overrides;
303
+ }
304
+
305
+ /**
306
+ * Tests each override to see if it's redundant.
307
+ *
308
+ * Optimization: Instead of running N installs (one per override), we run ONE install
309
+ * with all overrides removed and check each override against that single result.
310
+ * This is much faster for projects with multiple overrides.
311
+ */
312
+ private async findRedundantOverrides(project: ProjectInfo): Promise<Set<string>> {
313
+ const redundant = new Set<string>();
314
+
315
+ if (project.overrides.length === 0) {
316
+ return redundant;
317
+ }
318
+
319
+ try {
320
+ // Create package.json with ALL overrides removed
321
+ const packageJson = JSON.parse(project.originalPackageJson);
322
+ for (const override of project.overrides) {
323
+ this.removeOverrideFromObject(packageJson, project.packageManager, override.key);
324
+ }
325
+ const modifiedPackageJson = JSON.stringify(packageJson, null, 2);
326
+
327
+ // Run ONE install to see what versions get resolved without any overrides
328
+ const result = await runInstallInTempDir(
329
+ project.packageManager,
330
+ modifiedPackageJson,
331
+ {
332
+ configFiles: project.configFiles,
333
+ lockOnly: true
334
+ }
335
+ );
336
+
337
+ if (!result.success || !result.lockFileContent) {
338
+ // Can't determine - keep all overrides
339
+ return redundant;
340
+ }
341
+
342
+ // Check each override against the resolved versions
343
+ for (const override of project.overrides) {
344
+ const isRedundant = this.isOverrideRedundantForLockFile(
345
+ override,
346
+ result.lockFileContent,
347
+ project.packageManager
348
+ );
349
+ if (isRedundant) {
350
+ redundant.add(override.key);
351
+ }
352
+ }
353
+ } catch {
354
+ // Error during check - keep all overrides
355
+ }
356
+
357
+ return redundant;
358
+ }
359
+
360
+ /**
361
+ * Checks if a single override is redundant given a lock file without any overrides.
362
+ */
363
+ private isOverrideRedundantForLockFile(
364
+ override: OverrideInfo,
365
+ lockFileContent: string,
366
+ packageManager: PackageManager
367
+ ): boolean {
368
+ // Find the resolved version
369
+ const resolvedVersion = extractVersionFromLockFile(
370
+ lockFileContent,
371
+ override.packageName,
372
+ packageManager
373
+ );
374
+
375
+ if (!resolvedVersion) {
376
+ // Package not found in lock file - might no longer be a dependency
377
+ // This override is definitely redundant
378
+ return true;
379
+ }
380
+
381
+ // Check if resolved version satisfies the override
382
+ // The override is redundant if the natural resolution is >= the override version
383
+ try {
384
+ if (semver.gte(resolvedVersion, override.version)) {
385
+ return true;
386
+ }
387
+
388
+ // Also check if resolved version satisfies the override as a range
389
+ if (semver.satisfies(resolvedVersion, `>=${override.version}`)) {
390
+ return true;
391
+ }
392
+ } catch {
393
+ // Invalid version comparison - keep the override
394
+ return false;
395
+ }
396
+
397
+ return false;
398
+ }
399
+
400
+ /**
401
+ * Removes a single override from the package.json object.
402
+ */
403
+ private removeOverrideFromObject(
404
+ packageJson: Record<string, any>,
405
+ pm: PackageManager,
406
+ key: string
407
+ ): void {
408
+ switch (pm) {
409
+ case PackageManager.Npm:
410
+ case PackageManager.Bun:
411
+ if (packageJson.overrides) {
412
+ delete packageJson.overrides[key];
413
+ if (Object.keys(packageJson.overrides).length === 0) {
414
+ delete packageJson.overrides;
415
+ }
416
+ }
417
+ break;
418
+ case PackageManager.Pnpm:
419
+ if (packageJson.pnpm?.overrides) {
420
+ delete packageJson.pnpm.overrides[key];
421
+ if (Object.keys(packageJson.pnpm.overrides).length === 0) {
422
+ delete packageJson.pnpm.overrides;
423
+ }
424
+ if (Object.keys(packageJson.pnpm).length === 0) {
425
+ delete packageJson.pnpm;
426
+ }
427
+ }
428
+ break;
429
+ case PackageManager.YarnClassic:
430
+ case PackageManager.YarnBerry:
431
+ if (packageJson.resolutions) {
432
+ delete packageJson.resolutions[key];
433
+ if (Object.keys(packageJson.resolutions).length === 0) {
434
+ delete packageJson.resolutions;
435
+ }
436
+ }
437
+ break;
438
+ }
439
+ }
440
+
441
+ /**
442
+ * Removes the specified overrides from package.json content.
443
+ * Also removes associated comments.
444
+ */
445
+ private removeOverrides(
446
+ originalContent: string,
447
+ pm: PackageManager,
448
+ keysToRemove: Set<string>
449
+ ): string {
450
+ const packageJson = JSON.parse(originalContent);
451
+
452
+ // Determine field names
453
+ let overrideField: string;
454
+ let commentField: string;
455
+
456
+ switch (pm) {
457
+ case PackageManager.Npm:
458
+ case PackageManager.Bun:
459
+ overrideField = 'overrides';
460
+ commentField = '//overrides';
461
+ break;
462
+ case PackageManager.Pnpm:
463
+ overrideField = 'pnpm';
464
+ commentField = '//pnpm.overrides';
465
+ break;
466
+ case PackageManager.YarnClassic:
467
+ case PackageManager.YarnBerry:
468
+ overrideField = 'resolutions';
469
+ commentField = '//resolutions';
470
+ break;
471
+ default:
472
+ return originalContent;
473
+ }
474
+
475
+ // Remove overrides
476
+ if (pm === PackageManager.Pnpm) {
477
+ if (packageJson.pnpm?.overrides) {
478
+ for (const key of keysToRemove) {
479
+ delete packageJson.pnpm.overrides[key];
480
+ }
481
+ if (Object.keys(packageJson.pnpm.overrides).length === 0) {
482
+ delete packageJson.pnpm.overrides;
483
+ }
484
+ if (Object.keys(packageJson.pnpm).length === 0) {
485
+ delete packageJson.pnpm;
486
+ }
487
+ }
488
+ } else {
489
+ if (packageJson[overrideField]) {
490
+ for (const key of keysToRemove) {
491
+ delete packageJson[overrideField][key];
492
+ }
493
+ if (Object.keys(packageJson[overrideField]).length === 0) {
494
+ delete packageJson[overrideField];
495
+ }
496
+ }
497
+ }
498
+
499
+ // Remove associated comments
500
+ if (packageJson[commentField]) {
501
+ for (const key of keysToRemove) {
502
+ delete packageJson[commentField][key];
503
+ }
504
+ if (Object.keys(packageJson[commentField]).length === 0) {
505
+ delete packageJson[commentField];
506
+ }
507
+ }
508
+
509
+ // Preserve original indentation
510
+ const indentMatch = originalContent.match(/^(\s+)"/m);
511
+ const indent = indentMatch ? indentMatch[1].length : 2;
512
+
513
+ return JSON.stringify(packageJson, null, indent);
514
+ }
515
+ }