diff --git a/common/changes/@microsoft/rush/dependency-specifier-cache_2025-08-28-20-24.json b/common/changes/@microsoft/rush/dependency-specifier-cache_2025-08-28-20-24.json new file mode 100644 index 00000000000..06c2774945e --- /dev/null +++ b/common/changes/@microsoft/rush/dependency-specifier-cache_2025-08-28-20-24.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Deduplicate parsing of dependency specifiers.", + "type": "none" + } + ], + "packageName": "@microsoft/rush" +} \ No newline at end of file diff --git a/libraries/rush-lib/src/api/RushConfigurationProject.ts b/libraries/rush-lib/src/api/RushConfigurationProject.ts index 21e7062807a..5d16dbd4610 100644 --- a/libraries/rush-lib/src/api/RushConfigurationProject.ts +++ b/libraries/rush-lib/src/api/RushConfigurationProject.ts @@ -411,7 +411,10 @@ export class RushConfigurationProject { ]) { if (dependencySet) { for (const [dependency, version] of Object.entries(dependencySet)) { - const dependencySpecifier: DependencySpecifier = new DependencySpecifier(dependency, version); + const dependencySpecifier: DependencySpecifier = DependencySpecifier.parseWithCache( + dependency, + version + ); const dependencyName: string = dependencySpecifier.aliasTarget?.packageName ?? dependencySpecifier.packageName; // Skip if we can't find the local project or it's a cyclic dependency diff --git a/libraries/rush-lib/src/logic/ApprovedPackagesChecker.ts b/libraries/rush-lib/src/logic/ApprovedPackagesChecker.ts index 77ad8fb01d6..6058152b3e4 100644 --- a/libraries/rush-lib/src/logic/ApprovedPackagesChecker.ts +++ b/libraries/rush-lib/src/logic/ApprovedPackagesChecker.ts @@ -70,7 +70,7 @@ export class ApprovedPackagesChecker { // "dependencies": { // "alias-name": "npm:target-name@^1.2.3" // } - const dependencySpecifier: DependencySpecifier = new DependencySpecifier( + const dependencySpecifier: DependencySpecifier = DependencySpecifier.parseWithCache( packageName, dependencies[packageName] ); diff --git a/libraries/rush-lib/src/logic/DependencySpecifier.ts b/libraries/rush-lib/src/logic/DependencySpecifier.ts index 222d8d5cc24..3c0eefc96f9 100644 --- a/libraries/rush-lib/src/logic/DependencySpecifier.ts +++ b/libraries/rush-lib/src/logic/DependencySpecifier.ts @@ -89,6 +89,8 @@ export enum DependencySpecifierType { Workspace = 'Workspace' } +const dependencySpecifierParseCache: Map = new Map(); + /** * An NPM "version specifier" is a string that can appear as a package.json "dependencies" value. * Example version specifiers: `^1.2.3`, `file:./blah.tgz`, `npm:other-package@~1.2.3`, and so forth. @@ -131,7 +133,10 @@ export class DependencySpecifier { if (workspaceSpecResult.alias) { // "workspace:some-package@^1.2.3" should be resolved as alias - this.aliasTarget = new DependencySpecifier(workspaceSpecResult.alias, workspaceSpecResult.version); + this.aliasTarget = DependencySpecifier.parseWithCache( + workspaceSpecResult.alias, + workspaceSpecResult.version + ); } else { this.aliasTarget = undefined; } @@ -147,12 +152,38 @@ export class DependencySpecifier { if (!aliasResult.subSpec || !aliasResult.subSpec.name) { throw new InternalError('Unexpected result from npm-package-arg'); } - this.aliasTarget = new DependencySpecifier(aliasResult.subSpec.name, aliasResult.subSpec.rawSpec); + this.aliasTarget = DependencySpecifier.parseWithCache( + aliasResult.subSpec.name, + aliasResult.subSpec.rawSpec + ); } else { this.aliasTarget = undefined; } } + /** + * Clears the dependency specifier parse cache. + */ + public static clearCache(): void { + dependencySpecifierParseCache.clear(); + } + + /** + * Parses a dependency specifier with caching. + * @param packageName - The name of the package the version specifier corresponds to + * @param versionSpecifier - The version specifier to parse + * @returns The parsed dependency specifier + */ + public static parseWithCache(packageName: string, versionSpecifier: string): DependencySpecifier { + const cacheKey: string = `${packageName}\0${versionSpecifier}`; + let result: DependencySpecifier | undefined = dependencySpecifierParseCache.get(cacheKey); + if (!result) { + result = new DependencySpecifier(packageName, versionSpecifier); + dependencySpecifierParseCache.set(cacheKey, result); + } + return result; + } + public static getDependencySpecifierType(specifierType: string): DependencySpecifierType { switch (specifierType) { case 'git': diff --git a/libraries/rush-lib/src/logic/PublishUtilities.ts b/libraries/rush-lib/src/logic/PublishUtilities.ts index 6feb9070111..f0cb821008f 100644 --- a/libraries/rush-lib/src/logic/PublishUtilities.ts +++ b/libraries/rush-lib/src/logic/PublishUtilities.ts @@ -296,7 +296,7 @@ export class PublishUtilities { dependencyName: string, newProjectVersion: string ): string { - const currentDependencySpecifier: DependencySpecifier = new DependencySpecifier( + const currentDependencySpecifier: DependencySpecifier = DependencySpecifier.parseWithCache( dependencyName, dependencies[dependencyName] ); @@ -502,7 +502,7 @@ export class PublishUtilities { // TODO: treat prerelease version the same as non-prerelease version. // For prerelease, the newVersion needs to be appended with prerelease name. // And dependency should specify the specific prerelease version. - const currentSpecifier: DependencySpecifier = new DependencySpecifier( + const currentSpecifier: DependencySpecifier = DependencySpecifier.parseWithCache( depName, dependencies[depName] ); @@ -765,7 +765,7 @@ export class PublishUtilities { dependencies[change.packageName] && !PublishUtilities._isCyclicDependency(allPackages, parentPackageName, change.packageName) ) { - const requiredVersion: DependencySpecifier = new DependencySpecifier( + const requiredVersion: DependencySpecifier = DependencySpecifier.parseWithCache( change.packageName, dependencies[change.packageName] ); @@ -863,7 +863,7 @@ export class PublishUtilities { // "*", "~", and "^" are special cases for workspace ranges, since it will publish using the exact // version of the local dependency, so we need to modify what we write for our change // comment - const currentDependencySpecifier: DependencySpecifier = new DependencySpecifier( + const currentDependencySpecifier: DependencySpecifier = DependencySpecifier.parseWithCache( dependencyName, currentDependencyVersion ); @@ -873,7 +873,7 @@ export class PublishUtilities { ? undefined : currentDependencySpecifier.versionSpecifier; - const newDependencySpecifier: DependencySpecifier = new DependencySpecifier( + const newDependencySpecifier: DependencySpecifier = DependencySpecifier.parseWithCache( dependencyName, newDependencyVersion ); diff --git a/libraries/rush-lib/src/logic/VersionManager.ts b/libraries/rush-lib/src/logic/VersionManager.ts index 7e9ec657791..92ddb7b4701 100644 --- a/libraries/rush-lib/src/logic/VersionManager.ts +++ b/libraries/rush-lib/src/logic/VersionManager.ts @@ -341,7 +341,7 @@ export class VersionManager { oldDependencyVersion: string, newDependencyVersion: string ): void { - const oldSpecifier: DependencySpecifier = new DependencySpecifier( + const oldSpecifier: DependencySpecifier = DependencySpecifier.parseWithCache( updatedDependentProject.name, oldDependencyVersion ); diff --git a/libraries/rush-lib/src/logic/installManager/RushInstallManager.ts b/libraries/rush-lib/src/logic/installManager/RushInstallManager.ts index 0c016ca3106..61553a000be 100644 --- a/libraries/rush-lib/src/logic/installManager/RushInstallManager.ts +++ b/libraries/rush-lib/src/logic/installManager/RushInstallManager.ts @@ -132,7 +132,10 @@ export class RushInstallManager extends BaseInstallManager { if (shrinkwrapFile) { // Check any (explicitly) preferred dependencies first allExplicitPreferredVersions.forEach((version: string, dependency: string) => { - const dependencySpecifier: DependencySpecifier = new DependencySpecifier(dependency, version); + const dependencySpecifier: DependencySpecifier = DependencySpecifier.parseWithCache( + dependency, + version + ); if (!shrinkwrapFile.hasCompatibleTopLevelDependency(dependencySpecifier)) { shrinkwrapWarnings.push( @@ -230,7 +233,10 @@ export class RushInstallManager extends BaseInstallManager { Sort.sortMapKeys(tempDependencies); for (const [packageName, packageVersion] of tempDependencies.entries()) { - const dependencySpecifier: DependencySpecifier = new DependencySpecifier(packageName, packageVersion); + const dependencySpecifier: DependencySpecifier = DependencySpecifier.parseWithCache( + packageName, + packageVersion + ); // Is there a locally built Rush project that could satisfy this dependency? // If so, then we will symlink to the project folder rather than to common/temp/node_modules. @@ -391,7 +397,10 @@ export class RushInstallManager extends BaseInstallManager { } private _revertWorkspaceNotation(dependency: PackageJsonDependency): boolean { - const specifier: DependencySpecifier = new DependencySpecifier(dependency.name, dependency.version); + const specifier: DependencySpecifier = DependencySpecifier.parseWithCache( + dependency.name, + dependency.version + ); if (specifier.specifierType !== DependencySpecifierType.Workspace) { return false; } diff --git a/libraries/rush-lib/src/logic/installManager/WorkspaceInstallManager.ts b/libraries/rush-lib/src/logic/installManager/WorkspaceInstallManager.ts index 23cbd96912e..ca5f40a1cf5 100644 --- a/libraries/rush-lib/src/logic/installManager/WorkspaceInstallManager.ts +++ b/libraries/rush-lib/src/logic/installManager/WorkspaceInstallManager.ts @@ -220,7 +220,7 @@ export class WorkspaceInstallManager extends BaseInstallManager { continue; } - const dependencySpecifier: DependencySpecifier = new DependencySpecifier(name, version); + const dependencySpecifier: DependencySpecifier = DependencySpecifier.parseWithCache(name, version); // Is there a locally built Rush project that could satisfy this dependency? let referencedLocalProject: RushConfigurationProject | undefined = diff --git a/libraries/rush-lib/src/logic/npm/NpmShrinkwrapFile.ts b/libraries/rush-lib/src/logic/npm/NpmShrinkwrapFile.ts index 7c11ac6400d..f0dfe9c70d1 100644 --- a/libraries/rush-lib/src/logic/npm/NpmShrinkwrapFile.ts +++ b/libraries/rush-lib/src/logic/npm/NpmShrinkwrapFile.ts @@ -89,7 +89,7 @@ export class NpmShrinkwrapFile extends BaseShrinkwrapFile { return undefined; } - return new DependencySpecifier(dependencyName, dependencyJson.version); + return DependencySpecifier.parseWithCache(dependencyName, dependencyJson.version); } /** @@ -121,7 +121,7 @@ export class NpmShrinkwrapFile extends BaseShrinkwrapFile { return this.getTopLevelDependencyVersion(dependencySpecifier.packageName); } - return new DependencySpecifier(dependencySpecifier.packageName, dependencyJson.version); + return DependencySpecifier.parseWithCache(dependencySpecifier.packageName, dependencyJson.version); } /** @override */ diff --git a/libraries/rush-lib/src/logic/pnpm/PnpmShrinkwrapFile.ts b/libraries/rush-lib/src/logic/pnpm/PnpmShrinkwrapFile.ts index e6ba5b3a8d0..2da5025251b 100644 --- a/libraries/rush-lib/src/logic/pnpm/PnpmShrinkwrapFile.ts +++ b/libraries/rush-lib/src/logic/pnpm/PnpmShrinkwrapFile.ts @@ -151,7 +151,7 @@ export function parsePnpm9DependencyKey( // Example: 7.26.0 if (semver.valid(key)) { - return new DependencySpecifier(dependencyName, key); + return DependencySpecifier.parseWithCache(dependencyName, key); } } @@ -169,16 +169,16 @@ export function parsePnpm9DependencyKey( // Example: https://github.com/jonschlinkert/pad-left/tarball/2.1.0 // Example: https://codeload.github.com/jonschlinkert/pad-left/tar.gz/7798d648225aa5d879660a37c408ab4675b65ac7 if (/^https?:/.test(version)) { - return new DependencySpecifier(name, version); + return DependencySpecifier.parseWithCache(name, version); } // Is it an alias for a different package? if (name === dependencyName) { // No, it's a regular dependency - return new DependencySpecifier(name, version); + return DependencySpecifier.parseWithCache(name, version); } else { // If the parsed package name is different from the dependencyName, then this is an NPM package alias - return new DependencySpecifier(dependencyName, `npm:${name}@${version}`); + return DependencySpecifier.parseWithCache(dependencyName, `npm:${name}@${version}`); } } @@ -266,7 +266,10 @@ export function parsePnpmDependencyKey( // git@bitbucket.com+abc/def/188ed64efd5218beda276e02f2277bf3a6b745b2 // bitbucket.co.in/abc/def/188ed64efd5218beda276e02f2277bf3a6b745b2 if (urlRegex.test(dependencyKey)) { - const dependencySpecifier: DependencySpecifier = new DependencySpecifier(dependencyName, dependencyKey); + const dependencySpecifier: DependencySpecifier = DependencySpecifier.parseWithCache( + dependencyName, + dependencyKey + ); return dependencySpecifier; } else { return undefined; @@ -276,10 +279,13 @@ export function parsePnpmDependencyKey( // Is it an alias for a different package? if (parsedPackageName === dependencyName) { // No, it's a regular dependency - return new DependencySpecifier(parsedPackageName, parsedVersionPart); + return DependencySpecifier.parseWithCache(parsedPackageName, parsedVersionPart); } else { // If the parsed package name is different from the dependencyName, then this is an NPM package alias - return new DependencySpecifier(dependencyName, `npm:${parsedPackageName}@${parsedVersionPart}`); + return DependencySpecifier.parseWithCache( + dependencyName, + `npm:${parsedPackageName}@${parsedVersionPart}` + ); } } @@ -673,7 +679,7 @@ export class PnpmShrinkwrapFile extends BaseShrinkwrapFile { const dependency: IPnpmShrinkwrapDependencyYaml | undefined = this.packages.get(value); if (dependency?.resolution?.tarball && value.startsWith(dependency.resolution.tarball)) { - return new DependencySpecifier(dependencyName, dependency.resolution.tarball); + return DependencySpecifier.parseWithCache(dependencyName, dependency.resolution.tarball); } if (this.shrinkwrapFileMajorVersion >= ShrinkwrapFileMajorVersion.V9) { @@ -690,7 +696,7 @@ export class PnpmShrinkwrapFile extends BaseShrinkwrapFile { } } - return new DependencySpecifier(dependencyName, value); + return DependencySpecifier.parseWithCache(dependencyName, value); } return undefined; } diff --git a/libraries/rush-lib/src/logic/test/DependencySpecifier.test.ts b/libraries/rush-lib/src/logic/test/DependencySpecifier.test.ts index 73e7a798ba9..1d738bf9b0d 100644 --- a/libraries/rush-lib/src/logic/test/DependencySpecifier.test.ts +++ b/libraries/rush-lib/src/logic/test/DependencySpecifier.test.ts @@ -4,6 +4,10 @@ import { DependencySpecifier } from '../DependencySpecifier'; describe(DependencySpecifier.name, () => { + afterEach(() => { + DependencySpecifier.clearCache(); + }); + it('parses a simple version', () => { const specifier = new DependencySpecifier('dep', '1.2.3'); expect(specifier).toMatchInlineSnapshot(` @@ -135,4 +139,23 @@ DependencySpecifier { `); }); }); + + describe(DependencySpecifier.parseWithCache.name, () => { + it('returns a cached instance for the same input', () => { + const specifier1 = DependencySpecifier.parseWithCache('dep', '1.2.3'); + const specifier2 = DependencySpecifier.parseWithCache('dep', '1.2.3'); + expect(specifier1).toBe(specifier2); + }); + it('returns a cached instance for the same alias', () => { + const specifier1 = DependencySpecifier.parseWithCache('dep1', 'npm:dep@1.2.3'); + const specifier2 = DependencySpecifier.parseWithCache('dep2', 'npm:dep@1.2.3'); + expect(specifier1.aliasTarget).toBe(specifier2.aliasTarget); + }); + + it('returns different instances for different inputs', () => { + const specifier1 = DependencySpecifier.parseWithCache('dep', '1.2.3'); + const specifier2 = DependencySpecifier.parseWithCache('dep', '1.2.4'); + expect(specifier1).not.toBe(specifier2); + }); + }); });