Skip to content

Native Yarn Plug'n'Play module resolution#4568

Closed
ejc3 wants to merge 1 commit into
microsoft:mainfrom
ejc3:feat/yarn-pnp-resolution
Closed

Native Yarn Plug'n'Play module resolution#4568
ejc3 wants to merge 1 commit into
microsoft:mainfrom
ejc3:feat/yarn-pnp-resolution

Conversation

@ejc3

@ejc3 ejc3 commented Jul 8, 2026

Copy link
Copy Markdown

Re #460.

Draft — for design feedback, not merge. Feature work is on hold until 7.0 ships; I'm opening this only to get direction on the approach for the tracked Yarn PnP issue, and I'm happy to park it until the freeze lifts.

What

Native Yarn Plug'n'Play resolution. When a .pnp.data.json or .pnp.cjs manifest is found (walking up from the tsconfig / cwd), tsgo resolves modules from the manifest instead of a node_modules tree — so a PnP-installed project type-checks without the pnpify / editor-SDK shim TypeScript needs under PnP today.

No manifest found → resolution is byte-for-byte the classic walk. The whole feature is inert off-PnP; the existing compiler / execute / api suites pass unchanged.

How

Three pieces.

1. internal/pnp — manifest loader + resolver (a port of Yarn's RESOLVE_TO_UNQUALIFIED + FIND_LOCATOR).

  • Resolve(specifier, importerDir): bare-identifier parse → owning-locator lookup (longest-prefix walk up the importer path) → dependency target (direct / aliased / null-peer) → top-level fallback → the package directory + subpath.
  • Returns a typed status so the caller distinguishes not PnP-governed (fall through to the classic walk) from governed but unresolved (stop).
  • Package locations are returned in Yarn's __virtual__ space, not dereferenced — the locator table is keyed by those raw paths, so a file resolved under a virtual location must keep it for FIND_LOCATOR to identify the owning package when resolving that package's own imports.
  • Load reads the .pnp.data.json sidecar, or extracts the inlined RAW_RUNTIME_STATE literal from a default .pnp.cjs (no JS engine). ignorePatternData compiles to a Go regexp (dropping the negative-lookahead fragments RE2 rejects).

2. internal/vfs/zipoverlay — a vfs.FS decorator for reading package files.

  • Under PnP a package location can sit inside a cache .zip (…/cache/react-npm-18.3.1-<hash>.zip/node_modules/react/index.d.ts). The OS reports the .zip as a regular file, so every interior path fails FileExists/DirectoryExists and the resolver's parent checks abort. The overlay serves interior paths from the archive and reports interior directories as directories.
  • It dereferences __virtual__/$$virtual paths at the point of the read — to a cache .zip (npm package) or a plain directory (workspace / unplugged package). Realpath returns virtual/zip paths as identity, so program paths stay in the locator space.
  • Non-PnP paths delegate unchanged. Zip-interior lookups are case-sensitive (a documented limitation on case-insensitive hosts).

3. internal/module/resolver.go — wiring. loadModuleFromNearestNodeModulesDirectory consults the manifest before the ancestor node_modules walk:

  • a hit returns directly;
  • governed but unresolved (undeclared dep / unfulfilled peer) skips the ancestor walk — so a phantom dependency isn't picked up from a stray node_modules — while still allowing typeRoots;
  • a non-governed (ignored) importer falls through to the classic walk unchanged.

The three production FS construction sites (cmd/tsgo/sys.go, cmd/tsgo/lsp.go, internal/api/server.go) wrap the OS FS with the overlay.

Tests

Hermetic Go unit tests, no on-disk fixtures beyond t.TempDir zips.

internal/pnp — resolution: workspace dep · npm dep inside a .zip · subpath carry-through · aliased target · unfulfilled (null) peer · undeclared→NotFound vs ignored-importer→Skipped vs relative-importer→Skipped · top-level fallback + exclusion list · transitive import from inside a virtualized package (the case that must not fall through to root) · virtual location kept + findLocator round-trip.

internal/pnp — loader + DerefVirtualPath: inlined .pnp.cjs (default + tight spacing, marker requires =) · malformed / missing / marker-less rejected · the dereference cases checked against Yarn berry's own VirtualFS.resolveVirtual suite ($$virtual, non-numeric count left unchanged, count 0/1 with/without file, depth > parents, dots, empty string).

internal/vfs/zipoverlay — read / stat / list / walk inside a .zip · non-.zip virtual → plain-dir delegation · zip-backed virtual → served from archive · missing-.zip and .zip-substring edge cases.

Open questions

  • Discovery is automatic (walk up for .pnp.*). Keep it implicit, or gate behind an explicit option / env?
  • Inlined .pnp.cjs: extracting the literal without a JS engine is pragmatic but isn't a JS parser — acceptable, or restrict to the .pnp.data.json sidecar?
  • PnP + exports / imports conditions — this resolves to the package directory and reuses the existing exports resolution; worth a closer look?
  • Case-insensitive hosts — zip-interior lookups are currently case-sensitive; is folding worth adding here, or better handled upstream?

Happy to split into loader / overlay / resolver-wiring commits if that reviews more easily.


Disclosure: authored with Claude Code (agent-assisted); I've read and understood the result and will shepherd it through review myself, per CONTRIBUTING's AI-assistance policy.

Under Yarn PnP there is no node_modules tree; a manifest (.pnp.data.json, or
.pnp.cjs with the data inlined — Yarn's default) maps every (package, importer)
pair to the package's on-disk location, which for npm packages is a path inside a
Yarn cache .zip archive. tsc gets PnP support because Yarn ships a runtime patch
for it; the native binary cannot be patched that way, so tsgo fails to resolve any
dependency in a PnP project (TS2307). This implements PnP resolution natively.

- internal/pnp: loads and compiles a PnP manifest (a .pnp.data.json sidecar, or
  the JSON inlined in a .pnp.cjs, extracted from its RAW_RUNTIME_STATE literal)
  and answers RESOLVE_TO_UNQUALIFIED (package identifier + importer directory ->
  package directory), following the Yarn PnP specification and the reference
  resolver's actual behavior where it diverges: the trailing-slash longest-prefix
  locator loop, the top-level fallback (gated, like Yarn's makeApi, so the
  synthetic top-level locator never falls back and workspaces are excluded via the
  fallbackExclusionList), unfulfilled-peer vs not-found, and aliased targets.
  Discovery walks up from the tsconfig/current directory for a manifest.
  Resolved package locations are kept in Yarn's __virtual__/<hash>/<n> space (not
  dereferenced): the locator table is keyed by these paths, so a file resolved
  under a virtual location must keep it for findLocator to identify the owning
  package when resolving that package's own imports.

- internal/vfs/zipoverlay: a vfs.FS decorator that serves files inside Yarn cache
  .zip archives, reporting the archive and its interior directories as directories
  so the module resolver's parent-directory checks pass. It dereferences
  __virtual__/$$virtual paths to their backing location (a cache .zip or a plain
  workspace/unplugged directory) only at the point of a filesystem read, while
  Realpath returns virtual/zip paths as identity so program paths stay in the
  locator space. Non-PnP paths delegate unchanged. Inserted beneath the
  bundled/cached FS wrappers at the tsc, LSP, and API-server construction sites.
  Zip-interior lookups are case-sensitive (a documented limitation on
  case-insensitive hosts).

- internal/module/resolver: when a PnP manifest governs the project, the nearest
  node_modules walk is preceded by a PnP lookup that mirrors the walk's shape
  (preferred TS/DTS extensions on the implementation package, then DTS in the
  matching @types package, then fallback JS/JSON). When PnP governs the importer
  but the specifier does not resolve through the manifest, the classic ancestor
  walk is skipped (so an undeclared "phantom" dependency is not picked up from a
  stray node_modules) while typeRoots still runs. When no manifest is found the
  branch is a no-op, so non-PnP projects are unaffected.

Hermetic Go unit tests cover the manifest loader, resolution (workspace/npm/
aliased/peer/undeclared, top-level fallback and exclusion, transitive imports
from a virtualized package), the __virtual__ dereference (verified against Yarn
berry's VirtualFS resolveVirtual cases), and the zip overlay.

Refs microsoft#460
@jakebailey

Copy link
Copy Markdown
Member

We already have #1966

I don't think we need a second PR for this unless it's a group effort or this is better in some specific way? (I don't think your agent looked for existing PRs.)

@ejc3

ejc3 commented Jul 8, 2026

Copy link
Copy Markdown
Author

We already have #1966

I don't think we need a second PR for this unless it's a group effort or this is better in some specific way? (I don't think your agent looked for existing PRs.)

Yeah, read the issue but not the open PR's… I'll make sure there aren't any edge cases missed with the other since I ran into several in the loop.

@ejc3

ejc3 commented Jul 8, 2026

Copy link
Copy Markdown
Author

Closing this — @jakebailey is right that #1966 is the established effort (Datadog, reviewed by @arcanis, in production), and it's a strict superset of what I had here. My mistake for not checking for an existing PR before opening.

Rather than a duplicate, I've put the useful part into a small PR against #1966's branch: its TestResolveUnqualified was only running the first of the five imported berry/esbuild expectation suites (an if si != 0 { continue }), so ~11 cases never ran — and enabling them surfaced two real bugs (fallback-pool aliases, and ignorePatternData importers not falling back to the classic walk), which that PR fixes. Sorry for the noise here.

@ejc3

ejc3 commented Jul 8, 2026

Copy link
Copy Markdown
Author

The test-coverage + fixes PR is up against #1966's branch: GGomez99#8

It removes the if si != 0 { continue } that limited TestResolveUnqualified to 1 of 5 imported suites, and fixes the two bugs that enabling the other suites surfaces (fallback-pool aliases; ignorePatternData importers not falling back to the classic walk).

@ejc3

ejc3 commented Jul 8, 2026

Copy link
Copy Markdown
Author

@microsoft-github-policy-service agree

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.

2 participants