Skip to content

Dependency graph: lockfile overwrite on re-import causes version drift #10317

@zkochan

Description

@zkochan

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

  1. Enable BIT_FEATURES=deps-graph and set rootComponents: true in workspace.jsonc.
  2. Export comp1 (depends on foo@^100.0.0, lockfile resolves to foo@100.0.0) and comp2 (depends on bar@^100.0.0).
  3. In a fresh workspace, bit import comp1@latestpnpm-lock.yaml restored from comp1's graph with foo@100.0.0 locked.
  4. Bump the registry so foo@100.1.0 becomes the latest matching version.
  5. bit import comp2@latestpnpm-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`:

  1. importing a component into a workspace that already has an installed component — expected to fail today; documents the drift of unrelated deps.
  2. re-importing an updated version of an already-imported component — expected to fail today; same drift for the `bit import` update path.
  3. three components sharing a peer dependency — extends the existing 2-component highest-wins case to 3 merged graphs.
  4. 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`).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions