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, { await applyFilesToTempFsAndContext(tempFs, context, {
'tsconfig.base.json': JSON.stringify({ 'tsconfig.base.json': JSON.stringify({
extends: '@tsconfig/strictest/tsconfig.json',
exclude: ['node_modules', 'tmp'], exclude: ['node_modules', 'tmp'],
}), }),
'tsconfig.foo.json': JSON.stringify({ 'tsconfig.foo.json': '{}',
extends: './tsconfig.base', // extensionless relative path '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({ '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'], include: ['src/**/*.ts'],
// set this to keep outputs smaller // set this to keep outputs smaller
compilerOptions: { outDir: 'dist' }, compilerOptions: { outDir: 'dist' },
}), }),
'libs/my-lib/package.json': `{}`, 'libs/my-lib/package.json': `{}`,
}); });
// simulate @tsconfig/strictest package expect(await invokeCreateNodesOnMatchingFiles(context, {}))
tempFs.createFilesSync({ .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
'node_modules/@tsconfig/strictest/tsconfig.json': '{}', '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, {})) expect(await invokeCreateNodesOnMatchingFiles(context, {}))
.toMatchInlineSnapshot(` .toMatchInlineSnapshot(`
@ -1216,6 +1303,7 @@ describe(`Plugin: ${PLUGIN_NAME}`, () => {
"{projectRoot}/package.json", "{projectRoot}/package.json",
"{workspaceRoot}/tsconfig.foo.json", "{workspaceRoot}/tsconfig.foo.json",
"{workspaceRoot}/tsconfig.base.json", "{workspaceRoot}/tsconfig.base.json",
"{workspaceRoot}/libs/my-package/tsconfig.base.json",
"{projectRoot}/tsconfig.json", "{projectRoot}/tsconfig.json",
"{projectRoot}/src/**/*.ts", "{projectRoot}/src/**/*.ts",
"!{workspaceRoot}/node_modules", "!{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, { await applyFilesToTempFsAndContext(tempFs, context, {
'tsconfig.base.json': JSON.stringify({ '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'], exclude: ['node_modules', 'tmp'],
}), }),
'tsconfig.foo.json': JSON.stringify({ 'tsconfig.foo.json': JSON.stringify({
@ -3666,18 +3836,27 @@ describe(`Plugin: ${PLUGIN_NAME}`, () => {
}), }),
'libs/my-lib/tsconfig.json': '{}', 'libs/my-lib/tsconfig.json': '{}',
'libs/my-lib/tsconfig.lib.json': JSON.stringify({ '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: { compilerOptions: {
outDir: 'dist', outDir: 'dist',
}, },
include: ['src/**/*.ts'], include: ['src/**/*.ts'],
}), }),
'libs/my-lib/package.json': `{"main": "dist/index.js"}`, 'libs/my-lib/package.json': `{"main": "dist/index.js"}`,
}); 'libs/my-package/package.json': `{}`,
// simulate @tsconfig/strictest package 'libs/my-package/tsconfig.base.json': `{}`,
tempFs.createFilesSync({ // simulate @tsconfig/strictest package
'node_modules/@tsconfig/strictest/tsconfig.json': '{}', '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( expect(
await invokeCreateNodesOnMatchingFiles(context, { await invokeCreateNodesOnMatchingFiles(context, {
@ -3700,6 +3879,7 @@ describe(`Plugin: ${PLUGIN_NAME}`, () => {
"{projectRoot}/package.json", "{projectRoot}/package.json",
"{workspaceRoot}/tsconfig.foo.json", "{workspaceRoot}/tsconfig.foo.json",
"{workspaceRoot}/tsconfig.base.json", "{workspaceRoot}/tsconfig.base.json",
"{workspaceRoot}/libs/my-package/tsconfig.base.json",
"{projectRoot}/tsconfig.lib.json", "{projectRoot}/tsconfig.lib.json",
"{projectRoot}/src/**/*.ts", "{projectRoot}/src/**/*.ts",
"!{workspaceRoot}/node_modules", "!{workspaceRoot}/node_modules",

View File

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

View File

@ -6,6 +6,7 @@ import {
realpathSync, realpathSync,
renameSync, renameSync,
rmSync, rmSync,
symlinkSync,
unlinkSync, unlinkSync,
writeFileSync, writeFileSync,
} from 'node:fs'; } from 'node:fs';
@ -63,6 +64,24 @@ export class TempFs {
writeFileSync(joinPathFragments(this.tempDir, filePath), content); 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> { async readFile(filePath: string): Promise<string> {
return await readFile(joinPathFragments(this.tempDir, filePath), 'utf-8'); return await readFile(joinPathFragments(this.tempDir, filePath), 'utf-8');
} }