From 2d7d6b4b98125f42b123cf744e8148c7c82e2492 Mon Sep 17 00:00:00 2001 From: David First Date: Thu, 15 Oct 2026 12:49:43 -0400 Subject: [PATCH 1/3] fix(deps): keep env.jsonc component peers after lane merge, support +^/+~ versions --- .../consumer-config/component-overrides.ts | 3 +- .../legacy/e2e-helper/e2e-snapping-helper.ts | 2 + .../e2e-helper/snap-from-scope-runner.ts | 4 + e2e/harmony/lanes/lane-env-jsonc-dep.e2e.ts | 94 +++++++++++++++++++ .../isolator/isolator.main.runtime.ts | 13 ++- .../dependencies-loader/apply-overrides.ts | 13 ++- .../dependencies-versions-resolver.ts | 3 +- .../overrides-dependencies.ts | 8 +- .../dependency-resolver.main.runtime.spec.ts | 27 ++++++ .../dependency-resolver.main.runtime.ts | 3 +- .../dependencies/dependency-resolver/index.ts | 2 + .../manifest/deduping/hoist-dependencies.ts | 8 +- .../manifest/update-dependency-version.ts | 3 +- .../manifest/workspace-manifest-factory.ts | 17 ++-- .../manifest/workspace-manifest.ts | 10 +- .../dependency-resolver/plus-version.spec.ts | 46 +++++++++ .../dependency-resolver/plus-version.ts | 37 ++++++++ .../scope/objects/models/model-component.ts | 25 ++++- .../workspace/install/install.main.runtime.ts | 9 +- 19 files changed, 294 insertions(+), 33 deletions(-) create mode 100644 scopes/dependencies/dependency-resolver/plus-version.spec.ts create mode 100644 scopes/dependencies/dependency-resolver/plus-version.ts diff --git a/components/legacy/consumer-config/component-overrides.ts b/components/legacy/consumer-config/component-overrides.ts index ccfec2855aff..39ba12f297de 100644 --- a/components/legacy/consumer-config/component-overrides.ts +++ b/components/legacy/consumer-config/component-overrides.ts @@ -143,7 +143,8 @@ export class ComponentOverrides { }, {}); } _isValidVersion(ver: string) { - return ver !== MANUALLY_ADD_DEPENDENCY && ver !== MANUALLY_REMOVE_DEPENDENCY; + // "+", "+^" and "+~" are placeholders for the resolved version, not a version + return !ver.startsWith(MANUALLY_ADD_DEPENDENCY) && ver !== MANUALLY_REMOVE_DEPENDENCY; } getIgnored(field: string): string[] { const filtered = pickBy(this.overrides[field] || {}, (dep) => { diff --git a/components/legacy/e2e-helper/e2e-snapping-helper.ts b/components/legacy/e2e-helper/e2e-snapping-helper.ts index 23f1228824af..b41ca54424bb 100644 --- a/components/legacy/e2e-helper/e2e-snapping-helper.ts +++ b/components/legacy/e2e-helper/e2e-snapping-helper.ts @@ -23,6 +23,8 @@ export default class SnappingHelper { updateDependents?: boolean; push?: boolean; message?: string; + tag?: boolean; + skipLoadingAspects?: boolean; } = {} ): Promise { const runnerPath = path.resolve(__dirname, 'snap-from-scope-runner.js'); diff --git a/components/legacy/e2e-helper/snap-from-scope-runner.ts b/components/legacy/e2e-helper/snap-from-scope-runner.ts index 8b97bf4b8f64..9e4bbac484db 100644 --- a/components/legacy/e2e-helper/snap-from-scope-runner.ts +++ b/components/legacy/e2e-helper/snap-from-scope-runner.ts @@ -11,6 +11,7 @@ import { loadBit } from '@teambit/bit'; import type { SnappingMain } from '@teambit/snapping'; import { SnappingAspect } from '@teambit/snapping'; +import { ComponentIdList } from '@teambit/component-id'; async function main(): Promise { const [, , scopePath, snapDataJson, optionsJson] = process.argv; @@ -24,6 +25,9 @@ async function main(): Promise { updateDependents: options.updateDependents, push: options.push, message: options.message, + tag: options.tag, + // skips loading the components' aspects (e.g. envs) in capsules, which requires their packages in a registry + loadAspectOnlyForIds: options.skipLoadingAspects ? new ComponentIdList() : undefined, build: false, disableTagAndSnapPipelines: true, }); diff --git a/e2e/harmony/lanes/lane-env-jsonc-dep.e2e.ts b/e2e/harmony/lanes/lane-env-jsonc-dep.e2e.ts index 1b9aaf4e222b..7469290f4195 100644 --- a/e2e/harmony/lanes/lane-env-jsonc-dep.e2e.ts +++ b/e2e/harmony/lanes/lane-env-jsonc-dep.e2e.ts @@ -1,4 +1,6 @@ import { expect } from 'chai'; +import fs from 'fs-extra'; +import path from 'path'; import { Helper } from '@teambit/legacy.e2e-helper'; /** @@ -129,4 +131,96 @@ export default new MyEnv(); }); }); }); + + /** + * an env.jsonc peer that is a component and is part of the lane, set with a "+" placeholder. + * on the lane, it should follow the lane snap. once the lane is merged and released (tagged from a bare scope, + * the way bit.cloud releases a change-request), it should follow the new tag, with the "^" prefix. + * it also covers a bug where releasing from a bare scope dropped the component peer-dependencies from the + * dependency-resolver data, so on main they appeared only in the policy and every new lane showed them as new. + */ + describe('env.jsonc peer with "+^" that is a lane component, merged and released from a bare scope', () => { + let comp2PkgName: string; + let comp2LaneSnap: string; + let comp1DepsOnLane: Record[]; + const getComp2DepData = (deps: Record[]) => deps.find((d) => d.packageName === comp2PkgName); + before(async () => { + helper.scopeHelper.setWorkspaceWithRemoteScope(); + helper.fixtures.populateComponents(2); // comp1 requires comp2 + helper.env.setEmptyEnv(); + helper.command.setEnv('comp1', 'empty-env'); + helper.command.tagAllWithoutBuild(); + comp2PkgName = helper.general.getPackageNameByCompName('comp2', false); + helper.fixtures.generateEnvJsoncFile('empty-env', { + policy: { peers: [{ name: comp2PkgName, version: '+', supportedRange: '+^' }] }, + }); + helper.command.tagAllWithoutBuild(); + helper.command.export(); + + helper.command.createLane('dev'); + helper.fs.appendFile('comp2/index.js', '\n// v2'); + helper.command.snapAllComponentsWithoutBuild(); + helper.command.export(); + comp2LaneSnap = helper.command.getHeadOfLane('dev', 'comp2'); + comp1DepsOnLane = helper.command.showDependenciesData('comp1'); + + // merge the lane into main, then release it the way bit.cloud does: tag from a bare scope. + helper.command.switchLocalLane('main', '-x'); + helper.command.mergeLane('dev', '-x'); + helper.command.export(); + const bareScope = helper.scopeHelper.getNewBareScope('-bare-release'); + helper.scopeHelper.addRemoteScope(helper.scopes.remotePath, bareScope.scopePath); + await helper.snapping.snapFromScope( + bareScope.scopePath, + ['comp1', 'comp2', 'empty-env'].map((name) => ({ + componentId: `${helper.scopes.remote}/${name}`, + message: 'release', + })), + { tag: true, push: true, skipLoadingAspects: true } + ); + }); + it('on the lane, the peer should be the lane snap and keep the "+^" as the range', () => { + const comp2Dep = getComp2DepData(comp1DepsOnLane); + expect(comp2Dep).to.exist; + expect(comp2Dep!.lifecycle).to.equal('peer'); + expect(comp2Dep!.version).to.equal(comp2LaneSnap); + expect(comp2Dep!.versionRange).to.equal('+^'); + }); + describe('after the release', () => { + let comp2ReleasedVersion: string; + before(() => { + const comp2Model = helper.command.catComponent(`${helper.scopes.remote}/comp2`, helper.scopes.remotePath); + comp2ReleasedVersion = Object.keys(comp2Model.versions).find( + (tag) => comp2Model.versions[tag] === comp2Model.head + ) as string; + }); + it('should keep the component peer in the dependency-resolver data, pointing to the new tag', () => { + const comp1 = helper.command.catComponent(`${helper.scopes.remote}/comp1@latest`, helper.scopes.remotePath); + const depsResolver = comp1.extensions.find((e) => e.name === 'teambit.dependencies/dependency-resolver'); + const comp2Dep = getComp2DepData(depsResolver.data.dependencies); + expect(comp2Dep, 'the peer was dropped from the dependency-resolver data').to.exist; + expect(comp2Dep!.__type).to.equal('component'); + expect(comp2Dep!.lifecycle).to.equal('peer'); + expect(comp2Dep!.version).to.equal(comp2ReleasedVersion); + expect(comp2Dep!.versionRange).to.equal('+^'); + }); + describe('checking out the released versions in the workspace', () => { + before(() => { + helper.command.import(); + helper.command.checkoutHead('--all', '-x'); + }); + it('should not show any component as modified', () => { + helper.command.expectStatusToBeClean(); + }); + it('should write the peer with "^" and the new tag into the package.json of the capsule', () => { + helper.command.build('comp1'); + const comp1Capsule = helper.command + .capsuleListParsed() + .capsules.find((c: string) => c.includes('comp1')) as string; + const pkgJson = fs.readJsonSync(path.join(comp1Capsule, 'package.json')); + expect(pkgJson.peerDependencies[comp2PkgName]).to.equal(`^${comp2ReleasedVersion}`); + }); + }); + }); + }); }); diff --git a/scopes/component/isolator/isolator.main.runtime.ts b/scopes/component/isolator/isolator.main.runtime.ts index cb812d3321a7..d781f14da187 100644 --- a/scopes/component/isolator/isolator.main.runtime.ts +++ b/scopes/component/isolator/isolator.main.runtime.ts @@ -28,7 +28,7 @@ import type { PackageManagerInstallOptions, NodeLinker, } from '@teambit/dependency-resolver'; -import { DependencyResolverAspect, KEY_NAME_BY_LIFECYCLE_TYPE } from '@teambit/dependency-resolver'; +import { DependencyResolverAspect, KEY_NAME_BY_LIFECYCLE_TYPE, resolvePlusVersion } from '@teambit/dependency-resolver'; import type { Logger, LoggerMain, LongProcessLogger } from '@teambit/logger'; import { LoggerAspect } from '@teambit/logger'; import type { ComponentID } from '@teambit/component-id'; @@ -1501,8 +1501,10 @@ export class IsolatorMain { const keyName = KEY_NAME_BY_LIFECYCLE_TYPE[dep.lifecycle]; const entry = dep.toManifest(); if (entry) { - manifest[keyName][entry.packageName] = - dep.versionRange && dep.versionRange !== '+' ? dep.versionRange : version; + // a "+" range (e.g. "+^" from env.jsonc) follows the version of the dep. see resolvePlusVersion + manifest[keyName][entry.packageName] = dep.versionRange + ? resolvePlusVersion(dep.versionRange, version) + : version; } }); await Promise.all(promises); @@ -1627,7 +1629,10 @@ export class IsolatorMain { id: depId, isDependency: true, }); - acc[packageName] = versionWithRange || packageDependency; + acc[packageName] = + versionWithRange && packageDependency + ? resolvePlusVersion(versionWithRange, packageDependency) + : versionWithRange || packageDependency; return acc; }, {}); }; diff --git a/scopes/dependencies/dependencies/dependencies-loader/apply-overrides.ts b/scopes/dependencies/dependencies/dependencies-loader/apply-overrides.ts index 6227b9d517ab..9dd2592833a1 100644 --- a/scopes/dependencies/dependencies/dependencies-loader/apply-overrides.ts +++ b/scopes/dependencies/dependencies/dependencies-loader/apply-overrides.ts @@ -14,6 +14,7 @@ import type { ResolvedPackageData } from '../resolve-pkg-data'; import { resolvePackageData } from '../resolve-pkg-data'; import type { Workspace } from '@teambit/workspace'; import type { DependencyResolverMain } from '@teambit/dependency-resolver'; +import { isPlusVersion, resolvePlusVersion } from '@teambit/dependency-resolver'; import type { Consumer } from '@teambit/legacy.consumer'; import type { ComponentMap } from '@teambit/legacy.bit-map'; import type { Logger } from '@teambit/logger'; @@ -716,7 +717,11 @@ export class ApplyOverrides { comp.versionRange = pkgVal; this.allDependencies.peerDependencies.push(comp); } else { - this.allPackagesDependencies[key][pkgName] = pkgVal; + // a package has no separate version and range, so a "+" placeholder is resolved here. + // a component dep keeps it in the versionRange, and it's resolved when generating the package.json + this.allPackagesDependencies[key][pkgName] = isPlusVersion(pkgVal) + ? resolvePlusVersion(pkgVal, this.resolveEnvPeerDepVersion(pkgName)) + : pkgVal; } if (existsInCompsPeerDeps) { @@ -750,11 +755,11 @@ export class ApplyOverrides { }); const resolvedEnvPolicyManifest = Object.keys(envPolicyManifest).reduce((acc, pkgName) => { const version = envPolicyManifest[pkgName]; - if (version !== '+') { + if (!isPlusVersion(version)) { acc[pkgName] = version; return acc; } - acc[pkgName] = this.resolveEnvPeerDepVersion(pkgName); + acc[pkgName] = resolvePlusVersion(version, this.resolveEnvPeerDepVersion(pkgName)); return acc; }, {}); // TODO: handle component deps once we support peers between components @@ -765,7 +770,7 @@ export class ApplyOverrides { } /** - * in the env.jsonc file, a policy-peer package can have `+` sign in the version. it means that it should be resolved + * in the env.jsonc file, a policy-peer package can have `+` sign (or `+^`/`+~`) in the version. it means that it should be resolved * from the workspace. whatever version is installed/imported in the workspace, it should be used here. * in some cases, the package is not installed in the workspace, for example, the env is now imported without the * dep. so the dep is not in the node_modules. diff --git a/scopes/dependencies/dependencies/dependencies-loader/dependencies-versions-resolver.ts b/scopes/dependencies/dependencies/dependencies-loader/dependencies-versions-resolver.ts index e15c7d789fe7..ca51cc9241a0 100644 --- a/scopes/dependencies/dependencies/dependencies-loader/dependencies-versions-resolver.ts +++ b/scopes/dependencies/dependencies/dependencies-loader/dependencies-versions-resolver.ts @@ -8,6 +8,7 @@ import { isEmpty } from 'lodash'; import type { Dependency, Dependencies, ConsumerComponent as Component } from '@teambit/legacy.consumer-component'; import type { ExtensionDataEntry, ExtensionDataList } from '@teambit/legacy.extension-data'; import type { DependencyResolverMain } from '@teambit/dependency-resolver'; +import { isPlusVersion } from '@teambit/dependency-resolver'; import { DEPENDENCIES_FIELDS } from '@teambit/legacy.constants'; import type OverridesDependencies from './overrides-dependencies'; import type { DebugComponentsDependency } from './auto-detect-deps'; @@ -126,7 +127,7 @@ export async function updateDependenciesVersions( ) { dependency.versionRange = `${componentRangePrefix}${resolvedVersion}`; } - } else if (dependency.versionRange && depType !== 'peerDependencies' && dependency.versionRange !== '+') { + } else if (dependency.versionRange && depType !== 'peerDependencies' && !isPlusVersion(dependency.versionRange)) { dependency.versionRange = undefined; } } diff --git a/scopes/dependencies/dependencies/dependencies-loader/overrides-dependencies.ts b/scopes/dependencies/dependencies/dependencies-loader/overrides-dependencies.ts index b96ec9608b7b..fa2a625c7cff 100644 --- a/scopes/dependencies/dependencies/dependencies-loader/overrides-dependencies.ts +++ b/scopes/dependencies/dependencies/dependencies-loader/overrides-dependencies.ts @@ -1,5 +1,6 @@ -import { DEPENDENCIES_FIELDS, MANUALLY_ADD_DEPENDENCY, MANUALLY_REMOVE_DEPENDENCY } from '@teambit/legacy.constants'; +import { DEPENDENCIES_FIELDS, MANUALLY_REMOVE_DEPENDENCY } from '@teambit/legacy.constants'; import { logger } from '@teambit/legacy.logger'; +import { isPlusVersion, resolvePlusVersion } from '@teambit/dependency-resolver'; import type { ConsumerComponent as Component, ManuallyChangedDependencies } from '@teambit/legacy.consumer-component'; import type { FileType } from './auto-detect-deps'; @@ -75,14 +76,15 @@ export default class OverridesDependencies { packageJson: Record | null | undefined ): Record | null | undefined { const packageVersionToAdd = (): string | null | undefined => { - if (dependencyValue !== MANUALLY_ADD_DEPENDENCY) { + // "+", "+^" and "+~" take the version from the package.json + if (!isPlusVersion(dependencyValue)) { return dependencyValue; } if (!packageJson) return null; for (const depField of DEPENDENCIES_FIELDS) { if (packageJson[depField]) { const found = Object.keys(packageJson[depField]).find((pkg) => pkg === dependency); - if (found) return packageJson[depField][dependency]; + if (found) return resolvePlusVersion(dependencyValue, packageJson[depField][dependency]); } } return null; diff --git a/scopes/dependencies/dependency-resolver/dependency-resolver.main.runtime.spec.ts b/scopes/dependencies/dependency-resolver/dependency-resolver.main.runtime.spec.ts index 15516c9f9303..633434264fbf 100644 --- a/scopes/dependencies/dependency-resolver/dependency-resolver.main.runtime.spec.ts +++ b/scopes/dependencies/dependency-resolver/dependency-resolver.main.runtime.spec.ts @@ -50,6 +50,33 @@ describe('DepenendencyResolverMain.validateAspectData()', () => { } as any) ).to.equal(undefined); }); + for (const version of ['+', '+^', '+~']) { + it(`should accept "${version}" as a policy version of a component`, () => { + const depResolver = createDependencyResolverMain(); + expect( + depResolver.validateAspectData({ + dependencies: [ + { + __type: 'component', + id: 'org.scope/comp@0.0.1', + version: '0.0.1', + packageName: '@org/scope.comp', + versionRange: version, + }, + ], + policy: [{ dependencyId: '@org/scope.comp', value: { version } }], + } as any) + ).to.equal(undefined); + }); + } + it('should reject an unknown "+" spec as a policy version', () => { + const depResolver = createDependencyResolverMain(); + const result = depResolver.validateAspectData({ + dependencies: [], + policy: [{ dependencyId: 'some-pkg', value: { version: '+>' } }], + } as any); + expect(result?.errorMsg).to.include('"+>"'); + }); }); describe('DepenendencyResolverMain.isValidVersionSpecifier()', () => { diff --git a/scopes/dependencies/dependency-resolver/dependency-resolver.main.runtime.ts b/scopes/dependencies/dependency-resolver/dependency-resolver.main.runtime.ts index c4af9f9a221a..f056576644a0 100644 --- a/scopes/dependencies/dependency-resolver/dependency-resolver.main.runtime.ts +++ b/scopes/dependencies/dependency-resolver/dependency-resolver.main.runtime.ts @@ -36,6 +36,7 @@ import { import { ExtensionDataList } from '@teambit/legacy.extension-data'; import { componentIdToPackageName } from '@teambit/pkg.modules.component-package-name'; import { DetectorHook } from './detector-hook'; +import { PLUS_VERSIONS } from './plus-version'; import type { ProxyConfig, NetworkConfig } from '@teambit/scope.network'; import { Http } from '@teambit/scope.network'; import type { Dependency as LegacyDependency } from '@teambit/legacy.consumer-component'; @@ -1555,7 +1556,7 @@ export class DependencyResolverMain { }); data.policy?.forEach((policy) => { const policyVersion = policy.value.version; - const allowedSpecialChars = ['+', '-']; + const allowedSpecialChars: string[] = ['-', ...PLUS_VERSIONS]; if (policyVersion === '*') { // this is only valid for packages, not for components. const isComp = data.dependencies.find( diff --git a/scopes/dependencies/dependency-resolver/index.ts b/scopes/dependencies/dependency-resolver/index.ts index b8b6db867f33..b5085719512c 100644 --- a/scopes/dependencies/dependency-resolver/index.ts +++ b/scopes/dependencies/dependency-resolver/index.ts @@ -78,6 +78,8 @@ export type { DependencySource, VariantPolicyEntry } from './policy/variant-poli export type { OutdatedPkg, CurrentPkg } from './get-all-policy-pkgs'; export { extendWithComponentsFromDir } from './extend-with-components-from-dir'; export { isRange } from './manifest/deduping/hoist-dependencies'; +export { isPlusVersion, resolvePlusVersion, PLUS_VERSIONS } from './plus-version'; +export type { PlusVersion } from './plus-version'; export type { DependencyEnv } from './dependency-env'; export { DetectorHook, DependencyDetector, FileContext } from './detector-hook'; export { diff --git a/scopes/dependencies/dependency-resolver/manifest/deduping/hoist-dependencies.ts b/scopes/dependencies/dependency-resolver/manifest/deduping/hoist-dependencies.ts index aa96cde920f7..35671d8cf0bb 100644 --- a/scopes/dependencies/dependency-resolver/manifest/deduping/hoist-dependencies.ts +++ b/scopes/dependencies/dependency-resolver/manifest/deduping/hoist-dependencies.ts @@ -3,6 +3,7 @@ import semver from 'semver'; import { parseRange } from 'semver-intersect'; import { intersect } from 'semver-range-intersect'; import { isHash } from '@teambit/component-version'; +import { isPlusVersion } from '../../plus-version'; import { DEV_DEP_LIFECYCLE_TYPE, @@ -450,7 +451,12 @@ function groupByRangeOrVersion(indexItems: PackageNameIndexComponentItem[]): Ite export function isRange(version: string, compIdStr: string) { const validRange = semver.validRange(version); if (!validRange) { - if (!isHash(version) && !version.startsWith('workspace:') && !version.startsWith('npm:') && version !== '+') { + if ( + !isHash(version) && + !version.startsWith('workspace:') && + !version.startsWith('npm:') && + !isPlusVersion(version) + ) { throw new Error( `fatal: the version "${version}" originated from a dependent "${compIdStr}" is invalid semver range and not a hash` ); diff --git a/scopes/dependencies/dependency-resolver/manifest/update-dependency-version.ts b/scopes/dependencies/dependency-resolver/manifest/update-dependency-version.ts index f2d65b49fb17..cf21ddd14684 100644 --- a/scopes/dependencies/dependency-resolver/manifest/update-dependency-version.ts +++ b/scopes/dependencies/dependency-resolver/manifest/update-dependency-version.ts @@ -1,4 +1,5 @@ import { snapToSemver } from '@teambit/component-package-version'; +import { PLUS_VERSIONS } from '../plus-version'; import type { Dependency } from '../dependencies'; import type { VariantPolicy, WorkspacePolicy } from '../policy'; @@ -32,7 +33,7 @@ export function updateDependencyVersion( if (dependency.getPackageName) { const packageName = dependency.getPackageName(); const variantVersion = variantPolicy?.getDepVersion(packageName, dependency.lifecycle); - const specialChars = ['-', '+', '*']; + const specialChars: string[] = ['-', '*', ...PLUS_VERSIONS]; const variantVersionWithoutSpecialChar = variantVersion && !specialChars.includes(variantVersion) ? variantVersion : undefined; const version = diff --git a/scopes/dependencies/dependency-resolver/manifest/workspace-manifest-factory.ts b/scopes/dependencies/dependency-resolver/manifest/workspace-manifest-factory.ts index 0ec18c517927..5306be8d2599 100644 --- a/scopes/dependencies/dependency-resolver/manifest/workspace-manifest-factory.ts +++ b/scopes/dependencies/dependency-resolver/manifest/workspace-manifest-factory.ts @@ -23,6 +23,7 @@ import { dedupeDependencies, getEmptyDedupedDependencies } from './deduping'; import type { ManifestToJsonOptions, ManifestDependenciesObject, DepObjectValue } from './manifest'; import { updateDependencyVersion } from './update-dependency-version'; import { WorkspaceManifest } from './workspace-manifest'; +import { isPlusVersion, resolvePlusVersion } from '../plus-version'; export type DepsFilterFn = (dependencies: DependencyList) => DependencyList; @@ -201,7 +202,7 @@ export class WorkspaceManifestFactory { // No conflict — merge to root const entry = this.createResolvedEntry(componentsManifestsMap, pkgName, versions[0]); resolvedEntries.push(entry); - if (overridesFlags.get(pkgName) && versions[0] !== '+') { + if (overridesFlags.get(pkgName) && !isPlusVersion(versions[0])) { peerOverrides[pkgName] = versions[0]; } continue; @@ -216,7 +217,7 @@ export class WorkspaceManifestFactory { const chosenVersion = this.resolveConflictingPeerVersions(pkgName, versionsMap); resolvedEntries.push(this.createResolvedEntry(componentsManifestsMap, pkgName, chosenVersion)); conflictingPackages.push(pkgName); - if (overridesFlags.get(pkgName) && chosenVersion !== '+') { + if (overridesFlags.get(pkgName) && !isPlusVersion(chosenVersion)) { peerOverrides[pkgName] = chosenVersion; } } else { @@ -224,7 +225,7 @@ export class WorkspaceManifestFactory { // Each env gets its own version injected into its components for (const [version, envIds] of versionsMap.entries()) { // Skip "+" placeholder — it's resolved at the workspace root level - if (version === '+') continue; + if (isPlusVersion(version)) continue; for (const envId of envIds) { if (!componentPeerOverrides.has(envId)) { componentPeerOverrides.set(envId, {}); @@ -286,7 +287,7 @@ export class WorkspaceManifestFactory { const envsByVersion = Array.from(versionsMap.entries()); // Filter out non-semver versions (like '+') for comparison - const semverVersions = versions.filter((v) => v !== '+' && semver.coerce(v) !== null); + const semverVersions = versions.filter((v) => !isPlusVersion(v) && semver.coerce(v) !== null); if (semverVersions.length === 0) { // All versions are non-semver (e.g., '+'), just pick the first @@ -302,7 +303,7 @@ export class WorkspaceManifestFactory { if (!coercedCandidate) continue; let satisfiedCount = 0; for (const [version, envIds] of envsByVersion) { - if (version === candidateVersion || version === '+') { + if (version === candidateVersion || isPlusVersion(version)) { satisfiedCount += envIds.size; } else if (semver.validRange(version)) { // Check if the candidate could satisfy the range @@ -324,7 +325,7 @@ export class WorkspaceManifestFactory { // Only warn about envs whose version is NOT satisfied by the chosen version const coercedBestForCheck = semver.coerce(bestVersion); const unsatisfiedEnvs = envsByVersion.filter(([version]) => { - if (version === bestVersion || version === '+') return false; + if (version === bestVersion || isPlusVersion(version)) return false; if (coercedBestForCheck && semver.validRange(version) && semver.satisfies(coercedBestForCheck.version, version)) return false; return true; @@ -550,7 +551,7 @@ export class WorkspaceManifestFactory { const nameVersionTuples = selfPolicyWithoutLocal.toNameVersionTuple(); const resolved = nameVersionTuples.map(([name, version]) => { - if (version !== '+') { + if (!isPlusVersion(version)) { return [name, version]; } // Resolve "+" version placeholders by looking up the already resolved version that was set in @@ -559,7 +560,7 @@ export class WorkspaceManifestFactory { const found = currentDeps.findByPkgNameOrCompId(name); // If not found, use '*' as fallback // Use snapToSemver to convert raw hash versions to valid semver format (0.0.0-{hash}) - return [name, found?.version ? snapToSemver(found.version) : '*']; + return [name, found?.version ? resolvePlusVersion(version, snapToSemver(found.version)) : '*']; }); return fromPairs(resolved); } diff --git a/scopes/dependencies/dependency-resolver/manifest/workspace-manifest.ts b/scopes/dependencies/dependency-resolver/manifest/workspace-manifest.ts index 316f00c3e521..34d38b19c23a 100644 --- a/scopes/dependencies/dependency-resolver/manifest/workspace-manifest.ts +++ b/scopes/dependencies/dependency-resolver/manifest/workspace-manifest.ts @@ -4,6 +4,7 @@ import type { VariantPolicy } from '../policy'; import type { ComponentsManifestsMap } from '../types'; import type { ManifestToJsonOptions, ManifestDependenciesObject } from './manifest'; import { Manifest } from './manifest'; +import { isPlusVersion, resolvePlusVersion } from '../plus-version'; export interface WorkspaceManifestToJsonOptions extends ManifestToJsonOptions { installPeersFromEnvs?: boolean; @@ -39,7 +40,8 @@ export class WorkspaceManifest extends Manifest { manifest.dependencies = manifest.dependencies || {}; // Env peers are added as defaults — workspace.jsonc policy takes priority across all dep sections for (const [pkgName, version] of Object.entries(resolvedPeersManifest)) { - const alreadyDefined = manifest.dependencies?.[pkgName] || + const alreadyDefined = + manifest.dependencies?.[pkgName] || manifest.devDependencies?.[pkgName] || manifest.peerDependencies?.[pkgName] || manifest.optionalDependencies?.[pkgName]; @@ -53,12 +55,12 @@ export class WorkspaceManifest extends Manifest { /** * Resolves "+" version placeholders in the env peers policy. - * The "+" means: use the version from the workspace components (.bitmap or workspace.jsonc). + * The "+" (or "+^"/"+~") means: use the version from the workspace components (.bitmap or workspace.jsonc). */ private _resolvePlusVersions(peersManifest: Record): Record { return Object.keys(peersManifest).reduce((acc, pkgName) => { const version = peersManifest[pkgName]; - if (version !== '+') { + if (!isPlusVersion(version)) { acc[pkgName] = version; return acc; } @@ -68,7 +70,7 @@ export class WorkspaceManifest extends Manifest { this.dependencies.devDependencies?.[pkgName] || this.dependencies.peerDependencies?.[pkgName]; // Fallback to '*' if we can't resolve the version - acc[pkgName] = foundVersion || '*'; + acc[pkgName] = foundVersion ? resolvePlusVersion(version, foundVersion) : '*'; return acc; }, {}); } diff --git a/scopes/dependencies/dependency-resolver/plus-version.spec.ts b/scopes/dependencies/dependency-resolver/plus-version.spec.ts new file mode 100644 index 000000000000..f3152ee9768b --- /dev/null +++ b/scopes/dependencies/dependency-resolver/plus-version.spec.ts @@ -0,0 +1,46 @@ +import { expect } from 'chai'; +import { isPlusVersion, resolvePlusVersion } from './plus-version'; + +const snap = 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0'; + +describe('isPlusVersion()', () => { + it('should be true for "+", "+^" and "+~"', () => { + expect(isPlusVersion('+')).to.be.true; + expect(isPlusVersion('+^')).to.be.true; + expect(isPlusVersion('+~')).to.be.true; + }); + it('should be false for anything else', () => { + expect(isPlusVersion(undefined)).to.be.false; + expect(isPlusVersion('')).to.be.false; + expect(isPlusVersion('2.0.15')).to.be.false; + expect(isPlusVersion('^2.0.15')).to.be.false; + expect(isPlusVersion('+>')).to.be.false; + expect(isPlusVersion('-')).to.be.false; + }); +}); + +describe('resolvePlusVersion()', () => { + it('should return a non-plus version as is', () => { + expect(resolvePlusVersion('2.0.15', '2.0.21')).to.equal('2.0.15'); + expect(resolvePlusVersion('^1.0.0', '2.0.21')).to.equal('^1.0.0'); + }); + it('should resolve "+" to the exact version', () => { + expect(resolvePlusVersion('+', '2.0.21')).to.equal('2.0.21'); + }); + it('should resolve "+^" and "+~" to the version with the prefix', () => { + expect(resolvePlusVersion('+^', '2.0.21')).to.equal('^2.0.21'); + expect(resolvePlusVersion('+~', '2.0.21')).to.equal('~2.0.21'); + }); + it('should keep a pre-release tag with the prefix', () => { + expect(resolvePlusVersion('+^', '2.0.21-beta.1')).to.equal('^2.0.21-beta.1'); + }); + it('should not add a prefix to a snap, either as a hash or as semver', () => { + expect(resolvePlusVersion('+^', snap)).to.equal(snap); + expect(resolvePlusVersion('+~', `0.0.0-${snap}`)).to.equal(`0.0.0-${snap}`); + expect(resolvePlusVersion('+', snap)).to.equal(snap); + }); + it('should not add a prefix to a resolved value that is already a range', () => { + expect(resolvePlusVersion('+^', '^2.0.0')).to.equal('^2.0.0'); + expect(resolvePlusVersion('+^', '*')).to.equal('*'); + }); +}); diff --git a/scopes/dependencies/dependency-resolver/plus-version.ts b/scopes/dependencies/dependency-resolver/plus-version.ts new file mode 100644 index 000000000000..7c7ec0ff0079 --- /dev/null +++ b/scopes/dependencies/dependency-resolver/plus-version.ts @@ -0,0 +1,37 @@ +import semver from 'semver'; +import { isHash } from '@teambit/component-version'; +import { SNAP_VERSION_PREFIX } from '@teambit/component-package-version'; + +/** + * a "plus" version is a placeholder for "the version this dependency resolves to in the workspace" instead of a + * fixed version. it's mostly useful in env.jsonc (both "version" and "supportedRange") for dependencies that are + * components. on a lane, it follows the lane snap. once the lane is merged and tagged, it follows the new tag. + * - `+`: the exact resolved version. e.g. `2.0.21`. + * - `+^` / `+~`: the resolved version with a range prefix. e.g. `^2.0.21`. + * a prefix is meaningless for a snap, so on a lane, the snap is used as is. + */ +export const PLUS_VERSIONS = ['+', '+^', '+~'] as const; + +export type PlusVersion = (typeof PLUS_VERSIONS)[number]; + +export function isPlusVersion(version?: string): version is PlusVersion { + return Boolean(version && (PLUS_VERSIONS as readonly string[]).includes(version)); +} + +/** + * replace a plus-version placeholder with the version it resolved to. a non-plus version is returned as is. + * the resolved version can be a tag (`2.0.21`), a snap (raw hash or its `0.0.0-` semver form) or a range + * (e.g. when it's taken from workspace.jsonc). the prefix is added only to a tag. + */ +export function resolvePlusVersion(version: string, resolvedVersion: string): string { + if (!isPlusVersion(version)) return version; + const prefix = version.slice(1); + if (!prefix || !isTagVersion(resolvedVersion)) return resolvedVersion; + return `${prefix}${resolvedVersion}`; +} + +function isTagVersion(version: string): boolean { + if (isHash(version)) return false; + if (!semver.valid(version)) return false; + return !version.startsWith(SNAP_VERSION_PREFIX); +} diff --git a/scopes/scope/objects/models/model-component.ts b/scopes/scope/objects/models/model-component.ts index 36782dd74d17..9dbac7d7947c 100644 --- a/scopes/scope/objects/models/model-component.ts +++ b/scopes/scope/objects/models/model-component.ts @@ -12,8 +12,8 @@ import type { LegacyComponentLog } from '@teambit/legacy-component-log'; import { findDuplications } from '@teambit/toolbox.array.duplications-finder'; import { BitId } from '@teambit/legacy-bit-id'; import { DEFAULT_BIT_RELEASE_TYPE, DEFAULT_BIT_VERSION, DEFAULT_LANGUAGE, Extensions } from '@teambit/legacy.constants'; -import type { Dependencies, Dependency } from '@teambit/legacy.consumer-component'; -import { ConsumerComponent, SchemaName } from '@teambit/legacy.consumer-component'; +import type { Dependencies } from '@teambit/legacy.consumer-component'; +import { ConsumerComponent, Dependency, SchemaName } from '@teambit/legacy.consumer-component'; import { License, SourceFile, getRefsFromExtensions } from '@teambit/component.sources'; import { ComponentOverrides, getBindingPrefixByDefaultScope } from '@teambit/legacy.consumer-config'; import { ValidationError } from '@teambit/legacy.cli.error'; @@ -1199,6 +1199,7 @@ bit import ${this.id()}@${resolvedVersion} --objects --all-history` mainFile: version.mainFile, dependencies: this.addDepsInfoFromDepsResolver(version.dependencies, extensions), devDependencies: this.addDepsInfoFromDepsResolver(version.devDependencies, extensions), + peerDependencies: this.getPeerDepsFromDepsResolver(version, extensions), // built on first read and shared with clones (which copy this loader). a failed load is not kept, so it throws again. loadFlattenedDependencies: () => (flattenedDependencies ??= ComponentIdList.fromArray(version.loadFlattenedDependenciesSync(repository))), @@ -1240,6 +1241,26 @@ bit import ${this.id()}@${resolvedVersion} --objects --all-history` return cloned; } + /** + * component peer-dependencies (e.g. a component set as a peer in env.jsonc) are not saved in the legacy fields of + * the Version object, only in the dependency-resolver data. restore them, otherwise, any flow that re-calculates the + * dependency-resolver data out of this ConsumerComponent, drops them. e.g. a snap/tag from a bare scope, which is + * what happens when a lane is merged and released on the remote. + */ + private getPeerDepsFromDepsResolver(version: Version, extensions: ExtensionDataList): Dependency[] { + const depsResolverData = extensions.find((ext) => ext.name === 'teambit.dependencies/dependency-resolver'); + const entries: Array> = depsResolverData?.data?.dependencies || []; + const nonPeerIds = version.getAllDependenciesIds(); + return entries + .filter((entry) => entry.__type === 'component' && entry.lifecycle === 'peer' && entry.componentId) + .map((entry) => { + const id = + entry.componentId instanceof ComponentID ? entry.componentId : ComponentID.fromObject(entry.componentId); + return new Dependency(id, [], entry.packageName, entry.versionRange); + }) + .filter((dependency) => !nonPeerIds.hasWithoutVersion(dependency.id)); + } + // @todo: make sure it doesn't have the same ref twice, once as a version and once as a head refs(): Ref[] { const versions = Object.values(this.versionsIncludeOrphaned); diff --git a/scopes/workspace/install/install.main.runtime.ts b/scopes/workspace/install/install.main.runtime.ts index d7b8f8fbf6c0..2237909e2dce 100644 --- a/scopes/workspace/install/install.main.runtime.ts +++ b/scopes/workspace/install/install.main.runtime.ts @@ -58,6 +58,8 @@ import { DependencyResolverAspect, ComponentDependency, ensureHoistedDependencyResolution, + isPlusVersion, + resolvePlusVersion, } from '@teambit/dependency-resolver'; import type { WorkspaceConfigFilesMain } from '@teambit/workspace-config-files'; import { WorkspaceConfigFilesAspect } from '@teambit/workspace-config-files'; @@ -1167,7 +1169,7 @@ export class InstallMain { /** * Get the env's own peer dependencies from its policy (env.jsonc). - * Resolves "+" version placeholders using workspaceDeps. + * Resolves "+" (and "+^"/"+~") version placeholders using workspaceDeps. */ private async _getEnvDependencies( envId: ComponentID, @@ -1183,8 +1185,9 @@ export class InstallMain { let version = value.version; // Resolve "+" version placeholders using workspace dependencies // Similar to WorkspaceManifest._resolvePlusVersions - if (version === '+') { - version = workspaceDeps[dependencyId] || '*'; + if (isPlusVersion(version)) { + const workspaceVersion = workspaceDeps[dependencyId]; + version = workspaceVersion ? resolvePlusVersion(version, workspaceVersion) : '*'; } return [dependencyId, version]; }) From d48c359b63ead45af3a5eb5971c434f42145acd2 Mon Sep 17 00:00:00 2001 From: David First Date: Thu, 15 Oct 2026 13:59:23 -0400 Subject: [PATCH 2/3] refactor(deps): simplify plus-version helper and peer restore --- components/legacy/constants/constants.ts | 6 +++ .../consumer-config/component-overrides.ts | 5 +-- e2e/harmony/lanes/lane-env-jsonc-dep.e2e.ts | 41 +++++++++---------- .../isolator/isolator.main.runtime.ts | 8 ++-- .../dependency-resolver.main.runtime.ts | 6 +-- .../dependencies/dependency-resolver/index.ts | 3 +- .../manifest/update-dependency-version.ts | 8 ++-- .../manifest/workspace-manifest-factory.ts | 2 +- .../manifest/workspace-manifest.ts | 2 +- .../dependency-resolver/plus-version.spec.ts | 5 +++ .../dependency-resolver/plus-version.ts | 27 ++++-------- .../scope/objects/models/model-component.ts | 18 ++++---- .../workspace/install/install.main.runtime.ts | 9 +--- 13 files changed, 68 insertions(+), 72 deletions(-) diff --git a/components/legacy/constants/constants.ts b/components/legacy/constants/constants.ts index eaa9f5465429..913bc42c6039 100644 --- a/components/legacy/constants/constants.ts +++ b/components/legacy/constants/constants.ts @@ -515,6 +515,12 @@ export const MANUALLY_REMOVE_ENVIRONMENT = '-'; export const MANUALLY_ADD_DEPENDENCY = '+'; +/** + * placeholders for the version a dependency resolves to, optionally with a range prefix. e.g. "+^" => "^1.2.3". + * see resolvePlusVersion() of the dependency-resolver aspect. + */ +export const PLUS_VERSIONS = [MANUALLY_ADD_DEPENDENCY, '+^', '+~']; + export const OVERRIDE_COMPONENT_PREFIX = '@bit/'; export const ACCEPTABLE_NPM_VERSIONS = '>=5.0.0'; diff --git a/components/legacy/consumer-config/component-overrides.ts b/components/legacy/consumer-config/component-overrides.ts index 39ba12f297de..b43277688686 100644 --- a/components/legacy/consumer-config/component-overrides.ts +++ b/components/legacy/consumer-config/component-overrides.ts @@ -1,7 +1,7 @@ import { pickBy, keys, isEmpty, cloneDeep, get, merge } from 'lodash'; import type { ComponentID } from '@teambit/component-id'; import { - MANUALLY_ADD_DEPENDENCY, + PLUS_VERSIONS, MANUALLY_REMOVE_DEPENDENCY, OVERRIDE_COMPONENT_PREFIX, DEPENDENCIES_FIELDS, @@ -143,8 +143,7 @@ export class ComponentOverrides { }, {}); } _isValidVersion(ver: string) { - // "+", "+^" and "+~" are placeholders for the resolved version, not a version - return !ver.startsWith(MANUALLY_ADD_DEPENDENCY) && ver !== MANUALLY_REMOVE_DEPENDENCY; + return !PLUS_VERSIONS.includes(ver) && ver !== MANUALLY_REMOVE_DEPENDENCY; } getIgnored(field: string): string[] { const filtered = pickBy(this.overrides[field] || {}, (dep) => { diff --git a/e2e/harmony/lanes/lane-env-jsonc-dep.e2e.ts b/e2e/harmony/lanes/lane-env-jsonc-dep.e2e.ts index 7469290f4195..31badf66e505 100644 --- a/e2e/harmony/lanes/lane-env-jsonc-dep.e2e.ts +++ b/e2e/harmony/lanes/lane-env-jsonc-dep.e2e.ts @@ -1,6 +1,7 @@ import { expect } from 'chai'; import fs from 'fs-extra'; import path from 'path'; +import { Extensions } from '@teambit/legacy.constants'; import { Helper } from '@teambit/legacy.e2e-helper'; /** @@ -180,29 +181,27 @@ export default new MyEnv(); ); }); it('on the lane, the peer should be the lane snap and keep the "+^" as the range', () => { - const comp2Dep = getComp2DepData(comp1DepsOnLane); - expect(comp2Dep).to.exist; - expect(comp2Dep!.lifecycle).to.equal('peer'); - expect(comp2Dep!.version).to.equal(comp2LaneSnap); - expect(comp2Dep!.versionRange).to.equal('+^'); + expect(getComp2DepData(comp1DepsOnLane)).to.include({ + lifecycle: 'peer', + version: comp2LaneSnap, + versionRange: '+^', + }); }); describe('after the release', () => { - let comp2ReleasedVersion: string; - before(() => { - const comp2Model = helper.command.catComponent(`${helper.scopes.remote}/comp2`, helper.scopes.remotePath); - comp2ReleasedVersion = Object.keys(comp2Model.versions).find( - (tag) => comp2Model.versions[tag] === comp2Model.head - ) as string; - }); + // comp2 was tagged once (0.0.1) before the lane, so the release tags it as 0.0.2 + const comp2ReleasedVersion = '0.0.2'; it('should keep the component peer in the dependency-resolver data, pointing to the new tag', () => { - const comp1 = helper.command.catComponent(`${helper.scopes.remote}/comp1@latest`, helper.scopes.remotePath); - const depsResolver = comp1.extensions.find((e) => e.name === 'teambit.dependencies/dependency-resolver'); - const comp2Dep = getComp2DepData(depsResolver.data.dependencies); - expect(comp2Dep, 'the peer was dropped from the dependency-resolver data').to.exist; - expect(comp2Dep!.__type).to.equal('component'); - expect(comp2Dep!.lifecycle).to.equal('peer'); - expect(comp2Dep!.version).to.equal(comp2ReleasedVersion); - expect(comp2Dep!.versionRange).to.equal('+^'); + const depsResolverData = helper.command.getAspectsDataFromId( + `${helper.scopes.remote}/comp1`, + Extensions.dependencyResolver, + helper.scopes.remotePath + ); + expect(getComp2DepData(depsResolverData.dependencies), 'the peer was dropped').to.include({ + __type: 'component', + lifecycle: 'peer', + version: comp2ReleasedVersion, + versionRange: '+^', + }); }); describe('checking out the released versions in the workspace', () => { before(() => { @@ -216,7 +215,7 @@ export default new MyEnv(); helper.command.build('comp1'); const comp1Capsule = helper.command .capsuleListParsed() - .capsules.find((c: string) => c.includes('comp1')) as string; + .capsules.find((capsule: string) => capsule.includes('comp1')) as string; const pkgJson = fs.readJsonSync(path.join(comp1Capsule, 'package.json')); expect(pkgJson.peerDependencies[comp2PkgName]).to.equal(`^${comp2ReleasedVersion}`); }); diff --git a/scopes/component/isolator/isolator.main.runtime.ts b/scopes/component/isolator/isolator.main.runtime.ts index d781f14da187..e210065dc78f 100644 --- a/scopes/component/isolator/isolator.main.runtime.ts +++ b/scopes/component/isolator/isolator.main.runtime.ts @@ -1501,7 +1501,6 @@ export class IsolatorMain { const keyName = KEY_NAME_BY_LIFECYCLE_TYPE[dep.lifecycle]; const entry = dep.toManifest(); if (entry) { - // a "+" range (e.g. "+^" from env.jsonc) follows the version of the dep. see resolvePlusVersion manifest[keyName][entry.packageName] = dep.versionRange ? resolvePlusVersion(dep.versionRange, version) : version; @@ -1629,10 +1628,9 @@ export class IsolatorMain { id: depId, isDependency: true, }); - acc[packageName] = - versionWithRange && packageDependency - ? resolvePlusVersion(versionWithRange, packageDependency) - : versionWithRange || packageDependency; + acc[packageName] = versionWithRange + ? resolvePlusVersion(versionWithRange, packageDependency, versionWithRange) + : packageDependency; return acc; }, {}); }; diff --git a/scopes/dependencies/dependency-resolver/dependency-resolver.main.runtime.ts b/scopes/dependencies/dependency-resolver/dependency-resolver.main.runtime.ts index f056576644a0..4fb7ccea7d0d 100644 --- a/scopes/dependencies/dependency-resolver/dependency-resolver.main.runtime.ts +++ b/scopes/dependencies/dependency-resolver/dependency-resolver.main.runtime.ts @@ -36,7 +36,7 @@ import { import { ExtensionDataList } from '@teambit/legacy.extension-data'; import { componentIdToPackageName } from '@teambit/pkg.modules.component-package-name'; import { DetectorHook } from './detector-hook'; -import { PLUS_VERSIONS } from './plus-version'; +import { isPlusVersion } from './plus-version'; import type { ProxyConfig, NetworkConfig } from '@teambit/scope.network'; import { Http } from '@teambit/scope.network'; import type { Dependency as LegacyDependency } from '@teambit/legacy.consumer-component'; @@ -1556,7 +1556,6 @@ export class DependencyResolverMain { }); data.policy?.forEach((policy) => { const policyVersion = policy.value.version; - const allowedSpecialChars: string[] = ['-', ...PLUS_VERSIONS]; if (policyVersion === '*') { // this is only valid for packages, not for components. const isComp = data.dependencies.find( @@ -1568,7 +1567,8 @@ as an alternative, you can use "+" to keep the same version installed in the wor } const isVersionValid = Boolean( this.isValidVersionSpecifier(policyVersion) || - allowedSpecialChars.includes(policyVersion) || + policyVersion === '-' || + isPlusVersion(policyVersion) || allowedPrefixes.some((prefix) => policyVersion.startsWith(prefix)) ); if (isVersionValid) return; diff --git a/scopes/dependencies/dependency-resolver/index.ts b/scopes/dependencies/dependency-resolver/index.ts index b5085719512c..d54d9f35fb77 100644 --- a/scopes/dependencies/dependency-resolver/index.ts +++ b/scopes/dependencies/dependency-resolver/index.ts @@ -78,8 +78,7 @@ export type { DependencySource, VariantPolicyEntry } from './policy/variant-poli export type { OutdatedPkg, CurrentPkg } from './get-all-policy-pkgs'; export { extendWithComponentsFromDir } from './extend-with-components-from-dir'; export { isRange } from './manifest/deduping/hoist-dependencies'; -export { isPlusVersion, resolvePlusVersion, PLUS_VERSIONS } from './plus-version'; -export type { PlusVersion } from './plus-version'; +export { isPlusVersion, resolvePlusVersion } from './plus-version'; export type { DependencyEnv } from './dependency-env'; export { DetectorHook, DependencyDetector, FileContext } from './detector-hook'; export { diff --git a/scopes/dependencies/dependency-resolver/manifest/update-dependency-version.ts b/scopes/dependencies/dependency-resolver/manifest/update-dependency-version.ts index cf21ddd14684..f2429559fea2 100644 --- a/scopes/dependencies/dependency-resolver/manifest/update-dependency-version.ts +++ b/scopes/dependencies/dependency-resolver/manifest/update-dependency-version.ts @@ -1,5 +1,5 @@ import { snapToSemver } from '@teambit/component-package-version'; -import { PLUS_VERSIONS } from '../plus-version'; +import { isPlusVersion } from '../plus-version'; import type { Dependency } from '../dependencies'; import type { VariantPolicy, WorkspacePolicy } from '../policy'; @@ -33,9 +33,11 @@ export function updateDependencyVersion( if (dependency.getPackageName) { const packageName = dependency.getPackageName(); const variantVersion = variantPolicy?.getDepVersion(packageName, dependency.lifecycle); - const specialChars: string[] = ['-', '*', ...PLUS_VERSIONS]; + const specialChars = ['-', '*']; const variantVersionWithoutSpecialChar = - variantVersion && !specialChars.includes(variantVersion) ? variantVersion : undefined; + variantVersion && !specialChars.includes(variantVersion) && !isPlusVersion(variantVersion) + ? variantVersion + : undefined; const version = variantVersionWithoutSpecialChar || rootPolicy?.getValidSemverDepVersion(packageName, dependency.lifecycle === 'peer' ? 'peer' : 'runtime') || diff --git a/scopes/dependencies/dependency-resolver/manifest/workspace-manifest-factory.ts b/scopes/dependencies/dependency-resolver/manifest/workspace-manifest-factory.ts index 5306be8d2599..91da47957690 100644 --- a/scopes/dependencies/dependency-resolver/manifest/workspace-manifest-factory.ts +++ b/scopes/dependencies/dependency-resolver/manifest/workspace-manifest-factory.ts @@ -560,7 +560,7 @@ export class WorkspaceManifestFactory { const found = currentDeps.findByPkgNameOrCompId(name); // If not found, use '*' as fallback // Use snapToSemver to convert raw hash versions to valid semver format (0.0.0-{hash}) - return [name, found?.version ? resolvePlusVersion(version, snapToSemver(found.version)) : '*']; + return [name, resolvePlusVersion(version, found?.version && snapToSemver(found.version))]; }); return fromPairs(resolved); } diff --git a/scopes/dependencies/dependency-resolver/manifest/workspace-manifest.ts b/scopes/dependencies/dependency-resolver/manifest/workspace-manifest.ts index 34d38b19c23a..05ffc3633c69 100644 --- a/scopes/dependencies/dependency-resolver/manifest/workspace-manifest.ts +++ b/scopes/dependencies/dependency-resolver/manifest/workspace-manifest.ts @@ -70,7 +70,7 @@ export class WorkspaceManifest extends Manifest { this.dependencies.devDependencies?.[pkgName] || this.dependencies.peerDependencies?.[pkgName]; // Fallback to '*' if we can't resolve the version - acc[pkgName] = foundVersion ? resolvePlusVersion(version, foundVersion) : '*'; + acc[pkgName] = resolvePlusVersion(version, foundVersion); return acc; }, {}); } diff --git a/scopes/dependencies/dependency-resolver/plus-version.spec.ts b/scopes/dependencies/dependency-resolver/plus-version.spec.ts index f3152ee9768b..9bd789238601 100644 --- a/scopes/dependencies/dependency-resolver/plus-version.spec.ts +++ b/scopes/dependencies/dependency-resolver/plus-version.spec.ts @@ -39,6 +39,11 @@ describe('resolvePlusVersion()', () => { expect(resolvePlusVersion('+~', `0.0.0-${snap}`)).to.equal(`0.0.0-${snap}`); expect(resolvePlusVersion('+', snap)).to.equal(snap); }); + it('should return the fallback when the version was not resolved', () => { + expect(resolvePlusVersion('+^', undefined)).to.equal('*'); + expect(resolvePlusVersion('+', undefined, '+')).to.equal('+'); + expect(resolvePlusVersion('1.0.0', undefined)).to.equal('1.0.0'); + }); it('should not add a prefix to a resolved value that is already a range', () => { expect(resolvePlusVersion('+^', '^2.0.0')).to.equal('^2.0.0'); expect(resolvePlusVersion('+^', '*')).to.equal('*'); diff --git a/scopes/dependencies/dependency-resolver/plus-version.ts b/scopes/dependencies/dependency-resolver/plus-version.ts index 7c7ec0ff0079..13ad598013b7 100644 --- a/scopes/dependencies/dependency-resolver/plus-version.ts +++ b/scopes/dependencies/dependency-resolver/plus-version.ts @@ -1,6 +1,6 @@ -import semver from 'semver'; -import { isHash } from '@teambit/component-version'; +import { isTag } from '@teambit/component-version'; import { SNAP_VERSION_PREFIX } from '@teambit/component-package-version'; +import { PLUS_VERSIONS } from '@teambit/legacy.constants'; /** * a "plus" version is a placeholder for "the version this dependency resolves to in the workspace" instead of a @@ -10,28 +10,19 @@ import { SNAP_VERSION_PREFIX } from '@teambit/component-package-version'; * - `+^` / `+~`: the resolved version with a range prefix. e.g. `^2.0.21`. * a prefix is meaningless for a snap, so on a lane, the snap is used as is. */ -export const PLUS_VERSIONS = ['+', '+^', '+~'] as const; - -export type PlusVersion = (typeof PLUS_VERSIONS)[number]; - -export function isPlusVersion(version?: string): version is PlusVersion { - return Boolean(version && (PLUS_VERSIONS as readonly string[]).includes(version)); +export function isPlusVersion(version?: string): boolean { + return Boolean(version && PLUS_VERSIONS.includes(version)); } /** * replace a plus-version placeholder with the version it resolved to. a non-plus version is returned as is. * the resolved version can be a tag (`2.0.21`), a snap (raw hash or its `0.0.0-` semver form) or a range * (e.g. when it's taken from workspace.jsonc). the prefix is added only to a tag. + * when the version couldn't be resolved, the fallback is returned. */ -export function resolvePlusVersion(version: string, resolvedVersion: string): string { +export function resolvePlusVersion(version: string, resolvedVersion: string | undefined, fallback = '*'): string { if (!isPlusVersion(version)) return version; - const prefix = version.slice(1); - if (!prefix || !isTagVersion(resolvedVersion)) return resolvedVersion; - return `${prefix}${resolvedVersion}`; -} - -function isTagVersion(version: string): boolean { - if (isHash(version)) return false; - if (!semver.valid(version)) return false; - return !version.startsWith(SNAP_VERSION_PREFIX); + if (!resolvedVersion) return fallback; + const isRelease = isTag(resolvedVersion) && !resolvedVersion.startsWith(SNAP_VERSION_PREFIX); + return isRelease ? `${version.slice(1)}${resolvedVersion}` : resolvedVersion; } diff --git a/scopes/scope/objects/models/model-component.ts b/scopes/scope/objects/models/model-component.ts index 9dbac7d7947c..cb287b5f2dab 100644 --- a/scopes/scope/objects/models/model-component.ts +++ b/scopes/scope/objects/models/model-component.ts @@ -1227,7 +1227,7 @@ bit import ${this.id()}@${resolvedVersion} --objects --all-history` private addDepsInfoFromDepsResolver(dependencies: Dependencies, extensions: ExtensionDataList): Dependency[] { const cloned = dependencies.getClone(); - const depsResolverData = extensions.find((ext) => ext.name === 'teambit.dependencies/dependency-resolver'); + const depsResolverData = extensions.findCoreExtension(Extensions.dependencyResolver); if (!depsResolverData) return cloned; cloned.forEach((dependency) => { if (dependency.packageName) return; @@ -1248,17 +1248,21 @@ bit import ${this.id()}@${resolvedVersion} --objects --all-history` * what happens when a lane is merged and released on the remote. */ private getPeerDepsFromDepsResolver(version: Version, extensions: ExtensionDataList): Dependency[] { - const depsResolverData = extensions.find((ext) => ext.name === 'teambit.dependencies/dependency-resolver'); - const entries: Array> = depsResolverData?.data?.dependencies || []; - const nonPeerIds = version.getAllDependenciesIds(); - return entries - .filter((entry) => entry.__type === 'component' && entry.lifecycle === 'peer' && entry.componentId) + const entries: Array> = + extensions.findCoreExtension(Extensions.dependencyResolver)?.data?.dependencies || []; + const peerEntries = entries.filter( + (entry) => entry.__type === 'component' && entry.lifecycle === 'peer' && entry.componentId + ); + if (!peerEntries.length) return []; + const runtimeAndDevDeps = [...version.dependencies.get(), ...version.devDependencies.get()]; + const isRuntimeOrDevDep = (id: ComponentID) => runtimeAndDevDeps.some((dep) => dep.id.isEqualWithoutVersion(id)); + return peerEntries .map((entry) => { const id = entry.componentId instanceof ComponentID ? entry.componentId : ComponentID.fromObject(entry.componentId); return new Dependency(id, [], entry.packageName, entry.versionRange); }) - .filter((dependency) => !nonPeerIds.hasWithoutVersion(dependency.id)); + .filter((dependency) => !isRuntimeOrDevDep(dependency.id)); } // @todo: make sure it doesn't have the same ref twice, once as a version and once as a head diff --git a/scopes/workspace/install/install.main.runtime.ts b/scopes/workspace/install/install.main.runtime.ts index 2237909e2dce..fb39f4f432e7 100644 --- a/scopes/workspace/install/install.main.runtime.ts +++ b/scopes/workspace/install/install.main.runtime.ts @@ -58,7 +58,6 @@ import { DependencyResolverAspect, ComponentDependency, ensureHoistedDependencyResolution, - isPlusVersion, resolvePlusVersion, } from '@teambit/dependency-resolver'; import type { WorkspaceConfigFilesMain } from '@teambit/workspace-config-files'; @@ -1182,14 +1181,8 @@ export class InstallMain { policy.selfPolicy.entries .filter(({ force, value }) => force && value.version !== '-') .map(({ dependencyId, value }) => { - let version = value.version; // Resolve "+" version placeholders using workspace dependencies - // Similar to WorkspaceManifest._resolvePlusVersions - if (isPlusVersion(version)) { - const workspaceVersion = workspaceDeps[dependencyId]; - version = workspaceVersion ? resolvePlusVersion(version, workspaceVersion) : '*'; - } - return [dependencyId, version]; + return [dependencyId, resolvePlusVersion(value.version, workspaceDeps[dependencyId])]; }) ); } From f4963f042e74255b851ef4395fc6cbc4969607a9 Mon Sep 17 00:00:00 2001 From: David First Date: Fri, 16 Oct 2026 17:25:29 -0400 Subject: [PATCH 3/3] fix(deps): keep plus-version peers correct through bit update and scope releases --- .../isolator/isolator.main.runtime.ts | 17 ++++++-- .../prepare-package-json-to-write.spec.ts | 43 +++++++++++++++++++ .../snapping/snapping.main.runtime.ts | 6 ++- .../apply-overrides.spec.ts | 26 +++++++++++ .../dependencies-loader/apply-overrides.ts | 14 ++++-- .../dependency-resolver.main.runtime.spec.ts | 37 ++++++++++++++++ .../dependency-resolver.main.runtime.ts | 2 + .../workspace/install/install.main.runtime.ts | 4 +- 8 files changed, 140 insertions(+), 9 deletions(-) create mode 100644 scopes/component/isolator/prepare-package-json-to-write.spec.ts create mode 100644 scopes/dependencies/dependencies/dependencies-loader/apply-overrides.spec.ts diff --git a/scopes/component/isolator/isolator.main.runtime.ts b/scopes/component/isolator/isolator.main.runtime.ts index e210065dc78f..532750f34040 100644 --- a/scopes/component/isolator/isolator.main.runtime.ts +++ b/scopes/component/isolator/isolator.main.runtime.ts @@ -1541,7 +1541,10 @@ export class IsolatorMain { clonedFiles.forEach((file) => file.updatePaths({ newBase: writeToPath })); dataToPersist.removePath(new RemovePath(writeToPath)); clonedFiles.map((file) => dataToPersist.addFile(file)); - const packageJson = this.preparePackageJsonToWrite(component, writeToPath, ids); + const shouldMergePkgJsonFromLastBuild = Boolean( + populateArtifactsFromComps && !opts?.populateArtifactsIgnorePkgJson + ); + const packageJson = this.preparePackageJsonToWrite(component, writeToPath, ids, shouldMergePkgJsonFromLastBuild); if (!legacyComp.id.hasVersion()) { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion packageJson.addOrUpdateProperty('version', semver.inc(legacyComp.version!, 'prerelease') || '0.0.1-0'); @@ -1553,7 +1556,7 @@ export class IsolatorMain { await PackageJsonTransformer.applyTransformers(component, packageJson as any); const valuesToMerge = legacyComp.overrides.componentOverridesPackageJsonData; packageJson.mergePackageJsonObject(valuesToMerge); - if (populateArtifactsFromComps && !opts?.populateArtifactsIgnorePkgJson) { + if (populateArtifactsFromComps && shouldMergePkgJsonFromLastBuild) { const compParent = this.getCompForArtifacts(component, populateArtifactsFromComps); this.mergePkgJsonFromLastBuild(compParent, packageJson); } @@ -1606,10 +1609,16 @@ export class IsolatorMain { return compParent; } + /** + * @param includePeers whether to write the component peers. needed when the package.json of the last build is merged + * into this one (see mergePkgJsonFromLastBuild), which keeps only the dependencies that exist here. otherwise, a peer + * keeps the version of the last build, e.g. a lane snap of a component that was tagged since then. + */ private preparePackageJsonToWrite( component: Component, bitDir: string, - componentDepsToIgnore: ComponentIdList + componentDepsToIgnore: ComponentIdList, + includePeers = false ): PackageJsonFile { const legacyComp: ConsumerComponent = component.state._consumer; const compDeps = this.dependencyResolver.getComponentDependencies(component); @@ -1637,6 +1646,7 @@ export class IsolatorMain { const bitDependencies = getBitDependencies(legacyComp.dependencies.getAllIds()); const bitDevDependencies = getBitDependencies(legacyComp.devDependencies.getAllIds()); const bitExtensionDependencies = getBitDependencies(legacyComp.extensions.extensionsBitIds); + const bitPeerDependencies = includePeers ? getBitDependencies(legacyComp.peerDependencies.getAllIds()) : {}; const packageJson = PackageJsonFile.createFromComponent(bitDir, legacyComp); const main = pathNormalizeToLinux(legacyComp.mainFile); packageJson.addOrUpdateProperty('main', main); @@ -1646,6 +1656,7 @@ export class IsolatorMain { ...bitDevDependencies, ...bitExtensionDependencies, }); + packageJsonFile.addPeerDependencies(bitPeerDependencies); }; addDependencies(packageJson); const currentVersion = getComponentPackageVersion(component); diff --git a/scopes/component/isolator/prepare-package-json-to-write.spec.ts b/scopes/component/isolator/prepare-package-json-to-write.spec.ts new file mode 100644 index 000000000000..0f02dc749baf --- /dev/null +++ b/scopes/component/isolator/prepare-package-json-to-write.spec.ts @@ -0,0 +1,43 @@ +import { expect } from 'chai'; +import { ComponentID, ComponentIdList } from '@teambit/component-id'; +import { ExtensionDataList } from '@teambit/legacy.extension-data'; +import { IsolatorMain } from './isolator.main.runtime'; + +const depId = ComponentID.fromString('my-scope/dep@0.0.2'); + +function preparePackageJson(componentDepsToIgnore: ComponentIdList, includePeers: boolean) { + const isolator = Object.create(IsolatorMain.prototype); + isolator.logger = { debug: () => {} }; + isolator.dependencyResolver = { getComponentDependencies: () => [{ componentId: depId, versionRange: '+^' }] }; + const noDeps = { getAllIds: () => new ComponentIdList() }; + const legacyComp = { + id: ComponentID.fromString('my-scope/user@0.0.2'), + version: '0.0.2', + mainFile: 'index.js', + _getHomepage: () => undefined, + packageDependencies: {}, + devPackageDependencies: {}, + peerPackageDependencies: {}, + extensions: new ExtensionDataList(), + dependencies: noDeps, + devDependencies: noDeps, + peerDependencies: { getAllIds: () => ComponentIdList.fromArray([depId]) }, + }; + const component = { id: legacyComp.id, state: { _consumer: legacyComp } }; + return isolator.preparePackageJsonToWrite(component, '.', componentDepsToIgnore, includePeers).packageJsonObject; +} + +describe('IsolatorMain.preparePackageJsonToWrite()', () => { + it('should write the component peers with their resolved version when asked to', () => { + const packageJson = preparePackageJson(new ComponentIdList(), true); + expect(Object.values(packageJson.peerDependencies)).to.deep.equal(['^0.0.2']); + }); + it('should not write the component peers by default', () => { + const packageJson = preparePackageJson(new ComponentIdList(), false); + expect(packageJson.peerDependencies).to.deep.equal({}); + }); + it('should not write a component peer that is part of the capsules', () => { + const packageJson = preparePackageJson(ComponentIdList.fromArray([depId]), true); + expect(packageJson.peerDependencies).to.deep.equal({}); + }); +}); diff --git a/scopes/component/snapping/snapping.main.runtime.ts b/scopes/component/snapping/snapping.main.runtime.ts index 43507ef59e1a..fb5163271079 100644 --- a/scopes/component/snapping/snapping.main.runtime.ts +++ b/scopes/component/snapping/snapping.main.runtime.ts @@ -1408,7 +1408,11 @@ another option, in case this dependency is not in main yet is to remove all refe const updatedIds = ComponentIdList.fromArray([...currentBitIds, ...dependencies]); const componentIdStr = component.id.toString(); const legacyComponent: ConsumerComponent = component.state._consumer; - const deps = [...legacyComponent.dependencies.get(), ...legacyComponent.devDependencies.get()]; + const deps = [ + ...legacyComponent.dependencies.get(), + ...legacyComponent.devDependencies.get(), + ...legacyComponent.peerDependencies.get(), + ]; const dependenciesList = this.dependencyResolver.getDependencies(component); deps.forEach((dep) => { const updatedBitId = updatedIds.searchWithoutVersion(dep.id); diff --git a/scopes/dependencies/dependencies/dependencies-loader/apply-overrides.spec.ts b/scopes/dependencies/dependencies/dependencies-loader/apply-overrides.spec.ts new file mode 100644 index 000000000000..e69d7e58d2d9 --- /dev/null +++ b/scopes/dependencies/dependencies/dependencies-loader/apply-overrides.spec.ts @@ -0,0 +1,26 @@ +import { expect } from 'chai'; +import { ApplyOverrides } from './apply-overrides'; + +function createApplyOverrides({ installed, inWorkspacePolicy }: { installed?: string; inWorkspacePolicy?: string }) { + const applyOverrides = Object.create(ApplyOverrides.prototype); + applyOverrides.depsResolver = { + getWorkspacePolicyManifest: () => ({ dependencies: inWorkspacePolicy ? { 'my-pkg': inWorkspacePolicy } : {} }), + }; + applyOverrides._resolvePackageData = () => (installed ? { concreteVersion: installed } : undefined); + return applyOverrides; +} + +describe('ApplyOverrides.resolveEnvPeerDepVersion()', () => { + it('should prefer the installed version by default', () => { + const applyOverrides = createApplyOverrides({ installed: '2.0.0', inWorkspacePolicy: '3.0.1' }); + expect(applyOverrides.resolveEnvPeerDepVersion('my-pkg')).to.equal('2.0.0'); + }); + it('should prefer the workspace policy when asked to, as the installed version could be outdated', () => { + const applyOverrides = createApplyOverrides({ installed: '2.0.0', inWorkspacePolicy: '3.0.1' }); + expect(applyOverrides.resolveEnvPeerDepVersion('my-pkg', true)).to.equal('3.0.1'); + }); + it('should fall back to the installed version when the workspace policy does not have the package', () => { + const applyOverrides = createApplyOverrides({ installed: '2.0.0' }); + expect(applyOverrides.resolveEnvPeerDepVersion('my-pkg', true)).to.equal('2.0.0'); + }); +}); diff --git a/scopes/dependencies/dependencies/dependencies-loader/apply-overrides.ts b/scopes/dependencies/dependencies/dependencies-loader/apply-overrides.ts index 9dd2592833a1..40b7819bf192 100644 --- a/scopes/dependencies/dependencies/dependencies-loader/apply-overrides.ts +++ b/scopes/dependencies/dependencies/dependencies-loader/apply-overrides.ts @@ -720,7 +720,7 @@ export class ApplyOverrides { // a package has no separate version and range, so a "+" placeholder is resolved here. // a component dep keeps it in the versionRange, and it's resolved when generating the package.json this.allPackagesDependencies[key][pkgName] = isPlusVersion(pkgVal) - ? resolvePlusVersion(pkgVal, this.resolveEnvPeerDepVersion(pkgName)) + ? resolvePlusVersion(pkgVal, this.resolveEnvPeerDepVersion(pkgName, true)) : pkgVal; } @@ -777,14 +777,20 @@ export class ApplyOverrides { * strategy should be: .bitmap, workspace.jsonc, then model. * it's not in .bitmap, otherwise, it was linked and `_resolvePackageData` would have found it. * so either, it's in the workspace.jsonc or in the model. + * @param preferWorkspacePolicy check the workspace.jsonc before what's installed. needed for a package that the + * component gets with a plus version. what's installed for the component was installed according to the version it + * had before, so it doesn't follow an upgrade in the workspace.jsonc (e.g. by "bit update"). */ - private resolveEnvPeerDepVersion(pkgName: string): string { + private resolveEnvPeerDepVersion(pkgName: string, preferWorkspacePolicy = false): string { + const wsPolicy = this.depsResolver.getWorkspacePolicyManifest(); + const wsVersion = wsPolicy?.dependencies?.[pkgName] || wsPolicy?.peerDependencies?.[pkgName]; + if (preferWorkspacePolicy && wsVersion) { + return wsVersion; + } const resolved = this._resolvePackageData(pkgName); if (resolved && resolved.concreteVersion) { return resolved.concreteVersion; } - const wsPolicy = this.depsResolver.getWorkspacePolicyManifest(); - const wsVersion = wsPolicy?.dependencies?.[pkgName] || wsPolicy?.peerDependencies?.[pkgName]; if (wsVersion) { return wsVersion; } diff --git a/scopes/dependencies/dependency-resolver/dependency-resolver.main.runtime.spec.ts b/scopes/dependencies/dependency-resolver/dependency-resolver.main.runtime.spec.ts index 633434264fbf..b14ec01280dd 100644 --- a/scopes/dependencies/dependency-resolver/dependency-resolver.main.runtime.spec.ts +++ b/scopes/dependencies/dependency-resolver/dependency-resolver.main.runtime.spec.ts @@ -616,3 +616,40 @@ describe('DepenendencyResolverMain.getComponentEnvPolicyFromEnv()', () => { } }); }); + +describe('DepenendencyResolverMain.getEnvJsoncPolicyPkgs()', () => { + it('should not return env.jsonc entries with a plus version, as they follow the workspace version', () => { + const depResolver = new DependencyResolverMain( + {} as any, + {} as any, + {} as any, + { isEnv: () => true } as any, + logger as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any + ); + const envJsonc = { + policy: { + peers: [ + { name: 'plus-pkg', version: '+', supportedRange: '+^' }, + { name: 'plus-caret-pkg', version: '+^', supportedRange: '+^' }, + { name: 'fixed-pkg', version: '1.0.0', supportedRange: '^1.0.0' }, + ], + runtime: [{ name: 'plus-runtime-pkg', version: '+~' }], + }, + }; + const component = { + id: ComponentID.fromString('scope/my-env'), + filesystem: { files: [{ relative: 'env.jsonc', contents: Buffer.from(JSON.stringify(envJsonc)) }] }, + }; + const pkgs = depResolver.getEnvJsoncPolicyPkgs([component as any]); + expect(pkgs.map((pkg) => pkg.name)).to.deep.equal(['fixed-pkg']); + }); +}); diff --git a/scopes/dependencies/dependency-resolver/dependency-resolver.main.runtime.ts b/scopes/dependencies/dependency-resolver/dependency-resolver.main.runtime.ts index 4fb7ccea7d0d..3479c2e8dafe 100644 --- a/scopes/dependencies/dependency-resolver/dependency-resolver.main.runtime.ts +++ b/scopes/dependencies/dependency-resolver/dependency-resolver.main.runtime.ts @@ -1720,6 +1720,8 @@ as an alternative, you can use "+" to keep the same version installed in the wor for (const { field, targetField } of policies) { const deps: EnvJsoncPolicyEntry[] = envJsonc.policy?.[field] || []; for (const dep of deps) { + // a plus version follows the version resolved in the workspace, so there is nothing to update. + if (isPlusVersion(dep.version)) continue; pkgs.push({ name: dep.name, currentRange: dep.version, diff --git a/scopes/workspace/install/install.main.runtime.ts b/scopes/workspace/install/install.main.runtime.ts index fb39f4f432e7..b2ef5cf6df39 100644 --- a/scopes/workspace/install/install.main.runtime.ts +++ b/scopes/workspace/install/install.main.runtime.ts @@ -58,6 +58,7 @@ import { DependencyResolverAspect, ComponentDependency, ensureHoistedDependencyResolution, + isPlusVersion, resolvePlusVersion, } from '@teambit/dependency-resolver'; import type { WorkspaceConfigFilesMain } from '@teambit/workspace-config-files'; @@ -1314,7 +1315,8 @@ export class InstallMain { const depEntry = deps.find(({ name }) => name === pkg.name); if (depEntry) { depEntry.version = pkg.latestRange; - if (field === 'peers' && depEntry.supportedRange) { + // a plus supportedRange follows the version, so it doesn't need to be widened. + if (field === 'peers' && depEntry.supportedRange && !isPlusVersion(depEntry.supportedRange)) { if (!semver.intersects(pkg.latestRange, depEntry.supportedRange)) { depEntry.supportedRange = `${depEntry.supportedRange} || ${pkg.latestRange}`; }