Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

### Fixes

- In JavaScript and TypeScript, an import of a sibling package that a `package.json` depends on by path, like `"acme-shared": "file:../common"` (also `link:` and `portal:`), now resolves to that package's file without a root `workspaces` declaration — for the files of the package that declares the dependency, as npm installs it. Before, the import failed and its calls were matched by name, so when two functions shared a name the nearest one won. (#2456)
- In TypeScript, an awaited call with type arguments like `await fetchApi<User>("/me")` or `await api.get<T>(url)` now links to the function it calls. Before, the call was dropped, so typed fetch wrappers looked like they had no callers. (#2586)
- In React, a component rendered at module scope, in a top-level constant or inside a test's `it(…)` callback, like `createRoot(el).render(<App />)` or `render(<Card />)`, now counts as rendered there, so an app's entry reaches its root component and a component's tests show among its callers. (#2587)
- In Python, `import a.b as X` written inside a function now links its calls like `X.f()`, as the same import at module level already did. Thanks @Egrn for the report. (#2600)
Expand Down
118 changes: 118 additions & 0 deletions __tests__/npm-file-dependency-import.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
/**
* A sibling package declared as `"acme-shared": "file:../common"` is in the
* repository, so `import { formatLabel } from 'acme-shared/utils'` is an
* import of `packages/common/utils.ts` — with no root `workspaces` to say so.
* Before, the import failed and the call was matched by name: right while the
* name was unique, the nearest same-named function otherwise (#2456).
*
* The dependency is the declaring package's: a fixture app's `"left-pad":
* "file:../stub-left-pad"` is the stub for the fixture's own files, and
* `left-pad` stays the npm package for the rest of the project.
*/
import { describe, it, expect, afterAll, beforeAll } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { CodeGraph } from '../src';

let root = '';
let cg: CodeGraph;

beforeAll(async () => {
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-npm-file-dep-'));
const files: Record<string, string> = {
'packages/common/package.json': JSON.stringify({ name: 'acme-shared', version: '1.0.0' }),
'packages/common/utils.ts': `export function formatLabel(name: string): string {
return name.trim().toUpperCase();
}
`,
'packages/api/package.json': JSON.stringify({ name: 'acme-api', version: '1.0.0', dependencies: { 'acme-shared': 'file:../common' } }),
// A same-named function next to the caller: what name matching would pick.
'packages/api/src/labels.ts': `export function formatLabel(name: string): string {
return name;
}
`,
'packages/api/src/handler.ts': `import { formatLabel } from 'acme-shared/utils';

export function handleRequest(name: string): string {
return formatLabel(name);
}
`,
'packages/ui/package.json': JSON.stringify({ name: '@acme/ui', version: '1.0.0' }),
'packages/ui/button.ts': `export function renderButton(text: string): string {
return text;
}
`,
'packages/web/package.json': JSON.stringify({ name: 'acme-web', version: '1.0.0', devDependencies: { '@acme/ui': 'link:../ui' } }),
'packages/web/src/page.ts': `import { renderButton } from '@acme/ui/button';

export function renderPage(): string {
return renderButton('ok');
}
`,
'package.json': JSON.stringify({ name: 'acme-root', private: true, dependencies: { 'left-pad': '^1.0.0' } }),
'__tests__/fixtures/app/package.json': JSON.stringify({ name: 'fixture-app', dependencies: { 'left-pad': 'file:../stub-left-pad' } }),
'__tests__/fixtures/app/use.ts': `import leftPad from 'left-pad';

export function useStub(): string {
return leftPad('x', 1);
}
`,
'__tests__/fixtures/stub-left-pad/package.json': JSON.stringify({ name: 'left-pad', main: 'index.ts' }),
'__tests__/fixtures/stub-left-pad/index.ts': `export default function leftPad(s: string, n: number): string {
return s;
}
`,
'__tests__/fixtures/stub-left-pad/lib/pad.ts': `export function pad(s: string): string {
return s;
}
`,
'src/main.ts': `import leftPad from 'left-pad';
import { pad } from 'left-pad/lib/pad';

export function run(): string {
return leftPad(pad('x'), 3);
}
`,
};
for (const [rel, content] of Object.entries(files)) {
fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true });
fs.writeFileSync(path.join(root, rel), content);
}
cg = await CodeGraph.init(root, { index: true });
});

