Skip to content
Merged
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
8 changes: 8 additions & 0 deletions .bitmap
Original file line number Diff line number Diff line change
Expand Up @@ -884,6 +884,14 @@
"mainFile": "index.ts",
"rootDir": "scopes/defender/jest"
},
"json/jsonc-utils": {
"name": "json/jsonc-utils",
"scope": "",
"version": "",
"defaultScope": "teambit.toolbox",
"mainFile": "index.ts",
"rootDir": "scopes/toolbox/json/jsonc-utils"
},
"lanes": {
"name": "lanes",
"scope": "teambit.lanes",
Expand Down
67 changes: 67 additions & 0 deletions e2e/harmony/dependencies/env-jsonc-policies.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -498,4 +498,71 @@ describe('env-jsonc-policies', function () {
});
});
});
describe('bit update', function () {
let envId;
before(() => {
helper = new Helper();
helper.scopeHelper.setWorkspaceWithRemoteScope();
envId = 'react-based-env';
helper.env.setCustomNewEnv(undefined, undefined, {
policy: {
runtime: [
{
name: 'is-string',
version: '1.0.5',
force: true,
},
],
},
}, false, envId);
helper.command.install();
});
after(() => {
helper.scopeHelper.destroy();
});

it('should update env.jsonc dependency to latest version', () => {
const envJsoncPath = path.join(helper.scopes.localPath, envId, 'env.jsonc');
const originalEnvJsonc = fs.readJsonSync(envJsoncPath);
expect(originalEnvJsonc.policy.runtime[0].version).to.equal('1.0.5');

helper.command.update('is-string --yes');

const updatedEnvJsonc = fs.readJsonSync(envJsoncPath);
expect(updatedEnvJsonc.policy.runtime[0].version).to.not.equal('1.0.5');
});

it('should update supportedRange for peerDependencies when new version is outside existing range', () => {
const envId2 = 'react-based-env-peers';
helper.env.setCustomNewEnv(undefined, undefined, {
policy: {
peers: [
{
name: 'react',
version: '16.8.0',
supportedRange: '^16.8.0',
},
],
},
}, false, envId2);
helper.command.install();

const envJsoncPath = path.join(helper.scopes.localPath, envId2, 'env.jsonc');
const originalEnvJsonc = fs.readJsonSync(envJsoncPath);
expect(originalEnvJsonc.policy.peers[0].supportedRange).to.equal('^16.8.0');

// Update react to latest (which is > 16.8.0, likely 18.x)
helper.command.update('react --yes');

Comment on lines +535 to +556
Copy link

Copilot AI Apr 5, 2026

Choose a reason for hiding this comment

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

This e2e uses react as the package to update. React installs are relatively heavy and can make CI slower/flakier compared to the smaller packages used elsewhere in the update tests (e.g. is-odd/is-string). Consider switching to a lightweight package with a known non-intersecting semver jump to validate the supportedRange behavior without significantly increasing install time.

Copilot uses AI. Check for mistakes.
const updatedEnvJsonc = fs.readJsonSync(envJsoncPath);
const newVersion = updatedEnvJsonc.policy.peers[0].version;
const newSupportedRange = updatedEnvJsonc.policy.peers[0].supportedRange;

expect(newVersion).to.not.equal('16.8.0');
// Should now contain the old range OR the new range/version
expect(newSupportedRange).to.include(' || ');
expect(newSupportedRange).to.include('^16.8.0');
expect(newSupportedRange).to.include(newVersion);
Comment thread
zkochan marked this conversation as resolved.
});
Comment thread
zkochan marked this conversation as resolved.
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

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

The new bit update e2e coverage validates updating explicit env.jsonc versions, but it doesn’t cover the existing env.jsonc special case where a peer entry has version: "+" (resolved via supportedRange). With the new update flow, this case is at risk of being rewritten to an explicit version/range.

Add an e2e assertion that running bit update does not overwrite version: "+" entries (and define the expected behavior for updating supportedRange, if applicable).

Suggested change
});
});
it('should not overwrite peer version "+" and should update supportedRange to include the new version', () => {
const envId3 = 'react-based-env-peers-plus';
helper.env.setCustomNewEnv(undefined, undefined, {
policy: {
peers: [
{
name: 'react',
version: '+',
supportedRange: '^16.8.0',
},
],
},
}, false, envId3);
helper.command.install();
const envJsoncPath = path.join(helper.scopes.localPath, envId3, 'env.jsonc');
const originalEnvJsonc = fs.readJsonSync(envJsoncPath);
expect(originalEnvJsonc.policy.peers[0].version).to.equal('+');
expect(originalEnvJsonc.policy.peers[0].supportedRange).to.equal('^16.8.0');
// Update react to latest while version is resolved via "+" and supportedRange
helper.command.update('react --yes');
const updatedEnvJsonc = fs.readJsonSync(envJsoncPath);
const updatedPeer = updatedEnvJsonc.policy.peers[0];
// The special-case "+" version must be preserved
expect(updatedPeer.version).to.equal('+');
const updatedSupportedRange = updatedPeer.supportedRange;
expect(updatedSupportedRange).to.not.equal('^16.8.0');
expect(updatedSupportedRange).to.include('^16.8.0');
// The updated supportedRange should include the newly installed react version
const reactPkgJsonPath = resolveFrom(helper.scopes.localPath, 'react/package.json');
const reactPkgJson = fs.readJsonSync(reactPkgJsonPath);
const installedVersion = reactPkgJson.version;
expect(updatedSupportedRange).to.include(installedVersion);
});

Copilot uses AI. Check for mistakes.
});
Comment thread
zkochan marked this conversation as resolved.
Comment thread
zkochan marked this conversation as resolved.
});
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* eslint-disable max-lines */
import multimatch from 'multimatch';
import { isSnap } from '@teambit/component-version';
import { BitError } from '@teambit/bit-error';
Expand Down Expand Up @@ -37,7 +38,7 @@ import { Http } from '@teambit/scope.network';
import type { Dependency as LegacyDependency } from '@teambit/legacy.consumer-component';
import { ConsumerComponent as LegacyComponent } from '@teambit/legacy.consumer-component';
import fs from 'fs-extra';
import { assign } from 'comment-json';
import { assign, parse } from 'comment-json';
import { ComponentID } from '@teambit/component-id';
import { readCAFileSync } from '@pnpm/network.ca-file';
import { parseBareSpecifier } from '@pnpm/npm-resolver';
Expand Down Expand Up @@ -88,7 +89,7 @@ import { DependenciesFragment, DevDependenciesFragment, PeerDependenciesFragment
import { dependencyResolverSchema } from './dependency-resolver.graphql';
import type { DependencyDetector } from './detector-hook';
import { DependenciesService } from './dependencies.service';
import { EnvPolicy } from './policy/env-policy';
import { EnvPolicy, type EnvJsoncPolicyEntry } from './policy/env-policy';
import type { ConfigStoreMain } from '@teambit/config-store';
import { ConfigStoreAspect } from '@teambit/config-store';

Expand Down Expand Up @@ -1504,6 +1505,7 @@ as an alternative, you can use "+" to keep the same version installed in the wor
variantPoliciesByPatterns,
componentPolicies,
components,
includeEnvJsoncDeps: true,
});
if (patterns?.length) {
const selectedPkgNames = new Set(
Expand All @@ -1525,10 +1527,12 @@ as an alternative, you can use "+" to keep the same version installed in the wor
variantPoliciesByPatterns,
componentPolicies,
components,
includeEnvJsoncDeps = false,
}: {
variantPoliciesByPatterns: Record<string, VariantPolicyConfigObject>;
componentPolicies: Array<{ componentId: ComponentID; policy: any }>;
components: Component[];
includeEnvJsoncDeps?: boolean;
}): CurrentPkg[] {
const localComponentPkgNames = new Set(components.map((component) => this.getPackageName(component)));
const componentModelVersions: ComponentModelVersion[] = components
Expand All @@ -1553,12 +1557,55 @@ as an alternative, you can use "+" to keep the same version installed in the wor
}));
})
.flat();
return getAllPolicyPkgs({
rootPolicy: this.getWorkspacePolicyFromConfig(),
variantPoliciesByPatterns,
componentPolicies,
componentModelVersions,
});
return [
...getAllPolicyPkgs({
rootPolicy: this.getWorkspacePolicyFromConfig(),
variantPoliciesByPatterns,
componentPolicies,
componentModelVersions,
}),
...(includeEnvJsoncDeps ? this.getEnvJsoncPolicyPkgs(components) : []),
];
}

