diff --git a/rewrite-javascript/rewrite/src/index.ts b/rewrite-javascript/rewrite/src/index.ts index 392e5ae689..df1dd19ef5 100644 --- a/rewrite-javascript/rewrite/src/index.ts +++ b/rewrite-javascript/rewrite/src/index.ts @@ -38,9 +38,10 @@ export async function activate(registry: RecipeRegistry): Promise { const {ModernizeOctalEscapeSequences, ModernizeOctalLiterals, RemoveDuplicateObjectKeys} = await import("./javascript/migrate/es6/index.js"); const {ExportAssignmentToExportDefault} = await import("./javascript/migrate/typescript/index.js"); const {UseObjectPropertyShorthand, PreferOptionalChain, AddParseIntRadix} = await import("./javascript/cleanup/index.js"); - const {AsyncCallbackInSyncArrayMethod, AutoFormat, UpgradeDependencyVersion, UpgradeTransitiveDependencyVersion, OrderImports, ChangeImport} = await import("./javascript/recipes/index.js"); + const {AddDependency, AsyncCallbackInSyncArrayMethod, AutoFormat, UpgradeDependencyVersion, UpgradeTransitiveDependencyVersion, OrderImports, ChangeImport} = await import("./javascript/recipes/index.js"); const {FindDependency} = await import("./javascript/search/index.js"); + registry.register(AddDependency); registry.register(ExportAssignmentToExportDefault); registry.register(FindDependency); registry.register(OrderImports); diff --git a/rewrite-javascript/rewrite/src/javascript/node-resolution-result.ts b/rewrite-javascript/rewrite/src/javascript/node-resolution-result.ts index a15b2dfde9..997203c70f 100644 --- a/rewrite-javascript/rewrite/src/javascript/node-resolution-result.ts +++ b/rewrite-javascript/rewrite/src/javascript/node-resolution-result.ts @@ -89,6 +89,22 @@ export const enum NpmrcScope { Project = 'Project', // .npmrc in project root } +/** + * Represents a dependency scope in package.json that uses object structure {name: version}. + * Note: `bundledDependencies` is excluded because it's a string[] of package names, not a version map. + */ +export type DependencyScope = 'dependencies' | 'devDependencies' | 'peerDependencies' | 'optionalDependencies'; + +/** + * All dependency scopes in package.json that use object structure {name: version}. + */ +export const allDependencyScopes: readonly DependencyScope[] = [ + 'dependencies', + 'devDependencies', + 'peerDependencies', + 'optionalDependencies' +] as const; + export const NpmrcKind = "org.openrewrite.javascript.marker.NodeResolutionResult$Npmrc" as const; /** diff --git a/rewrite-javascript/rewrite/src/javascript/package-manager.ts b/rewrite-javascript/rewrite/src/javascript/package-manager.ts index e199d7a95f..5b1d512448 100644 --- a/rewrite-javascript/rewrite/src/javascript/package-manager.ts +++ b/rewrite-javascript/rewrite/src/javascript/package-manager.ts @@ -14,7 +14,16 @@ * limitations under the License. */ -import {PackageManager} from "./node-resolution-result"; +import { + createNodeResolutionResultMarker, + findNodeResolutionResult, + PackageJsonContent, + PackageLockContent, + PackageManager, + readNpmrcConfigs +} from "./node-resolution-result"; +import {replaceMarkerByKind} from "../markers"; +import {Json} from "../json"; import * as fs from "fs"; import * as fsp from "fs/promises"; import * as path from "path"; @@ -108,7 +117,7 @@ const LOCK_FILE_DETECTION: ReadonlyArray = [ /** * Result of running a package manager command. */ -export interface PackageManagerResult { +interface PackageManagerResult { success: boolean; stdout?: string; stderr?: string; @@ -118,7 +127,7 @@ export interface PackageManagerResult { /** * Options for running package manager install. */ -export interface InstallOptions { +interface InstallOptions { /** Working directory */ cwd: string; @@ -164,13 +173,6 @@ export function getLockFileDetectionConfig(): ReadonlyArray c.filename); } -/** - * Checks if a file path is a lock file. - */ -export function isLockFile(filePath: string): boolean { - const fileName = path.basename(filePath); - return getAllLockFileNames().includes(fileName); -} - /** * Runs the package manager install command. - * - * @param pm The package manager to use - * @param options Install options - * @returns Result of the install command */ -export function runInstall(pm: PackageManager, options: InstallOptions): PackageManagerResult { +function runInstall(pm: PackageManager, options: InstallOptions): PackageManagerResult { const config = PACKAGE_MANAGER_CONFIGS[pm]; const command = options.lockOnly ? config.installLockOnlyCommand : config.installCommand; const [cmd, ...args] = command; @@ -244,121 +234,6 @@ export function runInstall(pm: PackageManager, options: InstallOptions): Package } } -/** - * Options for adding/upgrading a package. - */ -export interface AddPackageOptions { - /** Working directory */ - cwd: string; - - /** Package name to add/upgrade */ - packageName: string; - - /** Version constraint (e.g., "^5.0.0") */ - version: string; - - /** If true, only update lock file without installing to node_modules */ - lockOnly?: boolean; - - /** Timeout in milliseconds (default: 120000 = 2 minutes) */ - timeout?: number; - - /** Additional environment variables */ - env?: Record; -} - -/** - * Runs a package manager command to add or upgrade a package. - * This updates both package.json and the lock file. - * - * @param pm The package manager to use - * @param options Add package options - * @returns Result of the command - */ -export function runAddPackage(pm: PackageManager, options: AddPackageOptions): PackageManagerResult { - const packageSpec = `${options.packageName}@${options.version}`; - - // Build command based on package manager - let cmd: string; - let args: string[]; - - switch (pm) { - case PackageManager.Npm: - cmd = 'npm'; - args = ['install', packageSpec]; - if (options.lockOnly) { - args.push('--package-lock-only'); - } - break; - case PackageManager.YarnClassic: - cmd = 'yarn'; - args = ['add', packageSpec]; - if (options.lockOnly) { - args.push('--ignore-scripts'); - } - break; - case PackageManager.YarnBerry: - cmd = 'yarn'; - args = ['add', packageSpec]; - if (options.lockOnly) { - args.push('--mode', 'skip-build'); - } - break; - case PackageManager.Pnpm: - cmd = 'pnpm'; - args = ['add', packageSpec]; - if (options.lockOnly) { - args.push('--lockfile-only'); - } - break; - case PackageManager.Bun: - cmd = 'bun'; - args = ['add', packageSpec]; - if (options.lockOnly) { - args.push('--ignore-scripts'); - } - break; - } - - try { - const result = spawnSync(cmd, args, { - cwd: options.cwd, - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'], - timeout: options.timeout ?? 120000, - env: options.env ? {...process.env, ...options.env} : process.env, - }); - - if (result.error) { - return { - success: false, - error: result.error.message, - stderr: result.stderr, - }; - } - - if (result.status !== 0) { - return { - success: false, - stdout: result.stdout, - stderr: result.stderr, - error: `Command exited with code ${result.status}`, - }; - } - - return { - success: true, - stdout: result.stdout, - stderr: result.stderr, - }; - } catch (error: any) { - return { - success: false, - error: error.message, - }; - } -} - /** * Runs a package manager list command to get dependency information. * @@ -390,55 +265,203 @@ export function runList(pm: PackageManager, cwd: string, timeout: number = 30000 } /** - * Checks if a package manager is available on the system. + * Result of running install in a temporary directory. + */ +export interface TempInstallResult { + /** Whether the install succeeded */ + success: boolean; + /** The updated lock file content (if successful and lock file exists) */ + lockFileContent?: string; + /** Error message (if failed) */ + error?: string; +} + +/** + * Generic accumulator for dependency recipes that run package manager operations. + * Used by scanning recipes to track state across scanning and editing phases. * - * @param pm The package manager to check - * @returns True if the package manager is available + * @typeParam T The recipe-specific project update info type */ -export function isPackageManagerAvailable(pm: PackageManager): boolean { - const config = PACKAGE_MANAGER_CONFIGS[pm]; - const cmd = config.installCommand[0]; +export interface DependencyRecipeAccumulator { + /** Projects that need updating: packageJsonPath -> update info */ + projectsToUpdate: Map; - try { - const result = spawnSync(cmd, ['--version'], { - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'], - timeout: 5000, - }); - return result.status === 0; - } catch { - return false; + /** After running package manager, store the updated lock file content */ + updatedLockFiles: Map; + + /** Updated package.json content (after npm install may have modified it) */ + updatedPackageJsons: Map; + + /** Track which projects have been processed (npm install has run) */ + processedProjects: Set; + + /** Track projects where npm install failed: packageJsonPath -> error message */ + failedProjects: Map; +} + +/** + * Creates a new empty accumulator for dependency recipes. + */ +export function createDependencyRecipeAccumulator(): DependencyRecipeAccumulator { + return { + projectsToUpdate: new Map(), + updatedLockFiles: new Map(), + updatedPackageJsons: new Map(), + processedProjects: new Set(), + failedProjects: new Map() + }; +} + +/** + * Checks if a source path is a lock file and returns the updated content if available. + * This is a helper for dependency recipes that need to update lock files. + * + * @param sourcePath The source path to check + * @param acc The recipe accumulator containing updated lock file content + * @returns The updated lock file content if this is a lock file that was updated, undefined otherwise + */ +export function getUpdatedLockFileContent( + sourcePath: string, + acc: DependencyRecipeAccumulator +): string | undefined { + for (const lockFileName of getAllLockFileNames()) { + if (sourcePath.endsWith(lockFileName)) { + // Find the corresponding package.json path + const packageJsonPath = sourcePath.replace(lockFileName, 'package.json'); + const updateInfo = acc.projectsToUpdate.get(packageJsonPath); + + if (updateInfo && acc.updatedLockFiles.has(sourcePath)) { + return acc.updatedLockFiles.get(sourcePath); + } + break; + } } + return undefined; +} + +/** + * Base interface for project update info used by dependency recipes. + * Recipes extend this with additional fields specific to their needs. + */ +export interface BaseProjectUpdateInfo { + /** Absolute path to the project directory */ + projectDir: string; + /** Relative path to package.json (from source root) */ + packageJsonPath: string; + /** The package manager used by this project */ + packageManager: PackageManager; } /** - * Gets a human-readable name for a package manager. + * Stores the result of a package manager install into the accumulator. + * This handles the common pattern of storing updated lock files and tracking failures. + * + * @param result The result from runInstallInTempDir + * @param acc The recipe accumulator + * @param updateInfo The project update info (must have packageJsonPath and packageManager) + * @param modifiedPackageJson The modified package.json content that was used for install */ -export function getPackageManagerDisplayName(pm: PackageManager): string { - switch (pm) { - case PackageManager.Npm: - return 'npm'; - case PackageManager.YarnClassic: - return 'Yarn Classic'; - case PackageManager.YarnBerry: - return 'Yarn Berry'; - case PackageManager.Pnpm: - return 'pnpm'; - case PackageManager.Bun: - return 'Bun'; +export function storeInstallResult( + result: TempInstallResult, + acc: DependencyRecipeAccumulator, + updateInfo: T, + modifiedPackageJson: string +): void { + if (result.success) { + acc.updatedPackageJsons.set(updateInfo.packageJsonPath, modifiedPackageJson); + + if (result.lockFileContent) { + const lockFileName = getLockFileName(updateInfo.packageManager); + const lockFilePath = updateInfo.packageJsonPath.replace('package.json', lockFileName); + acc.updatedLockFiles.set(lockFilePath, result.lockFileContent); + } + } else { + acc.failedProjects.set(updateInfo.packageJsonPath, result.error || 'Unknown error'); } } /** - * Result of running install in a temporary directory. + * Runs the package manager install for a project if it hasn't been processed yet. + * Updates the accumulator's processedProjects set after running. + * + * @param sourcePath The source path (package.json path) being processed + * @param acc The recipe accumulator + * @param runInstall Function that performs the actual install (recipe-specific) + * @returns The failure message if install failed, undefined otherwise */ -export interface TempInstallResult { - /** Whether the install succeeded */ - success: boolean; - /** The updated lock file content (if successful and lock file exists) */ - lockFileContent?: string; - /** Error message (if failed) */ - error?: string; +export async function runInstallIfNeeded( + sourcePath: string, + acc: DependencyRecipeAccumulator, + runInstall: () => Promise +): Promise { + if (!acc.processedProjects.has(sourcePath)) { + await runInstall(); + acc.processedProjects.add(sourcePath); + } + return acc.failedProjects.get(sourcePath); +} + +/** + * Updates the NodeResolutionResult marker on a JSON document after a package manager operation. + * This recreates the marker based on the updated package.json and lock file content. + * + * @param doc The JSON document containing the marker + * @param updateInfo Project update info with paths and package manager + * @param acc The recipe accumulator containing updated content + * @returns The document with the updated marker, or unchanged if no existing marker + */ +export async function updateNodeResolutionMarker( + doc: Json.Document, + updateInfo: T & { originalPackageJson: string }, + acc: DependencyRecipeAccumulator +): Promise { + const existingMarker = findNodeResolutionResult(doc); + if (!existingMarker) { + return doc; + } + + // Parse the updated package.json and lock file to create new marker + const updatedPackageJson = acc.updatedPackageJsons.get(updateInfo.packageJsonPath); + const lockFileName = getLockFileName(updateInfo.packageManager); + const updatedLockFile = acc.updatedLockFiles.get( + updateInfo.packageJsonPath.replace('package.json', lockFileName) + ); + + let packageJsonContent: PackageJsonContent; + let lockContent: PackageLockContent | undefined; + + try { + packageJsonContent = JSON.parse(updatedPackageJson || updateInfo.originalPackageJson); + } catch { + return doc; // Failed to parse, keep original marker + } + + if (updatedLockFile) { + try { + lockContent = JSON.parse(updatedLockFile); + } catch { + // Continue without lock file content + } + } + + // Read npmrc configs from the project directory + const npmrcConfigs = await readNpmrcConfigs(updateInfo.projectDir); + + // Create new marker + const newMarker = createNodeResolutionResultMarker( + existingMarker.path, + packageJsonContent, + lockContent, + existingMarker.workspacePackagePaths, + existingMarker.packageManager, + npmrcConfigs.length > 0 ? npmrcConfigs : undefined + ); + + // Replace the marker in the document + return { + ...doc, + markers: replaceMarkerByKind(doc.markers, newMarker) + }; } /** diff --git a/rewrite-javascript/rewrite/src/javascript/recipes/add-dependency.ts b/rewrite-javascript/rewrite/src/javascript/recipes/add-dependency.ts new file mode 100644 index 0000000000..c0e1c79390 --- /dev/null +++ b/rewrite-javascript/rewrite/src/javascript/recipes/add-dependency.ts @@ -0,0 +1,467 @@ +/* + * Copyright 2025 the original author or authors. + *

+ * Licensed under the Moderne Source Available License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * https://docs.moderne.io/licensing/moderne-source-available-license + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {Option, ScanningRecipe} from "../../recipe"; +import {ExecutionContext} from "../../execution"; +import {TreeVisitor} from "../../visitor"; +import {detectIndent, getMemberKeyName, isObject, Json, JsonParser, JsonVisitor, rightPadded, space} from "../../json"; +import { + allDependencyScopes, + DependencyScope, + findNodeResolutionResult, + PackageManager +} from "../node-resolution-result"; +import {emptyMarkers, markupWarn} from "../../markers"; +import {TreePrinters} from "../../print"; +import { + createDependencyRecipeAccumulator, + DependencyRecipeAccumulator, + getUpdatedLockFileContent, + runInstallIfNeeded, + runInstallInTempDir, + storeInstallResult, + updateNodeResolutionMarker +} from "../package-manager"; +import {randomId} from "../../uuid"; +import * as path from "path"; + +/** + * Information about a project that needs updating + */ +interface ProjectUpdateInfo { + /** Absolute path to the project directory */ + projectDir: string; + /** Relative path to package.json (from source root) */ + packageJsonPath: string; + /** Original package.json content */ + originalPackageJson: string; + /** The scope where the dependency should be added */ + dependencyScope: DependencyScope; + /** Version constraint to apply */ + newVersion: string; + /** The package manager used by this project */ + packageManager: PackageManager; +} + +type Accumulator = DependencyRecipeAccumulator; + +/** + * Adds a new dependency to package.json and updates the lock file. + * + * This recipe: + * 1. Finds package.json files that don't already have the specified dependency + * 2. Adds the dependency to the specified scope (defaults to 'dependencies') + * 3. Runs the package manager to update the lock file + * 4. Updates the NodeResolutionResult marker with new dependency info + * + * If the dependency already exists in any scope, no changes are made. + * This matches the behavior of org.openrewrite.maven.AddDependency. + */ +export class AddDependency extends ScanningRecipe { + readonly name = "org.openrewrite.javascript.dependencies.add-dependency"; + readonly displayName = "Add npm dependency"; + readonly description = "Adds a new dependency to `package.json` and updates the lock file by running the package manager."; + + @Option({ + displayName: "Package name", + description: "The name of the npm package to add (e.g., `lodash`, `@types/node`)", + example: "lodash" + }) + packageName!: string; + + @Option({ + displayName: "Version", + description: "The version constraint to set (e.g., `^5.0.0`, `~2.1.0`, `3.0.0`)", + example: "^5.0.0" + }) + version!: string; + + @Option({ + displayName: "Scope", + description: "The dependency scope: `dependencies`, `devDependencies`, `peerDependencies`, or `optionalDependencies`", + example: "dependencies", + required: false + }) + scope?: DependencyScope; + + initialValue(_ctx: ExecutionContext): Accumulator { + return createDependencyRecipeAccumulator(); + } + + private getTargetScope(): DependencyScope { + return this.scope ?? 'dependencies'; + } + + async scanner(acc: Accumulator): Promise> { + const recipe = this; + + return new class extends JsonVisitor { + protected async visitDocument(doc: Json.Document, _ctx: ExecutionContext): Promise { + // Only process package.json files + if (!doc.sourcePath.endsWith('package.json')) { + return doc; + } + + const marker = findNodeResolutionResult(doc); + if (!marker) { + return doc; + } + + // Check if dependency already exists in any scope + for (const scope of allDependencyScopes) { + const deps = marker[scope]; + if (deps?.some(d => d.name === recipe.packageName)) { + // Dependency already exists, don't add again + return doc; + } + } + + // Get the project directory and package manager + const projectDir = path.dirname(path.resolve(doc.sourcePath)); + const pm = marker.packageManager ?? PackageManager.Npm; + + acc.projectsToUpdate.set(doc.sourcePath, { + projectDir, + packageJsonPath: doc.sourcePath, + originalPackageJson: await this.printDocument(doc), + dependencyScope: recipe.getTargetScope(), + newVersion: recipe.version, + packageManager: pm + }); + + return doc; + } + + private async printDocument(doc: Json.Document): Promise { + return TreePrinters.print(doc); + } + }; + } + + async editorWithData(acc: Accumulator): Promise> { + const recipe = this; + + return new class extends JsonVisitor { + protected async visitDocument(doc: Json.Document, ctx: ExecutionContext): Promise { + const sourcePath = doc.sourcePath; + + // Handle package.json files + if (sourcePath.endsWith('package.json')) { + const updateInfo = acc.projectsToUpdate.get(sourcePath); + if (!updateInfo) { + return doc; // This package.json doesn't need updating + } + + // Run package manager install if needed, check for failure + const failureMessage = await runInstallIfNeeded(sourcePath, acc, () => + recipe.runPackageManagerInstall(acc, updateInfo, ctx) + ); + if (failureMessage) { + return markupWarn( + doc, + `Failed to add ${recipe.packageName} to ${recipe.version}`, + failureMessage + ); + } + + // Add the dependency to the JSON AST (preserves formatting) + const visitor = new AddDependencyVisitor( + recipe.packageName, + recipe.version, + updateInfo.dependencyScope + ); + const modifiedDoc = await visitor.visit(doc, undefined) as Json.Document; + + // Update the NodeResolutionResult marker + return updateNodeResolutionMarker(modifiedDoc, updateInfo, acc); + } + + // Handle lock files for all package managers + const updatedLockContent = getUpdatedLockFileContent(sourcePath, acc); + if (updatedLockContent) { + return await new JsonParser({}).parseOne({ + text: updatedLockContent, + sourcePath: doc.sourcePath + }) as Json.Document; + } + + return doc; + } + }; + } + + /** + * Runs the package manager in a temporary directory to update the lock file. + */ + private async runPackageManagerInstall( + acc: Accumulator, + updateInfo: ProjectUpdateInfo, + _ctx: ExecutionContext + ): Promise { + // Create modified package.json with the new dependency + const modifiedPackageJson = this.createModifiedPackageJson( + updateInfo.originalPackageJson, + updateInfo.dependencyScope + ); + + const result = await runInstallInTempDir( + updateInfo.projectDir, + updateInfo.packageManager, + modifiedPackageJson + ); + + storeInstallResult(result, acc, updateInfo, modifiedPackageJson); + } + + /** + * Creates a modified package.json with the new dependency added. + */ + private createModifiedPackageJson( + originalContent: string, + scope: DependencyScope + ): string { + const packageJson = JSON.parse(originalContent); + + if (!packageJson[scope]) { + packageJson[scope] = {}; + } + + packageJson[scope][this.packageName] = this.version; + + return JSON.stringify(packageJson, null, 2); + } +} + +/** + * Visitor that adds a new dependency to a specific scope in package.json. + * If the scope doesn't exist, it creates it. + */ +class AddDependencyVisitor extends JsonVisitor { + private readonly packageName: string; + private readonly version: string; + private readonly targetScope: DependencyScope; + private scopeFound = false; + private dependencyAdded = false; + private baseIndent: string = ' '; // Will be detected from document + + constructor(packageName: string, version: string, targetScope: DependencyScope) { + super(); + this.packageName = packageName; + this.version = version; + this.targetScope = targetScope; + } + + protected async visitDocument(doc: Json.Document, p: void): Promise { + // Detect indentation from the document + this.baseIndent = detectIndent(doc); + + const result = await super.visitDocument(doc, p) as Json.Document; + + // If scope wasn't found, we need to add it to the document + if (!this.scopeFound && !this.dependencyAdded) { + return this.addScopeToDocument(result); + } + + return result; + } + + protected async visitMember(member: Json.Member, p: void): Promise { + const keyName = getMemberKeyName(member); + + if (keyName === this.targetScope) { + this.scopeFound = true; + return this.addDependencyToScope(member); + } + + return super.visitMember(member, p); + } + + /** + * Adds the dependency to an existing scope object. + */ + private addDependencyToScope(scopeMember: Json.Member): Json.Member { + const value = scopeMember.value; + + if (!isObject(value)) { + return scopeMember; + } + + const members = [...(value.members || [])]; + + // Determine the closing whitespace (goes before closing brace after last element) + let closingWhitespace = '\n '; + if (members.length > 0) { + const lastMember = members[members.length - 1]; + closingWhitespace = lastMember.after.whitespace; + // Update the last member's after to be empty (comma will be added by printer) + members[members.length - 1] = { + ...lastMember, + after: space('') + }; + } + + const newMember = this.createDependencyMemberWithAfter(closingWhitespace); + this.dependencyAdded = true; + + members.push(newMember); + + return { + ...scopeMember, + value: { + ...value, + members + } as Json.Object + }; + } + + /** + * Creates a new dependency member node. + */ + private createDependencyMemberWithAfter(afterWhitespace: string): Json.RightPadded { + // Dependencies inside a scope are indented twice (e.g., 8 spaces if base is 4) + const depIndent = this.baseIndent + this.baseIndent; + + const keyLiteral: Json.Literal = { + kind: Json.Kind.Literal, + id: randomId(), + prefix: space('\n' + depIndent), + markers: emptyMarkers, + source: `"${this.packageName}"`, + value: this.packageName + }; + + const valueLiteral: Json.Literal = { + kind: Json.Kind.Literal, + id: randomId(), + prefix: space(' '), + markers: emptyMarkers, + source: `"${this.version}"`, + value: this.version + }; + + const member: Json.Member = { + kind: Json.Kind.Member, + id: randomId(), + prefix: space(''), + markers: emptyMarkers, + key: rightPadded(keyLiteral, space('')), + value: valueLiteral + }; + + return rightPadded(member, space(afterWhitespace)); + } + + /** + * Adds a new scope section to the document when the target scope doesn't exist. + */ + private addScopeToDocument(doc: Json.Document): Json.Document { + const docValue = doc.value; + + if (!isObject(docValue)) { + return doc; + } + + const members = [...(docValue.members || [])]; + + // Get the trailing whitespace from the last member + let closingWhitespace = '\n'; + if (members.length > 0) { + const lastMember = members[members.length - 1]; + closingWhitespace = lastMember.after.whitespace; + // Update the last member's after to be empty (comma will be added by printer) + members[members.length - 1] = { + ...lastMember, + after: space('') + }; + } + + const scopeMember = this.createScopeMemberWithAfter(closingWhitespace); + this.dependencyAdded = true; + + members.push(scopeMember); + + return { + ...doc, + value: { + ...docValue, + members + } as Json.Object + }; + } + + /** + * Creates a new scope member with the dependency inside. + */ + private createScopeMemberWithAfter(afterWhitespace: string): Json.RightPadded { + // Dependencies inside a scope are indented twice (e.g., 8 spaces if base is 4) + const depIndent = this.baseIndent + this.baseIndent; + + const keyLiteral: Json.Literal = { + kind: Json.Kind.Literal, + id: randomId(), + prefix: space('\n' + this.baseIndent), + markers: emptyMarkers, + source: `"${this.targetScope}"`, + value: this.targetScope + }; + + const depKeyLiteral: Json.Literal = { + kind: Json.Kind.Literal, + id: randomId(), + prefix: space('\n' + depIndent), + markers: emptyMarkers, + source: `"${this.packageName}"`, + value: this.packageName + }; + + const depValueLiteral: Json.Literal = { + kind: Json.Kind.Literal, + id: randomId(), + prefix: space(' '), + markers: emptyMarkers, + source: `"${this.version}"`, + value: this.version + }; + + const depMember: Json.Member = { + kind: Json.Kind.Member, + id: randomId(), + prefix: space(''), + markers: emptyMarkers, + key: rightPadded(depKeyLiteral, space('')), + value: depValueLiteral + }; + + const scopeObject: Json.Object = { + kind: Json.Kind.Object, + id: randomId(), + prefix: space(' '), + markers: emptyMarkers, + members: [rightPadded(depMember, space('\n' + this.baseIndent))] + }; + + const scopeMemberNode: Json.Member = { + kind: Json.Kind.Member, + id: randomId(), + prefix: space(''), + markers: emptyMarkers, + key: rightPadded(keyLiteral, space('')), + value: scopeObject + }; + + return rightPadded(scopeMemberNode, space(afterWhitespace)); + } +} diff --git a/rewrite-javascript/rewrite/src/javascript/recipes/index.ts b/rewrite-javascript/rewrite/src/javascript/recipes/index.ts index bca2b778ea..c19a2b2552 100644 --- a/rewrite-javascript/rewrite/src/javascript/recipes/index.ts +++ b/rewrite-javascript/rewrite/src/javascript/recipes/index.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +export * from "./add-dependency"; export * from "./async-callback-in-sync-array-method"; export * from "./auto-format"; export * from "./upgrade-dependency-version"; diff --git a/rewrite-javascript/rewrite/src/javascript/recipes/upgrade-dependency-version.ts b/rewrite-javascript/rewrite/src/javascript/recipes/upgrade-dependency-version.ts index c5aea20e6d..93b47752f5 100644 --- a/rewrite-javascript/rewrite/src/javascript/recipes/upgrade-dependency-version.ts +++ b/rewrite-javascript/rewrite/src/javascript/recipes/upgrade-dependency-version.ts @@ -17,25 +17,26 @@ import {Option, ScanningRecipe} from "../../recipe"; import {ExecutionContext} from "../../execution"; import {TreeVisitor} from "../../visitor"; -import {Json, JsonParser, JsonVisitor} from "../../json"; +import {getMemberKeyName, isLiteral, Json, JsonParser, JsonVisitor} from "../../json"; import { - createNodeResolutionResultMarker, + allDependencyScopes, + DependencyScope, findNodeResolutionResult, - PackageJsonContent, - PackageLockContent, - PackageManager, - readNpmrcConfigs + PackageManager } from "../node-resolution-result"; import * as path from "path"; import * as semver from "semver"; import {markupWarn, replaceMarkerByKind} from "../../markers"; import {TreePrinters} from "../../print"; -import {getAllLockFileNames, getLockFileName, runInstallInTempDir} from "../package-manager"; - -/** - * Represents a dependency scope in package.json - */ -type DependencyScope = 'dependencies' | 'devDependencies' | 'peerDependencies' | 'optionalDependencies'; +import { + createDependencyRecipeAccumulator, + DependencyRecipeAccumulator, + getUpdatedLockFileContent, + runInstallIfNeeded, + runInstallInTempDir, + storeInstallResult, + updateNodeResolutionMarker +} from "../package-manager"; /** * Information about a project that needs updating @@ -62,25 +63,7 @@ interface ProjectUpdateInfo { skipInstall: boolean; } -/** - * Accumulator for tracking state across scanning and editing phases - */ -interface Accumulator { - /** Projects that need updating: packageJsonPath -> update info */ - projectsToUpdate: Map; - - /** After running package manager, store the updated lock file content */ - updatedLockFiles: Map; - - /** Updated package.json content (after npm install may have modified it) */ - updatedPackageJsons: Map; - - /** Track which projects have been processed (npm install has run) */ - processedProjects: Set; - - /** Track projects where npm install failed: packageJsonPath -> error message */ - failedProjects: Map; -} +type Accumulator = DependencyRecipeAccumulator; /** * Upgrades the version of a direct dependency in package.json and updates the lock file. @@ -103,26 +86,20 @@ export class UpgradeDependencyVersion extends ScanningRecipe { @Option({ displayName: "Package name", - description: "The name of the npm package to upgrade (e.g., 'lodash', '@types/node')", + description: "The name of the npm package to upgrade (e.g., `lodash`, `@types/node`)", example: "lodash" }) packageName!: string; @Option({ - displayName: "New version", - description: "The new version constraint to set (e.g., '^5.0.0', '~2.1.0', '3.0.0')", + displayName: "Version", + description: "The version constraint to set (e.g., `^5.0.0`, `~2.1.0`, `3.0.0`)", example: "^5.0.0" }) newVersion!: string; initialValue(_ctx: ExecutionContext): Accumulator { - return { - projectsToUpdate: new Map(), - updatedLockFiles: new Map(), - updatedPackageJsons: new Map(), - processedProjects: new Set(), - failedProjects: new Map() - }; + return createDependencyRecipeAccumulator(); } /** @@ -178,7 +155,7 @@ export class UpgradeDependencyVersion extends ScanningRecipe { const pm = marker.packageManager ?? PackageManager.Npm; // Check each dependency scope for the target package - const scopes: DependencyScope[] = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']; + const scopes = allDependencyScopes; let foundScope: DependencyScope | undefined; let currentVersion: string | undefined; @@ -244,15 +221,13 @@ export class UpgradeDependencyVersion extends ScanningRecipe { return doc; // This package.json doesn't need updating } - // Run package manager install if we haven't processed this project yet + // Run package manager install if needed, check for failure // Skip if the resolved version already satisfies the new constraint - if (!updateInfo.skipInstall && !acc.processedProjects.has(sourcePath)) { - await recipe.runPackageManagerInstall(acc, updateInfo, ctx); - acc.processedProjects.add(sourcePath); - } - - // Check if the install failed - if so, don't update, just add warning - const failureMessage = acc.failedProjects.get(sourcePath); + const failureMessage = updateInfo.skipInstall + ? undefined + : await runInstallIfNeeded(sourcePath, acc, () => + recipe.runPackageManagerInstall(acc, updateInfo, ctx) + ); if (failureMessage) { return markupWarn( doc, @@ -270,54 +245,24 @@ export class UpgradeDependencyVersion extends ScanningRecipe { const modifiedDoc = await visitor.visit(doc, undefined) as Json.Document; // Update the NodeResolutionResult marker - return recipe.updateMarker(modifiedDoc, updateInfo, acc); + if (updateInfo.skipInstall) { + // Just update the versionConstraint in the marker - resolved version is unchanged + return recipe.updateMarkerVersionConstraint(modifiedDoc, updateInfo); + } + return updateNodeResolutionMarker(modifiedDoc, updateInfo, acc); } // Handle lock files for all package managers - for (const lockFileName of getAllLockFileNames()) { - if (sourcePath.endsWith(lockFileName)) { - // Find the corresponding package.json path - const packageJsonPath = sourcePath.replace(lockFileName, 'package.json'); - const updateInfo = acc.projectsToUpdate.get(packageJsonPath); - - if (updateInfo && acc.updatedLockFiles.has(sourcePath)) { - // Parse the updated lock file content and return it - const updatedContent = acc.updatedLockFiles.get(sourcePath)!; - return this.parseUpdatedLockFile(doc, updatedContent); - } - break; - } + const updatedLockContent = getUpdatedLockFileContent(sourcePath, acc); + if (updatedLockContent) { + return await new JsonParser({}).parseOne({ + text: updatedLockContent, + sourcePath: doc.sourcePath + }) as Json.Document; } return doc; } - - /** - * Parses updated lock file content and creates a new document. - */ - private async parseUpdatedLockFile( - originalDoc: Json.Document, - updatedContent: string - ): Promise { - // Parse the updated content using JsonParser - const parser = new JsonParser({}); - const parsed: Json.Document[] = []; - - for await (const sf of parser.parse({text: updatedContent, sourcePath: originalDoc.sourcePath})) { - parsed.push(sf as Json.Document); - } - - if (parsed.length > 0) { - // Preserve the original source path and markers - return { - ...parsed[0], - sourcePath: originalDoc.sourcePath, - markers: originalDoc.markers - }; - } - - return originalDoc; - } }; } @@ -343,20 +288,7 @@ export class UpgradeDependencyVersion extends ScanningRecipe { modifiedPackageJson ); - if (result.success) { - // Store the modified package.json (we'll use our visitor for actual output) - acc.updatedPackageJsons.set(updateInfo.packageJsonPath, modifiedPackageJson); - - // Store the updated lock file content - if (result.lockFileContent) { - const lockFileName = getLockFileName(updateInfo.packageManager); - const lockFilePath = updateInfo.packageJsonPath.replace('package.json', lockFileName); - acc.updatedLockFiles.set(lockFilePath, result.lockFileContent); - } - } else { - // Track the failure - don't update package.json, the version likely doesn't exist - acc.failedProjects.set(updateInfo.packageJsonPath, result.error || 'Unknown error'); - } + storeInstallResult(result, acc, updateInfo, modifiedPackageJson); } /** @@ -378,91 +310,29 @@ export class UpgradeDependencyVersion extends ScanningRecipe { } /** - * Updates the NodeResolutionResult marker with new dependency information. + * Updates just the versionConstraint in the marker for the target dependency. + * Used when skipInstall is true - the resolved version is unchanged. */ - private async updateMarker( + private updateMarkerVersionConstraint( doc: Json.Document, - updateInfo: ProjectUpdateInfo, - acc: Accumulator - ): Promise { + updateInfo: ProjectUpdateInfo + ): Json.Document { const existingMarker = findNodeResolutionResult(doc); if (!existingMarker) { return doc; } - // If we skipped install, just update the versionConstraint in the marker - // The resolved version is already correct, we only changed the constraint - if (updateInfo.skipInstall) { - return this.updateMarkerVersionConstraint(doc, existingMarker, updateInfo); - } - - // Parse the updated package.json and lock file to create new marker - const updatedPackageJson = acc.updatedPackageJsons.get(updateInfo.packageJsonPath); - const lockFileName = getLockFileName(updateInfo.packageManager); - const updatedLockFile = acc.updatedLockFiles.get( - updateInfo.packageJsonPath.replace('package.json', lockFileName) + // Update the versionConstraint for the target dependency + const deps = existingMarker[updateInfo.dependencyScope]; + const updatedDeps = deps?.map(dep => + dep.name === this.packageName + ? {...dep, versionConstraint: updateInfo.newVersion} + : dep ); - let packageJsonContent: PackageJsonContent; - let lockContent: PackageLockContent | undefined; - - try { - packageJsonContent = JSON.parse(updatedPackageJson || updateInfo.originalPackageJson); - } catch { - return doc; // Failed to parse, keep original marker - } - - if (updatedLockFile) { - try { - lockContent = JSON.parse(updatedLockFile); - } catch { - // Continue without lock file content - } - } - - // Read npmrc configs from the project directory - const npmrcConfigs = await readNpmrcConfigs(updateInfo.projectDir); - - // Create new marker - const newMarker = createNodeResolutionResultMarker( - existingMarker.path, - packageJsonContent, - lockContent, - existingMarker.workspacePackagePaths, - existingMarker.packageManager, - npmrcConfigs.length > 0 ? npmrcConfigs : undefined - ); - - // Replace the marker in the document - return { - ...doc, - markers: replaceMarkerByKind(doc.markers, newMarker) - }; - } - - /** - * Updates just the versionConstraint in the marker for the target dependency. - * Used when skipInstall is true - the resolved version is unchanged. - */ - private updateMarkerVersionConstraint( - doc: Json.Document, - existingMarker: any, - updateInfo: ProjectUpdateInfo - ): Json.Document { - // Create updated dependency lists with the new versionConstraint - const updateDeps = (deps: any[] | undefined) => { - if (!deps) return deps; - return deps.map(dep => { - if (dep.name === this.packageName) { - return {...dep, versionConstraint: updateInfo.newVersion}; - } - return dep; - }); - }; - const newMarker = { ...existingMarker, - [updateInfo.dependencyScope]: updateDeps(existingMarker[updateInfo.dependencyScope]) + [updateInfo.dependencyScope]: updatedDeps }; return { @@ -490,7 +360,7 @@ class UpdateVersionVisitor extends JsonVisitor { protected async visitMember(member: Json.Member, p: void): Promise { // Check if we're entering the target scope - const keyName = this.getMemberKeyName(member); + const keyName = getMemberKeyName(member); if (keyName === this.targetScope) { // We're entering the dependencies scope @@ -509,33 +379,16 @@ class UpdateVersionVisitor extends JsonVisitor { return super.visitMember(member, p); } - private getMemberKeyName(member: Json.Member): string | undefined { - const key = member.key.element; - if (key.kind === Json.Kind.Literal) { - // Remove quotes from string literal - const source = (key as Json.Literal).source; - if (source.startsWith('"') && source.endsWith('"')) { - return source.slice(1, -1); - } - return source; - } else if (key.kind === Json.Kind.Identifier) { - return (key as Json.Identifier).name; - } - return undefined; - } - private updateVersion(member: Json.Member): Json.Member { const value = member.value; - if (value.kind !== Json.Kind.Literal) { + if (!isLiteral(value)) { return member; // Not a literal value, can't update } - const literal = value as Json.Literal; - // Create new literal with updated version const newLiteral: Json.Literal = { - ...literal, + ...value, source: `"${this.newVersion}"`, value: this.newVersion }; diff --git a/rewrite-javascript/rewrite/src/javascript/recipes/upgrade-transitive-dependency-version.ts b/rewrite-javascript/rewrite/src/javascript/recipes/upgrade-transitive-dependency-version.ts index c041aa5d97..e7bbcacaac 100644 --- a/rewrite-javascript/rewrite/src/javascript/recipes/upgrade-transitive-dependency-version.ts +++ b/rewrite-javascript/rewrite/src/javascript/recipes/upgrade-transitive-dependency-version.ts @@ -19,19 +19,24 @@ import {ExecutionContext} from "../../execution"; import {TreeVisitor} from "../../visitor"; import {Json, JsonParser, JsonVisitor} from "../../json"; import { - createNodeResolutionResultMarker, + allDependencyScopes, findNodeResolutionResult, NodeResolutionResultQueries, - PackageJsonContent, - PackageLockContent, - PackageManager, - readNpmrcConfigs + PackageManager } from "../node-resolution-result"; import * as path from "path"; import * as semver from "semver"; -import {markupWarn, replaceMarkerByKind} from "../../markers"; +import {markupWarn} from "../../markers"; import {TreePrinters} from "../../print"; -import {getAllLockFileNames, getLockFileName, runInstallInTempDir} from "../package-manager"; +import { + createDependencyRecipeAccumulator, + DependencyRecipeAccumulator, + getUpdatedLockFileContent, + runInstallIfNeeded, + runInstallInTempDir, + storeInstallResult, + updateNodeResolutionMarker +} from "../package-manager"; import {applyOverrideToPackageJson, DependencyPathSegment, parseDependencyPath} from "../dependency-manager"; /** @@ -57,25 +62,7 @@ interface ProjectUpdateInfo { dependencyPathSegments?: DependencyPathSegment[]; } -/** - * Accumulator for tracking state across scanning and editing phases - */ -interface Accumulator { - /** Projects that need updating: packageJsonPath -> update info */ - projectsToUpdate: Map; - - /** After running package manager, store the updated lock file content */ - updatedLockFiles: Map; - - /** Updated package.json content (after npm install may have modified it) */ - updatedPackageJsons: Map; - - /** Track which projects have been processed (npm install has run) */ - processedProjects: Set; - - /** Track projects where npm install failed: packageJsonPath -> error message */ - failedProjects: Map; -} +type Accumulator = DependencyRecipeAccumulator; /** * Upgrades the version of a transitive dependency by adding override entries to package.json. @@ -98,14 +85,14 @@ export class UpgradeTransitiveDependencyVersion extends ScanningRecipe> { @@ -148,8 +129,7 @@ export class UpgradeTransitiveDependencyVersion extends ScanningRecipe d.name === recipe.packageName)) { // Package is a direct dependency, don't add override @@ -218,14 +198,10 @@ export class UpgradeTransitiveDependencyVersion extends ScanningRecipe + recipe.runPackageManagerInstall(acc, updateInfo, ctx) + ); if (failureMessage) { return markupWarn( doc, @@ -238,23 +214,16 @@ export class UpgradeTransitiveDependencyVersion extends ScanningRecipe 0) { - return { - ...parsed[0], - sourcePath: doc.sourcePath, - markers: doc.markers - }; - } - - return doc; - } - - /** - * Parses updated lock file content and creates a new document. - */ - private async parseUpdatedLockFile( - originalDoc: Json.Document, - updatedContent: string - ): Promise { - const parser = new JsonParser({}); - const parsed: Json.Document[] = []; - - for await (const sf of parser.parse({text: updatedContent, sourcePath: originalDoc.sourcePath})) { - parsed.push(sf as Json.Document); - } - - if (parsed.length > 0) { - return { - ...parsed[0], - sourcePath: originalDoc.sourcePath, - markers: originalDoc.markers - }; - } - - return originalDoc; + const parsed = await new JsonParser({}).parseOne({ + text: newContent, + sourcePath: doc.sourcePath + }) as Json.Document; + + return { + ...parsed, + markers: doc.markers + }; } }; } @@ -355,18 +293,7 @@ export class UpgradeTransitiveDependencyVersion extends ScanningRecipe { - const existingMarker = findNodeResolutionResult(doc); - if (!existingMarker) { - return doc; - } - - // Parse the updated package.json and lock file to create new marker - const updatedPackageJson = acc.updatedPackageJsons.get(updateInfo.packageJsonPath); - const lockFileName = getLockFileName(updateInfo.packageManager); - const updatedLockFile = acc.updatedLockFiles.get( - updateInfo.packageJsonPath.replace('package.json', lockFileName) - ); - - let packageJsonContent: PackageJsonContent; - let lockContent: PackageLockContent | undefined; - - try { - packageJsonContent = JSON.parse(updatedPackageJson || updateInfo.originalPackageJson); - } catch { - return doc; - } - - if (updatedLockFile) { - try { - lockContent = JSON.parse(updatedLockFile); - } catch { - // Continue without lock file content - } - } - - const npmrcConfigs = await readNpmrcConfigs(updateInfo.projectDir); - - const newMarker = createNodeResolutionResultMarker( - existingMarker.path, - packageJsonContent, - lockContent, - existingMarker.workspacePackagePaths, - existingMarker.packageManager, - npmrcConfigs.length > 0 ? npmrcConfigs : undefined - ); - - return { - ...doc, - markers: replaceMarkerByKind(doc.markers, newMarker) - }; - } } diff --git a/rewrite-javascript/rewrite/src/javascript/search/find-dependency.ts b/rewrite-javascript/rewrite/src/javascript/search/find-dependency.ts index 7467af0211..5d6af91fbb 100644 --- a/rewrite-javascript/rewrite/src/javascript/search/find-dependency.ts +++ b/rewrite-javascript/rewrite/src/javascript/search/find-dependency.ts @@ -5,7 +5,7 @@ * you may not use this file except in compliance with the License. * You may obtain a copy of the License at *

- * https://docs.moderne.io/licensing/moderate-source-available-license + * https://docs.moderne.io/licensing/moderne-source-available-license *

* Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, @@ -17,10 +17,12 @@ import {Option, Recipe} from "../../recipe"; import {ExecutionContext} from "../../execution"; import {TreeVisitor} from "../../visitor"; -import {Json, JsonVisitor} from "../../json"; +import {getMemberKeyName, isMember, Json, JsonVisitor} from "../../json"; import {foundSearchResult} from "../../markers"; import { + allDependencyScopes, Dependency, + DependencyScope, findNodeResolutionResult, NodeResolutionResult, ResolvedDependency @@ -29,12 +31,7 @@ import * as semver from "semver"; import * as picomatch from "picomatch"; /** Dependency section names in package.json */ -const DEPENDENCY_SECTIONS = new Set([ - 'dependencies', - 'devDependencies', - 'peerDependencies', - 'optionalDependencies' -]); +const DEPENDENCY_SECTIONS = new Set(allDependencyScopes); /** * Finds npm/Node.js dependencies declared in package.json. @@ -134,7 +131,7 @@ export class FindDependency extends Recipe { } // Get the package name from the member key - const depName = this.getMemberKeyName(member); + const depName = getMemberKeyName(member); if (!depName) { return super.visitMember(member, ctx); } @@ -179,18 +176,16 @@ export class FindDependency extends Recipe { * Checks if the current member's parent is a dependency section object. * Returns the section name if so, undefined otherwise. */ - private getParentDependencySection(): string | undefined { + private getParentDependencySection(): DependencyScope | undefined { // Walk up the cursor to find the parent member that contains this dependency // Structure: Document > Object > Member("dependencies") > Object > Member("lodash") let cursor = this.cursor.parent; while (cursor) { const tree = cursor.value; - if (tree && typeof tree === 'object' && 'kind' in tree) { - if (tree.kind === Json.Kind.Member) { - const memberKey = this.getMemberKeyName(tree as Json.Member); - if (memberKey && DEPENDENCY_SECTIONS.has(memberKey)) { - return memberKey; - } + if (tree && typeof tree === 'object' && 'kind' in tree && isMember(tree as Json)) { + const memberKey = getMemberKeyName(tree as Json.Member); + if (memberKey && DEPENDENCY_SECTIONS.has(memberKey as DependencyScope)) { + return memberKey as DependencyScope; } } cursor = cursor.parent; @@ -198,46 +193,12 @@ export class FindDependency extends Recipe { return undefined; } - /** - * Extracts the key name from a Json.Member - */ - private getMemberKeyName(member: Json.Member): string | undefined { - const key = member.key.element; - if (key.kind === Json.Kind.Literal) { - // Remove quotes from string literal - const source = (key as Json.Literal).source; - if (source.startsWith('"') && source.endsWith('"')) { - return source.slice(1, -1); - } - return source; - } else if (key.kind === Json.Kind.Identifier) { - return (key as Json.Identifier).name; - } - return undefined; - } - /** * Finds a dependency by name in the appropriate section of the resolution result. */ - private findDependencyByName(name: string, section: string): Dependency | undefined { + private findDependencyByName(name: string, section: DependencyScope): Dependency | undefined { if (!this.resolution) return undefined; - - let deps: Dependency[] | undefined; - switch (section) { - case 'dependencies': - deps = this.resolution.dependencies; - break; - case 'devDependencies': - deps = this.resolution.devDependencies; - break; - case 'peerDependencies': - deps = this.resolution.peerDependencies; - break; - case 'optionalDependencies': - deps = this.resolution.optionalDependencies; - break; - } - + const deps = this.resolution[section]; return deps?.find(d => d.name === name); } }; diff --git a/rewrite-javascript/rewrite/src/json/tree.ts b/rewrite-javascript/rewrite/src/json/tree.ts index defc882fb5..bf1f22543b 100644 --- a/rewrite-javascript/rewrite/src/json/tree.ts +++ b/rewrite-javascript/rewrite/src/json/tree.ts @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import {Markers} from "../markers"; +import {emptyMarkers, Markers} from "../markers"; import {SourceFile, Tree} from "../tree"; @@ -117,3 +117,100 @@ const jsonKindValues = new Set(Object.values(Json.Kind)); export function isJson(tree: any): tree is Json { return jsonKindValues.has(tree["kind"]); } + +export function isLiteral(json: Json): json is Json.Literal { + return json.kind === Json.Kind.Literal; +} + +export function isObject(json: Json): json is Json.Object { + return json.kind === Json.Kind.Object; +} + +export function isArray(json: Json): json is Json.Array { + return json.kind === Json.Kind.Array; +} + +export function isIdentifier(json: Json): json is Json.Identifier { + return json.kind === Json.Kind.Identifier; +} + +export function isMember(json: Json): json is Json.Member { + return json.kind === Json.Kind.Member; +} + +/** + * Gets the key name from a Json.Member. + * Handles both string literals (quoted keys) and identifiers (unquoted keys). + * + * @param member The JSON member to extract the key name from + * @returns The key name as a string, or undefined if extraction fails + */ +export function getMemberKeyName(member: Json.Member): string | undefined { + const key = member.key.element; + if (isLiteral(key)) { + const source = key.source; + // Remove quotes from string literal + if (source.startsWith('"') && source.endsWith('"')) { + return source.slice(1, -1); + } + return source; + } else if (isIdentifier(key)) { + return key.name; + } + return undefined; +} + +/** + * Detects the indentation used in a JSON document by examining existing members or array elements. + * Returns the base indent string (e.g., " " for 2 spaces, " " for 4 spaces). + * + * @param doc The JSON document to analyze + * @returns The detected indent string, or " " (4 spaces) as default + */ +export function detectIndent(doc: Json.Document): string { + const defaultIndent = ' '; // Default to 4 spaces + + if (isObject(doc.value)) { + if (doc.value.members && doc.value.members.length > 0) { + // Look at the prefix of the first member's key to detect indentation + const firstMemberRightPadded = doc.value.members[0]; + const firstMember = firstMemberRightPadded.element as Json.Member; + const prefix = firstMember.key.element.prefix.whitespace; + // Extract just the spaces/tabs after the newline + const match = prefix.match(/\n([ \t]+)/); + if (match) { + return match[1]; + } + } + } else if (isArray(doc.value)) { + if (doc.value.values && doc.value.values.length > 0) { + // Look at the prefix of the first array element to detect indentation + const firstElement = doc.value.values[0].element; + const prefix = firstElement.prefix.whitespace; + // Extract just the spaces/tabs after the newline + const match = prefix.match(/\n([ \t]+)/); + if (match) { + return match[1]; + } + } + } + + return defaultIndent; +} + +/** + * Creates a RightPadded wrapper for a JSON element. + * + * @param element The JSON element to wrap + * @param after The trailing space after the element + * @param markers Optional markers to attach + * @returns A RightPadded wrapper containing the element + */ +export function rightPadded(element: T, after: Json.Space, markers?: Markers): Json.RightPadded { + return { + kind: Json.Kind.RightPadded, + element, + after, + markers: markers ?? emptyMarkers + }; +} diff --git a/rewrite-javascript/rewrite/src/parser.ts b/rewrite-javascript/rewrite/src/parser.ts index 255ae5816b..17bd702372 100644 --- a/rewrite-javascript/rewrite/src/parser.ts +++ b/rewrite-javascript/rewrite/src/parser.ts @@ -57,6 +57,23 @@ export abstract class Parser { abstract parse(...sourcePaths: ParserInput[]): AsyncGenerator + /** + * Parses a single input and returns the first source file. + * This is a convenience method for when you know you're parsing exactly one input. + * + * @param input The input to parse + * @returns The parsed source file + * @throws Error if the parser produces no results + */ + async parseOne(input: ParserInput): Promise { + const result = await this.parse(input).next(); + if (result.done) { + const sourcePath = typeof input === 'string' ? input : input.sourcePath; + throw new Error(`Parser produced no results for: ${sourcePath}`); + } + return result.value; + } + protected relativePath(sourcePath: ParserInput): string { const path = typeof sourcePath === "string" ? sourcePath : sourcePath.sourcePath; return isAbsolute(path) && this.relativeTo ? relative(this.relativeTo, path) : path; diff --git a/rewrite-javascript/rewrite/test/javascript/package-json-parser.test.ts b/rewrite-javascript/rewrite/test/javascript/package-json-parser.test.ts index 92ed0c0c79..2906313a3e 100644 --- a/rewrite-javascript/rewrite/test/javascript/package-json-parser.test.ts +++ b/rewrite-javascript/rewrite/test/javascript/package-json-parser.test.ts @@ -5,7 +5,7 @@ * you may not use this file except in compliance with the License. * You may obtain a copy of the License at *

- * https://docs.moderne.io/licensing/moderate-source-available-license + * https://docs.moderne.io/licensing/moderne-source-available-license *

* Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, diff --git a/rewrite-javascript/rewrite/test/javascript/recipes/add-dependency.test.ts b/rewrite-javascript/rewrite/test/javascript/recipes/add-dependency.test.ts new file mode 100644 index 0000000000..963dc0249e --- /dev/null +++ b/rewrite-javascript/rewrite/test/javascript/recipes/add-dependency.test.ts @@ -0,0 +1,395 @@ +/* + * Copyright 2025 the original author or authors. + *

+ * Licensed under the Moderne Source Available License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * https://docs.moderne.io/licensing/moderne-source-available-license + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { + AddDependency, + findNodeResolutionResult, + npm, + packageJson, + packageLockJson, + typescript +} from "../../../src/javascript"; +import {Json} from "../../../src/json"; +import {RecipeSpec} from "../../../src/test"; +import {withDir} from "tmp-promise"; +import {findMarker, MarkersKind} from "../../../src"; + +describe("AddDependency", () => { + + test("adds dependency to package.json", async () => { + const spec = new RecipeSpec(); + spec.recipe = new AddDependency({ + packageName: "lodash", + version: "^4.17.21" + }); + + await withDir(async (repo) => { + await spec.rewriteRun( + npm( + repo.path, + typescript(`const x = 1;`), + packageJson(` + { + "name": "test-project", + "version": "1.0.0", + "dependencies": { + "uuid": "^9.0.0" + } + } + `, ` + { + "name": "test-project", + "version": "1.0.0", + "dependencies": { + "uuid": "^9.0.0", + "lodash": "^4.17.21" + } + } + `) + ) + ); + }, {unsafeCleanup: true}); + }); + + // This is the same behavior as org.openrewrite.maven.AddDependency + test("does not modify when dependency already exists", async () => { + const spec = new RecipeSpec(); + spec.recipe = new AddDependency({ + packageName: "uuid", + version: "^10.0.0" + }); + + await withDir(async (repo) => { + await spec.rewriteRun( + npm( + repo.path, + typescript(`const x = 1;`), + packageJson(` + { + "name": "test-project", + "version": "1.0.0", + "dependencies": { + "uuid": "^9.0.0" + } + } + `) + ) + ); + }, {unsafeCleanup: true}); + }); + + test("adds devDependency when scope is specified", async () => { + const spec = new RecipeSpec(); + spec.recipe = new AddDependency({ + packageName: "@types/lodash", + version: "^4.17.0", + scope: "devDependencies" + }); + + await withDir(async (repo) => { + await spec.rewriteRun( + npm( + repo.path, + typescript(`const x = 1;`), + packageJson(` + { + "name": "test-project", + "version": "1.0.0", + "dependencies": { + "uuid": "^9.0.0" + } + } + `, ` + { + "name": "test-project", + "version": "1.0.0", + "dependencies": { + "uuid": "^9.0.0" + }, + "devDependencies": { + "@types/lodash": "^4.17.0" + } + } + `) + ) + ); + }, {unsafeCleanup: true}); + }); + + test("adds to existing devDependencies section", async () => { + const spec = new RecipeSpec(); + spec.recipe = new AddDependency({ + packageName: "@types/lodash", + version: "^4.17.0", + scope: "devDependencies" + }); + + await withDir(async (repo) => { + await spec.rewriteRun( + npm( + repo.path, + typescript(`const x = 1;`), + packageJson(` + { + "name": "test-project", + "version": "1.0.0", + "devDependencies": { + "@types/node": "^20.0.0" + } + } + `, ` + { + "name": "test-project", + "version": "1.0.0", + "devDependencies": { + "@types/node": "^20.0.0", + "@types/lodash": "^4.17.0" + } + } + `) + ) + ); + }, {unsafeCleanup: true}); + }); + + test("updates NodeResolutionResult marker after adding dependency", async () => { + const spec = new RecipeSpec(); + spec.recipe = new AddDependency({ + packageName: "lodash", + version: "^4.17.21" + }); + + await withDir(async (repo) => { + await spec.rewriteRun( + npm( + repo.path, + typescript(`const x = 1;`), + { + ...packageJson(` + { + "name": "test-project", + "version": "1.0.0", + "dependencies": { + "uuid": "^9.0.0" + } + } + `, ` + { + "name": "test-project", + "version": "1.0.0", + "dependencies": { + "uuid": "^9.0.0", + "lodash": "^4.17.21" + } + } + `), afterRecipe: async (doc: Json.Document) => { + const marker = findNodeResolutionResult(doc); + expect(marker).toBeDefined(); + expect(marker!.dependencies).toHaveLength(2); + + const lodashDep = marker!.dependencies.find(d => d.name === "lodash"); + expect(lodashDep).toBeDefined(); + expect(lodashDep!.versionConstraint).toBe("^4.17.21"); + } + } + ) + ); + }, {unsafeCleanup: true}); + }); + + test("adds peerDependency when scope is specified", async () => { + const spec = new RecipeSpec(); + spec.recipe = new AddDependency({ + packageName: "react", + version: "^18.0.0", + scope: "peerDependencies" + }); + + await withDir(async (repo) => { + await spec.rewriteRun( + npm( + repo.path, + typescript(`const x = 1;`), + packageJson(` + { + "name": "test-project", + "version": "1.0.0", + "dependencies": { + "uuid": "^9.0.0" + } + } + `, ` + { + "name": "test-project", + "version": "1.0.0", + "dependencies": { + "uuid": "^9.0.0" + }, + "peerDependencies": { + "react": "^18.0.0" + } + } + `) + ) + ); + }, {unsafeCleanup: true}); + }); + + test("adds warning marker when package does not exist", async () => { + const spec = new RecipeSpec(); + spec.recipe = new AddDependency({ + packageName: "this-package-does-not-exist-12345", + version: "^1.0.0" + }); + + await withDir(async (repo) => { + await spec.rewriteRun( + npm( + repo.path, + typescript(`const x = 1;`), + { + ...packageJson(` + { + "name": "test-project", + "version": "1.0.0", + "dependencies": { + "uuid": "^9.0.0" + } + } + `, ` + /*~~(Failed to add this-package-does-not-exist-12345 to ^1.0.0)~~>*/{ + "name": "test-project", + "version": "1.0.0", + "dependencies": { + "uuid": "^9.0.0" + } + } + `), afterRecipe: async (doc: Json.Document) => { + const warnMarker = findMarker(doc, MarkersKind.MarkupWarn); + expect(warnMarker).toBeDefined(); + expect((warnMarker as any).message).toContain("Failed to add this-package-does-not-exist-12345"); + } + } + ) + ); + }, {unsafeCleanup: true}); + }); + + test("updates package-lock.json when adding dependency", async () => { + const spec = new RecipeSpec(); + spec.recipe = new AddDependency({ + packageName: "lodash", + version: "^4.17.21" + }); + + await withDir(async (repo) => { + await spec.rewriteRun( + npm( + repo.path, + typescript(`const x = 1;`), + packageJson(` + { + "name": "test-project", + "version": "1.0.0", + "dependencies": { + "uuid": "^9.0.0" + } + } + `, ` + { + "name": "test-project", + "version": "1.0.0", + "dependencies": { + "uuid": "^9.0.0", + "lodash": "^4.17.21" + } + } + `), + packageLockJson(` + { + "name": "test-project", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "test-project", + "version": "1.0.0", + "dependencies": { + "uuid": "^9.0.0" + } + }, + "node_modules/uuid": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.0.tgz", + "integrity": "sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==" + } + } + } + `, (actual: string) => { + const lockData = JSON.parse(actual); + + if (!lockData.packages) { + throw new Error("Expected packages in lock file"); + } + + // The root package should now include lodash + const rootPkg = lockData.packages[""]; + if (rootPkg?.dependencies?.["lodash"] !== "^4.17.21") { + throw new Error(`Expected root dependency lodash to be ^4.17.21, got ${rootPkg?.dependencies?.["lodash"]}`); + } + + // lodash should be in node_modules + const lodashPkg = lockData.packages["node_modules/lodash"]; + if (!lodashPkg?.version?.startsWith("4.17.")) { + throw new Error(`Expected lodash version to start with 4.17., got ${lodashPkg?.version}`); + } + + return actual; + }) + ) + ); + }, {unsafeCleanup: true}); + }); + + // Note - to be honest, I am not sure if this is the desired behavior + test("does not add if dependency exists in different scope", async () => { + const spec = new RecipeSpec(); + spec.recipe = new AddDependency({ + packageName: "uuid", + version: "^10.0.0", + scope: "devDependencies" + }); + + await withDir(async (repo) => { + // uuid exists in dependencies, so shouldn't add to devDependencies + await spec.rewriteRun( + npm( + repo.path, + typescript(`const x = 1;`), + packageJson(` + { + "name": "test-project", + "version": "1.0.0", + "dependencies": { + "uuid": "^9.0.0" + } + } + `) + ) + ); + }, {unsafeCleanup: true}); + }); +});