From 7716cd26b1e5d216fd819ce20070d3913973aa79 Mon Sep 17 00:00:00 2001 From: Benny Neugebauer Date: Tue, 29 Sep 2026 16:06:51 +0200 Subject: [PATCH] fix: Resolve tsconfig path aliases like TypeScript does in monorepos Path aliases were resolved relative to the directory of the converted project's tsconfig.json. TypeScript resolves them relative to "baseUrl" or, without it, relative to the tsconfig.json that declares "paths". In a monorepo where an app's tsconfig extends a root config, aliased imports like "@mylib/utils/helper" were looked up inside the app folder, not found, and left without a file extension. Two related issues in the same code path are fixed as well: - An alias with several targets was joined into one comma-separated path. Each target is now tried in order. - Wildcard patterns matched too much: "@mylib/*" matched "@mylib" and "@app/*" matched "@application/x". The resolved base path exposed this, because "@mylib" was turned into "@mylib/index.js". --- src/converter/convertFile.test.ts | 4 +++ .../apps/myapp/src/main.snap.ts | 7 ++++ .../monorepo-paths/apps/myapp/src/main.ts | 7 ++++ .../monorepo-paths/apps/myapp/tsconfig.json | 4 +++ .../monorepo-paths/libs/generated/schema.ts | 1 + .../monorepo-paths/libs/mylib/src/index.ts | 1 + .../libs/mylib/src/utils/helper.ts | 1 + .../fixtures/monorepo-paths/tsconfig.json | 11 ++++++ src/util/PathUtil.test.ts | 35 +++++++++++++------ src/util/PathUtil.ts | 20 ++++++----- src/util/ProjectUtil.ts | 17 +++++++++ src/util/replaceModulePath.ts | 22 +++++++----- 12 files changed, 104 insertions(+), 26 deletions(-) create mode 100644 src/test/fixtures/monorepo-paths/apps/myapp/src/main.snap.ts create mode 100644 src/test/fixtures/monorepo-paths/apps/myapp/src/main.ts create mode 100644 src/test/fixtures/monorepo-paths/apps/myapp/tsconfig.json create mode 100644 src/test/fixtures/monorepo-paths/libs/generated/schema.ts create mode 100644 src/test/fixtures/monorepo-paths/libs/mylib/src/index.ts create mode 100644 src/test/fixtures/monorepo-paths/libs/mylib/src/utils/helper.ts create mode 100644 src/test/fixtures/monorepo-paths/tsconfig.json diff --git a/src/converter/convertFile.test.ts b/src/converter/convertFile.test.ts index e1e3777..8e949ba 100644 --- a/src/converter/convertFile.test.ts +++ b/src/converter/convertFile.test.ts @@ -31,6 +31,10 @@ describe('convertFile', () => { await testFileConversion('tsconfig-include', 'consumer'); }); + it('resolves path aliases declared in an extended tsconfig of a monorepo', async () => { + await testFileConversion('monorepo-paths/apps/myapp'); + }); + it('converts CJS require statements into ESM imports', async () => { await testFileConversion('require-import'); }); diff --git a/src/test/fixtures/monorepo-paths/apps/myapp/src/main.snap.ts b/src/test/fixtures/monorepo-paths/apps/myapp/src/main.snap.ts new file mode 100644 index 0000000..fed08ab --- /dev/null +++ b/src/test/fixtures/monorepo-paths/apps/myapp/src/main.snap.ts @@ -0,0 +1,7 @@ +// Paths come from the root tsconfig.json, which this app's tsconfig.json extends +import {lib} from '@mylib'; +import {helper} from '@mylib/utils/helper.js'; +// Only the second alias target exists +import {schema} from '@generated/schema.js'; + +console.log(lib, helper, schema); diff --git a/src/test/fixtures/monorepo-paths/apps/myapp/src/main.ts b/src/test/fixtures/monorepo-paths/apps/myapp/src/main.ts new file mode 100644 index 0000000..7e7c91e --- /dev/null +++ b/src/test/fixtures/monorepo-paths/apps/myapp/src/main.ts @@ -0,0 +1,7 @@ +// Paths come from the root tsconfig.json, which this app's tsconfig.json extends +import {lib} from '@mylib'; +import {helper} from '@mylib/utils/helper'; +// Only the second alias target exists +import {schema} from '@generated/schema'; + +console.log(lib, helper, schema); diff --git a/src/test/fixtures/monorepo-paths/apps/myapp/tsconfig.json b/src/test/fixtures/monorepo-paths/apps/myapp/tsconfig.json new file mode 100644 index 0000000..596e2cf --- /dev/null +++ b/src/test/fixtures/monorepo-paths/apps/myapp/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src"] +} diff --git a/src/test/fixtures/monorepo-paths/libs/generated/schema.ts b/src/test/fixtures/monorepo-paths/libs/generated/schema.ts new file mode 100644 index 0000000..6f6a2a5 --- /dev/null +++ b/src/test/fixtures/monorepo-paths/libs/generated/schema.ts @@ -0,0 +1 @@ +export const schema = 1; diff --git a/src/test/fixtures/monorepo-paths/libs/mylib/src/index.ts b/src/test/fixtures/monorepo-paths/libs/mylib/src/index.ts new file mode 100644 index 0000000..dd55cf5 --- /dev/null +++ b/src/test/fixtures/monorepo-paths/libs/mylib/src/index.ts @@ -0,0 +1 @@ +export const lib = 1; diff --git a/src/test/fixtures/monorepo-paths/libs/mylib/src/utils/helper.ts b/src/test/fixtures/monorepo-paths/libs/mylib/src/utils/helper.ts new file mode 100644 index 0000000..e94ac33 --- /dev/null +++ b/src/test/fixtures/monorepo-paths/libs/mylib/src/utils/helper.ts @@ -0,0 +1 @@ +export const helper = 1; diff --git a/src/test/fixtures/monorepo-paths/tsconfig.json b/src/test/fixtures/monorepo-paths/tsconfig.json new file mode 100644 index 0000000..381c176 --- /dev/null +++ b/src/test/fixtures/monorepo-paths/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "module": "Node16", + "paths": { + "@generated/*": ["./libs/missing/*", "./libs/generated/*"], + "@mylib": ["./libs/mylib/src/index.ts"], + "@mylib/*": ["./libs/mylib/src/*"] + }, + "strict": true + } +} diff --git a/src/util/PathUtil.test.ts b/src/util/PathUtil.test.ts index 0b70907..8680419 100644 --- a/src/util/PathUtil.test.ts +++ b/src/util/PathUtil.test.ts @@ -1,6 +1,6 @@ import { findBestMatch, - getNormalizedPath, + getNormalizedPaths, hasRelativePath, isMatchingPath, removePathAlias, @@ -58,27 +58,42 @@ describe('PathUtil', () => { // falsy expect(isMatchingPath('@app/*', './removeSuffix')).toBeFalsy(); expect(isMatchingPath('🐵', './removeSuffix')).toBeFalsy(); + expect(isMatchingPath('@app/*', '@app')).toBeFalsy(); + expect(isMatchingPath('@app/*', '@application/dto')).toBeFalsy(); }); }); - describe('getNormalizedPath', () => { + describe('getNormalizedPaths', () => { const projectDirectory = '/home/bennycode/dev/bennycode/ts-demo-npm-cjs'; - const expectation = `${projectDirectory}/src/helpers/removeSuffix`; - const info = { normalized: '@helpers/removeSuffix', pathAlias: '@helpers/*', quoteSymbol: '"', }; - const paths = { - '@helpers/*': ['./src/helpers/*'], - '~/*': ['./src/*'], - 'helpers/*': ['./src/helpers/*'], - }; + it('resolves a path alias relative to the given directory', () => { + const paths = { + '@helpers/*': ['./src/helpers/*'], + '~/*': ['./src/*'], + 'helpers/*': ['./src/helpers/*'], + }; - expect(getNormalizedPath(projectDirectory, info, paths)).toBe(expectation); + expect(getNormalizedPaths(projectDirectory, info, paths)).toEqual([ + `${projectDirectory}/src/helpers/removeSuffix`, + ]); + }); + + it('returns one path per alias target', () => { + const paths = { + '@helpers/*': ['./src/helpers/*', './generated/helpers/*'], + }; + + expect(getNormalizedPaths(projectDirectory, info, paths)).toEqual([ + `${projectDirectory}/src/helpers/removeSuffix`, + `${projectDirectory}/generated/helpers/removeSuffix`, + ]); + }); }); describe('hasRelativePath', () => { diff --git a/src/util/PathUtil.ts b/src/util/PathUtil.ts index 15c1dae..2042220 100644 --- a/src/util/PathUtil.ts +++ b/src/util/PathUtil.ts @@ -7,8 +7,10 @@ import type {ModuleInfo} from '../parser/InfoParser.js'; * @see https://www.typescriptlang.org/docs/handbook/modules/reference.html#wildcard-substitutions */ export function isMatchingPath(pattern: string, path: string) { - const normalized = removeWildCards(pattern); - if (path.startsWith(normalized)) { + // Everything before the wildcard has to match, so "@lib/*" matches "@lib/utils" but not "@lib" or "@library" + const wildcardIndex = pattern.indexOf('*'); + const prefix = wildcardIndex === -1 ? pattern : pattern.slice(0, wildcardIndex); + if (path.startsWith(prefix)) { return pattern.length; } return 0; @@ -36,17 +38,19 @@ export function findBestMatch(aliasMap: Record, path: string) } /*** - * Use this if your path includes a path alias. + * Use this if your path includes a path alias. Returns one path per alias target, in the order TypeScript tries them. + * @param pathsBaseDirectory Directory that the "paths" in tsconfig.json are relative to */ -export function getNormalizedPath( - projectDirectory: string, +export function getNormalizedPaths( + pathsBaseDirectory: string, info: Pick, paths: Record ) { - const pathAliasResolution = `${paths[info.pathAlias]}`; - const normalizedResolution = removeWildCards(pathAliasResolution).replaceAll(info.quoteSymbol, ''); const normalizedFilePath = removePathAlias(info.pathAlias, info.normalized); - return path.join(projectDirectory, normalizedResolution, normalizedFilePath); + return (paths[info.pathAlias] ?? []).map(target => { + const normalizedResolution = removeWildCards(target).replaceAll(info.quoteSymbol, ''); + return path.join(pathsBaseDirectory, normalizedResolution, normalizedFilePath); + }); } export function hasRelativePath(path: string) { diff --git a/src/util/ProjectUtil.ts b/src/util/ProjectUtil.ts index 3100fb7..669585a 100644 --- a/src/util/ProjectUtil.ts +++ b/src/util/ProjectUtil.ts @@ -6,6 +6,23 @@ export const ProjectUtil = { // Note: getCompilerOptions() cannot be cached and has to be used everytime the config is accessed return project.getCompilerOptions().paths; }, + /** + * TypeScript resolves "paths" relative to "baseUrl" or, without "baseUrl", relative to the tsconfig.json that + * declares them. That can be a parent config pulled in through "extends" (i.e. a monorepo root config). + * @see https://github.com/bennycode/ts2esm/issues/127 + */ + getPathsBaseDirectory: (sourceFile: SourceFile): string => { + const options = sourceFile.getProject().getCompilerOptions(); + if (options.baseUrl) { + return options.baseUrl; + } + // "pathsBasePath" is set by TypeScript's config parser but is not part of its public types + if ('pathsBasePath' in options && typeof options.pathsBasePath === 'string') { + return options.pathsBasePath; + } + return path.dirname(ProjectUtil.getTsConfigFilePath(sourceFile)); + }, + getProject: (tsConfigFilePath: string) => { return new Project({ // Limit the scope of source files to those directly listed as opposed to also all diff --git a/src/util/replaceModulePath.ts b/src/util/replaceModulePath.ts index eff24cb..cfb456b 100644 --- a/src/util/replaceModulePath.ts +++ b/src/util/replaceModulePath.ts @@ -2,7 +2,7 @@ import {SourceFile, StringLiteral} from 'ts-morph'; import {ModuleInfo, parseInfo} from '../parser/InfoParser.js'; import {ProjectUtil} from './ProjectUtil.js'; import {toImport, toImportAttribute} from '../converter/ImportConverter.js'; -import {getNormalizedPath, isNodeModuleRoot} from './PathUtil.js'; +import {getNormalizedPaths, isNodeModuleRoot} from './PathUtil.js'; import path from 'node:path'; import {PathFinder} from './PathFinder.js'; @@ -18,8 +18,9 @@ export function replaceModulePath({ const paths = ProjectUtil.getPaths(sourceFile.getProject()); const tsConfigFilePath = ProjectUtil.getTsConfigFilePath(sourceFile); const projectDirectory = ProjectUtil.getRootDirectory(tsConfigFilePath); + const pathsBaseDirectory = ProjectUtil.getPathsBaseDirectory(sourceFile); const info = parseInfo(sourceFile.getFilePath(), stringLiteral, paths); - const replacement = createReplacementPath({hasAttributesClause, info, paths, projectDirectory}); + const replacement = createReplacementPath({hasAttributesClause, info, paths, pathsBaseDirectory, projectDirectory}); if (replacement) { stringLiteral.replaceWithText(replacement); return true; @@ -31,11 +32,13 @@ function createReplacementPath({ hasAttributesClause, info, paths, + pathsBaseDirectory, projectDirectory, }: { hasAttributesClause: boolean; info: ModuleInfo; paths: Record | undefined; + pathsBaseDirectory: string; projectDirectory: string; }) { if (hasAttributesClause) { @@ -51,15 +54,18 @@ function createReplacementPath({ // If an import does not have a file extension or isn't an extension recognized here and can't be found locally (perhaps // file had . in name), try to find a matching file by traversing through all valid TypeScript source file extensions. - let baseFilePath = comesFromPathAlias - ? getNormalizedPath(projectDirectory, info, paths) - : path.join(info.directory, info.normalized); + let baseFilePaths = comesFromPathAlias + ? getNormalizedPaths(pathsBaseDirectory, info, paths) + : [path.join(info.directory, info.normalized)]; if (isNodeModulesPath) { - baseFilePath = path.join(projectDirectory, 'node_modules', info.normalized); + baseFilePaths = [path.join(projectDirectory, 'node_modules', info.normalized)]; } - const foundPath = PathFinder.findPath(baseFilePath, info.extension); - if (foundPath) { + for (const baseFilePath of baseFilePaths) { + const foundPath = PathFinder.findPath(baseFilePath, info.extension); + if (!foundPath) { + continue; + } // TODO: Write test case for this condition, mock "path" and "fs" calls if necessary if (foundPath.extension === '/index.js' && isNodeModuleRoot(baseFilePath)) { // @fixes https://github.com/bennycode/ts2esm/issues/81#issuecomment-2437503011