diff --git a/docs/pm/global-store.mdx b/docs/pm/global-store.mdx index 54d598a95f8..8b07f23ba70 100644 --- a/docs/pm/global-store.mdx +++ b/docs/pm/global-store.mdx @@ -119,6 +119,7 @@ An entry only lives in the global store when it can be safely shared. Entries fa - the package has a **patch** applied via `bun patch` — the patched contents are project-specific; - the package is listed in **`trustedDependencies`** (or trusted via `bun add --trust`) — its lifecycle script may mutate the install directory, and a script running through the project symlink would mutate the shared copy; +- the package **exposes TypeScript declarations** — Bun runs the same entry-point resolution TypeScript uses (modelled on [arethetypeswrong.github.io](https://arethetypeswrong.github.io/)) against each package's `package.json`. `"exports"` with a `"types"` condition (including dual `.d.mts`/`.d.cts` objects and array-of-fallbacks), `"exports"` whose resolved JS target has a sibling declaration, top-level `"types"` / `"typings"`, non-empty `"typesVersions"`, and the implicit `index.d.ts` fallback all count. TypeScript resolves peer types (e.g. `React.FunctionComponent` in a package that never declared `@types/react` as a peer dependency) by walking `node_modules` ancestors from the declaration file's realpath; if that realpath is in the shared store those walks never reach the project's `@types/*`. Keeping type-shipping packages project-local puts their realpath back under `node_modules/.bun//`, whose ancestor walk reaches the hidden hoisted layer at `node_modules/.bun/node_modules/`. - the package, or **any** dependency it links to, is a `workspace:`, `file:`, or `link:` dependency — those resolve to project-local paths that other projects can't see. Ineligibility propagates: if `your-app` depends on `internal-utils` which is a workspace package, `internal-utils` is project-local, and so is every entry that links to it. An entry that loses eligibility between installs (newly patched, newly trusted) is detached from the global store and rebuilt project-locally on the next install; the shared entry is left untouched. @@ -135,6 +136,8 @@ When packages live under the project's `node_modules/.bun/`, Node's module resol In practice this only affects **true phantom dependencies**: a package doing `require('helper')` for something it never declared in `dependencies`, `peerDependencies`, or `peerDependenciesMeta`. If you hit this, add the helper to the consuming package's dependencies (the right fix) or set `globalStore = false`. +TypeScript triggers the same walk for peer type references inside a package's `.d.ts` files. Packages that ship types are detected (see [What stays project-local](#what-stays-project-local)) and kept out of the global store so their ancestor walk still reaches the project's `@types/*`; purely JavaScript packages with phantom `require('helper')` calls are the remaining case. + Note that [`publicHoistPattern`](/runtime/bunfig#install-publichoistpattern) and [`hoistPattern`](/runtime/bunfig#install-hoistpattern) hoist into the project's `node_modules`, which packages inside the global store can't reach. They still work for resolving hoisted packages from your own source code. ### `node_modules` is mostly symlinks diff --git a/src/install/isolated_install.zig b/src/install/isolated_install.zig index 521e638b010..7538386dd5f 100644 --- a/src/install/isolated_install.zig +++ b/src/install/isolated_install.zig @@ -854,10 +854,25 @@ pub fn installIsolatedPackages( // // Eligibility propagates: an entry is only global-store-eligible (hash != 0) // when the package itself comes from an immutable cache (npm/git/tarball, - // unpatched, no lifecycle scripts) *and* every dependency it links to is - // also eligible. The second condition matters because dep symlinks live - // inside the global entry; baking a project-local path (workspace, folder) - // into a shared directory would break for every other consumer. + // unpatched, no lifecycle scripts, does not ship TypeScript declarations) + // *and* every dependency it links to is also eligible. The dep-closure + // condition matters because dep symlinks live inside the global entry; + // baking a project-local path (workspace, folder) into a shared directory + // would break for every other consumer. + // + // The "ships TypeScript declarations" carve-out exists because TypeScript + // resolves peer type references (e.g. `React.FunctionComponent` inside a + // package's `.d.ts` that never declares `@types/react` as a peerDep) by + // walking `node_modules` ancestors from the file's *realpath*. A package + // in `/links/@-/` has no ancestor `node_modules` + // under which the project's `@types/*` devDependencies are visible, so + // the peer type silently resolves to `any`. Keeping type-shipping + // packages project-local puts their realpath back under + // `node_modules/.bun/@/`, whose ancestor walk reaches the + // hidden hoisted layer at `node_modules/.bun/node_modules/`. + var pkg_ships_types_cache: std.AutoHashMapUnmanaged(PackageID, bool) = .empty; + defer pkg_ships_types_cache.deinit(manager.allocator); + const WyhashWriter = struct { hasher: *std.hash.Wyhash, const E = error{}; @@ -976,6 +991,15 @@ pub fn installIsolatedPackages( { break :eligible false; } + if (packageShipsTypeDeclarations(manager, pkg_id, &pkg_ships_types_cache)) { + // See the block comment on the eligibility DFS: a + // package whose `.d.ts` references peer types it + // never declared (the usual React-component pattern) + // needs its realpath to sit under the project's + // `node_modules/.bun/` so TypeScript's ancestor walk + // can reach the hidden hoisted `@types/*` layer. + break :eligible false; + } break :eligible true; }, else => false, @@ -1927,15 +1951,420 @@ pub fn installIsolatedPackages( } } +/// Does the package at `pkg_id` expose TypeScript declarations to +/// consumers? +/// +/// Runs the same resolution algorithm TypeScript uses to discover a +/// package's declarations (modelled on arethetypeswrong.github.io), on +/// the extracted `//package.json`. We want the union of +/// everything ATW inspects — a `true` from any branch means some real +/// consumer (node16, node10, bundler) would resolve a declaration file +/// out of this package: +/// +/// 1. If `"exports"` is set, walk EVERY entry point in the exports +/// tree — not just `"."` — with condition priority `[types, import, +/// require, default]`, the same order `tsc --moduleResolution +/// node16/bundler` uses when `"types"` is in its condition set. If +/// any target resolves to (or has a sibling) declaration file, the +/// package exposes types. This catches subpath-only shapes like +/// `{ "./Button": { ... }, "./Card": { ... } }` that omit `"."`. +/// 2. Also check top-level `"types"` / `"typings"` — these are what +/// `moduleResolution: node10` (still the default under `module: +/// "commonjs"`) consults, and ATW reports against them in its +/// separate "node10" column. A package shaped `{ exports: { ".": +/// "./dist/index.js" }, types: "./types/index.d.ts" }` ships types +/// to node10 consumers even if the exports walk says no. +/// 3. node10 `"main"`-sibling — when neither `"types"` nor `"typings"` +/// is set, TS strips the extension from `"main"` and probes +/// `.d.ts`. A package shaped `{ "main": "./lib/index.js" }` +/// with `lib/index.d.ts` present ships types this way even when +/// the package root has no `index.d.ts`. +/// 4. If `"typesVersions"` is present and non-empty, TS treats the +/// package as type-shipping regardless of subpath content (it'll +/// pick the matching range and look up subpaths there). +/// 5. Last resort — implicit `index.d.ts` in the package root, the +/// conventional no-`exports` / no-`types` fallback. +/// +/// Any positive hit means the package's declaration file(s) are what a +/// consuming TypeScript project would try to resolve. If the package's +/// realpath sits in `/links/@-/`, that declaration +/// file's ancestor walk for peer types (`React`, `csstype`, etc.) never +/// reaches the project's `@types/*` — which is the #29727 regression. So +/// any type-exposing package is forced project-local. +/// +/// Results are memoized in `cache`; each distinct package is resolved at +/// most once per install. +/// +/// Cold-cache / frozen-lockfile caveat: on a `--frozen-lockfile` install +/// with an empty `//`, the eligibility DFS runs before +/// extraction has populated the per-package cache. We can't consult the +/// npm registry manifest either — Bun fetches the `install-v1+json` +/// manifest, which strips `"types"`/`"typings"`/`"exports"` from each +/// version. When the package.json can't be read, we conservatively +/// report "ineligible" (the package materializes project-locally on this +/// install; a subsequent warm-cache install recomputes the real answer). +/// The alternative — defaulting to eligible — silently routes type- +/// shipping packages into the global store and resurrects #29727 for +/// every CI runner that hits the no-diff path. +fn packageShipsTypeDeclarations( + manager: *PackageManager, + pkg_id: PackageID, + cache: *std.AutoHashMapUnmanaged(PackageID, bool), +) bool { + const gop = bun.handleOom(cache.getOrPut(manager.allocator, pkg_id)); + if (gop.found_existing) return gop.value_ptr.*; + gop.value_ptr.* = false; + + const pkgs = manager.lockfile.packages.slice(); + const pkg_names = pkgs.items(.name); + const pkg_resolutions = pkgs.items(.resolution); + const string_buf = manager.lockfile.buffers.string_bytes.items; + + const pkg_res: Resolution = pkg_resolutions[pkg_id]; + var folder_buf: bun.PathBuffer = undefined; + const folder: [:0]const u8 = switch (pkg_res.tag) { + .npm => manager.cachedNPMPackageFolderNamePrint( + &folder_buf, + pkg_names[pkg_id].slice(string_buf), + pkg_res.value.npm.version, + null, + ), + .git => PackageManager.cachedGitFolderNamePrint(&folder_buf, manager.lockfile.str(&pkg_res.value.git.resolved), null), + .github => PackageManager.cachedGitHubFolderNamePrint(&folder_buf, manager.lockfile.str(&pkg_res.value.github.resolved), null), + .local_tarball => PackageManager.cachedTarballFolderNamePrint(&folder_buf, manager.lockfile.str(&pkg_res.value.local_tarball), null), + .remote_tarball => PackageManager.cachedTarballFolderNamePrint(&folder_buf, manager.lockfile.str(&pkg_res.value.remote_tarball), null), + else => return false, + }; + + const cache_dir, const cache_dir_path = manager.getCacheDirectoryAndAbsPath(); + defer cache_dir_path.deinit(); + + var rel: bun.RelPath(.{ .sep = .auto }) = .from(folder); + defer rel.deinit(); + rel.append("package.json"); + + const source_bytes = switch (bun.sys.File.readFrom(cache_dir, rel.sliceZ(), manager.allocator)) { + .result => |b| b, + .err => { + // See the cold-cache caveat above. Conservative memoize — + // the package goes project-local on this install and the + // next install re-runs the full resolver against a warm + // cache. + gop.value_ptr.* = true; + return true; + }, + }; + defer manager.allocator.free(source_bytes); + + gop.value_ptr.* = resolvePackageExposesTypes(source_bytes, cache_dir, folder); + return gop.value_ptr.*; +} + +const type_decl_extensions = [_][]const u8{ ".d.ts", ".d.mts", ".d.cts" }; +/// TS pairs JS extensions with declaration file extensions when falling +/// back to sibling lookup: `foo.js` ↔ `foo.d.ts`, `foo.mjs` ↔ `foo.d.mts`, +/// `foo.cjs` ↔ `foo.d.cts`. Source-file extensions (`.ts`, `.mts`, `.cts`, +/// `.tsx`) are themselves type-carrying, handled in `pathExposesTypes`. +const js_to_dts_pairs = [_]struct { js: []const u8, dts: []const u8 }{ + .{ .js = ".mjs", .dts = ".d.mts" }, + .{ .js = ".cjs", .dts = ".d.cts" }, + .{ .js = ".js", .dts = ".d.ts" }, + .{ .js = ".jsx", .dts = ".d.ts" }, +}; + +/// The ATW-style resolver. Parses `package.json` and applies the same +/// entry-point resolution rules TypeScript uses, returning true if any +/// resolved path produces (or has a sibling) declaration file. See +/// `packageShipsTypeDeclarations` for the scenarios each branch covers. +fn resolvePackageExposesTypes(source_bytes: []const u8, cache_dir: FD, folder: []const u8) bool { + var arena = std.heap.ArenaAllocator.init(bun.default_allocator); + defer arena.deinit(); + const arena_allocator = arena.allocator(); + + var log_sink: logger.Log = .init(arena_allocator); + defer log_sink.deinit(); + + const source = logger.Source.initPathString("package.json", source_bytes); + + install.initializeStore(); + const json = JSON.parsePackageJSONUTF8(&source, &log_sink, arena_allocator) catch |err| switch (err) { + // OOM aborts the process rather than being swallowed into a + // mis-classification. Any other parse error (truncated, garbled, + // or just not JSON) is treated conservatively — the package is + // kept project-local, same as when the file itself can't be + // read. Returning `false` here would quietly route a type- + // shipping package with a corrupted cache into the global + // store, resurrecting #29727 for that subset. + error.OutOfMemory => bun.outOfMemory(), + else => return true, + }; + + // 1. `"exports"` — walk the conditional tree with types-priority + // conditions. Covers `moduleResolution: node16 / nodenext / + // bundler` consumers. + if (json.asProperty("exports")) |prop| { + if (exportsExposesTypes(prop.expr, cache_dir, folder)) return true; + // When `exports` doesn't resolve to a declaration, fall through + // to the top-level signals: `moduleResolution: node10` (still + // the default under `module: "commonjs"`, and what ATW's + // "node10" column reports against) ignores `"exports"` entirely + // and resolves declarations via top-level `"types"` / `"typings"` + // / implicit `index.d.ts`. Skipping those fields here would + // silently route packages shaped `{ exports: { ".": "./dist/index.js" }, + // types: "./types/index.d.ts" }` (where `dist/index.d.ts` + // doesn't exist) into the global store and resurrect #29727 for + // node10 consumers. + } + + // 2. Top-level `"types"` / `"typings"` — TS honors `types` first, + // then `typings` (the legacy name). + inline for (.{ "types", "typings" }) |field| { + if (json.asProperty(field)) |prop| { + if (prop.expr.asString(arena_allocator)) |s| { + if (pathExposesTypes(s, cache_dir, folder)) return true; + } + } + } + + // 3. node10 `"main"`-sibling — when no `types`/`typings` field is + // set, TypeScript's legacy `node` module resolution (still the + // default under `module: "commonjs"`, and ATW's "node10" column) + // strips the extension from `"main"` and probes + // `.d.ts` before falling back to `./index.d.ts`. + // Catches the classic `tsc --declaration --outDir lib` shape + // (`{ "main": "./lib/index.js" }` with `lib/index.d.ts` on disk + // and no explicit `"types"` field). `pathExposesTypes` handles + // the `.js`→sibling-`.d.ts` pairing via `js_to_dts_pairs`. + if (json.asProperty("main")) |prop| { + if (prop.expr.asString(arena_allocator)) |s| { + if (pathExposesTypes(s, cache_dir, folder)) return true; + } + } + + // 4. `"typesVersions"` — presence-only. TS resolves subpaths through + // whichever version range matches the consumer's TS version; we + // have no TS version to match here, so treat any non-empty map as + // a type-shipping signal. + if (json.asProperty("typesVersions")) |prop| { + if (prop.expr.data == .e_object and prop.expr.data.e_object.properties.len > 0) return true; + } + + // 5. Implicit `index.d.ts` — the no-`exports`/no-`types` fallback. + if (cacheFileExists(cache_dir, folder, "index.d.ts")) return true; + + return false; +} + +/// Walk an `"exports"` tree and check whether any reachable target is +/// (or has a sibling) declaration file. Returns true on the first hit. +/// +/// ATW inspects every entry point in the exports map, not just `"."` — +/// a package whose only declared subpaths are `"./Button"` and +/// `"./Card"` still ships types to any consumer doing +/// `import … from "pkg/Button"`. +fn exportsExposesTypes(expr: bun.js_parser.Expr, cache_dir: FD, folder: []const u8) bool { + // `exports` can be: + // "exports": "./index.js" → string (shorthand for `.`) + // "exports": [ ... ] → array of fallbacks (shorthand for `.`) + // "exports": { ".": ..., "./sub": ... } → subpath map + // "exports": { "import": ..., "require": ... } → condition map (shorthand for `.`) + switch (expr.data) { + .e_string, .e_array => return resolveTargetExposesTypes(expr, cache_dir, folder, 0), + .e_object => |obj| { + var has_dot_keys = false; + for (obj.properties.slice()) |prop| { + const key = prop.key orelse continue; + if (key.data != .e_string) continue; + const s = key.data.e_string.data; + if (s.len > 0 and s[0] == '.') { + has_dot_keys = true; + break; + } + } + if (has_dot_keys) { + // Subpath map: walk every subpath's target. A package + // shaped `{ "./Button": { ... }, "./Card": { ... } }` + // with no `"."` entry still ships types if any of its + // subpath targets resolve to a declaration file. + // + // Wildcard subpaths (`"./*"`, `"./icons/*"`) are handled + // inside `pathExposesTypes`: a pattern target whose + // literal path contains `*` can't be fstat'd, so we + // trust the declared extension — any wildcard whose + // target ends in `.d.ts` / `.d.mts` / `.d.cts` / `.ts` + // / `.tsx` / `.mts` / `.cts` counts as type-shipping. + for (obj.properties.slice()) |prop| { + const value = prop.value orelse continue; + if (resolveTargetExposesTypes(value, cache_dir, folder, 0)) return true; + } + return false; + } + // No subpath keys: the whole object is the condition tree + // for `.` (shorthand). + return resolveTargetExposesTypes(expr, cache_dir, folder, 0); + }, + else => return false, + } +} + +/// Walk a conditional-exports target (string, array, or condition object) +/// looking for a resolvable declaration file. Mirrors the Node.js +/// `PACKAGE_EXPORTS_RESOLVE` + `PACKAGE_TARGET_RESOLVE` algorithms, with +/// condition priority set to prefer `"types"` the way `tsc` does when +/// `types` is in the condition set. +fn resolveTargetExposesTypes( + target: bun.js_parser.Expr, + cache_dir: FD, + folder: []const u8, + depth: u8, +) bool { + if (depth > 16) return false; + switch (target.data) { + .e_string => |str| { + if (str.data.len == 0) return false; + return pathExposesTypes(str.data, cache_dir, folder); + }, + .e_array => |array| { + // Fallback array: try each alternative in order; the first + // resolvable target wins. For our yes/no question the order + // doesn't matter — any alternative exposing types is enough. + for (array.items.slice()) |item| { + if (resolveTargetExposesTypes(item, cache_dir, folder, depth + 1)) return true; + } + return false; + }, + .e_object => |obj| { + // Condition object: the Node spec says walk keys in + // declaration order, descending into the first key whose + // condition matches. TS treats `"types"` as its highest + // priority condition, then falls back to the module-system + // conditions. Our "does it expose types at all" question + // isn't order-sensitive in the same way the runtime + // resolver is: we just need to know whether ANY condition + // branch leads to a declaration file. So try `"types"` + // first (the fast path for dual-package type entry points) + // and fall back to all other conditions. + for (obj.properties.slice()) |prop| { + const key = prop.key orelse continue; + const value = prop.value orelse continue; + if (key.data != .e_string) continue; + if (key.data.e_string.eqlComptime("types")) { + if (resolveTargetExposesTypes(value, cache_dir, folder, depth + 1)) return true; + } + } + for (obj.properties.slice()) |prop| { + const key = prop.key orelse continue; + const value = prop.value orelse continue; + if (key.data != .e_string) continue; + if (key.data.e_string.eqlComptime("types")) continue; + if (resolveTargetExposesTypes(value, cache_dir, folder, depth + 1)) return true; + } + return false; + }, + else => return false, + } +} + +/// A path exposes TS types if it IS a declaration file (`.d.ts` / +/// `.d.mts` / `.d.cts`) or a TS source file (`.ts` / `.tsx` / `.mts` / +/// `.cts`), OR its JS sibling has a matching declaration file on disk. +fn pathExposesTypes(path: []const u8, cache_dir: FD, folder: []const u8) bool { + if (path.len == 0) return false; + + // Normalize leading "./" + const rel = if (bun.strings.hasPrefixComptime(path, "./")) path[2..] else path; + + // Wildcard subpath targets (`"./icons/*.d.ts"`) can't be + // `existsAt`-checked literally — the `*` stands for a consumer- + // supplied name the resolver substitutes at import time. Trust the + // declared extension: a pattern ending in a declaration or TS + // source extension means the author wrote a types entry point for + // those subpaths. We don't try to match JS patterns against hidden + // sibling `.d.ts` files — if the author wanted that treated as + // type-shipping they'd have added a `types` condition. + if (bun.strings.containsChar(rel, '*')) { + return hasTypeExtension(rel); + } + + // Strip any trailing "/" — `"types": "./dist/"` points at a directory + // whose resolver fallback is implicit `index.d.ts` inside it. + if (rel.len > 0 and rel[rel.len - 1] == '/') { + const dir = rel[0 .. rel.len - 1]; + return cacheFileExistsJoin(cache_dir, folder, dir, "index.d.ts"); + } + + // Extension-carrying paths: + for (type_decl_extensions) |ext| { + if (bun.strings.endsWith(rel, ext)) { + return cacheFileExists(cache_dir, folder, rel); + } + } + // TypeScript source files are themselves typed — the bundler/tsgo + // resolver picks them up directly. + for ([_][]const u8{ ".ts", ".tsx", ".mts", ".cts" }) |ext| { + if (bun.strings.endsWith(rel, ext)) { + return cacheFileExists(cache_dir, folder, rel); + } + } + // JavaScript paths → look for the parallel declaration file. + // + // `rel` comes straight from an untrusted `package.json` string; + // bound the concatenation the same way `cacheFileExists` / + // `cacheFileExistsJoin` do further down — `bufPrintZ` returns an + // error when the combined length doesn't fit, which we treat as + // "path too long to plausibly exist, skip". + for (js_to_dts_pairs) |pair| { + if (bun.strings.endsWith(rel, pair.js)) { + const stem = rel[0 .. rel.len - pair.js.len]; + var buf: bun.PathBuffer = undefined; + const sibling = std.fmt.bufPrint(&buf, "{s}{s}", .{ stem, pair.dts }) catch return false; + return cacheFileExists(cache_dir, folder, sibling); + } + } + // Extensionless: could be a directory-as-entry-point with implicit + // `index.d.ts`, or a TypeScript subpath target ("exports": "./src"). + if (cacheFileExistsJoin(cache_dir, folder, rel, "index.d.ts")) return true; + inline for (.{ ".d.ts", ".d.mts", ".d.cts" }) |ext| { + var buf: bun.PathBuffer = undefined; + const with_ext = std.fmt.bufPrint(&buf, "{s}{s}", .{ rel, ext }) catch return false; + if (cacheFileExists(cache_dir, folder, with_ext)) return true; + } + return false; +} + +fn hasTypeExtension(rel: []const u8) bool { + for (type_decl_extensions) |ext| { + if (bun.strings.endsWith(rel, ext)) return true; + } + for ([_][]const u8{ ".ts", ".tsx", ".mts", ".cts" }) |ext| { + if (bun.strings.endsWith(rel, ext)) return true; + } + return false; +} + +fn cacheFileExists(cache_dir: FD, folder: []const u8, subpath: []const u8) bool { + var buf: bun.PathBuffer = undefined; + const joined = std.fmt.bufPrintZ(&buf, "{s}/{s}", .{ folder, subpath }) catch return false; + return bun.sys.existsAt(cache_dir, joined); +} + +fn cacheFileExistsJoin(cache_dir: FD, folder: []const u8, dir: []const u8, file: []const u8) bool { + var buf: bun.PathBuffer = undefined; + const joined = std.fmt.bufPrintZ(&buf, "{s}/{s}/{s}", .{ folder, dir, file }) catch return false; + return bun.sys.existsAt(cache_dir, joined); +} + const std = @import("std"); const bun = @import("bun"); const Environment = bun.Environment; const FD = bun.FD; const Global = bun.Global; +const JSON = bun.json; const OOM = bun.OOM; const Output = bun.Output; const Progress = bun.Progress; +const logger = bun.logger; const sys = bun.sys; const Command = bun.cli.Command; diff --git a/test/cli/install/isolated-install.test.ts b/test/cli/install/isolated-install.test.ts index a406101e411..0cfb4843baa 100644 --- a/test/cli/install/isolated-install.test.ts +++ b/test/cli/install/isolated-install.test.ts @@ -1951,4 +1951,491 @@ describe("global virtual store", () => { expect(lstatSync(workspace).isSymbolicLink()).toBe(false); expect(await file(edited).text()).toBe("module.exports = 'USER_EDITS';\n"); }); + + test("packages that ship TypeScript declarations stay project-local (#29727)", async () => { + // A package whose `.d.ts` references peer types it never declared (the + // canonical React-component-typings pattern, see `next-yak@9.4.1`) needs + // its realpath to sit under the project's `node_modules/.bun/` — not + // in `/links/` — so TypeScript's ancestor walk from the + // declaration file can still reach `node_modules/.bun/node_modules/` + // and resolve the project's hoisted `@types/*`. When the global store + // is enabled, a package that ships types (top-level `"types"` or + // `"typings"`, or a `"types"` key under `"exports"`) is materialized + // as a real directory under `node_modules/.bun/`; a package without + // any of those signals stays on the shared-store fast path as a + // symlink into `/links/`. + // + // Fixtures are packed with `bun pm pack` and served from an in-process + // HTTP registry so the test doesn't depend on the shared Verdaccio. + using fixtures = tempDir("ships-types-fixtures-", { + "top-types/package.json": JSON.stringify({ + name: "top-types", + version: "1.0.0", + types: "./index.d.ts", + }), + "top-types/index.js": "module.exports = {};\n", + "top-types/index.d.ts": "export {};\n", + + "top-typings/package.json": JSON.stringify({ + name: "top-typings", + version: "1.0.0", + typings: "./index.d.ts", + }), + "top-typings/index.js": "module.exports = {};\n", + "top-typings/index.d.ts": "export {};\n", + + "exports-types/package.json": JSON.stringify({ + name: "exports-types", + version: "1.0.0", + exports: { + ".": { + types: "./index.d.ts", + default: "./index.js", + }, + }, + }), + "exports-types/index.js": "module.exports = {};\n", + "exports-types/index.d.ts": "export {};\n", + + // Dual-package types: a `"types"` key whose value is itself a + // conditional object — the format TypeScript recommends for + // packages shipping both `.d.mts` and `.d.cts`. Must still be + // detected. + "exports-types-dual/package.json": JSON.stringify({ + name: "exports-types-dual", + version: "1.0.0", + exports: { + ".": { + types: { + import: "./index.d.mts", + require: "./index.d.cts", + }, + import: "./index.mjs", + require: "./index.cjs", + }, + }, + }), + "exports-types-dual/index.mjs": "export default {};\n", + "exports-types-dual/index.cjs": "module.exports = {};\n", + "exports-types-dual/index.d.mts": "export {};\n", + "exports-types-dual/index.d.cts": "export {};\n", + + // Array-of-fallbacks: the Node resolution spec lets any target be a + // list of alternatives tried in order. A `"types"` key buried inside + // such an array still means the package ships types. + "exports-types-array/package.json": JSON.stringify({ + name: "exports-types-array", + version: "1.0.0", + exports: { + ".": [{ types: "./index.d.ts", default: "./index.js" }, "./fallback.js"], + }, + }), + "exports-types-array/index.js": "module.exports = {};\n", + "exports-types-array/index.d.ts": "export {};\n", + "exports-types-array/fallback.js": "module.exports = {};\n", + + // `typesVersions` ships version-gated declarations; some packages + // use it as their sole subpath-mapping mechanism and omit the + // top-level `"types"` field entirely. + "types-versions/package.json": JSON.stringify({ + name: "types-versions", + version: "1.0.0", + typesVersions: { + ">=4.0": { "*": ["ts4/*"] }, + }, + }), + "types-versions/index.js": "module.exports = {};\n", + "types-versions/ts4/index.d.ts": "export {};\n", + + // No `exports`, no `types`/`typings`, no `typesVersions` — but an + // `index.d.ts` sits at the package root. TS's no-`exports` fallback + // finds it, so the ATW-style resolver must too. + "implicit-index/package.json": JSON.stringify({ + name: "implicit-index", + version: "1.0.0", + }), + "implicit-index/index.js": "module.exports = {};\n", + "implicit-index/index.d.ts": "export {};\n", + + // node10 main-sibling: classic `tsc --declaration --outDir lib` + // shape. No `exports`/`types`/`typings`/`typesVersions`, no root + // `index.d.ts`, but `"main"` points at `./lib/index.js` and + // `lib/index.d.ts` sits next to it. TypeScript's legacy `node` + // resolution strips the `.js` extension from `main` and probes + // `.d.ts` → finds the declaration. + "main-sibling/package.json": JSON.stringify({ + name: "main-sibling", + version: "1.0.0", + main: "./lib/index.js", + }), + "main-sibling/lib/index.js": "module.exports = {};\n", + "main-sibling/lib/index.d.ts": "export {};\n", + + // `"exports"` set without a `types` condition, but the resolved + // JS target has a sibling `.d.ts` — TS picks that up via the + // JS-to-declaration pairing. Common for packages that haven't + // migrated to explicit `types` conditions yet. + "sibling-dts/package.json": JSON.stringify({ + name: "sibling-dts", + version: "1.0.0", + exports: { + ".": { + import: "./index.mjs", + require: "./index.cjs", + }, + }, + }), + "sibling-dts/index.mjs": "export default {};\n", + "sibling-dts/index.cjs": "module.exports = {};\n", + "sibling-dts/index.d.mts": "export {};\n", + "sibling-dts/index.d.cts": "export {};\n", + + // `"exports"` maps every condition to a plain JS file that has NO + // sibling declaration. The field-scan heuristic would have + // false-positive-d on the old `typesVersions`-presence check; ATW + // resolution correctly reports no types exposed. + "exports-no-types/package.json": JSON.stringify({ + name: "exports-no-types", + version: "1.0.0", + exports: { + ".": { + import: "./index.mjs", + require: "./index.cjs", + }, + }, + }), + "exports-no-types/index.mjs": "export default {};\n", + "exports-no-types/index.cjs": "module.exports = {};\n", + + // Subpath-only `"exports"` — no `"."` entry. Component libraries + // often ship like this, forcing consumers onto specific subpaths. + // ATW checks every entry point in the exports map, so any + // subpath that resolves to a declaration file counts as type- + // shipping. + "subpath-only/package.json": JSON.stringify({ + name: "subpath-only", + version: "1.0.0", + exports: { + "./Button": { types: "./Button.d.ts", default: "./Button.js" }, + "./Card": { types: "./Card.d.ts", default: "./Card.js" }, + }, + }), + "subpath-only/Button.js": "module.exports = {};\n", + "subpath-only/Button.d.ts": "export {};\n", + "subpath-only/Card.js": "module.exports = {};\n", + "subpath-only/Card.d.ts": "export {};\n", + + // `"exports"` is set and resolves WITHOUT types (no `types` + // condition, no sibling `.d.*`), but top-level `"types"` points + // at a real declaration file in a separate directory. node16 + // consumers miss these types, but `moduleResolution: node10` + // (still the commonjs default) reads top-level `"types"` and + // resolves them — so the package IS type-shipping for that + // audience. + "exports-no-types-top-types/package.json": JSON.stringify({ + name: "exports-no-types-top-types", + version: "1.0.0", + exports: { + ".": "./dist/index.js", + }, + types: "./types/index.d.ts", + }), + "exports-no-types-top-types/dist/index.js": "module.exports = {};\n", + "exports-no-types-top-types/types/index.d.ts": "export {};\n", + + // Pathologically long `"types"` path — exercises the two + // `bufPrint(&buf, "{s}{s}", ...) catch return false` guards in + // `pathExposesTypes`: + // 1. `.js` suffix (with no literal sibling `.d.ts`) routes + // through the `js_to_dts_pairs` sibling-concatenation site. + // 2. Extensionless path routes through the + // `.d.ts`/`.d.mts`/`.d.cts` append-extension site. + // 5000 chars exceeds both macOS's 1024-byte and Linux's 4096- + // byte `PathBuffer` ceilings, so the `bufPrint` actually fails + // with `NoSpaceLeft` and the guard returns false on every + // platform. Without the guards this would overflow the stack + // buffer with attacker-controlled bytes (ReleaseFast) or + // panic (Debug / ReleaseSafe). + "long-types-js/package.json": JSON.stringify({ + name: "long-types-js", + version: "1.0.0", + types: "./" + "a".repeat(5000) + ".js", + }), + "long-types-js/index.js": "module.exports = {};\n", + + "long-types-bare/package.json": JSON.stringify({ + name: "long-types-bare", + version: "1.0.0", + types: "./" + "a".repeat(5000), + }), + "long-types-bare/index.js": "module.exports = {};\n", + + // Wildcard subpath `"exports"` — component libraries publishing + // per-component entry points often use this shape. The `*` in + // the target path can't be fstat'd literally; the resolver + // trusts the declared extension so any wildcard target ending + // in `.d.ts` / `.d.mts` / `.d.cts` / `.ts` / `.tsx` / `.mts` / + // `.cts` counts as type-shipping. + "wildcard-types/package.json": JSON.stringify({ + name: "wildcard-types", + version: "1.0.0", + exports: { + "./icons/*": { types: "./icons/*.d.ts", default: "./icons/*.js" }, + }, + }), + "wildcard-types/icons/Heart.js": "module.exports = {};\n", + "wildcard-types/icons/Heart.d.ts": "export {};\n", + + // Wildcard subpath whose target has NO `types` condition and + // whose pattern ends in `.js`. We don't try to prove a sibling + // `.d.ts` exists for wildcard JS targets (would require globbing + // the extracted tree) — if the author wanted types treated, the + // convention is to add a `types` condition. Matches pre-PR + // behavior: no detected types signal → package is eligible. + "wildcard-no-types/package.json": JSON.stringify({ + name: "wildcard-no-types", + version: "1.0.0", + exports: { + "./icons/*": "./icons/*.js", + }, + }), + "wildcard-no-types/icons/Heart.js": "module.exports = {};\n", + + "pure-js/package.json": JSON.stringify({ + name: "pure-js", + version: "1.0.0", + }), + "pure-js/index.js": "module.exports = {};\n", + }); + + async function pack(subdir: string): Promise { + await using proc = spawn({ + cmd: [bunExe(), "pm", "pack", "--destination", String(fixtures)], + cwd: join(String(fixtures), subdir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + if (exitCode !== 0) throw new Error(`bun pm pack failed: ${stderr}`); + return Buffer.from(await Bun.file(join(String(fixtures), `${subdir}-1.0.0.tgz`)).arrayBuffer()); + } + + const fixtureNames = [ + "top-types", + "top-typings", + "exports-types", + "exports-types-dual", + "exports-types-array", + "types-versions", + "implicit-index", + "main-sibling", + "sibling-dts", + "subpath-only", + "exports-no-types-top-types", + "wildcard-types", + "exports-no-types", + "wildcard-no-types", + "long-types-js", + "long-types-bare", + "pure-js", + ] as const; + // Pack all fixtures in parallel — each `bun pm pack` is a debug-build + // subprocess launch (~2-3s cold); serialising ten of them makes this + // test needlessly flaky under load. + const packed = await Promise.all(fixtureNames.map(n => pack(n))); + const tarballs: Record = Object.fromEntries(fixtureNames.map((n, i) => [n, packed[i]])); + + using server = Bun.serve({ + port: 0, + async fetch(req) { + const url = new URL(req.url); + const tarballMatch = url.pathname.match(/^\/([^/]+)\/-\/([^/]+)\.tgz$/); + if (tarballMatch) { + const tgz = tarballs[tarballMatch[1]]; + if (!tgz) return new Response("Not found", { status: 404 }); + return new Response(tgz, { headers: { "Content-Type": "application/octet-stream" } }); + } + const name = decodeURIComponent(url.pathname.slice(1)); + const tgz = tarballs[name]; + if (!tgz) return new Response("Not found", { status: 404 }); + return new Response( + JSON.stringify({ + name, + "dist-tags": { latest: "1.0.0" }, + versions: { + "1.0.0": { + name, + version: "1.0.0", + dist: { + tarball: `http://localhost:${server.port}/${name}/-/${name}-1.0.0.tgz`, + }, + }, + }, + }), + { headers: { "Content-Type": "application/json" } }, + ); + }, + }); + + using packageDir = tempDir("ships-types-test-", {}); + const cacheDir = join(String(packageDir), ".bun-cache"); + await write( + join(String(packageDir), "bunfig.toml"), + `[install]\ncache = "${cacheDir.replaceAll("\\", "\\\\")}"\nregistry = "http://localhost:${server.port}/"\nlinker = "isolated"\n`, + ); + await write( + join(String(packageDir), "package.json"), + JSON.stringify({ + name: "ships-types-consumer", + dependencies: { + "top-types": "1.0.0", + "top-typings": "1.0.0", + "exports-types": "1.0.0", + "exports-types-dual": "1.0.0", + "exports-types-array": "1.0.0", + "types-versions": "1.0.0", + "implicit-index": "1.0.0", + "main-sibling": "1.0.0", + "sibling-dts": "1.0.0", + "subpath-only": "1.0.0", + "exports-no-types-top-types": "1.0.0", + "wildcard-types": "1.0.0", + "exports-no-types": "1.0.0", + "wildcard-no-types": "1.0.0", + "long-types-js": "1.0.0", + "long-types-bare": "1.0.0", + "pure-js": "1.0.0", + }, + }), + ); + + await runBunInstall(bunEnv, String(packageDir)); + + const bunDir = join(String(packageDir), "node_modules", ".bun"); + + // Packages without any resolvable declaration file stay in the + // global store → symlinked into `/links/-`. + // * `pure-js`: no types-related package.json signals at all. + // * `exports-no-types`: `"exports"` map whose resolved JS targets + // have no sibling `.d.*` and no `"types"` condition — exactly + // the case where the earlier field-scan heuristic would have + // false-positive-d on the presence of `"exports"` alone. + // * `wildcard-no-types`: wildcard subpath whose target has no + // types condition; we don't glob-expand JS patterns so no + // types signal fires. + // * `long-types-js`: `types: "./<5000 'a's>.js"` — routes into + // the `js_to_dts_pairs` bufPrint site with a combined length + // that overflows both macOS (1024) and Linux (4096) `PathBuffer` + // ceilings. The guard returns false; install succeeds; package + // stays symlinked. + // * `long-types-bare`: `types: "./<5000 'a's>"` (no extension) — + // same thing for the extensionless append-`.d.*` site. + for (const name of [ + "pure-js", + "exports-no-types", + "wildcard-no-types", + "long-types-js", + "long-types-bare", + ] as const) { + const entry = join(bunDir, `${name}@1.0.0`); + expect(lstatSync(entry).isSymbolicLink()).toBe(true); + expect(readlinkSync(entry)).toMatch(new RegExp(`links[/\\\\]${name}@1\\.0\\.0-[0-9a-f]{16}$`)); + } + + // Every path through the ATW-style resolver lands on a declaration + // file: + // * top-level `"types"` / `"typings"` → string points at .d.ts + // * `"exports"` walk with types-priority conditions → .d.ts + // * `"exports"` walk into a nested conditional `types` object + // (dual .d.mts/.d.cts) → .d.* + // * `"exports"` walk into an array fallback containing a types + // condition → .d.ts + // * non-empty `"typesVersions"` (TS resolves through version + // mappings) → true + // * no `"exports"`/`"types"`/`"typesVersions"` but implicit + // `index.d.ts` at package root → true + // * `"exports"` with no `"types"` condition but the JS target + // has a sibling `.d.ts`/`.d.mts`/`.d.cts` → true + // Each fixture is forced project-local as a real directory. + const typeShippingNames = [ + "top-types", + "top-typings", + "exports-types", + "exports-types-dual", + "exports-types-array", + "types-versions", + "implicit-index", + // node10 `main`-sibling: no explicit `types` field, but + // `main: "./lib/index.js"` has `lib/index.d.ts` next to it. The + // classic `tsc --declaration --outDir lib` output shape. + "main-sibling", + "sibling-dts", + // Every subpath carries a `types` condition → at least one entry + // point exposes declarations → package ships types. + "subpath-only", + // `"exports"` resolves without types, but top-level `"types"` + // points at a real .d.ts for node10 consumers. The fall-through + // after the exports walk catches this. + "exports-no-types-top-types", + // Wildcard subpath whose target has a `types` condition — the + // pattern can't be fstat'd literally but the declared `.d.ts` + // extension is enough of a signal. + "wildcard-types", + ] as const; + for (const name of typeShippingNames) { + const entry = join(bunDir, `${name}@1.0.0`); + expect(lstatSync(entry).isSymbolicLink()).toBe(false); + expect(lstatSync(entry).isDirectory()).toBe(true); + expect(existsSync(join(entry, "node_modules", name, "package.json"))).toBe(true); + } + + // CI scenario: fresh runner with a committed lockfile but no + // pre-warmed package cache. On the no-diff / frozen-lockfile path, + // `installWithManager` never enqueues any download or extract + // tasks before handing off to `installIsolatedPackages` + // (`pendingTaskCount() == 0` → the wait block is skipped), so the + // eligibility DFS runs against an empty cache. A naive + // `readFrom(//package.json)` hits ENOENT and, without + // the conservative fallback, would return "no types" → eligible → + // the type-shipping package lands in `/links/` anyway, + // resurrecting #29727. The fallback treats unreadable packages + // as ineligible, keeping type-shipping packages project-local + // even on the cold-cache CI path. + await rm(join(String(packageDir), "node_modules"), { recursive: true, force: true }); + await rm(cacheDir, { recursive: true, force: true }); + await runBunInstall(bunEnv, String(packageDir), { savesLockfile: false, frozenLockfile: true }); + for (const name of typeShippingNames) { + const entry = join(bunDir, `${name}@1.0.0`); + expect(lstatSync(entry).isSymbolicLink()).toBe(false); + expect(lstatSync(entry).isDirectory()).toBe(true); + expect(existsSync(join(entry, "node_modules", name, "package.json"))).toBe(true); + } + + // Warm-cache re-install (lockfile unchanged, per-package cache + // populated by the previous install). The DFS can now read every + // `package.json` and reaches the real decision: type-shippers stay + // project-local, and `pure-js` — which has no types signal of any + // kind — flips back to a symlink into `/links/`. This + // guards against a regression where the conservative fallback ends + // up sticky and forces every package project-local forever. + await rm(join(String(packageDir), "node_modules"), { recursive: true, force: true }); + await runBunInstall(bunEnv, String(packageDir), { savesLockfile: false, frozenLockfile: true }); + for (const name of typeShippingNames) { + const entry = join(bunDir, `${name}@1.0.0`); + expect(lstatSync(entry).isSymbolicLink()).toBe(false); + expect(lstatSync(entry).isDirectory()).toBe(true); + } + for (const name of [ + "pure-js", + "exports-no-types", + "wildcard-no-types", + "long-types-js", + "long-types-bare", + ] as const) { + const entry = join(bunDir, `${name}@1.0.0`); + expect(lstatSync(entry).isSymbolicLink()).toBe(true); + expect(readlinkSync(entry)).toMatch(new RegExp(`links[/\\\\]${name}@1\\.0\\.0-[0-9a-f]{16}$`)); + } + }); });