Skip to content

TypeScript workspace imports do not resolve package subpath exports #1308

Description

@pkudinov

Summary

Graphify's TypeScript/JavaScript workspace import resolver does not resolve package subpath imports through package.json exports.

For a workspace package import like:

import { value } from "@example/package-a/browser";

Graphify records an imports_from edge to a bare browser node instead of resolving the target to the exported source file from packages/package-a/package.json.

This breaks affected-file traversal and graph accuracy for monorepos that expose browser/server or other subpath entrypoints through package exports.

Minimal repro

repo/
  package.json
  packages/
    package-a/
      package.json
      src/browser.ts
  apps/
    package-b/
      src/consumer.ts

Root package.json:

{
  "private": true,
  "workspaces": ["packages/*", "apps/*"]
}

packages/package-a/package.json:

{
  "name": "@example/package-a",
  "exports": {
    "./browser": {
      "source": "./src/browser.ts",
      "import": "./dist/esm/browser.js",
      "require": "./dist/cjs/browser.js",
      "types": "./dist/types/browser.d.ts"
    }
  }
}

packages/package-a/src/browser.ts:

export const value = "ok";

apps/package-b/src/consumer.ts:

import { value } from "@example/package-a/browser";

export const consumerValue = value;

Actual behavior

Graphify emits an import edge similar to:

{
  "source": "src_consumer",
  "target": "browser",
  "relation": "imports_from",
  "source_file": "apps/package-b/src/consumer.ts"
}

Expected behavior

Graphify should resolve the package subpath through package.json exports and emit the import edge to the exported file node, for example:

{
  "source": "src_consumer",
  "target": "src_browser",
  "relation": "imports_from",
  "source_file": "apps/package-b/src/consumer.ts"
}

The exact node id may differ, but the target should be the workspace source file behind the export map, not the bare subpath segment.

Why this matters

Many TypeScript monorepos use package subpath exports for public entrypoints, especially to separate browser-safe and server-only APIs:

{
  "exports": {
    "./browser": { "source": "./src/browser.ts", "import": "./dist/browser.js" },
    "./server": { "source": "./src/server.ts", "import": "./dist/server.js" }
  }
}

When Graphify records these as browser or server, reverse traversal and affected-file analysis become either incomplete or noisy.

Suspected cause

In the workspace package resolver, the subpath appears to be checked as a filesystem path before package export maps are considered.

Conceptually, resolving @example/package-a/browser does something equivalent to:

package_dir / "browser"

When that path does not resolve to a concrete file, the resolver falls back to the bare subpath string rather than consulting:

exports["./browser"]

Suggested fix

For workspace package imports with a subpath:

  1. Locate the package root from the workspace package name.
  2. Read package.json.
  3. Look up the subpath in exports using the ./subpath key.
  4. Choose the best source target when available.
  5. Fall back to runtime/type targets if no source target exists.
  6. Only then fall back to direct filesystem probing such as package_dir / subpath.

Suggested condition preference for source-oriented graph extraction:

source -> import -> module -> default -> require -> types

For condition objects, Graphify could recursively walk nested condition maps and return the first target matching the preferred condition list.

For wildcard exports such as:

{
  "exports": {
    "./utils/*": {
      "source": "./src/utils/*.ts",
      "import": "./dist/utils/*.js"
    }
  }
}

replace the * segment from the import subpath before resolving the target.

This would make Graphify match modern Node/TypeScript monorepo package boundaries while preserving the existing direct-file fallback for packages that do not define exports.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions