Skip to content

feat(envs): remove core envs from the manifest and their sources from the workspace - #10465

Draft
davidfirst wants to merge 239 commits into
masterfrom
remove-core-envs-from-manifest
Draft

feat(envs): remove core envs from the manifest and their sources from the workspace#10465
davidfirst wants to merge 239 commits into
masterfrom
remove-core-envs-from-manifest

Conversation

@davidfirst

@davidfirst davidfirst commented Jul 2, 2026

Copy link
Copy Markdown
Member

Removes the env aspects (teambit.react/react, teambit.harmony/node, teambit.harmony/aspect, teambit.envs/env, teambit.mdx/mdx, teambit.mdx/readme) from the core manifest to slim Bit. They now act like any other env, installed from the registry.

New default env: teambit.harmony/empty-env (core). A totally empty env - no compiler, no tester, no preview, no dependency policy. Components with no env configured use it and work fully offline out of the box (add → compile no-op → tag/snap → export). Since it has no behavior, it has nothing to drift when bit itself changes - the one env that is safe to keep core (and versionless in models) forever. To get a dev experience, users configure a real env (bit create flows already do).

teambit.harmony/aspect and teambit.envs/env are removed like the rest, with zero behavior change. Their implementation is untouched (react-based, preview and all) - users get the exact released behavior after bit install (the pinned-version machinery auto-installs them). New envs are created from the bitdev env packages (bit create react-env etc.), so these built-in envs are legacy surface. The bit-aspect template and the harmony starters moved to the core generator aspect, so bit create bit-aspect and bit new keep working out of the box (the created aspect needs bit install before it loads, like any env).

Versionless by design. Config entries for the removed env ids are persisted by name, without a version - exactly as they were when core (registered as core-extension names). Keeping them versionless is deliberate on two counts. First, it keeps the env from becoming a dependency edge of its own components; otherwise an env such as react, whose dependency closure includes components that use it as their env, creates circular TS project references and breaks lane/tag builds. Second, it preserves forward compatibility: a re-tag under the new bit keeps the env id versionless, so a teammate who has not upgraded yet (whose bit still ships these as core) can import the re-tagged component and resolve the env - instead of receiving a versioned id their bit has no component for. The alternative (showing the component as modified and pinning the env on the next tag) would silently break not-yet-upgraded consumers.

Backward compatibility. Old components have the removed envs saved without a version. legacy-core-envs.ts maps them to pinned versions, applied only at the resolution/loading/install level - stored objects are never mutated. Versionless legacy ids match the env slot ignoring version, bit install auto-adds their packages, and single-instance semantics are enforced (a loaded version is reused rather than loading another copy). Not-installed legacy envs fail fast with a NonLoadedEnv issue suggesting bit install - no scope-capsule isolation in workspace context (which used to take minutes). Old components load without being reported as modified, and re-tagging keeps the env versionless - covered end-to-end by e2e/harmony/legacy-core-env-back-compat.e2e.ts, which imports a component exported by a pre-removal bit (env saved versionless) and asserts it is not modified and stays versionless after a re-tag.

Relocated core wiring: the bit aspect CLI command moved to teambit.workspace/workspace; validateBeforePersistHook moved to teambit.dependencies/dependency-resolver; the dead @teambit/legacy link is now skipped instead of crashing.

Also fixes latent issues this path exposed: versionless seeders filtering out all manifests in loadExtensionsByManifests, circular env chains causing infinite component-load recursion, versioned core-aspect ids escaping core filters and doRequire mutating shared core manifests, stack overflows from recursive graph traversal, and a spurious MissingDists issue for compiler-less envs.

