Skip to content

fix: resolve npm/yarn/pnpm workspace package imports (#1030) - #1093

Merged
1 commit merged into
tirth8205:stagingfrom
asafna2:feature/workspace-package-resolution
Nov 6, 2026
Merged

1 commit merged into
tirth8205:stagingfrom
asafna2:feature/workspace-package-resolution

Conversation

@asafna2

@asafna2 asafna2 commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

Linked issue

Closes #1030

What & why

In an npm, yarn or pnpm workspace, an import such as from '@acme/policies' names a sibling package through the package manager's links, not a compilerOptions.paths alias. TsconfigResolver therefore left it as a bare specifier, and, as #1030 measured, importers_of, the impact radius and references_to collapsed at every package boundary. Calls through a package's barrel file (export { can } from './can') stayed bare as well.

This PR adds WorkspaceResolver (code_review_graph/workspace_resolver.py), consulted in _do_resolve_module right after tsconfig paths for javascript, typescript, tsx and vue imports. It follows the design sketched in #1030:

  1. Find the nearest pnpm-workspace.yaml or package.json with workspaces (array or yarn's object form), walking up from the importer but never above the repository root.
  2. Expand the globs (! negation; ** through an os.walk that never enters node_modules) and map each member's name to its directory.
  3. Split the specifier the npm way (@scope/name plus subpath) and resolve the subpath through exports: exact keys, condition objects (every reachable target is tried, types last), fallback arrays, * patterns with Node's PATTERN_KEY_COMPARE priority, and null blocking. Then source, module, main, types and typings, then src/<subpath> and <subpath>.
  4. Packages usually point every entry at compiled output under dist/, which a checkout does not contain. A target under dist/, build/, out/ or lib/ is therefore tried under src/ with source extensions first (dist/index.d.ts becomes src/index.ts; dist/types/index.d.ts becomes src/index.ts unless the subpath itself names types), then probed as written.

A wrong edge is worse than a missing one, so:

  • an unscoped name resolves only when the importer's nearest named package.json declares it (otherwise components/Button could just as well be a baseUrl import of the importer's own src/components/), and Node builtin names (events, path, ...) never resolve to a workspace member;
  • probes are case-exact on every platform (the parser's _file_exists_exact, plus every directory component below the package), so Windows and macOS do not get edges that Linux would not;
  • a result must stay inside its package and the repository, and the parser drops any result that _path_is_indexed rejects;
  • malformed manifests (invalid JSON, deeply nested JSON raising RecursionError, non-UTF-8 YAML, absolute or .. workspace patterns) are caught, logged and cached, so one bad file cannot fail every later parse.

The parser change is 12 lines, and tsconfig paths still win when both apply.

Measured on public monorepos

Stock 2.3.9 against this branch, full builds of the same checkout:

documenso/documenso @ cd0cc5f (npm workspaces, 16 packages) formbricks/formbricks @ a70296b (pnpm, 20 packages)
imports of workspace packages left bare 6,720 → 144 4,319 → 6
cross-package IMPORTS_FROM edges 43 → 6,233 25 → 4,338
cross-package CALLS edges 18 → 12,170 39 → 1,521
cross-package REFERENCES edges 11 → 1,251 34 → 8,516
importers of the most-imported package entry 22 → 530 (packages/prisma/index.ts) 0 → 486 (packages/database/src/index.ts)

The 144 still bare in documenso are all @documenso/prisma/generated/..., which is generated at build time and absent from the checkout, so they stay bare by design. In formbricks, 5 of the 6 left are storybook/test and storybook/preview-api, which come from the npm storybook package even though an app in the workspace is also named storybook (neither subpath exists in that app), and the last is a Vite ?inline CSS import.

How it was tested

uv run pytest tests/test_workspace_resolver.py tests/test_workspace_package_imports.py -q   # 49 passed
uv run pytest tests/ --tb=short -q
uv run ruff check code_review_graph/                                                        # All checks passed
uv run mypy code_review_graph/ --ignore-missing-imports --no-strict-optional
  • 49 new tests. tests/test_workspace_resolver.py covers entry points, workspace discovery, specifiers, caching and the hardening cases above. tests/test_workspace_package_imports.py is the Workspace package names (pnpm/npm/yarn workspaces) are never resolved — importers_of/impact_radius collapse at every package boundary #1030 regression, run through CodeParser, GraphStore, query_graph("importers_of"), get_impact_radius and a real full_build.
  • Full suite on Linux (Docker python:3.12-slim, this exact commit): 4,311 passed. The only failure, tests/test_e2e_real_repo.py::test_hybrid_search_ranks_impact_radius_nodes_first, fails the same way on unmodified staging in that container.
  • Full suite on Windows 11 with Python 3.12: 103 tests fail on unmodified staging too, mostly path-separator assertions such as 'C:/...' == 'C:\\...'. The same 103 node IDs fail with and without this branch, and nothing else fails.
  • ruff and mypy are clean on Linux. On Windows, mypy reports only the existing fcntl errors in daemon.py.

Checklist

  • Tests added for new functionality
  • All tests pass: uv run pytest tests/ --tb=short -q (see the note above)
  • Linting passes: uv run ruff check code_review_graph/
  • Type checking passes: uv run mypy code_review_graph/ --ignore-missing-imports --no-strict-optional
  • Lines are at most 100 characters
  • Docs updated where behavior changed (CHANGELOG.md, CLAUDE.md, docs/architecture.md)

Out of scope, noticed while measuring

tsconfig alias imports written with NodeNext-style .js extensions (@/utils/format.js for src/utils/format.ts) also stay bare. The relative-import branch maps .js to .ts, but TsconfigResolver does not. I can send that as a separate PR.

Imports of sibling workspace packages (`@acme/shared`, `@acme/sdk/browser`)
were stored as bare specifiers, so importers_of, the impact radius and
references_to stopped at every package boundary.

Add WorkspaceResolver, consulted after tsconfig paths for JS/TS imports:
- find the nearest pnpm-workspace.yaml or package.json `workspaces` inside
  the repository and map member names to their directories
- read `exports` (subpaths, conditions, fallback arrays, `*` patterns with
  Node's key priority, `null` blocking), then source/module/main/types/typings
- retry build-output targets (dist/, build/, out/, lib/) under src/ first
- prefer a missing edge to a wrong one: unscoped names need a declared
  dependency, Node builtin names never resolve, probes are case-exact on
  every platform, and results stay inside the repository and must be indexed

Closes tirth8205#1030
@Balghisa12

Copy link
Copy Markdown
Contributor

Tested this branch (f800ca2) on a pnpm + Turborepo monorepo: 5,076 indexed files, NestJS apps importing internal @scope/* workspace packages whose package.json main points at dist/ (not indexed).

Against a 2.3.9 build of the same checkout:

  • unresolved @scope/* import edges from the apps: 1,496 → 0
  • app files with an import edge into the main shared package: 0 → 1,060 (git grep finds 1,057 importing files)
  • resolved CALLS edges to one shared helper: 15 → 429 (git grep finds 414 call sites)
  • full build: 3m11s → 6m34s on the same machine

It closes the biggest gap we hit on this repo.

@tirth8205

Copy link
Copy Markdown
Owner

Thank you for this PR.

I ran a full build on small npm and pnpm workspaces.

Before I can merge it, please fix these:

  1. A subpath that exports does not list still resolves. See workspace_resolver.py:235-239 and workspace_resolver.py:520. Example: @acme/a has "exports": {".": "./src/index.ts"} and a file src/private.ts. The import @acme/a/private gets an edge to src/private.ts. Node refuses it with ERR_PACKAGE_PATH_NOT_EXPORTED. Please do not resolve it, and add a test.

  2. When two packages share a name, the first one found wins. See workspace_resolver.py:392. Example: packages/b and packages/c are both named @acme/dupe. The edge goes to packages/b. But pnpm links @acme/dupe to packages/c. npm refuses this layout. Please skip a duplicate name, and add a test.

After you push the fix, I will test it again.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Workspace resolution defects can leave imports stale after manifest changes, misidentify workspace members, and resolve imports to incorrect source files or unsupported targets without graph nodes.

5 open issues
What changed in this PR

Adds workspace package import resolution to the code graph, addressing #1030 so imports and calls can cross npm, yarn and pnpm package boundaries.

Changes:

  • Introduces workspace discovery, package entry resolution and source-file fallbacks for compiled output.
  • Integrates resolution after tsconfig aliases, preserving alias precedence.
  • Adds resolver and graph regression tests, plus documentation updates.
File Description
tests/​test_workspace_resolver.py Tests discovery, entry resolution, caching and defensive handling.
tests/​test_workspace_package_imports.py Tests cross-package imports, calls, graph queries and full builds.
docs/​architecture.md Adds the workspace resolver to the module inventory.
code_review_graph/​workspace_resolver.py Implements workspace discovery and package-to-source resolution.
code_review_graph/​parser.py Consults workspace resolution after tsconfig aliases.
CLAUDE.md Documents the new resolver and test module.
CHANGELOG.md Describes workspace resolution behavior and safeguards.

Review effort: Balanced

return resolved
# Then a sibling package of an npm/yarn/pnpm workspace (#1030),
# but never an edge to a file the build would not index.
resolved = self._workspace_resolver.resolve(module, file_path)
Comment on lines +15692 to +15695
if resolved and (
self._repo_root is None
or self._path_is_indexed(Path(resolved), self._repo_root)
):
candidate = base.with_suffix(ext) if not base.suffix else Path(str(base) + ext)
if exists(candidate):
return candidate
if base.is_dir():
if "package.json" not in filenames:
continue
directory = Path(dirpath)
if fnmatch.fnmatch(directory.relative_to(root).as_posix(), pattern):
candidates.extend([stripped, kept])
else:
candidates.append(kept)
candidates.append(base.joinpath(*parts))
@tirth8205 tirth8205 closed this pull request by merging all changes into tirth8205:staging in 0160eb0 Nov 6, 2026
gowrishacv pushed a commit to gowrishacv/code-review-graph that referenced this pull request Nov 6, 2026
…ce probes (review of tirth8205#1093)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
gowrishacv pushed a commit to gowrishacv/code-review-graph that referenced this pull request Nov 6, 2026
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.

Workspace package names (pnpm/npm/yarn workspaces) are never resolved — importers_of/impact_radius collapse at every package boundary

4 participants