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:
parent
2d210b8d0e
commit
9b84926d0b
@ -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': `{}`,
|
||||
});
|
||||
// simulate @tsconfig/strictest package
|
||||
tempFs.createFilesSync({
|
||||
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
|
||||
'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"}`,
|
||||
});
|
||||
// simulate @tsconfig/strictest package
|
||||
tempFs.createFilesSync({
|
||||
'libs/my-package/package.json': `{}`,
|
||||
'libs/my-package/tsconfig.base.json': `{}`,
|
||||
// simulate @tsconfig/strictest package
|
||||
'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",
|
||||
|
||||
@ -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,25 +1176,27 @@ function readTsConfigAndCache(
|
||||
}
|
||||
|
||||
function getExtendedFilesHash(
|
||||
extendedConfigFile: ParsedTsconfigData['extendedConfigFile'] | null,
|
||||
extendedConfigFiles: ExtendedConfigFile[],
|
||||
workspaceRoot: string
|
||||
): string {
|
||||
const hashes: string[] = [];
|
||||
if (!extendedConfigFile) {
|
||||
if (!extendedConfigFiles.length) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (extendedConfigFile.externalPackage) {
|
||||
hashes.push(extendedConfigFile.externalPackage);
|
||||
} else if (extendedConfigFile.filePath) {
|
||||
hashes.push(getFileHash(extendedConfigFile.filePath, workspaceRoot));
|
||||
hashes.push(
|
||||
getExtendedFilesHash(
|
||||
readTsConfigAndCache(extendedConfigFile.filePath, workspaceRoot)
|
||||
.extendedConfigFile,
|
||||
workspaceRoot
|
||||
)
|
||||
);
|
||||
for (const extendedConfigFile of extendedConfigFiles) {
|
||||
if (extendedConfigFile.externalPackage) {
|
||||
hashes.push(extendedConfigFile.externalPackage);
|
||||
} else if (extendedConfigFile.filePath) {
|
||||
hashes.push(getFileHash(extendedConfigFile.filePath, workspaceRoot));
|
||||
hashes.push(
|
||||
getExtendedFilesHash(
|
||||
readTsConfigAndCache(extendedConfigFile.filePath, workspaceRoot)
|
||||
.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(
|
||||
|
||||
@ -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[];
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@ -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');
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user