@openrewrite/rewrite 8.68.0-20251204-145030 → 8.68.0-20251204-171340

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,554 @@
1
+ /*
2
+ * Copyright 2025 the original author or authors.
3
+ * <p>
4
+ * Licensed under the Moderne Source Available License (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ * <p>
8
+ * https://docs.moderne.io/licensing/moderne-source-available-license
9
+ * <p>
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+ import * as fs from 'fs';
17
+ import * as fsp from 'fs/promises';
18
+ import * as path from 'path';
19
+ import {spawn, spawnSync} from 'child_process';
20
+ import {Recipe, RecipeRegistry} from '../recipe';
21
+ import {SourceFile} from '../tree';
22
+ import {JavaScriptParser, PackageJsonParser} from '../javascript';
23
+ import {JsonParser} from '../json';
24
+
25
+ export interface RecipeSpec {
26
+ packageName: string;
27
+ recipeName: string;
28
+ isLocalPath: boolean;
29
+ }
30
+
31
+ /**
32
+ * Parse a recipe specification in format "package:recipe" or "/path/to/module:recipe"
33
+ *
34
+ * Examples:
35
+ * @openrewrite/recipes-nodejs:replace-deprecated-slice
36
+ * some-package:my-recipe
37
+ * @scope/package:org.openrewrite.recipe.name
38
+ * /Users/dev/my-recipes:my-recipe
39
+ * ./local-recipes:my-recipe
40
+ * ../other-recipes:my-recipe
41
+ */
42
+ export function parseRecipeSpec(arg: string): RecipeSpec | null {
43
+ // Format: "package:recipe" where package can be scoped (@scope/package) or a local path
44
+ const lastColonIndex = arg.lastIndexOf(':');
45
+ if (lastColonIndex === -1) {
46
+ return null;
47
+ }
48
+
49
+ const packageName = arg.substring(0, lastColonIndex);
50
+ const recipeName = arg.substring(lastColonIndex + 1);
51
+
52
+ if (!packageName || !recipeName) {
53
+ return null;
54
+ }
55
+
56
+ // Check if this is a local path
57
+ const isLocalPath = packageName.startsWith('/') ||
58
+ packageName.startsWith('./') ||
59
+ packageName.startsWith('../') ||
60
+ /^[A-Za-z]:[\\/]/.test(packageName); // Windows absolute path
61
+
62
+ return {packageName, recipeName, isLocalPath};
63
+ }
64
+
65
+ /**
66
+ * Parse recipe options from command line format
67
+ *
68
+ * Options can be:
69
+ * - key=value pairs (e.g., "text=hello")
70
+ * - boolean flags (just the key name, implies true)
71
+ * - JSON values (e.g., "items=[1,2,3]")
72
+ */
73
+ export function parseRecipeOptions(options: string[]): Record<string, any> {
74
+ const result: Record<string, any> = {};
75
+
76
+ for (const opt of options) {
77
+ const eqIndex = opt.indexOf('=');
78
+ if (eqIndex === -1) {
79
+ // Boolean flag
80
+ result[opt] = true;
81
+ } else {
82
+ const key = opt.substring(0, eqIndex);
83
+ const value = opt.substring(eqIndex + 1);
84
+
85
+ // Try to parse as JSON for complex types
86
+ try {
87
+ result[key] = JSON.parse(value);
88
+ } catch {
89
+ result[key] = value;
90
+ }
91
+ }
92
+ }
93
+
94
+ return result;
95
+ }
96
+
97
+ /**
98
+ * Load recipes from a local directory path
99
+ */
100
+ export async function loadLocalRecipes(
101
+ localPath: string,
102
+ registry: RecipeRegistry,
103
+ verbose: boolean = false
104
+ ): Promise<void> {
105
+ // Resolve the path
106
+ const resolvedPath = path.resolve(localPath);
107
+
108
+ if (!fs.existsSync(resolvedPath)) {
109
+ throw new Error(`Local path does not exist: ${resolvedPath}`);
110
+ }
111
+
112
+ // Check if it's a directory or file
113
+ const stat = fs.statSync(resolvedPath);
114
+ let modulePath: string;
115
+
116
+ if (stat.isDirectory()) {
117
+ // Look for package.json to find the main entry point
118
+ const packageJsonPath = path.join(resolvedPath, 'package.json');
119
+ if (fs.existsSync(packageJsonPath)) {
120
+ const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
121
+ // Use the main field, or default to dist/index.js
122
+ const main = pkg.main || 'dist/index.js';
123
+ modulePath = path.join(resolvedPath, main);
124
+ } else {
125
+ // Try common entry points
126
+ const candidates = ['dist/index.js', 'index.js', 'src/index.ts'];
127
+ const found = candidates.find(c => fs.existsSync(path.join(resolvedPath, c)));
128
+ if (found) {
129
+ modulePath = path.join(resolvedPath, found);
130
+ } else {
131
+ throw new Error(`Cannot find entry point in ${resolvedPath}. Expected package.json with main field or dist/index.js`);
132
+ }
133
+ }
134
+ } else {
135
+ modulePath = resolvedPath;
136
+ }
137
+
138
+ if (!fs.existsSync(modulePath)) {
139
+ throw new Error(`Module entry point not found: ${modulePath}. Did you run 'npm run build'?`);
140
+ }
141
+
142
+ if (verbose) {
143
+ console.log(`Loading recipes from ${modulePath}...`);
144
+ }
145
+
146
+ // Set up shared dependencies
147
+ setupSharedDependencies(modulePath);
148
+
149
+ const recipeModule = require(modulePath);
150
+
151
+ if (typeof recipeModule.activate !== 'function') {
152
+ throw new Error(`${localPath} does not export an 'activate' function`);
153
+ }
154
+
155
+ const activatePromise = recipeModule.activate(registry);
156
+ if (activatePromise instanceof Promise) {
157
+ await activatePromise;
158
+ }
159
+
160
+ if (verbose) {
161
+ console.log(`Loaded ${registry.all.size} recipes`);
162
+ }
163
+ }
164
+
165
+ /**
166
+ * Install a recipe package from NPM and load its recipes into the registry
167
+ */
168
+ export async function installRecipePackage(
169
+ packageName: string,
170
+ installDir: string,
171
+ registry: RecipeRegistry,
172
+ verbose: boolean = false
173
+ ): Promise<void> {
174
+ // Ensure directory exists
175
+ if (!fs.existsSync(installDir)) {
176
+ fs.mkdirSync(installDir, {recursive: true});
177
+ }
178
+
179
+ // Check if package.json exists, if not create one
180
+ const packageJsonPath = path.join(installDir, 'package.json');
181
+ if (!fs.existsSync(packageJsonPath)) {
182
+ const packageJson = {
183
+ name: 'openrewrite-recipes',
184
+ version: '1.0.0',
185
+ description: 'OpenRewrite recipe installation',
186
+ private: true
187
+ };
188
+ fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2));
189
+ if (verbose) {
190
+ console.log('Created package.json for recipe dependencies');
191
+ }
192
+ }
193
+
194
+ // Install the package
195
+ if (verbose) {
196
+ console.log(`Installing ${packageName}...`);
197
+ }
198
+
199
+ await new Promise<void>((resolve, reject) => {
200
+ const child = spawn('npm', ['install', packageName, '--no-fund'], {
201
+ cwd: installDir,
202
+ stdio: verbose ? 'inherit' : 'pipe'
203
+ });
204
+
205
+ child.on('error', reject);
206
+ child.on('close', (exitCode) => {
207
+ if (exitCode === 0) {
208
+ resolve();
209
+ } else {
210
+ reject(new Error(`npm install exited with code ${exitCode}`));
211
+ }
212
+ });
213
+ });
214
+
215
+ // Load the module and activate recipes
216
+ const resolvedPath = require.resolve(path.join(installDir, 'node_modules', packageName));
217
+
218
+ // Set up shared dependencies to avoid instanceof failures
219
+ setupSharedDependencies(resolvedPath);
220
+
221
+ const recipeModule = require(resolvedPath);
222
+
223
+ if (typeof recipeModule.activate !== 'function') {
224
+ throw new Error(`${packageName} does not export an 'activate' function`);
225
+ }
226
+
227
+ const activatePromise = recipeModule.activate(registry);
228
+ if (activatePromise instanceof Promise) {
229
+ await activatePromise;
230
+ }
231
+
232
+ if (verbose) {
233
+ console.log(`Loaded ${registry.all.size} recipes`);
234
+ }
235
+ }
236
+
237
+ /**
238
+ * Set up shared dependencies to avoid instanceof failures when loading recipes
239
+ * from a separate node_modules directory
240
+ */
241
+ export function setupSharedDependencies(targetModulePath: string): void {
242
+ const sharedDeps = ['@openrewrite/rewrite'];
243
+ const targetDir = path.dirname(targetModulePath);
244
+
245
+ for (const depName of sharedDeps) {
246
+ try {
247
+ const hostPackageEntry = require.resolve(depName);
248
+
249
+ // Find host package root
250
+ let hostPackageRoot = path.dirname(hostPackageEntry);
251
+ while (hostPackageRoot !== path.dirname(hostPackageRoot)) {
252
+ const pkgJsonPath = path.join(hostPackageRoot, 'package.json');
253
+ if (fs.existsSync(pkgJsonPath)) {
254
+ try {
255
+ const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'));
256
+ if (pkg.name === depName) {
257
+ break;
258
+ }
259
+ } catch {
260
+ // Not a valid package.json
261
+ }
262
+ }
263
+ hostPackageRoot = path.dirname(hostPackageRoot);
264
+ }
265
+
266
+ // Find target package root
267
+ let targetPackageRoot: string | undefined;
268
+ let searchDir = targetDir;
269
+ while (searchDir !== path.dirname(searchDir)) {
270
+ const nodeModulesPath = path.join(searchDir, 'node_modules', ...depName.split('/'));
271
+ if (fs.existsSync(nodeModulesPath)) {
272
+ const pkgJsonPath = path.join(nodeModulesPath, 'package.json');
273
+ if (fs.existsSync(pkgJsonPath)) {
274
+ try {
275
+ const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'));
276
+ if (pkg.name === depName) {
277
+ targetPackageRoot = nodeModulesPath;
278
+ break;
279
+ }
280
+ } catch {
281
+ // Not a valid package.json
282
+ }
283
+ }
284
+ }
285
+ searchDir = path.dirname(searchDir);
286
+ }
287
+
288
+ if (!targetPackageRoot || hostPackageRoot === targetPackageRoot) {
289
+ continue;
290
+ }
291
+
292
+ // Map all cached modules
293
+ const hostPrefix = hostPackageRoot + path.sep;
294
+ for (const cachedPath of Object.keys(require.cache)) {
295
+ if (cachedPath.startsWith(hostPrefix)) {
296
+ const relativePath = cachedPath.substring(hostPrefix.length);
297
+ const targetPath = path.join(targetPackageRoot, relativePath);
298
+ require.cache[targetPath] = require.cache[cachedPath];
299
+ }
300
+ }
301
+ } catch {
302
+ // Failed to set up shared dependency
303
+ }
304
+ }
305
+ }
306
+
307
+ /**
308
+ * Find a recipe by name in the registry
309
+ *
310
+ * Supports:
311
+ * - Exact match by FQN
312
+ * - Match by suffix (last segment of FQN)
313
+ * - Partial match (case-insensitive)
314
+ *
315
+ * Returns null if not found or if ambiguous (prints error message)
316
+ */
317
+ export function findRecipe(
318
+ registry: RecipeRegistry,
319
+ recipeName: string,
320
+ options: Record<string, any>
321
+ ): Recipe | null {
322
+ // Try exact match first
323
+ const RecipeClass = registry.all.get(recipeName);
324
+ if (RecipeClass) {
325
+ return new RecipeClass(options);
326
+ }
327
+
328
+ // Try matching by suffix (last segment of FQN)
329
+ const matches: string[] = [];
330
+ for (const name of registry.all.keys()) {
331
+ if (name.endsWith('.' + recipeName) || name.endsWith('-' + recipeName) ||
332
+ name === recipeName || name.toLowerCase().endsWith(recipeName.toLowerCase())) {
333
+ matches.push(name);
334
+ }
335
+ }
336
+
337
+ if (matches.length === 0) {
338
+ // Try partial match
339
+ for (const name of registry.all.keys()) {
340
+ if (name.toLowerCase().includes(recipeName.toLowerCase())) {
341
+ matches.push(name);
342
+ }
343
+ }
344
+ }
345
+
346
+ if (matches.length === 0) {
347
+ console.error(`Recipe not found: ${recipeName}`);
348
+ console.error('\nAvailable recipes:');
349
+ for (const name of [...registry.all.keys()].sort()) {
350
+ console.error(` ${name}`);
351
+ }
352
+ return null;
353
+ }
354
+
355
+ if (matches.length > 1) {
356
+ console.error(`Ambiguous recipe name: ${recipeName}`);
357
+ console.error('\nMatching recipes:');
358
+ for (const name of matches.sort()) {
359
+ console.error(` ${name}`);
360
+ }
361
+ console.error('\nPlease use a more specific recipe name.');
362
+ return null;
363
+ }
364
+
365
+ const MatchedRecipeClass = registry.all.get(matches[0])!;
366
+ return new MatchedRecipeClass(options);
367
+ }
368
+
369
+ /**
370
+ * Discover source files in a project directory, respecting .gitignore
371
+ */
372
+ export async function discoverFiles(projectRoot: string, verbose: boolean = false): Promise<string[]> {
373
+ const files: string[] = [];
374
+
375
+ if (verbose) {
376
+ console.log(`Discovering files in ${projectRoot}...`);
377
+ }
378
+
379
+ // Get list of git-ignored files
380
+ const ignoredFiles = new Set<string>();
381
+ try {
382
+ const result = spawnSync('git', ['ls-files', '--ignored', '--exclude-standard', '-o'], {
383
+ cwd: projectRoot,
384
+ encoding: 'utf8'
385
+ });
386
+ if (result.stdout) {
387
+ for (const line of result.stdout.split('\n')) {
388
+ if (line.trim()) {
389
+ ignoredFiles.add(path.join(projectRoot, line.trim()));
390
+ }
391
+ }
392
+ }
393
+ } catch {
394
+ // Git not available or not a git repository
395
+ }
396
+
397
+ // Get tracked and untracked (but not ignored) files
398
+ const trackedFiles = new Set<string>();
399
+ try {
400
+ // Get tracked files
401
+ const tracked = spawnSync('git', ['ls-files'], {
402
+ cwd: projectRoot,
403
+ encoding: 'utf8'
404
+ });
405
+ if (tracked.stdout) {
406
+ for (const line of tracked.stdout.split('\n')) {
407
+ if (line.trim()) {
408
+ trackedFiles.add(path.join(projectRoot, line.trim()));
409
+ }
410
+ }
411
+ }
412
+
413
+ // Get untracked but not ignored files
414
+ const untracked = spawnSync('git', ['ls-files', '--others', '--exclude-standard'], {
415
+ cwd: projectRoot,
416
+ encoding: 'utf8'
417
+ });
418
+ if (untracked.stdout) {
419
+ for (const line of untracked.stdout.split('\n')) {
420
+ if (line.trim()) {
421
+ trackedFiles.add(path.join(projectRoot, line.trim()));
422
+ }
423
+ }
424
+ }
425
+ } catch {
426
+ // Not a git repository, fall back to recursive directory scan
427
+ await walkDirectory(projectRoot, files, ignoredFiles, projectRoot);
428
+ return files.filter(isAcceptedFile);
429
+ }
430
+
431
+ // Filter to accepted file types
432
+ for (const file of trackedFiles) {
433
+ if (!ignoredFiles.has(file) && isAcceptedFile(file)) {
434
+ files.push(file);
435
+ }
436
+ }
437
+
438
+ return files;
439
+ }
440
+
441
+ /**
442
+ * Walk a directory recursively, collecting files
443
+ */
444
+ export async function walkDirectory(
445
+ dir: string,
446
+ files: string[],
447
+ ignored: Set<string>,
448
+ projectRoot: string
449
+ ): Promise<void> {
450
+ const entries = await fsp.readdir(dir, {withFileTypes: true});
451
+
452
+ for (const entry of entries) {
453
+ const fullPath = path.join(dir, entry.name);
454
+
455
+ // Skip hidden files and common ignore patterns
456
+ if (entry.name.startsWith('.') || entry.name === 'node_modules' || entry.name === 'dist' ||
457
+ entry.name === 'build' || entry.name === 'coverage') {
458
+ continue;
459
+ }
460
+
461
+ if (ignored.has(fullPath)) {
462
+ continue;
463
+ }
464
+
465
+ if (entry.isDirectory()) {
466
+ await walkDirectory(fullPath, files, ignored, projectRoot);
467
+ } else if (entry.isFile() && isAcceptedFile(fullPath)) {
468
+ files.push(fullPath);
469
+ }
470
+ }
471
+ }
472
+
473
+ /**
474
+ * Check if a file is accepted for parsing based on its extension
475
+ */
476
+ export function isAcceptedFile(filePath: string): boolean {
477
+ const ext = path.extname(filePath).toLowerCase();
478
+
479
+ // JavaScript/TypeScript files
480
+ if (['.js', '.jsx', '.ts', '.tsx', '.mjs', '.mts', '.cjs', '.cts'].includes(ext)) {
481
+ return true;
482
+ }
483
+
484
+ // JSON files (including package.json which gets special parsing)
485
+ if (ext === '.json') {
486
+ return true;
487
+ }
488
+
489
+ return false;
490
+ }
491
+
492
+ /**
493
+ * Parse source files using appropriate parsers
494
+ */
495
+ export async function parseFiles(
496
+ filePaths: string[],
497
+ projectRoot: string,
498
+ verbose: boolean = false
499
+ ): Promise<SourceFile[]> {
500
+ const parsed: SourceFile[] = [];
501
+
502
+ // Group files by type
503
+ const jsFiles: string[] = [];
504
+ const packageJsonFiles: string[] = [];
505
+ const jsonFiles: string[] = [];
506
+
507
+ for (const filePath of filePaths) {
508
+ const basename = path.basename(filePath);
509
+ const ext = path.extname(filePath).toLowerCase();
510
+
511
+ if (basename === 'package.json') {
512
+ packageJsonFiles.push(filePath);
513
+ } else if (['.js', '.jsx', '.ts', '.tsx', '.mjs', '.mts', '.cjs', '.cts'].includes(ext)) {
514
+ jsFiles.push(filePath);
515
+ } else if (ext === '.json') {
516
+ jsonFiles.push(filePath);
517
+ }
518
+ }
519
+
520
+ // Parse JavaScript/TypeScript files
521
+ if (jsFiles.length > 0) {
522
+ if (verbose) {
523
+ console.log(`Parsing ${jsFiles.length} JavaScript/TypeScript files...`);
524
+ }
525
+ const jsParser = new JavaScriptParser({relativeTo: projectRoot});
526
+ for await (const sf of jsParser.parse(...jsFiles)) {
527
+ parsed.push(sf);
528
+ }
529
+ }
530
+
531
+ // Parse package.json files
532
+ if (packageJsonFiles.length > 0) {
533
+ if (verbose) {
534
+ console.log(`Parsing ${packageJsonFiles.length} package.json files...`);
535
+ }
536
+ const pkgParser = new PackageJsonParser({relativeTo: projectRoot});
537
+ for await (const sf of pkgParser.parse(...packageJsonFiles)) {
538
+ parsed.push(sf);
539
+ }
540
+ }
541
+
542
+ // Parse other JSON files
543
+ if (jsonFiles.length > 0) {
544
+ if (verbose) {
545
+ console.log(`Parsing ${jsonFiles.length} JSON files...`);
546
+ }
547
+ const jsonParser = new JsonParser({relativeTo: projectRoot});
548
+ for await (const sf of jsonParser.parse(...jsonFiles)) {
549
+ parsed.push(sf);
550
+ }
551
+ }
552
+
553
+ return parsed;
554
+ }