Skip to content

TypeScript: an npm file: dependency on a sibling package is not used to resolve package-name imports when there is no root workspaces #2456

Closed

Description

@Y-Osawa2022

Related: #2455

Summary

CodeGraph does not resolve this package-name import when the sibling package is declared only through an npm file: dependency ("acme-shared": "file:../common").
The import is recorded as failed, and the file-level imports edge that a relative import produces is missing.
The named calls still land on the real function, but only through name matching.

This is a valid npm layout, not a regression: npm documents local paths as a dependency form (https://docs.npmjs.com/cli/v11/configuring-npm/package-json/#local-paths).

Environment

  • codegraph 1.6.2, installed with npm install -g @colbymchenry/codegraph@1.6.2
  • macOS 26.6 (arm64)
  • Node v22.21.1

Minimal reproduction

All names are invented.
The script creates the repositories (each is a git repository, because the indexer lists files with git ls-files), indexes them, and queries the result.
file-dep is the case in question and has no root package.json.
relative is a control that imports by relative path.
file-dep-ws is file-dep plus a root package.json with workspaces.

#!/usr/bin/env bash
# npm install -g @colbymchenry/codegraph@1.6.2
set -euo pipefail
REPRO=$(mktemp -d)
echo "repro root: $REPRO"

# mkrepo <name> <acme-shared dependency spec> <import specifier> <mock file under packages/api, or -> <root workspaces: yes|no>
mkrepo() {
  local d="$REPRO/$1"
  mkdir -p "$d/packages/common" "$d/packages/api/src"
  if [ "$5" = yes ]; then
    echo '{ "name": "acme-monorepo", "private": true, "workspaces": ["packages/*"] }' > "$d/package.json"
  fi
  echo '{ "name": "acme-shared", "version": "1.0.0" }' > "$d/packages/common/package.json"
  cat > "$d/packages/common/utils.ts" <<'TS'
export function formatLabel(name: string): string {
  return `[${name.trim().toUpperCase()}]`;
}

export function isAdmin(role: string): boolean {
  return role === 'admin';
}
TS
  echo "{ \"name\": \"acme-api\", \"version\": \"1.0.0\", \"dependencies\": { \"acme-shared\": \"$2\" } }" > "$d/packages/api/package.json"
  cat > "$d/packages/api/src/handler.ts" <<TS
import { formatLabel, isAdmin } from '$3';

export function handleRequest(name: string, role: string): string {
  if (!isAdmin(role)) {
    return 'forbidden';
  }
  return formatLabel(name);
}
TS
  if [ "$4" != - ]; then
    mkdir -p "$(dirname "$d/packages/api/$4")"
    cat > "$d/packages/api/$4" <<'TS'
export function formatLabel(name: string): string {
  return 'mock-label';
}

export function isAdmin(role: string): boolean {
  return true;
}
TS
  fi
  (cd "$d" && git init -q && git add -A)
  codegraph init -y "$d" > /dev/null
}

#      name         dependency        import specifier    mock  workspaces
mkrepo file-dep     'file:../common'  acme-shared/utils   -     no
mkrepo relative     'file:../common'  ../../common/utils  -     no
mkrepo file-dep-ws  'file:../common'  acme-shared/utils   -     yes

# Query each completed index read-only (we used immutable=1 after indexing finished).
for d in "$REPRO"/*/; do
  echo "### $(basename "$d")"
  sqlite3 "file:${d}.codegraph/codegraph.db?immutable=1" <<'SQL'
.mode list
SELECT s.name || ' (' || s.kind || ') | ' || e.kind || ' | ' || t.name || ' | ' || t.file_path || ' | line ' || e.line || ' | ' || e.metadata
FROM edges e JOIN nodes s ON s.id = e.source JOIN nodes t ON t.id = e.target
WHERE e.kind IN ('calls', 'imports') ORDER BY e.kind, e.line, t.file_path;
SELECT 'unresolved: ' || reference_name || ' | ' || reference_kind || ' | ' || status || ' | line ' || line
FROM unresolved_refs WHERE file_path LIKE '%handler.ts' ORDER BY line, reference_kind;
SQL
done

Actual

file-dep:

handleRequest (function) | calls | isAdmin | packages/common/utils.ts | line 4 | {"confidence":0.9,"resolvedBy":"exact-match","refName":"isAdmin"}
handleRequest (function) | calls | formatLabel | packages/common/utils.ts | line 7 | {"confidence":0.9,"resolvedBy":"exact-match","refName":"formatLabel"}
handler.ts (file) | imports | formatLabel | packages/common/utils.ts | line 1 | {"confidence":0.9,"resolvedBy":"exact-match","refName":"formatLabel"}
handler.ts (file) | imports | isAdmin | packages/common/utils.ts | line 1 | {"confidence":0.9,"resolvedBy":"exact-match","refName":"isAdmin"}
unresolved: acme-shared/utils | imports | failed | line 1

relative (control):

handleRequest (function) | calls | isAdmin | packages/common/utils.ts | line 4 | {"confidence":0.9,"resolvedBy":"import","refName":"isAdmin"}
handleRequest (function) | calls | formatLabel | packages/common/utils.ts | line 7 | {"confidence":0.9,"resolvedBy":"import","refName":"formatLabel"}
handler.ts (file) | imports | utils.ts | packages/common/utils.ts | line 1 | {"confidence":0.9,"resolvedBy":"import","refName":"../../common/utils"}
handler.ts (file) | imports | formatLabel | packages/common/utils.ts | line 1 | {"confidence":0.9,"resolvedBy":"import","refName":"formatLabel"}
handler.ts (file) | imports | isAdmin | packages/common/utils.ts | line 1 | {"confidence":0.9,"resolvedBy":"import","refName":"isAdmin"}

Differences in file-dep:

  • The package import is recorded as a failed unresolved ref.
  • There is no file-level imports edge handler.ts -> utils.ts.
  • The named calls land on the real function through name matching (resolvedBy: "exact-match") instead of through the import (resolvedBy: "import").
    With a single same-named function in the repository that gives the right target.
    With two same-named candidates, the choice comes from directory proximity, not from the import.

In file-dep-ws (the file-dep repository plus the root package.json below, file: dependency kept):

{ "name": "acme-monorepo", "private": true, "workspaces": ["packages/*"] }
  • The calls and the named imports resolve with resolvedBy: "import".
  • The file-level handler.ts -> utils.ts imports edge is present.
  • The failed ref for acme-shared/utils is gone.

So the missing piece is the file: form itself.

Why

The package-name import goes through the workspace map that loadWorkspacePackages builds.
isExternalImport treats a bare specifier as external when the map does not resolve it, and resolveImportPath then returns early.
Without workspace declarations, this fixture's npm file: dependency does not populate the map.
npm file: declarations are recognized by the fallback guard (manifestScope classifies them as in-repository) but are not used to discover package directories for import resolution.

Expected

A package-name import of a sibling package declared as "acme-shared": "file:../common" resolves to the target directory when that directory is inside the indexed repository, the way a workspace member does.
That gives resolvedBy: "import" and the file-level imports edge.

Source references

Pinned main is b635dd467f0578926a9c01a37b9d28d2b26689f1.

  • Workspace discovery reads only root package.json workspaces and pnpm-workspace.yaml, and reads file: dependencies only from oh-package.json5 (comment L68-L71, collectOhpmFileDeps L132, file: test L193): v1.6.2 L233-L262, main L233-L262.
    The file is identical at both refs.
  • isExternalImport consults the workspace map, then classifies a bare specifier as external; resolveImportPath returns early for external imports: v1.6.2 L423-L449 and L246-L251, main L460 and L277.
  • manifestScope records workspace:, file:, link: and portal: dependencies as in-repository: v1.6.2 L3022, main L3377.

Executed against 1.6.2; the pinned main was read as source only.

Activity

  1. colbymchenry commented on Oct 22, 2026

    @colbymchenry
    Owner

    Thanks for the report and the three-layout repro.

    #2799 is merged to main. A package that another one depends on by path (file:, link: or portal:) now counts as part of your project, with no root workspaces needed. So import { formatLabel, isAdmin } from 'acme-shared/utils' links to packages/common/utils.ts, and its calls resolve through the import.

    As with npm, a dependency declared in a nested package.json applies only to that package's files. codegraph sync follows edits to it. It ships in the next release.

  2. added a commit that references this issue on Oct 22, 2026
    e8ddc4e
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

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions