Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions components/legacy/constants/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
4 changes: 2 additions & 2 deletions components/legacy/consumer-config/component-overrides.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -143,7 +143,7 @@ export class ComponentOverrides {
}, {});
}
_isValidVersion(ver: string) {
return ver !== 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) => {
Expand Down
2 changes: 2 additions & 0 deletions components/legacy/e2e-helper/e2e-snapping-helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ export default class SnappingHelper {
updateDependents?: boolean;
push?: boolean;
message?: string;
tag?: boolean;
skipLoadingAspects?: boolean;
} = {}
): Promise<void> {
const runnerPath = path.resolve(__dirname, 'snap-from-scope-runner.js');
Expand Down
4 changes: 4 additions & 0 deletions components/legacy/e2e-helper/snap-from-scope-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
const [, , scopePath, snapDataJson, optionsJson] = process.argv;
Expand All @@ -24,6 +25,9 @@ async function main(): Promise<void> {
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,
});
Expand Down
93 changes: 93 additions & 0 deletions e2e/harmony/lanes/lane-env-jsonc-dep.e2e.ts
Original file line number Diff line number Diff line change
@@ -1,4 +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';

/**
Expand Down Expand Up @@ -129,4 +132,94 @@ 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<string, any>[];
const getComp2DepData = (deps: Record<string, any>[]) => 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', () => {
expect(getComp2DepData(comp1DepsOnLane)).to.include({
lifecycle: 'peer',
version: comp2LaneSnap,
versionRange: '+^',
});
});
describe('after the release', () => {
// 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 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(() => {
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((capsule: string) => capsule.includes('comp1')) as string;
const pkgJson = fs.readJsonSync(path.join(comp1Capsule, 'package.json'));
expect(pkgJson.peerDependencies[comp2PkgName]).to.equal(`^${comp2ReleasedVersion}`);
});
});
});
});
});
28 changes: 21 additions & 7 deletions scopes/component/isolator/isolator.main.runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -1501,8 +1501,9 @@ 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;
manifest[keyName][entry.packageName] = dep.versionRange
? resolvePlusVersion(dep.versionRange, version)
: version;
}
});
await Promise.all(promises);
Expand Down Expand Up @@ -1540,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');
Expand All @@ -1552,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);
}
Expand Down Expand Up @@ -1605,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);
Expand All @@ -1627,13 +1637,16 @@ export class IsolatorMain {
id: depId,
isDependency: true,
});
acc[packageName] = versionWithRange || packageDependency;
acc[packageName] = versionWithRange
? resolvePlusVersion(versionWithRange, packageDependency, versionWithRange)
: packageDependency;
return acc;
}, {});
};
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);
Expand All @@ -1643,6 +1656,7 @@ export class IsolatorMain {
...bitDevDependencies,
...bitExtensionDependencies,
});
packageJsonFile.addPeerDependencies(bitPeerDependencies);
};
addDependencies(packageJson);
const currentVersion = getComponentPackageVersion(component);
Expand Down
43 changes: 43 additions & 0 deletions scopes/component/isolator/prepare-package-json-to-write.spec.ts
Original file line number Diff line number Diff line change
@@ -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({});
});
});
6 changes: 5 additions & 1 deletion scopes/component/snapping/snapping.main.runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

1. Hidden peer updates fail with an error 🐞 Bug ≡ Correctness

updateDependenciesVersionsOfComponent() now iterates legacy peer dependencies, but its
package-name lookup uses getDependencies(component), which excludes hidden dependencies by
default. When an updated component ID matches a hidden peer, the lookup returns no dependency and
the method throws instead of updating it.
Agent Prompt
## Issue description
Updating the ID of a hidden component peer now throws because the peer is in the legacy list but absent from the default dependency-resolver lookup.

## Fix Focus Areas
- scopes/component/snapping/snapping.main.runtime.ts[1411-1425]

## Recommended Fix
Request hidden dependencies when building the lookup used to update legacy dependency IDs, and retain the existing package-name validation.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

];
const dependenciesList = this.dependencyResolver.getDependencies(component);
deps.forEach((dep) => {
const updatedBitId = updatedIds.searchWithoutVersion(dep.id);
Expand Down
Original file line number Diff line number Diff line change
@@ -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');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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, true))
: pkgVal;
Comment on lines +722 to +724

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

2. A tilde peer setting turns into a caret range 🐞 Bug ≡ Correctness

applyPackageVersion() now calls resolveEnvPeerDepVersion(pkgName, true), which returns the
workspace.jsonc value first, and that value is usually a range such as ^18.0.0.
resolvePlusVersion() adds the ^/~ prefix only to a plain tag and returns a range unchanged, so
+~ (and also the "exact" +) becomes the workspace's ^18.0.0 instead of ~18.2.0 or 18.2.0.
The component's saved package peer and its released package.json then get a different range than the
one env.jsonc asked for.
Agent Prompt
## Issue description
For a package peer, `+~` and `+` take the workspace.jsonc range as is (for example `^18.0.0`), so the `~` prefix or the exact version that env.jsonc asked for is lost.

## Fix Focus Areas
- scopes/dependencies/dependencies/dependencies-loader/apply-overrides.ts[722-724]
- scopes/dependencies/dependencies/dependencies-loader/apply-overrides.ts[784-795]
- scopes/dependencies/dependency-resolver/plus-version.ts[23-28]

## Recommended Fix
When the resolved value is a range and the placeholder is `+~` or `+`, reduce it to a concrete version before adding the prefix. For example, use `semver.minVersion(range)`, or use the installed concrete version that satisfies the workspace range. Only fall back to the raw range when no concrete version is found. Add unit tests for `+~` with a workspace.jsonc `^` range.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

}

if (existsInCompsPeerDeps) {
Expand Down Expand Up @@ -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));
Comment thread
qodo-free-for-open-source-projects[bot] marked this conversation as resolved.
return acc;
}, {});
// TODO: handle component deps once we support peers between components
Expand All @@ -765,21 +770,27 @@ 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.
* 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;
}
Expand Down
Loading
Loading