Comment thread
zkochan marked this conversation as resolved.
getEnvJsoncPolicyPkgs(components: Component[]): CurrentPkg[] {
const policies = [
{ field: 'peers', targetField: 'peerDependencies' as const },
{ field: 'dev', targetField: 'devDependencies' as const },
{ field: 'runtime', targetField: 'dependencies' as const },
];
const pkgs: CurrentPkg[] = [];
for (const component of components) {
const isEnv = this.envs.isEnv(component);
if (!isEnv) continue;

const envJsoncFile = component.filesystem.files.find((file) => file.relative === 'env.jsonc');
if (!envJsoncFile) continue;

let envJsonc: EnvJsonc;
try {
envJsonc = parse(envJsoncFile.contents.toString()) as EnvJsonc;
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
this.logger.warn(`Failed to parse env.jsonc for component ${component.id.toString()}: ${errorMessage}`);
continue;
}
if (!envJsonc.policy) continue;

for (const { field, targetField } of policies) {
const deps: EnvJsoncPolicyEntry[] = envJsonc.policy?.[field] || [];
for (const dep of deps) {
pkgs.push({
name: dep.name,
currentRange: dep.version,
source: 'env-jsonc',
componentId: component.id,
targetField,
});
Comment on lines +1595 to +1604
Copy link

Copilot AI Apr 5, 2026

Choose a reason for hiding this comment

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

getEnvJsoncPolicyPkgs() currently assumes envJsonc.policy[field] is an array and iterates it directly. If an env.jsonc has policy.<field> set to a non-array (or contains special versions like "+", "-", or "*" which are valid in env.jsonc), this flow can either throw at runtime (non-array) or later break the update UI/logic (semverDiff/semver.valid expect semver-ish ranges) and even cause bit update to overwrite the special placeholder semantics. Consider guarding with Array.isArray() and skipping (or resolving) entries whose version is one of the special placeholders before pushing them into CurrentPkg[].

Copilot uses AI. Check for mistakes.
}
Comment on lines +1571 to +1605
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

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

getEnvJsoncPolicyPkgs() returns currentRange: dep.version for env.jsonc policy entries. When an env.jsonc peer entry uses the special version "+" (supported elsewhere in the codebase/tests), bit update will treat it as an outdated range and then updateEnvJsoncPolicies() will overwrite the "+" with an explicit version/range, changing the meaning of the policy.

Consider either skipping env.jsonc entries whose version is "+"/"-" from outdated detection, or resolving them to an actual semver range (e.g. for peers: use supportedRange) and, on update, keep version: "+" while updating the appropriate range field.

Copilot uses AI. Check for mistakes.
}
}
return pkgs;
}

