Summary
The dependency graph stored with each signed component (dependenciesGraphRef on the Version object) is used to regenerate pnpm-lock.yaml on import. The regeneration overwrites the existing workspace lockfile instead of merging into it. Because ComponentWriterMain.installPackagesGracefully only passes the IDs of the components just written to Scope.getDependenciesGraphByComponentIds, subsequent imports into an already-populated workspace can silently drift the locked versions of unrelated workspace dependencies.
Feature is gated behind the DEPS_GRAPH feature toggle and rootComponents: true, so it is off by default — but when enabled, the behavior above is surprising.
Repro path
- Enable
BIT_FEATURES=deps-graph and set rootComponents: true in workspace.jsonc.
- Export
comp1 (depends on foo@^100.0.0, lockfile resolves to foo@100.0.0) and comp2 (depends on bar@^100.0.0).
- In a fresh workspace,
bit import comp1@latest → pnpm-lock.yaml restored from comp1's graph with foo@100.0.0 locked.
- Bump the registry so
foo@100.1.0 becomes the latest matching version.
bit import comp2@latest → pnpm-lock.yaml is regenerated from comp2's graph only. foo is no longer in the lockfile, so pnpm re-resolves it from the manifest specifier and picks foo@100.1.0.
Same class of drift also hits the "pull new version of an already-imported component" path.
Code pointers
scopes/component/component-writer/component-writer.main.runtime.ts:91-114 — only passes IDs of newly-written components:
```ts
installationError = await this.installPackagesGracefully(
opts.components.map(({ id }) => id), // only the components just written
opts.skipWriteConfigFiles
);
...
dependenciesGraph: await this.workspace.scope.getDependenciesGraphByComponentIds(componentIds),
```
scopes/dependencies/pnpm/pnpm.package-manager.ts:94-104 — writes the graph-derived lockfile directly, no read-merge-write:
```ts
const lockfile: LockfileFile = await convertGraphToLockfile(dependenciesGraph, { ...opts, resolve });
Object.assign(lockfile, { bit: { restoredFromModel: true } });
const lockfilePath = join(opts.rootDir, 'pnpm-lock.yaml');
await writeLockfileFile(lockfilePath, lockfile);
```
Proposed fix
In PnpmPackageManager.dependenciesGraphToLockfile, read the existing wanted lockfile first and overlay the graph-derived packages, snapshots, and affected importers onto it. Graph entries win for the imported component's subtree; everything else preserved. Alternative (heavier): fetch graphs for the whole workspace on every install and merge them before conversion — still doesn't cover workspace-only deps that were never graph-stored, so option A is preferred.
Other issues surfaced during audit
- Cached-graph mutation (
components/legacy/scope/scope.ts:745 + scopes/scope/objects/models/dependencies-graph.ts:79). getDependenciesGraphByComponentIds assigns the first loaded graph to `allGraph` and then calls `allGraph.merge(other)`. That graph is the cached `Version._dependenciesGraph`, so the merge mutates the cache in place; subsequent calls starting with the same component observe an already-merged graph.
- Duplicate root edges (
dependencies-graph.ts:99). After smart-merging direct-deps neighbours in place, the method pushes all edges of the other graph including its root edge. findRootEdge() only returns the first match so behavior is correct today, but the list keeps growing on each merge.
- Duplicate non-root edges. Edges are concatenated without dedup. If two graphs carry the same edge ID with different neighbour lists, `convertGraphToLockfile` does `snapshots[edge.id] = {}` on each iteration and the later iteration clobbers the earlier one silently.
- Silent drops in lockfile reconstruction (
scopes/dependencies/pnpm/lockfile-deps-graph-converter.ts:304-314). A manifest dep whose specifier doesn't match any root edge by `(name, specifier)` or `name@specifier` is skipped without warning.
- Schema completeness. `buildPackages` (line 182) preserves a 12-field subset of `LockfilePackageInfo`. Not round-tripped: `bin`, `dev`, package-level `optional`, `patchedDependencies`, `overrides`, `packageExtensions`, registry hints, `ignoredOptionalDependencies`. Also `resolve()` recovery only extracts `integrity`, dropping tarball/commit fields.
Test coverage added in advance of the fix
New blocks in `e2e/harmony/deps-graph.e2e.ts`:
- importing a component into a workspace that already has an installed component — expected to fail today; documents the drift of unrelated deps.
- re-importing an updated version of an already-imported component — expected to fail today; same drift for the `bit import` update path.
- three components sharing a peer dependency — extends the existing 2-component highest-wins case to 3 merged graphs.
- dev and optional dependencies round-trip through the graph — documents lifecycle flags survive tag → import.
Follow-up coverage not yet added
- Missing graph `Source` (older scope stored only the ref) — fallback path.
- `patchedDependencies` / `overrides` from `workspace.jsonc` — may not survive the graph at all.
- Graph entries carrying `resolution.type === 'directory'` that need re-resolve at import.
- Specifier drift between tag time and import time (component tagged against `^1.0.0`, workspace now pins `1.2.3`).
Summary
The dependency graph stored with each signed component (
dependenciesGraphRefon theVersionobject) is used to regeneratepnpm-lock.yamlon import. The regeneration overwrites the existing workspace lockfile instead of merging into it. BecauseComponentWriterMain.installPackagesGracefullyonly passes the IDs of the components just written toScope.getDependenciesGraphByComponentIds, subsequent imports into an already-populated workspace can silently drift the locked versions of unrelated workspace dependencies.Feature is gated behind the
DEPS_GRAPHfeature toggle androotComponents: true, so it is off by default — but when enabled, the behavior above is surprising.Repro path
BIT_FEATURES=deps-graphand setrootComponents: trueinworkspace.jsonc.comp1(depends onfoo@^100.0.0, lockfile resolves tofoo@100.0.0) andcomp2(depends onbar@^100.0.0).bit import comp1@latest→pnpm-lock.yamlrestored from comp1's graph withfoo@100.0.0locked.foo@100.1.0becomes the latest matching version.bit import comp2@latest→pnpm-lock.yamlis regenerated from comp2's graph only.foois no longer in the lockfile, so pnpm re-resolves it from the manifest specifier and picksfoo@100.1.0.Same class of drift also hits the "pull new version of an already-imported component" path.
Code pointers
scopes/component/component-writer/component-writer.main.runtime.ts:91-114— only passes IDs of newly-written components:```ts
installationError = await this.installPackagesGracefully(
opts.components.map(({ id }) => id), // only the components just written
opts.skipWriteConfigFiles
);
...
dependenciesGraph: await this.workspace.scope.getDependenciesGraphByComponentIds(componentIds),
```
scopes/dependencies/pnpm/pnpm.package-manager.ts:94-104— writes the graph-derived lockfile directly, no read-merge-write:```ts
const lockfile: LockfileFile = await convertGraphToLockfile(dependenciesGraph, { ...opts, resolve });
Object.assign(lockfile, { bit: { restoredFromModel: true } });
const lockfilePath = join(opts.rootDir, 'pnpm-lock.yaml');
await writeLockfileFile(lockfilePath, lockfile);
```
Proposed fix
In
PnpmPackageManager.dependenciesGraphToLockfile, read the existing wanted lockfile first and overlay the graph-derivedpackages,snapshots, and affectedimportersonto it. Graph entries win for the imported component's subtree; everything else preserved. Alternative (heavier): fetch graphs for the whole workspace on every install and merge them before conversion — still doesn't cover workspace-only deps that were never graph-stored, so option A is preferred.Other issues surfaced during audit
components/legacy/scope/scope.ts:745+scopes/scope/objects/models/dependencies-graph.ts:79).getDependenciesGraphByComponentIdsassigns the first loaded graph to `allGraph` and then calls `allGraph.merge(other)`. That graph is the cached `Version._dependenciesGraph`, so the merge mutates the cache in place; subsequent calls starting with the same component observe an already-merged graph.dependencies-graph.ts:99). After smart-merging direct-deps neighbours in place, the method pushes all edges of the other graph including its root edge.findRootEdge()only returns the first match so behavior is correct today, but the list keeps growing on each merge.scopes/dependencies/pnpm/lockfile-deps-graph-converter.ts:304-314). A manifest dep whose specifier doesn't match any root edge by `(name, specifier)` or `name@specifier` is skipped without warning.Test coverage added in advance of the fix
New blocks in `e2e/harmony/deps-graph.e2e.ts`:
Follow-up coverage not yet added