Verified locally: fresh workspace (JS and TS components) - clean status in ~1s, tag/snap/export offline, bit envs/bit test graceful; this repo's workspace - status/insights/list-core clean; the seven repo components that relied on the default env are now explicitly set to the node env. bit create <template> --env <removed-env> loads the env's templates on demand from the global scope (pinned version); this path also loads the full manifest graph, and binds manifest deps of legacy envs to their pinned versions (models built when these envs were core don't list them as dependencies). The e2e setCustomEnv helper installs the env package the fixture imports (e.g. @teambit/node).


Also removes the former-core env sources from this repo's workspace (scopes/harmony/node, scopes/react/react, scopes/harmony/aspect, scopes/envs/env, scopes/mdx/mdx, scopes/docs/readme) - bit now dogfoods them as installed packages like any consumer, and the source-vs-installed duality is gone. Making this pass end-to-end surfaced several general fixes that ride along:

  • workspace-aspects-loader: an unresolvable dependency-env no longer aborts the whole load group (it degrades to a reported load failure for that env only), and on aspect-path collisions the dedup keeps the def matching the requested id instead of the first one seen.
  • dependency-resolver: new fallback md/mdx import detector, so .docs.mdx imports are detected even when the mdx aspect isn't loaded (latent gap once mdx is no longer core - without it, docs deps silently drop from dependency computation and preview bundling fails).
  • builder: Module._extensions require hooks are restored after each build task. An in-process tester leaves @babel/register's pirates hook installed; the hook claims all .js files (including node_modules, regardless of babel ignore config) and breaks require() of ESM-only packages in every later task in the process (pirates drops the format arg node >=22.12 uses to route require(esm)).
  • preview: pre-bundle loads the mdx options via a native import() instead of a top-level require, immune to the same stale-hook hazard.
  • e2e: fixture env extensions marked @bit-no-check; timings manifest covers the split spec files so shard balancing accounts for the heavier env-install suites.

@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

Load former core envs as regular registry envs with legacy version pinning

✨ Enhancement 🐞 Bug fix 🕐 40+ Minutes

Grey Divider

AI Description

• Remove env aspects from core manifest; load them as regular, versioned env components.
• Add legacy core-env mapping to pin versions and auto-install missing packages.
• Prevent recursion/stack overflows in aspect/env loading and graph traversal paths.
Diagram

graph TD
  A["Component env id (may be versionless)"] --> C["EnvsMain (env resolution)"] --> D["Aspects loaders (ws/scope)"] --> E["InstallMain (workspace policy)"] --> F["Registry packages (@teambit/*)"]
  C --> B["legacy-core-envs.ts (pinned versions)"] --> D
  C --> G["Fallback TS compiler"]
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Migrate stored component env ids to include versions
  • ➕ Eliminates ongoing special-casing for versionless ids
  • ➕ Makes resolution/slot lookups simpler and more consistent
  • ➖ Mutates historical objects/models (explicitly avoided by this PR)
  • ➖ Requires migration tooling and careful rollout across scopes/workspaces
2. Resolve legacy envs to a semver range (e.g. ^1.x) instead of pinned
  • ➕ Reduces maintenance of pinned versions
  • ➕ Allows automatic uptake of compatible env fixes
  • ➖ Less deterministic; can break builds when env behavior changes
  • ➖ Harder to reproduce old snapshots and debug regressions
3. Keep env aspects in core manifest but lazy-load/bundle-split
  • ➕ Avoids registry dependency for default/basic envs
  • ➕ Minimizes behavior change in resolution codepaths
  • ➖ Does not achieve the same binary/core slimming goal
  • ➖ Still couples env release cadence to core distribution

Recommendation: The PR’s approach (treat former core envs as regular external envs, while preserving backward compatibility via a non-mutating legacy-id resolver + pinned versions) is the best tradeoff for slimming the core without breaking old components. The main follow-up to ensure long-term health is to formalize the pinned-version bump as part of the release workflow (as noted in the PR description) and consider adding a small regression test matrix around versionless legacy env ids + fallback-default-env behavior.

Files changed (15) +503 / -74

Enhancement (7) +352 / -30
environments.main.runtime.tsAdd legacy core env compatibility and fallback default env +153/-25

Add legacy core env compatibility and fallback default env

• Introduces legacy-core-env detection, slot lookups that ignore version, and special handling for versionless legacy ids. Adds a minimal fallback default env (with TS transpiler) to keep commands working before env installation and prevents self-referential env component loading loops.

scopes/envs/envs/environments.main.runtime.ts

fallback-typescript-compiler.tsAdd minimal transpile-only TypeScript compiler for fallback env +43/-0

Add minimal transpile-only TypeScript compiler for fallback env

• Implements a lightweight TypeScript transpiler (no type-checking) used by the fallback default env to produce requirable dists in capsules when the real env is not installed/loaded yet.

scopes/envs/envs/fallback-typescript-compiler.ts

index.tsExport legacy core env utilities from envs public API +7/-0

Export legacy core env utilities from envs public API

• Re-exports helper functions for legacy core env identification, pinning, package naming, and id resolution so workspace/scope/install hosts can share the same compatibility logic.

scopes/envs/envs/index.ts

legacy-core-envs.tsDefine pinned versions and helpers for legacy core env ids +59/-0

Define pinned versions and helpers for legacy core env ids

• Adds a central mapping from legacy core env ids to pinned versions plus helpers to resolve versionless ids and derive registry package names. Includes a list of older removed env ids to suppress invalid-config errors even without a pinned package.

scopes/envs/envs/legacy-core-envs.ts

scope-aspects-loader.tsNormalize legacy core env ids to pinned versions in scope loading +10/-1

Normalize legacy core env ids to pinned versions in scope loading

• Resolves versionless legacy core env ids to pinned versions before importing/loading, enabling external env loading from registry. Improves core-aspect filtering to exclude core aspects even when requested with versions (dependency-induced).

scopes/scope/scope/scope-aspects-loader.ts

install.main.runtime.tsAuto-install legacy core env packages via workspace policy pinning +42/-1

Auto-install legacy core env packages via workspace policy pinning

• Adds legacy core envs used by components (without versions) to the workspace policy using pinned versions and derived @teambit/* package names. Extends missing-env package resolution to install pinned legacy env packages when env ids are versionless and not in workspace.

scopes/workspace/install/install.main.runtime.ts

workspace-component-loader.tsEnsure legacy core env extensions and default env participate in load groups +38/-3

Ensure legacy core env extensions and default env participate in load groups

• Collects name-only legacy env extensions so they are resolved and loaded before dependent components, and ensures DEFAULT_ENV is included for components without explicit env configuration. Treats legacy core env components as env aspects even when env-data is computed via fallback env.

scopes/workspace/workspace/workspace-component/workspace-component-loader.ts

Bug fix (5) +149 / -23
dev-files.main.runtime.tsSkip env manifest detection for legacy core env ids +3/-0

Skip env manifest detection for legacy core env ids

• Avoids fetching legacy core env components solely to look for env.jsonc, since old-style envs intentionally lack it. Keeps core/legacy envs out of dev-files env-manifest logic for faster/safer resolution.

scopes/component/dev-files/dev-files.main.runtime.ts

dependency-resolver.main.runtime.tsHarden env-root module resolution and legacy env policy handling +19/-4

Harden env-root module resolution and legacy env policy handling

• Guards getPackageDirInEnvRoot against cases where component env cannot be determined, falling back to root node_modules. Extends legacy peer-policy inclusion and env.jsonc detection to treat legacy core envs like core envs (no env.jsonc fetch).

scopes/dependencies/dependency-resolver/dependency-resolver.main.runtime.ts

aspect-loader.main.runtime.tsAvoid mutating shared core manifests when requiring aspects +7/-0

Avoid mutating shared core manifests when requiring aspects

• Prevents overriding manifest.id when require() resolves to a core aspect module, avoiding shared-object mutation that can break core aspect resolution (e.g. accidentally searching core ids with a version suffix).

scopes/harmony/aspect-loader/aspect-loader.main.runtime.ts

workspace-aspects-loader.tsLoad legacy envs reliably and guard against circular/aspect-graph recursion +93/-11

Load legacy envs reliably and guard against circular/aspect-graph recursion

• Adds versionless-legacy env matching when checking whether aspects are already loaded, resolves pinned versions for non-workspace legacy envs, and includes resolved ids as seeders to prevent manifest filtering bugs. Introduces in-flight load tracking to break circular env chains and replaces recursive predecessor traversal with safer inEdges-based logic to avoid stack overflows on large graphs.

scopes/workspace/workspace/workspace-aspects-loader.ts

workspace.tsTrack in-flight aspect loads and avoid recursive dependent traversal +27/-8

Track in-flight aspect loads and avoid recursive dependent traversal

• Adds a workspace-level inFlightAspectsLoads set used to prevent circular env/aspect load chains. Reworks getDependentsIds to iterative traversal to avoid maximum call stack errors, and skips misconfigured-env warnings for legacy core env ids.

scopes/workspace/workspace/workspace.ts

Refactor (1) +1 / -2
ui.main.runtime.tsDrop unused AspectMain dependency from UI deps tuple +1/-2

Drop unused AspectMain dependency from UI deps tuple

• Simplifies UI aspect dependency typing by removing an unused AspectMain type from UIDeps.

scopes/ui-foundation/ui/ui.main.runtime.ts

Tests (1) +1 / -7
core-aspects-ids.jsonUpdate core aspect id list to exclude former core envs +1/-7

Update core aspect id list to exclude former core envs

• Removes env aspect ids from the core-aspects test fixture list to reflect the slimmer core manifest set.

scopes/harmony/testing/load-aspect/core-aspects-ids.json

Other (1) +0 / -12
manifests.tsRemove env aspects from core manifests map +0/-12

Remove env aspects from core manifests map

• Stops bundling former core env aspects (node/react/mdx/readme/env/aspect-related) as core manifests, aligning with the new model where they are installed and loaded as regular env components.

scopes/harmony/bit/manifests.ts

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Jul 2, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (13) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Unfixable NonLoadedEnv remediation 🐞 Bug ≡ Correctness
Description
teambit.harmony/bit-custom-aspect is classified as a legacy-core env even though it has no pinned
package version, so versionless usage bypasses ExternalEnvWithoutVersion and becomes a
NonLoadedEnv whose hardcoded remediation is bit install (but install will never add it). This
can leave affected components blocked for tag/snap with a misleading/unsatisfiable fix instruction.
Code

scopes/envs/envs/environments.main.runtime.ts[R1356-1359]

+        // (except for envs that used to be core aspects - old components use them without a
+        // version, and bit knows how to install and load them)
+        if (!envIdStr.includes('@') && !isLegacyCoreEnvId(envIdStr)) {
const foundComp = components.find((c) => c.id.toStringWithoutVersion() === envIdStr);
Evidence
The repo explicitly marks teambit.harmony/bit-custom-aspect as an older removed core env with no
published package to pin, but isLegacyCoreEnv() includes it; env issue classification then treats
versionless legacy-core envs as valid and emits NonLoadedEnv instead of
ExternalEnvWithoutVersion. NonLoadedEnv’s remediation is always bit install, while the install
policy augmentation only happens when a pinned version exists—so this env can never be installed via
that mechanism.

scopes/envs/envs/legacy-core-envs.ts[22-55]
scopes/envs/envs/environments.main.runtime.ts[1348-1370]
components/component-issues/non-loaded-env.ts[3-7]
scopes/workspace/install/install.main.runtime.ts[935-955]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`OLDER_REMOVED_CORE_ENVS` entries (e.g. `teambit.harmony/bit-custom-aspect`) are treated as `isLegacyCoreEnvId() === true`, which causes `addNonLoadedEnvAsComponentIssues()` to emit `NonLoadedEnv` (solution hardcoded to `bit install`) instead of `ExternalEnvWithoutVersion`. But these env ids explicitly have no pinned version, and the install flow only auto-adds legacy-core env packages when a pinned version exists, so `bit install` cannot fix this issue.
### Issue Context
This breaks the intended UX/back-compat behavior for env ids that are allowed to remain in config for historical reasons but are not installable.
### Fix Focus Areas
- scopes/envs/envs/legacy-core-envs.ts[22-55]
- scopes/envs/envs/environments.main.runtime.ts[1348-1370]
- scopes/workspace/install/install.main.runtime.ts[935-955]
- components/component-issues/non-loaded-env.ts[3-7]
### Implementation guidance
- Introduce a helper that distinguishes **installable legacy core envs** from **older removed core envs**. For example:
- `isInstallableLegacyCoreEnv(id) := isLegacyCoreEnv(id) && Boolean(getPinnedLegacyCoreEnvVersion(id))`
- Keep `getLegacyCoreEnvsIds()` (for "persist-by-name") including older removed ids if needed.
- In `addNonLoadedEnvAsComponentIssues()`, only exempt versionless env ids from `ExternalEnvWithoutVersion` when `isInstallableLegacyCoreEnv(envIdStr)` is true.
- Optionally add a dedicated issue type/message for older-removed env ids (e.g. "env removed; must change env"), instead of producing `NonLoadedEnv` with `bit install`.
- Audit any other places using `envs.isLegacyCoreEnv()` for suppression/auto-remediation and ensure the older-removed ids don’t receive "install"-style treatment.

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


2. Stale installed-aspect cache 🐞 Bug ☼ Reliability
Description
getInstalledAspectResolver() now suppresses errors for non-requested dependency aspects
(throwOnError gated by requestedIds), but resolveInstalledAspectRecursively memoizes failures as
null and returns the cached null on later attempts. If an env/aspect package becomes available
later in the same process (e.g. during multi-cycle bit install), the loader won’t retry resolution
and the aspect can remain unresolved until cache invalidation/restart.
Code

scopes/workspace/workspace/workspace-aspects-loader.ts[R834-836]

+      const localPath = await this.resolveInstalledAspectRecursively(component, rootIds, graph, {
+        throwOnError: opts.throwOnError && isRequested,
+      });
Evidence
The new requestedIds gating makes resolution failures for dependency aspects non-fatal, allowing
them to flow into the negative-cache (null) write; later attempts short-circuit on the cache and
do not retry. Workspace cache clearing does not clear this map, so the stale negative result can
persist within the same process even after packages become available.

scopes/workspace/workspace/workspace-aspects-loader.ts[823-868]
scopes/workspace/workspace/workspace-aspects-loader.ts[925-933]
scopes/workspace/workspace/workspace.ts[871-890]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`WorkspaceAspectsLoader.resolveInstalledAspectRecursively()` caches failed resolutions as `null` in `resolvedInstalledAspects`. This PR also introduces a path where dependency aspects are resolved with `throwOnError: false` (based on `requestedIds`), so transient resolution failures during install can be cached and then never retried after packages are installed in the same process.
## Issue Context
- The installed-aspect resolver memoizes both successes and failures.
- Workspace cache clearing (`workspace.clearCache`) does not clear `resolvedInstalledAspects`.
- During `bit install` (and other multi-stage flows), aspects may become resolvable after `node_modules` changes, but the loader will still return cached `null`.
## Fix Focus Areas
- Add an explicit invalidation method on `WorkspaceAspectsLoader` (e.g. `clearResolvedInstalledAspectsCache()`), and call it from `Workspace.clearCache()` (and/or other places that mutate/refresh node_modules, such as post-install hooks).
- Alternatively, avoid caching `null` (or cache it only for the duration of a single load trace), so subsequent attempts can re-resolve after installation.
### Code references
- scopes/workspace/workspace/workspace-aspects-loader.ts[823-937]
- scopes/workspace/workspace/workspace.ts[871-890]

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


3. Empty-env main points dist 🐞 Bug ≡ Correctness
Description
NodeModuleLinker.createPackageJson rewrites TS/TSX mains to dist/*.js unless it can positively
detect teambit.harmony/empty-env, but linkToNodeModulesByIds loads components with
loadExtensions: false so empty-env components with no explicit env config can be misdetected and
get a non-existent dist main. This breaks requiring/importing such components from node_modules
because only source files are linked and empty-env has no compiler to generate dists.
Code

scopes/workspace/modules/node-modules-linker/node-modules-linker.ts[R280-283]

+    if (
+      !isCompilerLessEnv &&
+      typeof mainFile === 'string' &&
+      /\.(ts|tsx|jsx|mts|cts)$/.test(mainFile) &&
Evidence
The linking path explicitly loads components with extensions disabled, then calls createPackageJson,
which uses envs extension data/config to decide whether to rewrite main. For default empty-env
components without explicit env config, both values can be absent, so isCompilerLessEnv becomes
false and main is rewritten to dist/..., but the linker only symlinks bitmap/source files and
empty-env is defined to provide no compiler/dists.

scopes/workspace/modules/node-modules-linker/node-modules-linker.ts[368-383]
scopes/workspace/modules/node-modules-linker/node-modules-linker.ts[49-101]
scopes/workspace/modules/node-modules-linker/node-modules-linker.ts[160-167]
scopes/workspace/modules/node-modules-linker/node-modules-linker.ts[169-177]
scopes/envs/envs/environments.main.runtime.ts[112-115]
scopes/harmony/empty-env/empty-env.main.runtime.ts[8-16]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`NodeModuleLinker.createPackageJson()` rewrites a TS/TSX/etc `main` field to `dist/<main>.js` unless `isCompilerLessEnv` is true. In the `linkToNodeModulesByIds()` flow, components are loaded with `loadExtensions: false`, so `envsExt.data.id` is typically unset; for components that rely on the new default env (empty-env) and have no explicit env config, `configuredEnvId` is also unset, making `isCompilerLessEnv` false and forcing a dist main that will never exist under empty-env.
### Issue Context
This breaks consumers that import these linked packages via node resolution because the linker symlinks only bitmap/source files (not compiled `dist`), and empty-env intentionally has no compiler/dists.
### How to fix
1. Treat “no env configured / env data missing” as empty-env in this linking path (since `DEFAULT_ENV` is now `teambit.harmony/empty-env`).
2. Keep the existing safety behavior when empty-env is only a fallback for a *configured* non-empty env (i.e., if `configuredEnvId` exists and is not empty-env, do **not** treat it as compiler-less).
A concrete approach:
- Compute an `effectiveConfiguredEnvId = envsExt?.config?.env?.split('@')[0] ?? 'teambit.harmony/empty-env'`.
- Compute `effectiveDataEnvId = envsExt?.data?.id?.split('@')[0]`.
- Set `isCompilerLessEnv = (effectiveConfiguredEnvId === 'teambit.harmony/empty-env') && ((effectiveDataEnvId ?? effectiveConfiguredEnvId) === 'teambit.harmony/empty-env')`.
- Only rewrite `main` when `!isCompilerLessEnv`.
### Fix Focus Areas
- scopes/workspace/modules/node-modules-linker/node-modules-linker.ts[263-287]
- scopes/workspace/modules/node-modules-linker/node-modules-linker.ts[368-383]

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


View action required (1)
4. Versioned env lookup fails ✓ Resolved 🐞 Bug ≡ Correctness
Description
EnvsMain.getEnvDefinitionById() can no longer resolve a versioned env ID to an env registered in the
slot under its versionless ID, because getEnvDefinitionByStringId() only performs legacy-core
fallbacks for versionless IDs. This can cause env resolution to fail (and fall back to default env /
warnings) in flows where aspect-entry IDs become versioned (e.g. during tag) while the env slot
entry remains versionless.
Code

scopes/envs/envs/environments.main.runtime.ts[R1209-1212]

+    if (!envId.includes('@')) {
+      // versionless references hit the slot only for legacy core envs, which old components store
+      // without a version by design while the loaded env registers versioned. any other env must
+      // be looked up with its exact version: two components in the same workspace may use the
Evidence
calculateEnv() explicitly relies on getEnvDefinitionById(matchedEntry.id) because aspect-entry
IDs can change versions during tag and not match the env slot registration; with the new
getEnvDefinitionByStringId() behavior, that lookup can no longer succeed when the slot key is
versionless. Additionally, isEnvRegistered() documents/implements that versioned IDs should match
a versionless slot entry, but getEnvDefinitionByStringId() does not provide the analogous fallback
for env definition retrieval.

scopes/envs/envs/environments.main.runtime.ts[847-864]
scopes/envs/envs/environments.main.runtime.ts[1197-1219]
scopes/envs/envs/environments.main.runtime.ts[1247-1253]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`EnvsMain.getEnvDefinitionById()` calls `getEnvDefinitionByStringId(id.toString())` and then `getEnvDefinitionByStringId(id.toString({ ignoreVersion: true }))`. After this PR, `getEnvDefinitionByStringId()` only performs a special lookup for *versionless* IDs (and only for legacy core envs). This means a **versioned** ID (e.g. `my-scope/my-env@1.0.0`) will not match an env that is registered in the slot under `my-scope/my-env`.
This breaks env resolution in scenarios explicitly documented in `calculateEnv()` where aspect-entry IDs can become versioned during tag even though the slot registration isn’t.
### Issue Context
The code already acknowledges that versioned IDs should match versionless slot entries (see `isEnvRegistered()`), but `getEnvDefinitionById()` / `getEnvDefinitionByStringId()` do not implement the same matching behavior.
### Fix Focus Areas
- scopes/envs/envs/environments.main.runtime.ts[1197-1220]
### Suggested fix
Implement a safe fallback for **versioned -> versionless** lookup when the exact lookup misses:
- In `getEnvDefinitionById()` (preferred):
- After failing exact match, try `id.toStringWithoutVersion()` **only if** `this.envSlot.get(id.toStringWithoutVersion())` exists, and return that `EnvDefinition`.
- Or in `getEnvDefinitionByStringId()`:
- If `envId.includes('@')` and `this.envSlot.get(envId.split('@')[0])` exists, return that.
This preserves the PR’s intent of avoiding ambiguous ignore-version scans across multiple versions, while still supporting the explicit versionless-slot contract used by core/workspace envs.

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



Remediation recommended

5. Legacy env canonical ID unstable ✓ Resolved 🐞 Bug ☼ Reliability
Description
AspectLoaderMain.getLoadedAspectIdIgnoringVersion() returns the first loaded aspect ID matching the
versionless ID, making legacy-core env canonicalization dependent on Harmony extension ordering
rather than a deterministic policy (e.g. highest version).
DependencyResolverMain.getCanonicalLegacyCoreEnvId() uses this result to enforce legacy-core env
single-instance semantics, so when multiple versions are loaded it can bind to an unintended version
despite EnvsMain explicitly sorting/warning for multi-version legacy envs.
Code

scopes/harmony/aspect-loader/aspect-loader.main.runtime.ts[R248-251]

+  getLoadedAspectIdIgnoringVersion(idWithoutVersion: string): string | undefined {
+    return this.harmony.extensionsIds.find(
+      (extId) => extId.split('@')[0] === idWithoutVersion && Boolean(this.harmony.extensions.get(extId)?.loaded)
+    );
Evidence
AspectLoaderMain chooses the first loaded matching ID, and DependencyResolverMain relies on it as
the canonical legacy-core env ID. Meanwhile EnvsMain explicitly implements deterministic
multi-version selection (semver sort + warning), demonstrating multi-version legacy envs are a
handled case and that deterministic selection is desirable.

scopes/harmony/aspect-loader/aspect-loader.main.runtime.ts[237-251]
scopes/dependencies/dependency-resolver/dependency-resolver.main.runtime.ts[1574-1585]
scopes/envs/envs/environments.main.runtime.ts[301-327]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`getLoadedAspectIdIgnoringVersion()` uses `Array.find()` over `harmony.extensionsIds`, so when multiple versions of the same aspect/env are loaded it picks whichever was registered first. This is inconsistent with EnvsMain’s deterministic “pick highest version and warn” behavior for multi-version legacy-core envs.
### Issue Context
This function is used by dependency resolution to rewrite legacy-core env dependency IDs to a single canonical instance. If multiple loaded versions exist, order-dependent selection can lead to confusing/unstable behavior.
### Fix Focus Areas
- scopes/harmony/aspect-loader/aspect-loader.main.runtime.ts[237-251]
- scopes/dependencies/dependency-resolver/dependency-resolver.main.runtime.ts[1578-1585]
- scopes/envs/envs/environments.main.runtime.ts[304-327]

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


6. Legacy env failures suppressed 🐞 Bug ◔ Observability
Description
Workspace.getWorkspaceIssues() suppresses legacy-core env MODULE_NOT_FOUND failures by checking only
/node_modules/, but roots-based env resolution installs them under
node_modules/.bit_roots//node_modules/. This can hide real env load failures from bit status
even when the env package is actually installed (just not hoisted to root node_modules).
Code

scopes/workspace/workspace/workspace.ts[R1809-1812]

+          const envPackageName = getLegacyCoreEnvPackageName(failedIdWithoutVersion);
+          const isEnvPackageInstalled = fs.existsSync(path.join(this.path, 'node_modules', envPackageName));
+          // the missing module may be reported by its package name or by its absolute path in
+          // the workspace node_modules (when the require used a resolved path).
Evidence
The new suppression logic uses a root-node_modules-only existence check to decide whether to hide a
legacy-core env MODULE_NOT_FOUND error; however the workspace supports resolving envs from the roots
layout under node_modules/.bit_roots, so the env can be installed without existing at the checked
path.

scopes/workspace/workspace/workspace.ts[1800-1826]
scopes/workspace/workspace/types.ts[50-58]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`Workspace.getWorkspaceIssues()` suppresses certain legacy-core env load failures when it believes the env package is "not installed yet". The installation check only looks for `<workspace>/node_modules/<envPackageName>`, but with `resolveEnvsFromRoots` the env package may be installed under the roots layout (`node_modules/.bit_roots/...`). This can incorrectly suppress real failures from `bit status`.
### Issue Context
The suppression intends to hide the expected pre-`bit install` state, not hide failures when the env is actually present.
### Fix Focus Areas
- scopes/workspace/workspace/workspace.ts[1807-1818]
### What to change
- When deciding `isEnvPackageInstalled`, check both:
- `<workspace>/node_modules/<envPackageName>` (current)
- `<workspace>/node_modules/.bit_roots/<failedIdWithoutVersion>/node_modules/<envPackageName>` (roots layout, note that roots dir is keyed by versionless env id per comments in the resolver)
- Only suppress when **neither** location exists.
- Keep the existing `isEnvModuleNotFound` guard, but broaden it if needed to match the roots-path form as well.

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


7. Core errors overly suppressed 🐞 Bug ☼ Reliability
Description
WorkspaceAspectsLoader.resolveCoreAspectDefs() suppresses all core-aspect resolution errors when
throwOnError is false and only logs err.message, so non-transient core breakages (not just
missing dists) can be skipped during best-effort flows like install’s env reload. This can allow
commands to proceed with missing core aspects and fail later with less actionable errors.
Code

scopes/workspace/workspace/workspace-aspects-loader.ts[R286-289]

+        } catch (err: any) {
+          if (throwOnError) throw err;
+          this.logger.warn(`unable to resolve the core aspect "${coreId}", skipping it. ${err.message}`);
+          return undefined;
Evidence
The loader’s new helper explicitly catches all errors and skips the core aspect whenever
throwOnError is false. Install’s env reload path passes throwOnError: false into
resolveAspects, which then calls resolveCoreAspectDefs using that flag, so this broad
suppression can occur during common install flows.

scopes/workspace/workspace/workspace-aspects-loader.ts[266-294]
scopes/workspace/workspace/workspace-aspects-loader.ts[404-414]
scopes/workspace/install/install.main.runtime.ts[669-679]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`resolveCoreAspectDefs()` catches **any** error and skips the core aspect when `throwOnError` is false. The comment justifies this for an expected transient state (missing `dist` during install), but the implementation also suppresses unrelated failures (e.g. runtime errors, corrupted JS, invalid exports), reducing diagnosability and allowing later, harder-to-trace failures.
### Issue Context
Install explicitly calls `workspace.resolveAspects(..., { throwOnError: false })` during env reload. With the current broad catch, any core aspect failure in that path is reduced to a warning containing only `err.message`.
### Fix Focus Areas
- scopes/workspace/workspace/workspace-aspects-loader.ts[266-294]
- scopes/workspace/workspace/workspace-aspects-loader.ts[404-414]
- scopes/workspace/install/install.main.runtime.ts[669-679]
### Implementation guidance
- When `throwOnError` is false, suppress only the known/expected transient failures (e.g. MODULE_NOT_FOUND / missing dist entry) and rethrow other error types.
- Log richer context for suppressed errors (at least `err.stack` when available) to keep install debuggable.
- (Optional) Collect suppressed core-aspect failures and surface them as a structured non-blocking workspace issue so users can see which core aspects were skipped.

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


View review recommended (9)
8. In-flight loads not awaited 🐞 Bug ☼ Reliability
Description
WorkspaceAspectsLoader.loadAspects() (and the scope-side getManifestsGraphRecursively()) filters out
IDs that are already “in flight” and returns without waiting for the original load to finish, so
concurrent callers can proceed while the requested aspect/env is still not loaded.
Code

scopes/workspace/workspace/workspace-aspects-loader.ts[R130-133]

+    if (inFlightIds.length) {
+      this.logger.debug(`${loggerPrefix} skipping aspects that are already loading: ${inFlightIds.join(', ')}`);
+    }
+    notLoadedIds = idsToLoad;
Evidence
The workspace loader explicitly partitions requested IDs into inFlightIds and then drops them
(notLoadedIds = idsToLoad) without awaiting any existing load. The Workspace stores the
in-flight set globally and constructs a new WorkspaceAspectsLoader per call, so overlapping calls
share only the set (not a promise). The scope loader mirrors the same “skip” behavior.

scopes/workspace/workspace/workspace-aspects-loader.ts[121-144]
scopes/workspace/workspace/workspace.ts[210-215]
scopes/workspace/workspace/workspace.ts[2133-2157]
scopes/scope/scope/scope-aspects-loader.ts[115-151]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The new in-flight cycle breaker uses a `Set` to detect currently-loading aspects and then *skips* those IDs. If `loadAspects()` is invoked concurrently (multiple `WorkspaceAspectsLoader` instances are created per call), a later caller can return before the in-flight aspect finishes loading, violating the implied contract of `await workspace.loadAspects(...)`.
## Issue Context
This guard is necessary to break *re-entrant/circular* chains, but it should not cause *concurrent* callers to observe “not loaded yet” aspects. A `Set` can only answer “is loading”, not “wait until loaded”.
## Fix Focus Areas
- scopes/workspace/workspace/workspace-aspects-loader.ts[121-145]
- scopes/workspace/workspace/workspace.ts[2133-2157]
- scopes/scope/scope/scope-aspects-loader.ts[115-152]
### Suggested approach
1. Replace/augment the `Set` with a `Map<string, Promise<...>>` (or `{ promise, ownerToken }`) keyed by `aspectLoadInFlightKey(id)`.
2. When an ID is already in-flight:
- If it belongs to the *current re-entrant chain* (cycle), keep the “skip to break the cycle” behavior.
- Otherwise, `await` the existing promise so concurrent callers observe the aspect as loaded upon return.
3. Ensure the promise entry is removed in `finally`, and that rejections propagate or are handled consistently with `throwOnError`.
(Using an owner token via AsyncLocalStorage or an explicit `loadContextId` parameter is one pragmatic way to distinguish re-entrant cycles from concurrency.)

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


9. Legacy dep not sanitized 🐞 Bug ☼ Reliability
Description
The PR removes several guardrails that previously stripped/rewrote @teambit/legacy, so any
workspace/component/env that still carries it can now leak it into dependency policies/manifests and
be handed to pnpm during bit install. This can break installs (or force unexpected registry
resolution) because @teambit/legacy is no longer treated as a special core/bvm-linked package
anywhere in the pipeline.
Code

scopes/dependencies/dependency-resolver/manifest/workspace-manifest-factory.ts[L432-433]

-      // Remove bit bin from dep list
-      depList = depList.filter((dep) => dep.id !== '@teambit/legacy');
Evidence
Current code shows there is no longer any pnpm hook stripping @teambit/legacy, no manifest-level
filtering of it, and no override step that rewrites it into a peer. Additionally, forking now allows
all package deps through unconditionally, increasing the chance the legacy package enters saved
policies/manifests.

scopes/dependencies/pnpm/lynx.ts[612-629]
scopes/dependencies/dependency-resolver/manifest/workspace-manifest-factory.ts[134-138]
scopes/dependencies/dependency-resolver/manifest/workspace-manifest-factory.ts[423-434]
scopes/dependencies/dependencies/dependencies-loader/apply-overrides.ts[182-192]
scopes/component/forking/forking.main.runtime.ts[458-469]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`@teambit/legacy` is no longer filtered/rewritten across the dependency-manifest and dependency-policy pipeline. If a component/env/workspace policy still includes this package, it can now propagate into generated manifests and be sent to pnpm, leading to install failures or unexpected dependency graph shape.
### Issue Context
This PR intentionally stops linking `@teambit/legacy` as a core/bvm package, but it also removed the protective normalization that prevented stale manifests from introducing it into installs.
### Fix Focus Areas
Re-add a single, explicit compatibility filter (preferably centralized) that strips `@teambit/legacy` from:
- workspace/root policy filtering and component dependency lists used to generate manifests
- pnpm readPackage hooks (so transitive manifests also cannot re-introduce it)
- dependency-policy derivation paths that convert deps into policies (e.g. forking)
- scopes/dependencies/dependency-resolver/manifest/workspace-manifest-factory.ts[134-138]
- scopes/dependencies/dependency-resolver/manifest/workspace-manifest-factory.ts[423-434]
- scopes/dependencies/pnpm/lynx.ts[612-629]
- scopes/dependencies/dependencies/dependencies-loader/apply-overrides.ts[182-192]
- scopes/component/forking/forking.main.runtime.ts[458-469]

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


10. Unbounded pnpm dir restores ✓ Resolved 🐞 Bug ➹ Performance
Description
restoreRemovedLoadedVirtualStoreDirs() performs Promise.all() over directory restores and runs
an fs.copy() per removed virtual-store dir, with no concurrency limit. In large installs (many
loaded packages / many re-keyed dirs), this can create a burst of deep parallel filesystem
operations that significantly slows installs and can fail under resource limits.
Code

scopes/dependencies/pnpm/preserve-loaded-virtual-store-dirs.ts[R91-94]

+  await Promise.all(
+    removed.map(async ({ dirName, dirPath, pkgName }) => {
+      const donorDirName = findDonorDirName(dirName, pkgName, currentDirs);
+      if (!donorDirName) {
Evidence
The restore helper explicitly uses Promise.all() over all removed dirs and performs fs.copy()
within each task; pnpm.package-manager invokes this helper after every install, so the unbounded
concurrency can directly impact the install critical path.

scopes/dependencies/pnpm/preserve-loaded-virtual-store-dirs.ts[73-115]
scopes/dependencies/pnpm/pnpm.package-manager.ts[200-269]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`restoreRemovedLoadedVirtualStoreDirs()` restores directories using `Promise.all` and performs potentially heavy recursive `fs.copy()` operations for each removed dir concurrently. This creates unbounded I/O and file-descriptor pressure during `bit install`.
### Issue Context
This restore runs after every pnpm install, so it sits on a hot path. Even if correctness is best-effort, the concurrency behavior can be the dominant cost or cause failures in constrained CI/container environments.
### Fix Focus Areas
- Replace `Promise.all(removed.map(... fs.copy ...))` with a bounded-concurrency mapper (e.g. `p-map` or a simple queue), and consider logging aggregate stats (removed count, restored count, duration).
- scopes/dependencies/pnpm/preserve-loaded-virtual-store-dirs.ts[73-115]
- scopes/dependencies/pnpm/pnpm.package-manager.ts[200-269]

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


11. Core link sync suppressed 🐞 Bug ☼ Reliability
Description
DependencyLinker.syncCoreAspectLinksForEnvs() now catches any error from the entire reconciliation
and only logs a warning, so unexpected failures (not just transient missing-dists) won’t fail the
install or be surfaced to the caller. This makes it easy to proceed with partially reconciled
core-aspect links and reduces diagnosability (no stack / no structured failure signal).
Code

scopes/dependencies/dependency-resolver/dependency-linker.ts[R673-676]

+    try {
+      await this.syncCoreAspectLinksForEnvsUnsafe(rootDir, componentIds);
+    } catch (err: any) {
+      this.logger.warn(`syncCoreAspectLinksForEnvs: skipped, ${err.message}`);
Evidence
The method now suppresses all thrown errors from the reconciliation, and it is invoked during
install cycles; suppressing unexpected failures here makes reconciliation silently best-effort even
when the failure is not the transient “missing dist” case described in comments.

scopes/dependencies/dependency-resolver/dependency-linker.ts[664-677]
scopes/workspace/install/install.main.runtime.ts[470-489]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`DependencyLinker.syncCoreAspectLinksForEnvs()` wraps the full sync in a blanket `try/catch` and proceeds after logging a warning. This suppresses unexpected failures (e.g. filesystem/permission errors, logic regressions) the same way as the intended transient `MODULE_NOT_FOUND` during mid-install, and it also loses stack/context.
### Issue Context
This method is called from the install flow in workspaces with `linkCoreAspects` disabled, so failures here can affect core-aspect bootstrapping/link reconciliation.
### Fix Focus Areas
- scopes/dependencies/dependency-resolver/dependency-linker.ts[664-678]
### What to change
- Catch only the specific, expected transient errors (e.g. `MODULE_NOT_FOUND` / `ERR_MODULE_NOT_FOUND` when core-aspect dists are temporarily missing).
- For all other errors: either rethrow (preferred) or return a structured failure (e.g. boolean/result) that the install flow can handle explicitly.
- Improve the warning to include enough diagnostics (at least `err.stack` when available) when you do intentionally suppress an expected transient error.

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


12. Fallback compiler can crash 🐞 Bug ☼ Reliability
Description
The new fallback env compiler throws if it can’t require('typescript'), so any code path that uses
the fallback default env can terminate instead of degrading gracefully. This is especially risky
because the fallback env is explicitly used as a safety net for early bootstrap and for components
whose env failed to load.
Code

scopes/envs/envs/fallback-typescript-compiler.ts[R20-23]

+    ts = require('typescript');
+  } catch {
+    throw new Error(
+      'the fallback compiler requires the "typescript" package, which is not installed. run "bit install" to install the component env'
Evidence
The fallback compiler explicitly throws when typescript cannot be required, and EnvsMain wires
this compiler into the fallback default env used for early bootstrap and env-load failures. The repo
root manifest does not itself guarantee typescript is present, so availability depends on
external/transitive installation context.

scopes/envs/envs/fallback-typescript-compiler.ts[17-25]
scopes/envs/envs/environments.main.runtime.ts[233-255]
package.json[59-65]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`getFallbackTypescriptCompiler()` hard-requires `typescript` at runtime and throws when it’s missing. This can crash Bit in scenarios where the fallback env is meant to keep the system operational (early bootstrap or when an env fails to load).
### Issue Context
The fallback env is used from `EnvsMain.getFallbackDefaultEnv()` as a safety net. If `typescript` is not present in the current node_modules resolution context, the fallback path becomes a hard failure.
### Fix Focus Areas
- scopes/envs/envs/fallback-typescript-compiler.ts[17-25]
- scopes/envs/envs/environments.main.runtime.ts[233-255]
### What to change
- Ensure `typescript` is guaranteed to be resolvable in all supported runtimes where fallback env can be invoked (e.g., add it as a runtime dependency of the package that ships `fallback-typescript-compiler`, or otherwise ensure it’s bundled/available).
- Alternatively (or additionally), make the fallback path truly best-effort: if `typescript` cannot be resolved, return a no-op compiler (or a clearer structured failure) that doesn’t crash unrelated commands, while still surfacing an actionable issue to the user.

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


13. Dependents search stops early 🐞 Bug ≡ Correctness
Description
Workspace.getDependentsIds() now stops traversing when it hits a non-workspace node, which can miss
workspace dependents reachable through scope-only components (e.g. A(ws) -> X(scope) -> B(ws),
querying dependents of B will not return A). This can cause includeDependents flows
(build/test/compile via getComponentsByUserInput) to silently skip required dependent components.
Code

scopes/workspace/workspace/workspace.ts[R752-755]

+        // when the node is filtered out, don't traverse through it (same semantics as
+        // graph.predecessors with a nodeFilter)
+        if (filterOutNowWorkspaceIds && !this.hasId(node.attr)) return;
+        dependents.push(node.attr);
Evidence
The new BFS explicitly returns before enqueuing a predecessor when it is not a workspace component,
so traversal cannot reach any workspace dependents behind that node. The graph builder shows that
scope-only components are inserted as nodes (via workspace.get), making such intermediate nodes
realistic. Existing code uses graph.predecessors() with a nodeFilter to find workspace dependents,
which would be undermined if filtered nodes also blocked traversal—indicating the new behavior is
likely a regression.

scopes/workspace/workspace/workspace.ts[733-757]
scopes/workspace/workspace/build-graph-from-fs.ts[205-213]
scopes/component/remove/remove.main.runtime.ts[222-229]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`Workspace.getDependentsIds()` was rewritten to iterative BFS, but it currently **returns early** for predecessors that are not workspace components when `filterOutNowWorkspaceIds` is true. This prunes traversal and can miss workspace dependents that are reachable only through scope-only nodes.
## Issue Context
The workspace dependency graph can contain non-workspace nodes because graph building loads deps via `workspace.get(depId)` (which can resolve from scope). The previous `graph.predecessors(..., { nodeFilter })` behavior is used elsewhere to find workspace dependents, implying filtering should exclude results but still allow traversal through intermediate nodes.
## Fix Focus Areas
- scopes/workspace/workspace/workspace.ts[733-758]
### Suggested change
Keep traversing (`queue.push(predecessorId)`) even when the predecessor node is filtered out; only add it to the returned `dependents` list when it passes the filter. This preserves “filter results” semantics without turning the filter into a traversal cutoff.

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


14. CI versions legacy envs 🐞 Bug ≡ Correctness
Description
attachEnvVersionToLaneConfig() rewrites a versionless env id into a versioned id when the lane
Version contains a matching env aspect-entry with a version, and adjustEnvsOnConfigObject() then
materializes it as a versioned config key. This can conflict with the workspace behavior that
legacy-core envs must remain configured versionless and can cause CI’s restoreLaneConfigChanges() to
persist a versioned env entry into .bitmap before tagging.
Code

scopes/git/ci/attach-env-version-to-lane-config.ts[R19-22]

+  const envVersion = envEntry?.extensionId?.version;
+  if (!envVersion) return;
+  laneConfig[EnvsAspect.id].env = `${envId}@${envVersion}`;
+  ExtensionDataList.adjustEnvsOnConfigObject(laneConfig);
Evidence
The CI helper explicitly attaches a version and then normalizes the config object into a versionless
env + versioned env-aspect entry; CI then persists that config into .bitmap. Separately, workspace
logic explicitly keeps legacy-core env IDs versionless in config, and legacy-core env IDs are
defined as envs that historically existed without versions.

scopes/git/ci/attach-env-version-to-lane-config.ts[15-22]
components/legacy/extension-data/extension-data-list.ts[230-239]
scopes/git/ci/ci.main.runtime.ts[1800-1820]
scopes/workspace/workspace/workspace.ts[2493-2505]
scopes/envs/envs/legacy-core-envs.ts[1-8]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`attachEnvVersionToLaneConfig()` upgrades a versionless `env` config to a versioned id (and then expands it into a versioned env-aspect key via `ExtensionDataList.adjustEnvsOnConfigObject`). This is correct for regular env-set cases, but it should **not** apply to legacy-core env IDs, which the workspace explicitly keeps versionless in config.
### Issue Context
- CI restore flow calls `attachEnvVersionToLaneConfig()` before writing `laneConfig` entries into `.bitmap`.
- `ExtensionDataList.adjustEnvsOnConfigObject()` will create a `laneConfig["<envId>@<version>"] = {}` entry once the env is rewritten to include a version.
- Workspace code explicitly preserves versionless config for legacy-core envs.
### Fix Focus Areas
- scopes/git/ci/attach-env-version-to-lane-config.ts[16-22]
### Suggested change
- Import `isLegacyCoreEnv` from `@teambit/envs`.
- Add an early return guard:
- after reading `envId` and before finding/attaching `envVersion`, do:
- `if (isLegacyCoreEnv(envId)) return;`
This keeps the CI restoration logic from pinning versions for legacy-core envs while preserving the intended behavior for regular envs.

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


15. Install masks env errors 🐞 Bug ☼ Reliability
Description
InstallMain.reloadOneAspectsGroup() treats broad module-not-found failures from an env/aspect
runtime provider as “not requirable yet” and only warns, so installs can succeed even when a
non-workspace env is genuinely broken (e.g. missing runtime dependency). This can leave the
workspace in a misleading “installed” state until later commands fail when the env is actually
needed.
Code

scopes/workspace/install/install.main.runtime.ts[R759-762]

+            err.code === 'ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING' ||
+            err.code === 'ERR_REQUIRE_ESM' ||
+            err.message?.includes('Cannot find module') ||
+            // a CJS dist evaluated as ESM (or vice versa) - happens when the package manager
Evidence
The provider call catches and suppresses MODULE_NOT_FOUND/Cannot find module errors and only
logs a warning, which can hide real runtime failures. The same reload path is invoked for aspects in
the scope group (not in the workspace), so this suppression applies to external envs/aspects too,
where such errors are not expected to be transient compilation gaps.

scopes/workspace/install/install.main.runtime.ts[744-770]
scopes/workspace/install/install.main.runtime.ts[787-799]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`InstallMain.reloadOneAspectsGroup()` suppresses provider failures for *all* reloaded aspects/envs when the error looks like module-not-found. This suppression is appropriate only for workspace aspects that may not be compiled yet during early install cycles; for non-workspace aspects (coming from scope/node_modules) a `MODULE_NOT_FOUND`/`Cannot find module` typically indicates a real broken env that should fail the install.
## Issue Context
The grouping logic explicitly creates a `scope` group for aspects not in the workspace, but the provider error suppression does not differentiate between workspace and non-workspace groups.
## Fix Focus Areas
- scopes/workspace/install/install.main.runtime.ts[744-772]
- scopes/workspace/install/install.main.runtime.ts[787-799]
## Suggested fix
1. Carry enough context into the `loadedPlugins` entries (e.g. `{ id, plugins, isWorkspace: group.workspace }`, optionally `localPath`).
2. In the provider `catch`, only apply the "not requirable yet" suppression when `group.workspace === true` (and possibly `group.envOfAspect === true`).
3. For `group.workspace === false` (scope/node_modules aspects), rethrow `MODULE_NOT_FOUND` / `Cannot find module` errors so `bit install` fails fast with an actionable message.
4. (Optional) If you still need some tolerance for non-workspace aspects, narrow it to very specific transient cases (e.g. ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING for a TS main) rather than the generic "Cannot find module" substring.

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


16. Hardcoded dist main path 🐞 Bug ≡ Correctness
Description
WorkspaceAspectsLoader.getDistMain() hardcodes the compiled main lookup to /dist/..., so the
Node 22 fallback can fail to find an existing compiled entry when an aspect/env compiler outputs to
a different distDir. In that case, requiring the aspect still fails even though compiled JS
exists, preventing aspect/env loading.
Code

scopes/workspace/workspace/workspace-aspects-loader.ts[R724-727]

+    const mainFile = component.state._consumer.mainFile;
+    if (!mainFile) return undefined;
+    const distMain = join(localPath, DEFAULT_DIST_DIRNAME, mainFile.replace(/\.(ts|tsx|mts|cts|jsx)$/, '.js'));
+    return fs.pathExistsSync(distMain) ? distMain : undefined;
Evidence
The fallback require path is computed only as /dist/.js and does not consult the component
compiler’s getDistPathBySrcPath(), even though the compiler contract supports arbitrary distDir
and path mapping. The aspect-loader already demonstrates the correct approach (use
getDistPathBySrcPath() when a compiler exists), so this omission can cause fallback loading to
miss the actual compiled output location.

scopes/workspace/workspace/workspace-aspects-loader.ts[679-727]
scopes/harmony/aspect-loader/aspect-loader.main.runtime.ts[173-224]
scopes/compilation/compiler/types.ts[45-49]
scopes/compilation/compiler/types.ts[119-123]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`WorkspaceAspectsLoader.getDistMain()` builds a fallback path using `DEFAULT_DIST_DIRNAME` (`dist`) and `mainFile.replace(... => .js)`. This ignores the compiler’s `getDistPathBySrcPath()` mapping, so aspects compiled to a non-`dist/` output directory won’t be found and the fallback require will still fail.
## Issue Context
`AspectLoaderMain.getAspectFilePath()` / `getRuntimePath()` already implement the correct pattern: try to obtain the component compiler and call `compiler.getDistPathBySrcPath(srcRelativePath)`, falling back to `DEFAULT_DIST_DIRNAME` only when the compiler isn't available.
## Fix Focus Areas
- scopes/workspace/workspace/workspace-aspects-loader.ts[679-728]
- scopes/harmony/aspect-loader/aspect-loader.main.runtime.ts[173-224]

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


Grey Divider

Tip of the day
💡 Did you know, you can enable the Remediation agent and Qodo fixes findings in a dedicated fix PR

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread scopes/workspace/workspace/workspace.ts Outdated
Comment thread scopes/envs/envs/fallback-typescript-compiler.ts
Comment thread scopes/workspace/workspace/workspace-aspects-loader.ts Outdated
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit c7dd1a7

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Jul 2, 2026

Copy link
Copy Markdown

Code Review by Qodo

Grey Divider

New Review Started

This review has been superseded by a new analysis

Grey Divider

Qodo Logo

Comment thread scopes/workspace/workspace/workspace-aspects-loader.ts
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit e6418b9

@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit c9eca3d

Comment thread scopes/harmony/empty-env/empty-env.aspect.ts
Comment thread scopes/envs/envs/environments.main.runtime.ts
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 3f5c24e

…nv templates on demand

- register legacy core env ids as core extension names so their config entries stay name-only
  (versionless): prevents env-as-dependency edges that created circular TS project references
  in lane/tag builds
- require the aspect env eslint/prettier configs lazily (saves ~400 file reads per bit command)
- bit create: fall back to --env for template lookup, incl. templates registered on the
  generator slot by envs loaded from the global scope
- rewrite e2e node-env fixtures to compose on the core aspect env instead of @teambit/node
Comment thread scopes/harmony/aspect/aspect.env.ts Outdated
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 0607c7d

- teambit.harmony/aspect and teambit.envs/env become regular envs with pinned
  legacy versions. components using them get the exact released behavior after
  bit install (the react-free aspect env rewrite is reverted)
- move the bit-aspect template and harmony starters to the generator aspect so
  'bit create bit-aspect' and 'bit new' work without loading the env
- bind manifest deps of legacy envs to pinned versions in scope context (models
  built when these envs were core don't list them as dependencies)
- load the full manifest graph when loading aspects from the global scope
- keep legacy core env ids versionless when configured via bit create/env set
Comment thread scopes/workspace/workspace/workspace.ts
Comment thread components/legacy/e2e-helper/e2e-env-helper.ts
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit b23b273

…ion when available

- fixes the ci snap failure: pinned-version copies of workspace components
  leaked into the load groups and into the snap list
- review fixes: index-based BFS queue in getDependentsIds, guard the
  typescript require in the fallback compiler, suppress legacy-env load
  failures only when the env package itself is missing, match both quote
  styles when detecting fixture env packages
Comment thread scopes/generator/generator/builtin-templates.ts
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 94eddce

Comment thread scopes/compilation/compiler/compiler.main.runtime.ts
zkochan added a commit that referenced this pull request Aug 10, 2026
…s an install that re-keys them (#10595)

Extracted from #10465 so the fix can land on its own.

## Problem

pnpm keys a virtual-store directory by the package's peer-resolution
hash, so an install that changes the dependency set gives the same
`name@version` a **new** directory and deletes the one this process
loaded its modules from. Node keeps the loaded module objects, but not
the files — so any `require`/`import` the loaded code defers past load
time resolves against the deleted directory and throws
`MODULE_NOT_FOUND`.

Every package the process loaded out of the workspace's own virtual
store is exposed to this, and an env is the worst case:
`@teambit/aspect` defers `require('./babel/babel-config')` until
`getCompiler()` is called — which the install flow itself does right
after the package-manager run, when it compiles components and reloads
envs. The install then dies with `Cannot find module
'./babel/babel-config'`.

Replacing the in-memory instances instead does not work (verified by
trying): every reload path — `reloadMovedEnvs`, loading components as
aspects — has to consult the registered env to do its work, and
consulting it is exactly what throws. `reloadMovedEnvs` is additionally
a no-op for these envs: it filters on `env.__path`/`env.id`, which only
plugin-loaded envs carry.

## Fix

Follow the rule an OS applies to a running binary's deleted files: what
the process has loaded stays available for the process's lifetime.

- Before the package-manager run, snapshot which virtual-store
directories back loaded modules; afterwards, restore any that vanished
from their re-keyed twin — same `name@version`, different peer hash —
whose package content is identical (same tarball; the peer set only
affects the sibling dependency symlinks, which are relative and stay
valid from the restored location).
- `pnpmPruneModules` skips directories backing loaded modules so it does
not re-delete a restored one. A later command's process, which has
nothing loaded from it, prunes it.
- CJS modules are found via `require.cache`. ESM modules live in node's
ESM module map, which has no enumeration API, so `aspect-loader` records
every file it loads through dynamic `import()` (the env-plugin loader
and `loadEsm()`, the only two such call sites) in a `Symbol.for`-keyed
global set that the pnpm side reads. A global symbol rather than a
shared import because the reader lives in the pnpm package manager,
which `aspect-loader` must not depend on — the dependency runs the other
way — and `Symbol.for` resolves to the same key even when a module is
duplicated in `node_modules`. Both sides document the contract and point
at each other.
- Restores run sequentially: this sits right after every install, where
the engine has just saturated the disk, and each restore is a recursive
copy. The common case is zero removed directories and the checks stay
cheap.

## Commits

Cherry-picked from #10465 (`-x` trailers reference the originals):

- `fix(deps): keep packages the running process loaded requireable
across an install that re-keys them`
- `fix(deps): extend loaded-package preservation across installs to ESM
modules`
- `fix(deps): restore removed loaded virtual-store dirs sequentially`

#10465 also carries `fix(generator): stop reporting an env-load failure
as "template not found"`, which is **not** included here: it fixes
`getTemplateWithIdOrEnvFallback`, a method that only exists on that
branch, so there is nothing on master for it to apply to. It stays with
#10465.

## Verification

- `bit test teambit.harmony/aspect-loader teambit.dependencies/pnpm` —
55/55 passing (4 new specs for the ESM recorder, 6 new for the
snapshot/donor/prune logic).
- `npm run lint` (tsc + oxlint) green.
- Behavior verified on #10465 with `deps-in-capsules.e2e.ts`: the second
install re-keys a dozen loaded `@teambit/*` slots; without the fix the
suite fails on the babel-config require, with it all tests pass and the
debug log shows each removed slot restored.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…from-manifest

The loaded-package preservation work this branch carried was upstreamed as
#10595, so master and the branch both hold it and every conflict is between
the branch's original and the reviewed version that landed. Master's version
wins throughout: it is the same work with the realpath spellings, the
patch-hash donor check, the slot-owner attribution and the prune early-return
added on top.

plugins.ts and aspect-loader's loadEsm merged cleanly into a double
recordLoadedEsmFile - the branch recorded before the load, master after it, on
the grounds that only a load that succeeded leaves something in memory worth
keeping files for. Kept master's single post-load call in both.

The branch-only aspect-loader changes (the versionless loaded-aspect lookup,
the core-aspect manifest guard, the requested-id-preserving def dedup) are
untouched - master never had them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment on lines +1809 to +1812
const envPackageName = getLegacyCoreEnvPackageName(failedIdWithoutVersion);
const isEnvPackageInstalled = fs.existsSync(path.join(this.path, 'node_modules', envPackageName));
// the missing module may be reported by its package name or by its absolute path in
// the workspace node_modules (when the require used a resolved path).

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. Legacy env failures suppressed 🐞 Bug ◔ Observability

Workspace.getWorkspaceIssues() suppresses legacy-core env MODULE_NOT_FOUND failures by checking only
<workspace>/node_modules/<envPkg>, but roots-based env resolution installs them under
node_modules/.bit_roots/<envId>/node_modules/<envPkg>. This can hide real env load failures from
bit status even when the env package is actually installed (just not hoisted to root
node_modules).
Agent Prompt
### Issue description
`Workspace.getWorkspaceIssues()` suppresses certain legacy-core env load failures when it believes the env package is "not installed yet". The installation check only looks for `<workspace>/node_modules/<envPackageName>`, but with `resolveEnvsFromRoots` the env package may be installed under the roots layout (`node_modules/.bit_roots/...`). This can incorrectly suppress real failures from `bit status`.

### Issue Context
The suppression intends to hide the expected pre-`bit install` state, not hide failures when the env is actually present.

### Fix Focus Areas
- scopes/workspace/workspace/workspace.ts[1807-1818]

### What to change
- When deciding `isEnvPackageInstalled`, check both:
  - `<workspace>/node_modules/<envPackageName>` (current)
  - `<workspace>/node_modules/.bit_roots/<failedIdWithoutVersion>/node_modules/<envPackageName>` (roots layout, note that roots dir is keyed by versionless env id per comments in the resolver)
- Only suppress when **neither** location exists.
- Keep the existing `isEnvModuleNotFound` guard, but broaden it if needed to match the roots-path form as well.

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

@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 854eed1

…s nested copy

An install that decides a hoisted copy satisfies what was nested deletes the
nested directory - and if this process loaded modules out of it, node keeps the
module objects but not the files, so any require the loaded code deferred past
load time throws MODULE_NOT_FOUND. `@teambit/aspect` defers
`require('./babel/babel-config')` until `getCompiler()`, which the install flow
itself calls right after the package-manager run, so the install dies with
`Cannot find module './babel/babel-config'`.

This is the same hazard #10595 fixed for the virtual store, reached by a
different route. That snapshot only scans `node_modules/.pnpm`, so a package
loaded from a root component's own node_modules was invisible to it and the
preservation was inert. root-components' hoisted-linker suite hit exactly this:
17 loaded directories under `.bit_roots/teambit.harmony_aspect/node_modules`
were removed by the second install.

`reloadMovedEnvs` does not cover it either - it skips any env without a
`__path`, which is only set for plugin-registered envs, and `@teambit/aspect`'s
AspectEnv is registered by its aspect provider. Reloading is no substitute
anyway: consulting the registered env is what triggers the deferred require.

So preserve these the same way: snapshot the package directories under the
workspace's node_modules that back loaded modules, and afterwards restore any
that vanished from a same-version copy found by walking the node_modules chain
up from where it used to be - what node itself would resolve now - bounded at
the workspace root. A donor must match on version, since the point is to keep
serving the files belonging to the modules already in memory. The loaded-files
plumbing shared with the virtual-store module moves to loaded-module-files.ts.

Verified with root-components.e2e.ts's hoisted-linker suite: 0 passing/1 failing
before (the before-all hook died on the babel-config require), 12 passing after,
with the debug log showing 17 of 17 removed directories restored.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 62ff0c2

… of a nested capsule's

A nested capsule install is rooted at the capsule, but it was handed the root
links keyed by the capsules root - the directory one level *above* its root. That
describes an importer outside the workspace, and the package manager then has to
give the root project itself a directory dep path relative to itself. That path
is empty, so it comes out as `<name>@file:`, which its own parser rejects:

  Failed to build lockfile from resolved dependency graph: Resolved dependency
  path "@teambit/react@file:(...)" keys no lockfile entry:
  Failed to parse suffix: Empty path after `file:` scheme

Only the first nested capsule got the links (`index === 0`), so this needed that
capsule to be a named package - which is exactly what an env capsule is. The
mis-keying dates to #10453/#10457; it stayed latent while envs were core aspects
and no env capsule was installed this way.

Give the links the install they describe: one rooted at the capsules root. It
runs before the capsules rather than alongside them, so they can resolve the core
aspects it links while they install and nothing races it over the root's
node_modules. When a cyclic group exists it is already rooted there and keeps
owning them, unchanged.

Verified against optional-dependencies.e2e.ts, whose "before all" runs
`bit create react button --env teambit.react/react`: 0 passing/1 failing before,
10 passing after. The failure needs a cold capsule cache - once the env capsule
is cached the install is rooted at the capsules root instead and the bug is
skipped - so reproduce with `rm -rf ~/Library/Caches/Bit/capsules/*` first. On CI
this cluster accounts for 12 failing e2e tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment on lines +1725 to +1726
// envs that used to be core aspects are old-style envs, they are not of type env by design
if (this.envs.isLegacyCoreEnv(envId)) return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Informational

1. Core env check ignores versions 🐞 Bug ≡ Correctness

Workspace.warnAboutMisconfiguredEnv() checks core envs via getCoreEnvsIds().includes(envId) without
stripping a version, even though EnvsMain.isCoreEnv() was updated to ignore versions for core env
IDs. If a core env ID ever reaches this method in a versioned form (the scenario is explicitly
documented in EnvsMain.isCoreEnv), the workspace may perform unnecessary resolution and emit an
incorrect misconfiguration warning.
Agent Prompt
### Issue description
`Workspace.warnAboutMisconfiguredEnv()` uses a raw string `includes()` check against `getCoreEnvsIds()`, but core env IDs are now explicitly treated as version-insensitive elsewhere (`EnvsMain.isCoreEnv()` strips `@version`). This creates inconsistent core-env detection and can mis-handle versioned core-env IDs.

### Issue Context
This method is on a hot path during component load and is used to warn about envs that are “not of type env”. With versioned core-env IDs, it may attempt resolution for a core env (which may not exist as a component) and/or warn incorrectly.

### Fix Focus Areas
- scopes/workspace/workspace/workspace.ts[1722-1726]

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

Comment on lines +248 to +251
getLoadedAspectIdIgnoringVersion(idWithoutVersion: string): string | undefined {
return this.harmony.extensionsIds.find(
(extId) => extId.split('@')[0] === idWithoutVersion && Boolean(this.harmony.extensions.get(extId)?.loaded)
);

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. Legacy env canonical id unstable 🐞 Bug ☼ Reliability

AspectLoaderMain.getLoadedAspectIdIgnoringVersion() returns the first loaded aspect ID matching the
versionless ID, making legacy-core env canonicalization dependent on Harmony extension ordering
rather than a deterministic policy (e.g. highest version).
DependencyResolverMain.getCanonicalLegacyCoreEnvId() uses this result to enforce legacy-core env
single-instance semantics, so when multiple versions are loaded it can bind to an unintended version
despite EnvsMain explicitly sorting/warning for multi-version legacy envs.
Agent Prompt
### Issue description
`getLoadedAspectIdIgnoringVersion()` uses `Array.find()` over `harmony.extensionsIds`, so when multiple versions of the same aspect/env are loaded it picks whichever was registered first. This is inconsistent with EnvsMain’s deterministic “pick highest version and warn” behavior for multi-version legacy-core envs.

### Issue Context
This function is used by dependency resolution to rewrite legacy-core env dependency IDs to a single canonical instance. If multiple loaded versions exist, order-dependent selection can lead to confusing/unstable behavior.

### Fix Focus Areas
- scopes/harmony/aspect-loader/aspect-loader.main.runtime.ts[237-251]
- scopes/dependencies/dependency-resolver/dependency-resolver.main.runtime.ts[1578-1585]
- scopes/envs/envs/environments.main.runtime.ts[304-327]

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

@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 825cfeb

@zkochan

zkochan commented Aug 10, 2026

Copy link
Copy Markdown
Member

e2e status after merging master: 20 → 8 failures

Merged master into the branch, then root-caused the e2e failures. Two were unambiguous bugs and are fixed; the rest are triaged below.

The merge itself is clean

Set comparison of failing test names, merge commit vs. the run before it: 0 new failures, 3 fixed. Every conflict was this branch's copy of the loaded-package-preservation work that master picked up as #10595, so master's reviewed version won throughout.

One thing worth flagging because git did not: plugins.ts auto-merged cleanly into a bug — a duplicated recordLoadedEsmFile call (this branch recorded before the load, master after). No conflict marker, so it would have shipped silently. Fixed to master's single post-load call.

Fixed

1. Cannot find module './babel/babel-config' — root-components, hoisted linker

The second install deletes .bit_roots/teambit.harmony_aspect/node_modules/@teambit/aspect, the directory this process loaded the env from, because the hoisted copy at the workspace root now satisfies it. Node keeps the module objects but not the files, so getCompiler()'s deferred require throws — and the install flow calls it right after the package-manager run.

snapshotLoadedVirtualStoreDirs only scans <root>/node_modules/.pnpm/, so it never saw that path and the preservation from #10595 was inert. reloadMovedEnvs cannot cover it either: it skips any env without __path, which is only set for plugin-registered envs, and @teambit/aspect's AspectEnv is registered by its aspect provider.

Fix: a sibling module preserve-loaded-nested-pkg-dirs.ts that snapshots loaded package dirs under the workspace's node_modules outside .pnpm, and afterwards restores any the install removed from a same-version copy found by walking the node_modules chain upward, bounded at the workspace root. Shared loaded-files plumbing extracted to loaded-module-files.ts.

Verified: hoisted-linker suite 0 passing/1 failing → 12 passing, debug log showing 17 of 17 removed directories restored. On CI this signature went 1 → 0.

2. Failed to parse suffix: Empty path after \file:` scheme` — 10 tests

Every bit create … --env <remote env> failed in installInCapsules:

Resolved dependency path "@teambit/react@file:(...)" keys no lockfile entry:
Failed to parse suffix: Empty path after `file:` scheme
code: pacquet_package_manager::dependencies_graph_to_lockfile

This is not the deps-graph converter — "generated a lockfile from dependencies graph" appears 0 times in the debug log and no capsule has a pnpm-lock.yaml, so convertGraphToLockfile never ran. It is pacquet's own graph→lockfile step. It is also not a dropOrphanFilePkgs gap: isFilePkgId('@teambit/react@file:(react@19.2.8)') returns true, so the sanitizer would catch it if it ever saw it.

The actual cause is in isolator.main.runtime.ts. In the useNesting path the capsules-root links were attached to the first nested capsule's install:

if (index === 0 && !cyclicCapsules.length) {
  linkedDependencies[capsulesDir] = rootLinks;   // capsulesDir is the PARENT
}
await this.installInCapsules(capsule.path, ...)   // rootDir is the CHILD

That describes an importer one level above the install root, so the package manager has to give the root project a directory dep path relative to itself — empty, hence <name>@file:. It needed the first nested capsule to be a named package, which is exactly what an env capsule is. Confirmed by dumping the in-memory manifests: keys were [<env capsule>, <capsules root>], and disabling that one line turned the suite green.

The mis-keying dates to #10453/#10457 and is latent on master — it only bites here because envs are no longer core aspects, so an env capsule gets installed this way.

Fix: give those links the install they describe, rooted at the capsules root, run before the capsules so they can resolve the core aspects it links and nothing races the root's node_modules. The cyclic-group case was already rooted correctly and is unchanged.

Verified: optional-dependencies.e2e.ts 0 passing/1 failing → 10 passing. On CI this cluster went 12 → 0.

Reproducing these locally needs a cold capsule cache. Once an env capsule is cached, the install roots at the capsules root instead and the bug is skipped — an identical re-run went green and nearly sent me down a wrong path. rm -rf ~/Library/Caches/Bit/capsules/* first.

Needs a decision: install.e2e.ts "old envs" (3 failures)

Not a code bug — the test's premise no longer holds. It asserts that the first install warns and does not apply the env's deps, and the second applies them. That worked when teambit.envs/env / @teambit/node were core aspects: always present, so the env was loadable from the start and only needed one round for its policy to apply.

Both configurations were run end to end:

  • With skipInstall: true (current): the env can never load. Its legacy-core-env chain must be installed and fails one link at a time@teambit/nodeteambit.react/react@1.0.1042teambit.harmony/aspect@1.0.1042, each surfacing only once the previous is installed. lodash.get never appears. 3 failing.
  • Without skipInstall (setCustomEnv's normal install, the shape dependency-resolver.e2e.ts uses): the env loads on the first install, so its deps apply immediately. That fixes the two second-install assertions and breaks the two first-install ones. 2 failing.

Either the env never loads or it loads too early; there is no configuration where "an old env needs exactly two installs" still describes reality, because what used to make the second install necessary is now decided by whether the env's packages were installed beforehand.

The product side is correct: bit reports add-deps-env.extension.ts -> @teambit/node precisely, and bit install --add-missing-deps resolves it — env loads, lodash.get installed.

Options, in your court:

  1. Accept load-on-first-install — drop skipInstall and rewrite the two first-install assertions; the "two installs" contract retires along with core envs.
  2. Preserve the two-install contract — keep skipInstall and seed the full LEGACY_CORE_ENVS_VERSIONS set up front, not just the packages the fixture text mentions.
  3. Retire the suite if the scenario is no longer supported post-removal.

I reverted both attempts rather than leave a half-fix that silently redefines what the test checks.

Remaining 5

Count Failure Notes
2 global-virtual-storecomp1/dist/index.js not written to the package dir; TSCompiler failure in bit tag --build not investigated
1 root-componentsreact-dom nested in comp4 pnpm regression, filed as pnpm/pnpm#13775
1 bit-import-on-lanesfailed loading env same family as the bit_pr failure below
1 build-cmdCannot read properties of undefined (reading 'files') likely a cascade from an earlier failed step

pnpm/pnpm#13775 (the react-dom one): with dedupeDirectDeps, a project's symlink that becomes redundant is not removed on a later install. Reduced to a plain-pnpm fixture, and it is a regression in 12 — 10.34.5 and 11.21.0 both remove it, 12.0.0-rc.3 does not. A clean install of the identical final state dedupes correctly, so only the incremental path is affected. Nothing to change in bit; this branch's two-phase install just exposes it.

Unrelated to these fixes

bit_pr has failed on every pipeline of this branch (12+ runs, well before the merge), currently:

teambit.mdx/aspect-docs/mdx@…
   failed loading env  (run "bit install"): bitdev.general/envs/js-env@0.0.8

Also note one CI run showed 63 failures rather than 20 — 44 of those were a registry incident (No matching version found … node-registry.bit.cloud), not regressions.

🤖 Generated with Claude Code

@GiladShoham
GiladShoham marked this pull request as draft August 10, 2026 21:01
…as a loadable env again

The suite asserts that an old-format env's dependency policy is missing after
the first install and applied after the second. It skips setCustomEnv's install
because it measures what each install does - which used to be fine, since the
envs the fixture needs were core aspects that were always present.

They are packages now, so with nothing installed the env cannot load at *any*
point and the policy is never applied: the second-install and recurring-install
assertions all fail. The chain fails one link at a time - @teambit/node first,
then teambit.react/react, then teambit.harmony/aspect - so the fixture's own
imports are only the first of what loading it needs.

State the chain as a workspace policy rather than installing it up front. The
first install then fetches it while the env is still unloadable - which is
exactly what makes it an "old env" for the first-install assertions - and the
second install finds it loadable. Installing the packages beforehand instead
makes the env load on the first install and applies the policy there, which
inverts the two first-install assertions.

Not env.jsonc, though it would sidestep the chain: `calculateEnvManifest` reads
the policy without loading the env, and `setOldNonLoadedEnvs` intersects with
`envsWithoutManifest`, so a manifest would statically apply the deps on the
first install and silence the warning - turning this suite into a duplicate of
env-jsonc-policies.e2e.ts and dropping the only coverage of the legacy path.

install.e2e.ts's old-envs suite: 2 passing/3 failing before, 5 passing after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@zkochan

zkochan commented Aug 10, 2026

Copy link
Copy Markdown
Member

Update: the "needs a decision" item is resolved — 8 → 5 failures

@zkochan asked whether env.jsonc could solve the old-envs suite. It can, and checking why led to a fix that keeps the original assertions intact — pushed in 8517cbd74.

Why env.jsonc works but isn't the right lever here. calculateEnvManifest parses env.jsonc straight from the component's files, with no env loading, and setOldNonLoadedEnvs computes intersection([...], envsWithoutManifest) — so an env carrying a manifest can never enter the non-loaded list. Giving the fixture an env.jsonc would therefore apply its deps statically on the first install and silence the warning entirely, inverting two of the five assertions and leaving a suite that duplicates env-jsonc-policies.e2e.ts. The legacy path would lose its only coverage.

What it did point at. The suite's premise is fine; the env simply had nothing to load from. The fix is to seed the legacy-core-env chain as a workspace policy instead of installing it up front:

  • as a policy, the first install fetches the chain while the env is still unloadable — which is precisely what makes it an "old env" for the first-install assertions — and the second install finds it loadable;
  • installed beforehand, the env loads on the first install and applies its policy there, inverting those same two assertions (this is what my earlier attempt did).

My first attempt failed only because I seeded two packages (@teambit/env, @teambit/node — what the fixture's own imports suggest) while loading also demands teambit.react/react and teambit.harmony/aspect. The chain surfaces one missing link at a time, so the full set is what's needed.

install.e2e.ts old-envs suite: 2 passing/3 failing → 5 passing, with every original assertion unchanged.

Remaining 5

Count Failure Notes
2 global-virtual-storecomp1/dist/index.js not written to the package dir; TSCompiler failure in bit tag --build not investigated
1 root-componentsreact-dom nested in comp4 pnpm/pnpm#13775, blocked on the engine
1 bit-import-on-lanesfailed loading env same family as the chronic bit_pr failure
1 build-cmdCannot read properties of undefined (reading 'files') likely a cascade from an earlier failed step

🤖 Generated with Claude Code

zkochan added a commit that referenced this pull request Aug 10, 2026
…compiler (#10592)

Closes #10591.

## The reported failure is not a global-virtual-store failure

`global-virtual-store.e2e.ts` → *"should compile the component into its
package directory"* failed on `remove-core-envs-from-manifest` (#10465).
That branch sets `DEFAULT_ENV` to `teambit.harmony/empty-env` — an env
that deliberately provides no compiler — and the test's component takes
the default env, so nothing compiles and no `dist` is written. The store
plays no part in it.

Reproduced on master by pinning that same env, with the store both on
and off — identical outcome:

```
COMPILE-OUTPUT-GVS:    ✔ 0/0 components compiled successfully.   dist exists: false
COMPILE-OUTPUT-NO-GVS: ✔ 0/0 components compiled successfully.   dist exists: false
```

For the other direction: build 436414 (#10589, a master-based branch
that extends this same e2e file) is green across all 2933 tests, this
one included. The build the report came from had 14 of 40 shards red,
the rest of them on that branch's own env changes (`bit create react`
failing, `Cannot find module './babel/babel-config'`).

## What is worth fixing: the run reports success

`bit compile` counted only the components it ran a compiler on. A run
where every component's env provides no compiler therefore ended in `✔
0/0 components compiled successfully.`, exit code 0, with the skip
recorded nowhere but the debug log. A 0/0 ratio counted as a success is
indistinguishable from an empty workspace, so a missing `dist` looks
like it was produced and lost rather than never produced — which is how
this came to be filed against the store.

Such a component is now carried in the results with a `skipped` reason,
kept out of the compiled ratio, and named on its own line:

```
   ⚠ my.scope/comp1 ... not compiled, the env "teambit.harmony/empty-env" provides no compiler

⚠ nothing was compiled. 1 component(s) have an env that provides no compiler.
```

Mixed with components that do compile:

```
   ⚠ my.scope/comp1 ... not compiled, the env "teambit.harmony/empty-env" provides no compiler

✔ 1/1 components compiled successfully. 1 component(s) skipped, no compiler.
```

The ratio's denominator counts what was attempted, so it no longer
stands in for what exists. Exit code is unchanged — a component with no
compiler is not a failure. `bit watch`'s per-change output ignores
skipped entries, as before.

## Also here

The global-virtual-store e2e now names the env it needs
(`teambit.harmony/node`) rather than taking whatever the default env is,
so what that file covers stays the store layout and a default-env change
cannot present itself as a store bug again.

## Testing

- new e2e in `compile.e2e.ts`: a component on `empty-env` — output names
the component and the env, and does not claim success
- `global-virtual-store.e2e.ts` and the new describe run locally: 7
passing
- `npm run lint` green
- output checked by hand for all three shapes: all-skipped, mixed, and
the unchanged all-compiled case

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
zkochan and others added 8 commits August 11, 2026 00:14
… compiler

The suite's last assertion is that the component compiles into its package
directory, but the component takes the default env - which on this branch is the
empty env, with no compiler - so no dist is ever written. The file landed on
master after this branch named an env explicitly in the e2e suites that assert a
dist, so it never got that treatment.

Use the workspace-local ts env rather than a published one: it installs nothing,
so what the global store does or does not serve is still exactly what the
assertions measure. `setBitdevNodeEnv` would install its env package and fails
to load under the global virtual store ("The requested module '@teambit/component'
does not provide an export named 'ComponentMap'"), which is a separate matter
from this suite's subject.

The "building an aspect" block still fails and is untouched: its TSCompiler
type-checks bit's own repo sources through the linked core aspects, against the
`@types/react@17` the pinned `@teambit/aspect@1.0.1042` env brings, so react 18
APIs (`startTransition`, `useSyncExternalStore`) come back as missing exports.

global-virtual-store.e2e.ts: 4 passing/2 failing before, 6 passing/1 failing after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The suite deletes the component's dist artifact from the remote and asserts on
the error that follows, so the tag has to record one. It did not: the ts env
this branch put here compiles in the workspace but emits nothing in a capsule
build - its TSCompiler task runs and reports success while the capsule ends up
with no dist directory, so builder records only the schema and package-tar
artifacts and `artifacts.find(a => a.name === 'dist')` is undefined.

A workspace compile is what made that look fine elsewhere: it copies the files
it cannot compile into the dist, so a dist appears there regardless. A build
does not copy, so nothing appears.

Use the bitdev node env instead, the same env this file already relies on for
the --loose build below, and install it after the re-import rather than
importing it - it is a published package, not a scope component.

build-cmd.e2e.ts "dist file is deleted from the remote": the before hook threw
on `undefined.files` before, 2 passing after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…'s so the bridge is the only variable

The aspect built here uses the pinned legacy aspect env, which brings older
typings than this repo does - @types/react@17 against its 19, @types/mime@1
against its 2. The type-paths bridge maps the program's `react` to whatever the
workspace hoisted, which is correct for the workspace's own packages; but bit's
own sources share that program, because a core aspect in the dev repo exposes
`types: index.ts` rather than a built `.d.ts`, and they need the repo's typings.
So the compile failed on `startTransition` and `useSyncExternalStore` missing
from react, and `getType` missing from mime - none of which is about the
reachability this suite measures.

The mismatch is broad (13 of the 19 typings packages the workspace and the repo
share differ), so pinning per package as errors appear would be endless. Take
the versions from the running repo instead, at runtime, so a repo-side bump
cannot silently reintroduce the skew.

Filtering the errors by file was the other option and would have been wrong:
with the bridge stubbed out to return no paths, all 47 resulting errors land in
bit's own sources - the same files the version skew shows up in. Ignoring them
would leave a suite that passes with the bridge fully disabled. That the guard
still bites was verified both ways: bridge on with these versions pinned is 0
errors, bridge off with them pinned is still 47.

global-virtual-store.e2e.ts: 6 passing/1 failing before, 7 passing after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… an aspect

`createAspect` configures the component with the aspect env, which used to be a
core aspect and is now a package. This suite is the one createAspect caller that
also configures the aspect in workspace.jsonc, so bit has to load it - and with
the env's package absent the load fails, the component is reported with a
"failed loading env" issue, and the snap in the before hook throws before the
import under test ever runs.

The other callers scaffold an aspect without the workspace using it, so nothing
forces the env to load and they are unaffected.

bit-import-on-lanes.e2e.ts: the before hook threw before, 19 passing after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ests after fixing the oom issue in pnpm side
… has not landed

e7cffe7 dropped e2e_test and e2e_test_bbit back to medium on the grounds that
the OOM was fixed on the pnpm side. The run it produced (#437962) says otherwise:
15 failures, 12 of them a bare `Killed`, 11 of those on the `bit install` of an
env package - @teambit/react.react-env@1.3.5, @teambit/env + @teambit/node.
2793 tests reported instead of 2948, the missing 155 being suites whose
before-hooks were killed before they could run.

The premise has not been met yet. pnpm/pnpm#13681 - the engine still peaking
about 2x pnpm v10, 4.4GB for a 3.8k-package graph - is open, so the peak does
not fit in medium's 4GB whichever way the fix lands. This branch is also pinned
to @pnpm/napi 12.0.0-rc.1, behind the rc.2/rc.3 that any such fix would ship in,
so it could not have picked one up.

Restored with the reasoning in the file so the next attempt starts from what to
re-measure: once #13681 closes and the engine is bumped here, drop this and
watch for `Killed`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…tion spec

The default-export describe builds its harmony without TypescriptAspect. On
this branch the mock component gets the default empty-env, which provides no
schema extractor; the fallback extractor is registered by the typescript
aspect, so without it getSchema swallows the no-extractor error, falls back to
a non-existent artifact and returns an empty schema - failing the test with
"a `default` export should exist: expected undefined to exist".

The two sibling describes in this file were already patched exactly this way
(eedfdcd). This describe landed on master after that (#10378) and arrived
via the 886f6a2 merge with no textual conflict, so the semantic conflict
went unnoticed.

Also assert schema availability in the before hook, so a future regression
fails fast with the actual reason (e.g. NO_EXTRACTOR) instead of a puzzling
empty-exports assertion.

Verified: bit test teambit.semantics/schema - 39 passing / 1 failing before,
40/40 passing after.
@zkochan

zkochan commented Aug 11, 2026

Copy link
Copy Markdown
Member

The e2e OOM is not fixable on the pnpm side — measurements

After e7cffe7d1 put e2e_test back on resource_class: medium, job #437962 came back with 15 failures, 12 of them a bare Killed — 11 of those on the bit install of an env package, and 2793 tests reported instead of 2948 (the missing 155 are suites whose before-hooks were killed before they could run).

I went looking for the pnpm-side fix and could not find one. The measurements below are why.

The engine improvements already landed, and are already in use

@pnpm/napi 12.0.0-rc.1 — what this branch pins — already contains both memory fixes:

v12.0.0-rc.1 contains ea90e2dd56   (pnpm/pnpm#13686, packument cache)
v12.0.0-rc.1 contains a8ce5f6575   (pnpm/pnpm#13695, peer-issue parent chains)

(git merge-base --is-ancestor <commit> v12.0.0-rc.1.) rc.1 was published after both merged. Bumping to rc.2/rc.3 adds nothing relevant — 11 commits, one perf commit, and it is CLI startup overhead. So job #437962 is the re-test pnpm/pnpm#13681 was waiting for, and the answer is that the fixes landed, helped, and were not enough for this workload at 4 GB.

The OOM predates the Rust engine entirely

From CircleCI history: job 430568, 2026-07-24, this same branch, commit e9adba6a8d — byte-for-byte the same test, same bare Killed, on a commit with no @pnpm/napi entry at all. That is the TypeScript engine failing identically.

Rate across the v12 merge: pre-v12 49/511 (9.6%), post-v12 7/48 (14.6%), Fisher exact p = 0.31. And 0 of 56 OOM-affected jobs were on master — it is a PR-branch phenomenon, with this branch accounting for 44 of them, well before v12 existed.

The Rust engine is not a regression here. It uses less than the TypeScript one did.

The install is not where the memory goes

The decisive measurement. Same command, same packages, same warm store:

context peak
bit install <4 env packages> in an empty workspace ~400 MB
the same install inside the e2e suite ~3.1 GB

Fetching and linking that closure costs ~400 MB. The other ~2.7 GB is what bit does around it, because the e2e workspace has components and a custom env — per-component dependency resolution, capsule isolation, aspect loading, compile. This matches an earlier profile on this branch where the peak was bit tag --build at 2.2 GB with an 843 MB tsc child, again bit's own work rather than the install path.

Reproduced under CI's exact budget (systemd-run -p MemoryMax=4G -p CPUQuota=200%, NODE_OPTIONS=--max-old-space-size=5000 as e2e-test-circle uses). At peak, only two processes are above 200 MB:

3153MB  node bbit install @teambit/react.react-env @teambit/typescript.typescript-compiler ...
 400MB  node mocha.js

Things that do not move it

  • PNPM_MAX_WORKERS is a no-op under v12. The engine reads exactly one PNPM_* variable, PNPM_AUTO_APPROVE_BUILDS_FOR_TESTS. The caps in .circleci/config.yml (bit_pr: 3, setup_harmony: 6) and their comments are dead weight — they were TypeScript-engine knobs. Worth deleting so they stop implying protection that is not there.
  • Concurrency settings apply but change nothing. networkConcurrency: 8 + maxSockets: 4 (set through teambit.dependencies/dependency-resolver, the only channel that actually reaches the engine — verified in debug.log; npm_config_* and a user .npmrc never get past bit's explicitSettings gate) gave 330 MB vs 334 MB. Expected, given the fetch path is ~400 MB of a 3.5 GB peak.
  • The memory is not V8 heap. Halving the child heap cap (--max-old-space-size 2048 → 1024) moved the peak by ~14 MB. A heap-bound process would have dropped ~1 GB.
  • Bit already serializes everything it controls. NESTED_CAPSULE_INSTALL_CONCURRENCY = 1, and only one bit process is alive at peak. There is no parallel install left to make blocking.

What is actually left

The workload is the driver, and it is one this branch creates: removing core envs turns envs into installed components, so suites that previously needed no install now install and process multi-thousand-package closures. That is why this branch dominates the OOM history both before and after v12.

Levers, in the order I would take them:

  1. resource_class: medium+ for e2e_test / e2e_test_bbit. The only change I can demonstrate works.
  2. Reduce what the env-heavy suites install — several pull four env packages where one would do.
  3. Profile the bit process during a populated-workspace install if the ~2.7 GB needs attributing. That is a bit-side investigation, not an engine one.

Caveats

The single-run comparisons in the 4 GB context are noisy — peak anon ranged 3452–3985 MB across arms, so no one pair of runs proves an effect. The conclusions above rest on things outside that noise: the source reading for PNPM_MAX_WORKERS, the ~8x gap between the empty-workspace and e2e installs, and the CI history for the pre-v12 OOM. My local store is warm, so a cold CI store would fetch more — but the empty-workspace run still performed the full link of the closure and stayed at ~400 MB. I have not attributed the ~2.7 GB precisely; that needs profiling, not more engine tuning.

🤖 Generated with Claude Code

@zkochan

zkochan commented Aug 11, 2026

Copy link
Copy Markdown
Member

Follow-up: why master doesn't OOM on the same 4 GB

The obvious objection to the above is "master runs the same suite and is fine". It does — on the same budget. Master's e2e_test and e2e_test_bbit are both resource_class: medium, identical to this branch after e7cffe7d1. So this is not a CI configuration difference at all.

What changed is what a single helper call costs. setCustomEnv:

// master
if (!options.skipInstall) this.command.install();

// this branch
this.command.install([ENVS_ENV_PACKAGE, ...this.getFixtureEnvBasePackages(extensionsBaseFolder)].join(' '));

On master that bit install pulls essentially nothing: the envs the fixture needs — teambit.envs/env, teambit.harmony/node, teambit.react/react, teambit.harmony/aspect — are core aspects, bundled with the binary and already present. On this branch they are published packages, so the same call now installs each env's full dependency closure and then processes it.

The multiplier:

master this branch
explicit install('@teambit/…') in e2e/ 4 (across 4 files) 14 (across 12 files)
setCustomEnv / setCustomNewEnv call sites 65 66
does that helper install env packages? no yes

So the cost is not the ten extra explicit installs. It is that ~65 pre-existing call sites, untouched in the test files, silently became multi-thousand-package installs. Every suite that scaffolds a custom env now carries the ~3.1 GB profile measured above, where on master it carried almost none.

That is consistent with everything else in this thread:

  • Why only this branch, historically — 44 of 56 OOM-affected jobs, and it OOM-ed identically before the Rust engine existed. The workload changed; the engine did not.
  • Why more RAM is the only thing that demonstrably works — the cost is intrinsic to installing and processing those closures, not to any tunable inside pnpm.
  • Why "reduce what the suites install" is the real bit-side lever — it is aimed at those ~65 call sites, not at the ten explicit ones.

Worth stating the milder reading too: e2e may simply be reporting a genuine product change faithfully. A user upgrading to a bit without core envs pays this install cost once per workspace. This suite pays it ~65 times per run, inside 4 GB, alongside mocha and verdaccio.

Which is the argument for fixing it in the helper or the resource class rather than in pnpm: the engine is being asked to make a workload fit that master never asked it to run.

🤖 Generated with Claude Code

GiladShoham and others added 2 commits August 12, 2026 00:41
BabelMain.createCompiler() has zero callers in shipped app code - only
in docs examples and e2e fixtures (babel-env, multiple-compilers-env),
same shape as the earlier mocha removal. Fixed the two e2e fixtures to
install @teambit/babel as a regular package (it's no longer a core
aspect), matching how react/aspect/node were already handled.
setCustomEnv installs the env packages and then compiles. `bit install`
compiles the workspace itself, so the workspace was compiled twice per
call, and a workspace compile is the memory peak of an env-scaffolding
test. Pass --skip-compile when the explicit compile is going to run
anyway; when the caller asked for no compile, leave the install's
compile alone as the only one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@zkochan

zkochan commented Aug 12, 2026

Copy link
Copy Markdown
Member

The engine-side memory fix now exists: pnpm/pnpm#13844

Following up on the OOM thread, and on 2e402f8ec's note that the engine fix "has not landed" — I found the root cause and opened a PR for the part that is safe to land now.

Root cause

Peer resolution realizes one tree node per distinct root-to-package path. Replaying the exact options bit passes to @pnpm/napi for the env-package install (5 importers, 3,807 merged packages), the walk realizes 2,008,313 occurrences to produce a 6,133-node graph — 327x amplification. Peak RSS 3670 MB, of which only ~90 MB is V8 heap.

Why so many occurrences:

WALK_STATS full_walks=188707 ended_in_cycle=171508 ended_pure=6806 ended_cacheable=10393
HIT_STATS  find_hit_calls=188862 miss_no_bucket=51803 miss_context_mismatch=136904 hits=155

Children are only realized for a node that misses the peers cache. 91% of full walks end in a cycle, and a cycle-resolved walk caches nothing by design (pnpm#5108 — its truncated subtree makes its peer sets non-authoritative). So only 10,393 walks ever populate the cache, find_hit succeeds 155 times in 188,862 lookups (0.08%), and nearly every occurrence re-walks its whole subtree.

@teambit/* packages peer-depend on each other heavily, which is exactly the shape that triggers this. That is why this branch — which turns the core envs into installed packages — surfaces it and master does not.

What landed in the PR

pnpm/pnpm#13844 cuts the per-occurrence cost without touching the algorithm:

  • DependenciesTreeNode held four wanted-lockfile fields inline that are None for every node the resolver produces today (~88 bytes each). Boxed: 144 -> 64 bytes/node, tree table 676 -> 356 MB.
  • TreeChildren::Realized held its children map by value, so the whole BTreeMap was cloned on each revisit and stored twice. Now shared via Arc.
  • Duplicate pending peer edges dropped: 2,224,865 -> 114,778 entries.

3670 -> 3224 MB, -12% (n=3 each, non-overlapping ranges), no wall-time change, 314 resolver tests passing.

What this means for medium+

-12% is real but it is not the 4 GB fix. The remaining 88% is the occurrence explosion itself, which needs the cycle-caching rule reworked — that changes what gets cached and can produce wrong lockfiles if the invariant is wrong, so I left it for a maintainer decision rather than guessing. Keeping medium+ is the right call until that lands.

Also worth knowing

The engine is not deterministic run to run. Two stock runs of the identical install produce lockfiles differing by 197 diff lines (classnames, tippy.js, lodash, postcss landing on different versions). This is unrelated to the memory work — I found it while trying to use lockfile equality to validate the patch — but it means lockfile churn on this branch is not necessarily anyone's change.

Full analysis in pnpm/pnpm#13681.

🤖 Generated with Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants