Skip to content

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

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@latest → pnpm-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@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`:

  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`).

Activity

  1. zkochan commented on Apr 20, 2026

    @zkochan
    MemberAuthor

    Test run results

    Ran each new `describe` block in isolation via `.only`:

    Test Result
    `importing a component into a workspace that already has an installed component` 2 pass, 1 fail — drift reproduced: after importing a second component, `foo@100.0.0` is missing from `pnpm-lock.yaml` (pnpm re-resolves to the newer `foo@100.1.0`)
    `re-importing an updated version of an already-imported component` 1 fail — same drift on the update path: `bar@100.0.0` is replaced with `bar@100.1.0` after re-importing comp1
    `three components sharing a peer dependency` 3 pass (third assertion documents a newly-surfaced bug, see below)
    `dev and optional dependencies round-trip through the graph` 3 pass

    New finding from the 3-component merge

    Unexpected behavior surfaced while verifying the 3-way merge. Setup: three components (bar, foo, baz) all consume a package that carries a peer dep on `@pnpm.e2e/abc`. Their tag-time `abc` versions were 1.0.0, 2.0.0, 1.0.0 respectively. After import:

    • `abc@2.0.0` is present (highest-wins on the root edge) — correct.
    • `abc@1.0.0` is also present.

    Root cause: `DependenciesGraph.merge` applies highest-wins to the root edge only. Transitive snapshots from the lower-version graphs (e.g. baz's snapshot of bar, which references `abc@1.0.0`) are carried over verbatim. pnpm then sees a live reference to `abc@1.0.0` and keeps it in the lockfile.

    Peer packages don't leak the same way because pnpm reconciles peers across importers at install time. Regular deps do.

    Net effect: lockfiles grow with every 3-way merge, and unused older versions of non-peer packages may be downloaded and linked.

    Status of the fix in `PnpmPackageManager.dependenciesGraphToLockfile`

    Not started. Two gaps now clearly documented in tests: (1) re-import drift, (2) transitive lower-version leakage in 3-way merges.

  2. zkochan commented on Apr 20, 2026

    @zkochan
    MemberAuthor

    Fix landed in #10318 (ready for review)

    Both drift paths are fixed. All 20 `e2e/harmony/deps-graph.e2e.ts` cases pass locally.

    What changed

    • `PnpmPackageManager.dependenciesGraphToLockfile` now reads the existing wanted lockfile and overlays the graph-derived `importers` / `packages` / `snapshots` onto it. Importer merge is per-project / per-dep-type / per-dep-key, which keeps unrelated projects' locked deps intact even though `convertGraphToLockfile` emits a sparse importer entry for every workspace project.
    • `Scope.getDependenciesGraphByComponentIds` clones the first loaded graph before merging. Previously it mutated `Version._dependenciesGraph` in place and leaked merged state to subsequent callers of the same component.

    Still open, tracked here

    • Merge leaves lower-version non-peer transitives in the lockfile (surfaced by the new 3-component test). The root edge upgrades, but snapshots from the lower-version graphs still reference the older version, and pnpm keeps both.
    • Coverage not added yet: missing graph Source fallback, `patchedDependencies`, `overrides`, `directory`-type resolutions, specifier drift between tag and import.
    • Duplicate root edges accumulated by `merge()` (cosmetic today, but makes the graph grow on every merge).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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