afterAll(() => {
cg?.close();
if (root) fs.rmSync(root, { recursive: true, force: true });
});

/** `kind -> file (resolvedBy)` of what `name` in `file` calls, and of what `file` imports. */
const edgesOf = (file: string, name: string) => {
const nodes = cg.getNodesInFile(file).filter((n) => n.name === name || n.kind === 'file');
return cg.getOutgoingEdgesFrom(nodes.map((n) => n.id)).filter((e) => e.kind === 'calls' || e.kind === 'imports')
.map((e) => `${e.kind} -> ${cg.getNode(e.target)!.filePath} (${String(e.metadata?.resolvedBy)})`);
};

describe('an npm dependency on a sibling directory', () => {
it('file: — the package-name import is the sibling package\'s file', () => {
const edges = edgesOf('packages/api/src/handler.ts', 'handleRequest');
expect(edges).toContain('calls -> packages/common/utils.ts (import)');
expect(edges).toContain('imports -> packages/common/utils.ts (import)');
expect(edges.filter((e) => e.includes('packages/api/src/labels.ts'))).toEqual([]);
});

it('link: in devDependencies, a scoped name', () => {
expect(edgesOf('packages/web/src/page.ts', 'renderPage')).toContain('calls -> packages/ui/button.ts (import)');
});
});

describe('a file: dependency a fixture app declares on a stub of an npm package', () => {
it('is the stub for the fixture\'s own files', () => {
expect(edgesOf('__tests__/fixtures/app/use.ts', 'useStub')).toContain('calls -> __tests__/fixtures/stub-left-pad/index.ts (import)');
});

it('leaves the npm package external for the rest of the project: no edge from production code into the stub', () => {
expect(edgesOf('src/main.ts', 'run').filter((e) => e.includes('stub-left-pad'))).toEqual([]);
});
});
5 changes: 3 additions & 2 deletions src/resolution/callback-synthesizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
* tagged `provenance:'heuristic'`. See docs/design/callback-edge-synthesis.md.
*/
import type { Edge, Language, Node, NodeKind } from '../types';
import { workspaceMemberDirs } from './workspace-packages';
import type { QueryBuilder } from '../db/queries';
import type { ResolutionContext } from './types';
import { isGeneratedFile } from '../extraction/generated-detection';
Expand Down Expand Up @@ -584,7 +585,7 @@ async function arkuiEmitterEdges(ctx: ResolutionContext, onYield: MaybeYield): P

const moduleDirs = (() => {
const ws = ctx.getWorkspacePackages?.();
return ws ? [...new Set(ws.byName.values())].sort((a, b) => b.length - a.length) : [];
return ws ? workspaceMemberDirs(ws) : [];
})();
const moduleScopeOf = (file: string): string => {
for (const dir of moduleDirs) {
Expand Down Expand Up @@ -688,7 +689,7 @@ async function arkuiRouterEdges(ctx: ResolutionContext, onYield: MaybeYield): Pr
const allFiles = ctx.getAllFiles();
const moduleDirs = (() => {
const ws = ctx.getWorkspacePackages?.();
return ws ? [...new Set(ws.byName.values())].sort((a, b) => b.length - a.length) : [];
return ws ? workspaceMemberDirs(ws) : [];
})();
const moduleScopeOf = (file: string): string => {
for (const dir of moduleDirs) {
Expand Down
15 changes: 9 additions & 6 deletions src/resolution/import-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -389,7 +389,7 @@ function resolveImportPathUncached(
// alias map first (custom prefixes like `@components/*` would
// otherwise be misclassified as npm). What still looks like a package
// may name a file through the importing app's own config.
if (isExternalImport(importPath, language, context)) {
if (isExternalImport(importPath, language, context, { fromFile })) {
return resolveFromOwnConfig(importPath, fromFile, language, context);
}

Expand Down Expand Up @@ -1105,12 +1105,15 @@ const RUST_STDLIB_ROOTS = new Set(['std', 'core', 'alloc', 'proc_macro']);
* asked `resolveImportPath` whether an alias maps the specifier to a file.
* Matching a prefix proves nothing by itself: a catch-all `"*"` pattern
* (`"*": ["./typings/*"]`) has an empty prefix and matches every package.
*
* `fromFile` (projectRoot-relative) is the importing file, for the path
* dependencies only the files under a package.json see.
*/
export function isExternalImport(
importPath: string,
language: Language,
context?: ResolutionContext,
options: { aliasPrefixes?: boolean } = {}
options: { aliasPrefixes?: boolean; fromFile?: string } = {}
): boolean {
// Relative imports are not external
if (importPath.startsWith('.')) {
Expand All @@ -1122,7 +1125,7 @@ export function isExternalImport(
// workspace map first so they aren't misclassified as external (#629). The
// map is null for single-package repos, so this is a no-op there.
const workspaces = context?.getWorkspacePackages?.();
if (workspaces && resolveWorkspaceImport(importPath, workspaces)) {
if (workspaces && resolveWorkspaceImport(importPath, workspaces, options.fromFile)) {
return false;
}

Expand Down Expand Up @@ -1302,7 +1305,7 @@ function resolveAliasedImport(
// extension/index permutations below then find its barrel (#629).
const workspaces = context.getWorkspacePackages?.();
if (workspaces) {
const base = resolveWorkspaceImport(importPath, workspaces);
const base = resolveWorkspaceImport(importPath, workspaces, fromFile);
if (base) {
const hit = tryWithExt(base);
if (hit) return hit;
Expand Down Expand Up @@ -4218,7 +4221,7 @@ export function isBoundToOutOfRepoImport(
if (imp.localName !== name) continue;
// A package the repository declares itself (`declare module 'x'`) has
// its declarations in the repository.
return isExternalImport(imp.source, ref.language, context) &&
return isExternalImport(imp.source, ref.language, context, { fromFile: ref.filePath }) &&
resolveImportPath(imp.source, ref.filePath, ref.language, context) === null &&
!ambientModuleDeclares(imp.source, imp.exportedName, imp.isDefault, ref, context);
}
Expand Down Expand Up @@ -4291,7 +4294,7 @@ export function importBindingTail(ref: UnresolvedRef, context: ResolutionContext
const binding = mappings.find((m) => m.localName === name) ??
(receiver && ref.referenceKind === 'calls' ? localTypeBinding(receiver, ref, context, mappings) : undefined);
if (!binding) return undefined;
if (isExternalImport(binding.source, ref.language, context) &&
if (isExternalImport(binding.source, ref.language, context, { fromFile: ref.filePath }) &&
// A monorepo app's own tsconfig alias (`#/views/…`): isExternalImport reads the root's only.
!context.getNearestAliases?.(ref.filePath)?.patterns.some((p) => p.prefix !== '' && binding.source.startsWith(p.prefix)) &&
// A module the app's `baseUrl` or catch-all `paths` reads (`layout/MainLayout`).
Expand Down
2 changes: 1 addition & 1 deletion src/resolution/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -700,7 +700,7 @@ export class ReferenceResolver {
// `"lit/decorators": ["./node_modules/lit/decorators.js"]` lands in
// `node_modules`, topcoder's `config` package on its `config/` folder.
isOutOfRepoImport: (source, fromFile, language) => {
if (!isExternalImport(source, language, this.context, { aliasPrefixes: false })) return false;
if (!isExternalImport(source, language, this.context, { aliasPrefixes: false, fromFile })) return false;
const resolved = resolveImportPath(source, fromFile, language, this.context);
if (resolved !== null && this.isIndexedFile(resolved)) return false;
return this.isDeclaredOutsidePackage(source, fromFile);
Expand Down
Loading