Repository navigation
TypeScript: an npm file: dependency on a sibling package is not used to resolve package-name imports when there is no root workspaces #2456
Description
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
doneActual
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
failedunresolved ref. - There is no file-level
importsedgehandler.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.tsimports edge is present. - The
failedref foracme-shared/utilsis 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.jsonworkspacesandpnpm-workspace.yaml, and readsfile:dependencies only fromoh-package.json5(comment L68-L71,collectOhpmFileDepsL132,file:test L193): v1.6.2 L233-L262, main L233-L262.
The file is identical at both refs. isExternalImportconsults the workspace map, then classifies a bare specifier as external;resolveImportPathreturns early for external imports: v1.6.2 L423-L449 and L246-L251, main L460 and L277.manifestScoperecordsworkspace:,file:,link:andportal:dependencies as in-repository: v1.6.2 L3022, main L3377.
Executed against 1.6.2; the pinned main was read as source only.
Activity
fix(resolution): a package.json dependency on a sibling directory res…
fix(resolution): a package.json dependency on a sibling directory res…
fix(resolution): a package.json dependency on a sibling directory res…
fix(resolution): a package.json dependency on a sibling directory res…
fix(resolution): a package.json dependency on a sibling directory res…
fix(resolution): a package.json dependency on a sibling directory res…
fix(resolution): a package.json dependency on a sibling directory res…
fix(resolution): a package.json dependency on a sibling directory res…
colbymchenry commentedon Oct 22, 2026
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:orportal:) now counts as part of your project, with no rootworkspacesneeded. Soimport { formatLabel, isAdmin } from 'acme-shared/utils'links topackages/common/utils.ts, and its calls resolve through the import.As with npm, a dependency declared in a nested
package.jsonapplies only to that package's files.codegraph syncfollows edits to it. It ships in the next release.fix(js,ts): imports of a repo's own packages follow their package.jso…
fix(rust): workspace package entries, path dependencies and import sp…