fix(js): handle extending from multiple config files and from local workspace packages in plugin (#30486)

## Current Behavior

The `@nx/js/typescript` plugin doesn't handle [extending from multiple
tsconfig
files](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-5-0.html#supporting-multiple-configuration-files-in-extends).
It also identifies local workspace packages linked by the package
manager as external dependencies.

## Expected Behavior

The `@nx/js/typescript` plugin should support extending from multiple
tsconfig files. It should also identify local workspace packages linked
by the package manager correctly and add their resolved path to the task
inputs (not as external dependencies).

## Related Issue(s)

Fixes #29678
This commit is contained in:
Leosvel Pérez Espinosa 2025-04-02 10:08:02 +02:00 committed by GitHub
parent 2d210b8d0e
commit 9b84926d0b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 285 additions and 62 deletions

View File

@ -1177,27 +1177,114 @@ describe(`Plugin: ${PLUGIN_NAME}`, () => {
`);
});
it('should add extended config files supporting node.js style resolution and set npm packages as external dependencies', async () => {
it('should add extended config files when there are multiple extended config files', async () => {
await applyFilesToTempFsAndContext(tempFs, context, {
'tsconfig.base.json': JSON.stringify({
extends: '@tsconfig/strictest/tsconfig.json',
exclude: ['node_modules', 'tmp'],
}),
'tsconfig.foo.json': JSON.stringify({
extends: './tsconfig.base', // extensionless relative path
'tsconfig.foo.json': '{}',
'tsconfig.bar.json': JSON.stringify({
extends: './tsconfig.foo.json',
exclude: ['node_modules', 'dist'], // extended last, it will override the base config
}),
'libs/my-lib/tsconfig.json': JSON.stringify({
extends: '../../tsconfig.foo.json',
extends: ['../../tsconfig.base.json', '../../tsconfig.bar.json'], // should collect both and any recursive extended configs as inputs
include: ['src/**/*.ts'],
// set this to keep outputs smaller
compilerOptions: { outDir: 'dist' },
}),
'libs/my-lib/package.json': `{}`,
});
expect(await invokeCreateNodesOnMatchingFiles(context, {}))
.toMatchInlineSnapshot(`
{
"projects": {
"libs/my-lib": {
"projectType": "library",
"targets": {
"typecheck": {
"cache": true,
"command": "tsc --build --emitDeclarationOnly",
"dependsOn": [
"^typecheck",
],
"inputs": [
"{projectRoot}/package.json",
"{workspaceRoot}/tsconfig.base.json",
"{workspaceRoot}/tsconfig.bar.json",
"{workspaceRoot}/tsconfig.foo.json",
"{projectRoot}/tsconfig.json",
"{projectRoot}/src/**/*.ts",
"!{workspaceRoot}/node_modules",
"!{workspaceRoot}/dist",
"^production",
{
"externalDependencies": [
"typescript",
],
},
],
"metadata": {
"description": "Runs type-checking for the project.",
"help": {
"command": "npx tsc --build --help",
"example": {
"args": [
"--force",
],
},
},
"technologies": [
"typescript",
],
},
"options": {
"cwd": "libs/my-lib",
},
"outputs": [
"{projectRoot}/dist",
],
"syncGenerators": [
"@nx/js:typescript-sync",
],
},
},
},
},
}
`);
});
it('should add extended config files supporting node.js style resolution and local workspace packages', async () => {
await applyFilesToTempFsAndContext(tempFs, context, {
'tsconfig.base.json': JSON.stringify({
extends: '@tsconfig/strictest/tsconfig.json', // should be resolved and the package name should be included in inputs as an external dependency
exclude: ['node_modules', 'tmp'],
}),
'tsconfig.foo.json': JSON.stringify({
extends: './tsconfig.base', // extensionless relative path
}),
'libs/my-lib/tsconfig.json': JSON.stringify({
extends: [
'../../tsconfig.foo.json',
'@my-org/my-package/tsconfig.base.json', // should be resolved and the path should be included in inputs
],
include: ['src/**/*.ts'],
// set this to keep outputs smaller
compilerOptions: { outDir: 'dist' },
}),
'libs/my-lib/package.json': `{}`,
'libs/my-package/package.json': `{}`,
'libs/my-package/tsconfig.base.json': `{}`,
// simulate @tsconfig/strictest package
tempFs.createFilesSync({
'node_modules/@tsconfig/strictest/tsconfig.json': '{}',
});
// create a symlink to simulate a local workspace package linked by a package manager
tempFs.createSymlinkSync(
'libs/my-package',
'node_modules/@my-org/my-package',
'dir'
);
expect(await invokeCreateNodesOnMatchingFiles(context, {}))
.toMatchInlineSnapshot(`
@ -1216,6 +1303,7 @@ describe(`Plugin: ${PLUGIN_NAME}`, () => {
"{projectRoot}/package.json",
"{workspaceRoot}/tsconfig.foo.json",
"{workspaceRoot}/tsconfig.base.json",
"{workspaceRoot}/libs/my-package/tsconfig.base.json",
"{projectRoot}/tsconfig.json",
"{projectRoot}/src/**/*.ts",
"!{workspaceRoot}/node_modules",
@ -3655,10 +3743,92 @@ describe(`Plugin: ${PLUGIN_NAME}`, () => {
`);
});
it('should add extended config files supporting node.js style resolution and set npm packages as external dependencies', async () => {
it('should add extended config files when there are multiple extended config files', async () => {
await applyFilesToTempFsAndContext(tempFs, context, {
'tsconfig.base.json': JSON.stringify({
extends: '@tsconfig/strictest/tsconfig.json',
exclude: ['node_modules', 'tmp'],
}),
'tsconfig.foo.json': '{}',
'tsconfig.bar.json': JSON.stringify({
extends: './tsconfig.foo.json',
exclude: ['node_modules', 'dist'], // extended last, it will override the base config
}),
'libs/my-lib/tsconfig.json': '{}',
'libs/my-lib/tsconfig.lib.json': JSON.stringify({
extends: ['../../tsconfig.base.json', '../../tsconfig.bar.json'], // should collect both and any recursive extended configs as inputs
include: ['src/**/*.ts'],
compilerOptions: { outDir: 'dist' },
}),
'libs/my-lib/package.json': `{"main": "dist/index.js"}`,
});
expect(
await invokeCreateNodesOnMatchingFiles(context, {
typecheck: false,
build: true,
})
).toMatchInlineSnapshot(`
{
"projects": {
"libs/my-lib": {
"projectType": "library",
"targets": {
"build": {
"cache": true,
"command": "tsc --build tsconfig.lib.json",
"dependsOn": [
"^build",
],
"inputs": [
"{projectRoot}/package.json",
"{workspaceRoot}/tsconfig.base.json",
"{workspaceRoot}/tsconfig.bar.json",
"{workspaceRoot}/tsconfig.foo.json",
"{projectRoot}/tsconfig.lib.json",
"{projectRoot}/src/**/*.ts",
"!{workspaceRoot}/node_modules",
"!{workspaceRoot}/dist",
"^production",
{
"externalDependencies": [
"typescript",
],
},
],
"metadata": {
"description": "Builds the project with \`tsc\`.",
"help": {
"command": "npx tsc --build --help",
"example": {
"args": [
"--force",
],
},
},
"technologies": [
"typescript",
],
},
"options": {
"cwd": "libs/my-lib",
},
"outputs": [
"{projectRoot}/dist",
],
"syncGenerators": [
"@nx/js:typescript-sync",
],
},
},
},
},
}
`);
});
it('should add extended config files supporting node.js style resolution and local workspace packages', async () => {
await applyFilesToTempFsAndContext(tempFs, context, {
'tsconfig.base.json': JSON.stringify({
extends: '@tsconfig/strictest/tsconfig.json', // should be resolved and the package name should be included in inputs as an external dependency
exclude: ['node_modules', 'tmp'],
}),
'tsconfig.foo.json': JSON.stringify({
@ -3666,18 +3836,27 @@ describe(`Plugin: ${PLUGIN_NAME}`, () => {
}),
'libs/my-lib/tsconfig.json': '{}',
'libs/my-lib/tsconfig.lib.json': JSON.stringify({
extends: '../../tsconfig.foo.json',
extends: [
'../../tsconfig.foo.json',
'@my-org/my-package/tsconfig.base.json', // should be resolved and the path should be included in inputs
],
compilerOptions: {
outDir: 'dist',
},
include: ['src/**/*.ts'],
}),
'libs/my-lib/package.json': `{"main": "dist/index.js"}`,
});
'libs/my-package/package.json': `{}`,
'libs/my-package/tsconfig.base.json': `{}`,
// simulate @tsconfig/strictest package
tempFs.createFilesSync({
'node_modules/@tsconfig/strictest/tsconfig.json': '{}',
});
// create a symlink to simulate a local workspace package linked by a package manager
tempFs.createSymlinkSync(
'libs/my-package',
'node_modules/@my-org/my-package',
'dir'
);
expect(
await invokeCreateNodesOnMatchingFiles(context, {
@ -3700,6 +3879,7 @@ describe(`Plugin: ${PLUGIN_NAME}`, () => {
"{projectRoot}/package.json",
"{workspaceRoot}/tsconfig.foo.json",
"{workspaceRoot}/tsconfig.base.json",
"{workspaceRoot}/libs/my-package/tsconfig.base.json",
"{projectRoot}/tsconfig.lib.json",
"{projectRoot}/src/**/*.ts",
"!{workspaceRoot}/node_modules",

View File

@ -37,7 +37,8 @@ import type { Extension, ParsedCommandLine, System } from 'typescript';
import {
addBuildAndWatchDepsTargets,
isValidPackageJsonBuildConfig,
ParsedTsconfigData,
type ExtendedConfigFile,
type ParsedTsconfigData,
} from './util';
export interface TscPluginOptions {
@ -889,25 +890,25 @@ function pathToInputOrOutput(
function getExtendedConfigFiles(
tsConfig: ParsedTsconfigData,
workspaceRoot: string
workspaceRoot: string,
extendedConfigFiles = new Set<string>(),
extendedExternalPackages = new Set<string>()
): {
files: string[];
packages: string[];
} {
const extendedConfigFiles = new Set<string>();
const extendedExternalPackages = new Set<string>();
let currentExtendedConfigFile = tsConfig.extendedConfigFile;
while (currentExtendedConfigFile) {
if (currentExtendedConfigFile.externalPackage) {
extendedExternalPackages.add(currentExtendedConfigFile.externalPackage);
break;
for (const extendedConfigFile of tsConfig.extendedConfigFiles) {
if (extendedConfigFile.externalPackage) {
extendedExternalPackages.add(extendedConfigFile.externalPackage);
} else if (extendedConfigFile.filePath) {
extendedConfigFiles.add(extendedConfigFile.filePath);
getExtendedConfigFiles(
retrieveTsConfigFromCache(extendedConfigFile.filePath, workspaceRoot),
workspaceRoot,
extendedConfigFiles,
extendedExternalPackages
);
}
extendedConfigFiles.add(currentExtendedConfigFile.filePath);
currentExtendedConfigFile = retrieveTsConfigFromCache(
currentExtendedConfigFile.filePath,
workspaceRoot
).extendedConfigFile;
}
return {
@ -1130,7 +1131,7 @@ function readTsConfigAndCache(
tsConfigCache[relativePath].hash === hash
) {
extendedFilesHash = getExtendedFilesHash(
tsConfigCache[relativePath].data.extendedConfigFile,
tsConfigCache[relativePath].data.extendedConfigFiles,
workspaceRoot
);
if (tsConfigCache[relativePath].extendedFilesHash === extendedFilesHash) {
@ -1139,17 +1140,33 @@ function readTsConfigAndCache(
}
const tsConfig = readTsConfig(tsConfigPath, workspaceRoot);
const extendedConfigFile = tsConfig.raw?.extends
? resolveExtendedTsConfigPath(tsConfig.raw.extends, dirname(tsConfigPath))
: null;
extendedFilesHash ??= getExtendedFilesHash(extendedConfigFile, workspaceRoot);
const extendedConfigFiles: ExtendedConfigFile[] = [];
if (tsConfig.raw?.extends) {
const extendsArray =
typeof tsConfig.raw.extends === 'string'
? [tsConfig.raw.extends]
: tsConfig.raw.extends;
for (const extendsPath of extendsArray) {
const extendedConfigFile = resolveExtendedTsConfigPath(
extendsPath,
dirname(tsConfigPath)
);
if (extendedConfigFile) {
extendedConfigFiles.push(extendedConfigFile);
}
}
}
extendedFilesHash ??= getExtendedFilesHash(
extendedConfigFiles,
workspaceRoot
);
tsConfigCache[relativePath] = {
data: {
options: tsConfig.options,
projectReferences: tsConfig.projectReferences,
raw: tsConfig.raw,
extendedConfigFile: extendedConfigFile ?? null,
extendedConfigFiles,
},
hash,
extendedFilesHash,
@ -1159,14 +1176,15 @@ function readTsConfigAndCache(
}
function getExtendedFilesHash(
extendedConfigFile: ParsedTsconfigData['extendedConfigFile'] | null,
extendedConfigFiles: ExtendedConfigFile[],
workspaceRoot: string
): string {
const hashes: string[] = [];
if (!extendedConfigFile) {
if (!extendedConfigFiles.length) {
return '';
}
for (const extendedConfigFile of extendedConfigFiles) {
if (extendedConfigFile.externalPackage) {
hashes.push(extendedConfigFile.externalPackage);
} else if (extendedConfigFile.filePath) {
@ -1174,11 +1192,12 @@ function getExtendedFilesHash(
hashes.push(
getExtendedFilesHash(
readTsConfigAndCache(extendedConfigFile.filePath, workspaceRoot)
.extendedConfigFile,
.extendedConfigFiles,
workspaceRoot
)
);
}
}
return hashes.join('|');
}
@ -1256,13 +1275,16 @@ function normalizePluginOptions(
function resolveExtendedTsConfigPath(
tsConfigPath: string,
directory?: string
): { filePath: string; externalPackage?: string } | null {
): ExtendedConfigFile | null {
try {
const resolvedPath = require.resolve(tsConfigPath, {
paths: directory ? [directory] : undefined,
});
if (tsConfigPath.startsWith('.')) {
if (
tsConfigPath.startsWith('.') ||
!resolvedPath.includes('/node_modules/')
) {
return { filePath: resolvedPath };
}
@ -1311,7 +1333,7 @@ function toAbsolutePaths(
raw: {
nx: { addTypecheckTarget: data.raw?.['nx']?.addTypecheckTarget },
},
extendedConfigFile: data.extendedConfigFile,
extendedConfigFiles: data.extendedConfigFiles,
},
extendedFilesHash,
hash,
@ -1340,11 +1362,10 @@ function toAbsolutePaths(
data.options.tsBuildInfoFile
);
}
if (data.extendedConfigFile?.filePath) {
updatedCache[key].data.extendedConfigFile.filePath = join(
workspaceRoot,
data.extendedConfigFile.filePath
);
if (data.extendedConfigFiles.length) {
updatedCache[key].data.extendedConfigFiles.forEach((file) => {
file.filePath = join(workspaceRoot, file.filePath);
});
}
if (data.projectReferences) {
updatedCache[key].data.projectReferences = data.projectReferences.map(
@ -1370,7 +1391,7 @@ function toRelativePaths(
raw: {
nx: { addTypecheckTarget: data.raw?.['nx']?.addTypecheckTarget },
},
extendedConfigFile: data.extendedConfigFile,
extendedConfigFiles: data.extendedConfigFiles,
},
extendedFilesHash,
hash,
@ -1399,11 +1420,10 @@ function toRelativePaths(
data.options.tsBuildInfoFile
);
}
if (data.extendedConfigFile?.filePath) {
updatedCache[key].data.extendedConfigFile.filePath = posixRelative(
workspaceRoot,
data.extendedConfigFile.filePath
);
if (data.extendedConfigFiles.length) {
updatedCache[key].data.extendedConfigFiles.forEach((file) => {
file.filePath = posixRelative(workspaceRoot, file.filePath);
});
}
if (data.projectReferences) {
updatedCache[key].data.projectReferences = data.projectReferences.map(

View File

@ -5,11 +5,15 @@ import { type PackageManagerCommands } from 'nx/src/utils/package-manager';
import { join } from 'path';
import { type ParsedCommandLine } from 'typescript';
export type ExtendedConfigFile = {
filePath: string;
externalPackage?: string;
};
export type ParsedTsconfigData = Pick<
ParsedCommandLine,
'options' | 'projectReferences' | 'raw'
> & {
extendedConfigFile: { filePath: string; externalPackage?: string } | null;
extendedConfigFiles: ExtendedConfigFile[];
};
/**

View File

@ -6,6 +6,7 @@ import {
realpathSync,
renameSync,
rmSync,
symlinkSync,
unlinkSync,
writeFileSync,
} from 'node:fs';
@ -63,6 +64,24 @@ export class TempFs {
writeFileSync(joinPathFragments(this.tempDir, filePath), content);
}
createSymlinkSync(
fileOrDirPath: string,
symlinkPath: string,
type: 'dir' | 'file'
) {
const absoluteFileOrDirPath = joinPathFragments(
this.tempDir,
fileOrDirPath
);
const absoluteSymlinkPath = joinPathFragments(this.tempDir, symlinkPath);
const symlinkDir = dirname(absoluteSymlinkPath);
if (!existsSync(symlinkDir)) {
mkdirSync(symlinkDir, { recursive: true });
}
symlinkSync(absoluteFileOrDirPath, absoluteSymlinkPath, type);
}
async readFile(filePath: string): Promise<string> {
return await readFile(joinPathFragments(this.tempDir, filePath), 'utf-8');
}