getAllDedupedDirectDependencies(opts: {
Expand Down Expand Up @@ -1629,9 +1676,7 @@ as an alternative, you can use "+" to keep the same version installed in the wor
rootDir: string;
forceVersionBump?: 'major' | 'minor' | 'patch' | 'compatible';
},
pkgs: Array<
{ name: string; currentRange: string; source: 'variants' | 'component' | 'rootPolicy' | 'component-model' } & T
>
pkgs: Array<{ name: string; currentRange: string; source: CurrentPkgSource; } & T>
): Promise<Array<{ name: string; currentRange: string; latestRange: string } & T>> {
this.logger.setStatusLine('checking the latest versions of dependencies');
const resolver = await this.getVersionResolver();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ export type CurrentPkgSource =
// these are dependencies from the dependencies policy in "workspace.jsonc"
| 'rootPolicy'
// these are dependencies stored in the component object (snapped/tagged version)
| 'component-model';
| 'component-model'
// these are dependencies defined in env.jsonc files
| 'env-jsonc';

export type CurrentPkg = {
name: string;
Expand Down
2 changes: 1 addition & 1 deletion scopes/dependencies/dependency-resolver/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export type {
SemverVersion,
DependenciesManifest,
} from './dependencies';
export { WorkspacePolicy, VariantPolicy, EnvPolicy } from './policy';
export { WorkspacePolicy, VariantPolicy, EnvPolicy, EnvPolicyEnvJsoncConfigObject } from './policy';
export type {
WorkspacePolicyEntry,
WorkspacePolicyConfigObject,
Expand Down
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export { EnvPolicy, EnvPolicyConfigObject } from './env-policy';
export { EnvPolicy, EnvPolicyConfigObject, EnvJsoncPolicyEntry, EnvPolicyEnvJsoncConfigObject } from './env-policy';
2 changes: 1 addition & 1 deletion scopes/dependencies/dependency-resolver/policy/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,4 @@ export {
VariantPolicyConfigArr,
} from './variant-policy';

export { EnvPolicy, EnvPolicyConfigObject } from './env-policy';
export { EnvPolicy, EnvPolicyConfigObject, EnvPolicyEnvJsoncConfigObject } from './env-policy';
7 changes: 7 additions & 0 deletions scopes/toolbox/json/jsonc-utils/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export {
detectJsoncFormatting,
parseJsoncWithFormatting,
stringifyJsonc,
updateJsoncPreservingFormatting,
type JsoncFormatting,
} from './jsonc-utils';
91 changes: 91 additions & 0 deletions scopes/toolbox/json/jsonc-utils/jsonc-utils.docs.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
---
labels: ['typescript', 'utils', 'json', 'jsonc', 'formatting', 'comments', 'parser']
description: 'Utilities for parsing and stringifying JSONC files while preserving formatting and comments.'
---

# JSONC Utils

Utilities for working with JSONC (JSON with Comments) files while preserving their original formatting, including:
- **Indentation style** (2 spaces, 4 spaces, tabs)
- **Newline characters** (LF, CRLF)
- **Comments**

This is particularly useful when you need to programmatically update configuration files without losing their human-readable formatting.

## Installation

```bash
bit install @teambit/toolbox.json.jsonc-utils
```

## Quick Start

The easiest way to update a JSONC file while preserving its formatting:

```ts
import { updateJsoncPreservingFormatting } from '@teambit/toolbox.json.jsonc-utils';

const originalContent = `{
// This is a comment
"name": "my-package",
"version": "1.0.0"
}`;

const updatedContent = updateJsoncPreservingFormatting(originalContent, (data) => {
data.version = "2.0.0";
return data;
});

// Output preserves comments and formatting:
// {
// // This is a comment
// "name": "my-package",
// "version": "2.0.0"
// }
```

## Use Cases

### Updating env.jsonc Files

When updating component environment configurations, preserve the original formatting:

```ts
import fs from 'fs-extra';
import { updateJsoncPreservingFormatting } from '@teambit/toolbox.json.jsonc-utils';

async function updateEnvDependency(
envJsoncPath: string,
pkgName: string,
newVersion: string
) {
const content = await fs.readFile(envJsoncPath, 'utf-8');

const updated = updateJsoncPreservingFormatting(content, (envJsonc) => {
const dep = envJsonc.policy?.runtime?.find((d) => d.name === pkgName);
if (dep) {
dep.version = newVersion;
}
return envJsonc;
});

await fs.writeFile(envJsoncPath, updated);
}
```

### Working with Different Indentation Styles

Handle files with different formatting preferences:

```ts
import { detectJsoncFormatting, stringifyJsonc } from '@teambit/toolbox.json.jsonc-utils';

// Detect existing formatting
const formatting = detectJsoncFormatting(originalContent);

// Modify data
const updatedData = { ...parsedData, version: '2.0.0' };

// Stringify with original formatting
const result = stringifyJsonc(updatedData, formatting);
```
Loading