diff --git a/packages/js/src/plugins/typescript/plugin.spec.ts b/packages/js/src/plugins/typescript/plugin.spec.ts index 97b9dad964bd0..bb4e7334863f1 100644 --- a/packages/js/src/plugins/typescript/plugin.spec.ts +++ b/packages/js/src/plugins/typescript/plugin.spec.ts @@ -1,11 +1,17 @@ import { detectPackageManager, type CreateNodesContext } from '@nx/devkit'; import { TempFs } from '@nx/devkit/internal-testing-utils'; import { minimatch } from 'minimatch'; +import { mkdirSync, rmdirSync } from 'node:fs'; // eslint-disable-next-line @typescript-eslint/no-restricted-imports import { getLockFileName } from 'nx/src/plugins/js/lock-file/lock-file'; import { setupWorkspaceContext } from 'nx/src/utils/workspace-context'; import { PLUGIN_NAME, createNodesV2, type TscPluginOptions } from './plugin'; +jest.mock('nx/src/utils/cache-directory', () => ({ + ...jest.requireActual('nx/src/utils/cache-directory'), + workspaceDataDirectory: 'tmp/project-graph-cache', +})); + describe(`Plugin: ${PLUGIN_NAME}`, () => { let context: CreateNodesContext; let cwd = process.cwd(); @@ -13,6 +19,7 @@ describe(`Plugin: ${PLUGIN_NAME}`, () => { let originalCacheProjectGraph: string | undefined; beforeEach(() => { + mkdirSync('tmp/project-graph-cache', { recursive: true }); tempFs = new TempFs('typescript-plugin'); context = { nxJsonConfiguration: { @@ -38,6 +45,7 @@ describe(`Plugin: ${PLUGIN_NAME}`, () => { tempFs.cleanup(); process.chdir(cwd); process.env.NX_CACHE_PROJECT_GRAPH = originalCacheProjectGraph; + rmdirSync('tmp/project-graph-cache', { recursive: true }); }); it('should not create nodes for root tsconfig.json files', async () => { @@ -59,7 +67,7 @@ describe(`Plugin: ${PLUGIN_NAME}`, () => { it('should create a node with a typecheck target for a project level tsconfig.json file by default (when there is a sibling package.json or project.json)', async () => { // Sibling package.json await applyFilesToTempFsAndContext(tempFs, context, { - 'libs/my-lib/tsconfig.json': `{}`, + 'libs/my-lib/tsconfig.json': JSON.stringify({ files: [] }), 'libs/my-lib/package.json': `{}`, }); expect(await invokeCreateNodesOnMatchingFiles(context, {})) @@ -114,7 +122,7 @@ describe(`Plugin: ${PLUGIN_NAME}`, () => { // Sibling project.json await applyFilesToTempFsAndContext(tempFs, context, { - 'libs/my-lib/tsconfig.json': `{}`, + 'libs/my-lib/tsconfig.json': JSON.stringify({ files: [] }), 'libs/my-lib/project.json': `{}`, }); expect(await invokeCreateNodesOnMatchingFiles(context, {})) @@ -169,7 +177,7 @@ describe(`Plugin: ${PLUGIN_NAME}`, () => { // Other tsconfigs present await applyFilesToTempFsAndContext(tempFs, context, { - 'libs/my-lib/tsconfig.json': `{}`, + 'libs/my-lib/tsconfig.json': JSON.stringify({ files: [] }), 'libs/my-lib/tsconfig.lib.json': `{}`, 'libs/my-lib/tsconfig.build.json': `{}`, 'libs/my-lib/tsconfig.spec.json': `{}`, @@ -229,7 +237,7 @@ describe(`Plugin: ${PLUGIN_NAME}`, () => { it('should create a node with a typecheck target with "--verbose" flag when the "verboseOutput" plugin option is true', async () => { // Sibling package.json await applyFilesToTempFsAndContext(tempFs, context, { - 'libs/my-lib/tsconfig.json': `{}`, + 'libs/my-lib/tsconfig.json': JSON.stringify({ files: [] }), 'libs/my-lib/package.json': `{}`, }); expect( @@ -285,7 +293,7 @@ describe(`Plugin: ${PLUGIN_NAME}`, () => { // Sibling project.json await applyFilesToTempFsAndContext(tempFs, context, { - 'libs/my-lib/tsconfig.json': `{}`, + 'libs/my-lib/tsconfig.json': JSON.stringify({ files: [] }), 'libs/my-lib/project.json': `{}`, }); expect( @@ -341,7 +349,7 @@ describe(`Plugin: ${PLUGIN_NAME}`, () => { // Other tsconfigs present await applyFilesToTempFsAndContext(tempFs, context, { - 'libs/my-lib/tsconfig.json': `{}`, + 'libs/my-lib/tsconfig.json': JSON.stringify({ files: [] }), 'libs/my-lib/tsconfig.lib.json': `{}`, 'libs/my-lib/tsconfig.build.json': `{}`, 'libs/my-lib/tsconfig.spec.json': `{}`, @@ -446,6 +454,7 @@ describe(`Plugin: ${PLUGIN_NAME}`, () => { await applyFilesToTempFsAndContext(tempFs, context, { 'libs/my-lib/tsconfig.json': JSON.stringify({ compilerOptions: { noEmit: true }, + files: [], }), 'libs/my-lib/package.json': `{}`, }); @@ -506,6 +515,7 @@ describe(`Plugin: ${PLUGIN_NAME}`, () => { }), 'libs/my-lib/tsconfig.json': JSON.stringify({ extends: '../../tsconfig.base.json', + files: [], }), 'libs/my-lib/package.json': `{}`, }); @@ -568,6 +578,7 @@ describe(`Plugin: ${PLUGIN_NAME}`, () => { }), 'libs/my-lib/tsconfig.lib.json': JSON.stringify({ compilerOptions: { noEmit: true }, + files: [], }), 'libs/my-lib/package.json': `{}`, }); @@ -641,6 +652,8 @@ describe(`Plugin: ${PLUGIN_NAME}`, () => { 'libs/my-lib/tsconfig.json': JSON.stringify({ include: ['src/**/*.ts'], exclude: ['src/**/foo.ts'], + // set this to keep outputs smaller + compilerOptions: { outDir: 'dist' }, }), 'libs/my-lib/package.json': `{}`, }); @@ -686,7 +699,9 @@ describe(`Plugin: ${PLUGIN_NAME}`, () => { "options": { "cwd": "libs/my-lib", }, - "outputs": [], + "outputs": [ + "{projectRoot}/dist", + ], "syncGenerators": [ "@nx/js:typescript-sync", ], @@ -709,6 +724,8 @@ describe(`Plugin: ${PLUGIN_NAME}`, () => { 'libs/my-lib/tsconfig.json': JSON.stringify({ extends: '../../tsconfig.foo.json', include: ['src/**/*.ts'], + // set this to keep outputs smaller + compilerOptions: { outDir: 'dist' }, }), 'libs/my-lib/package.json': `{}`, }); @@ -757,7 +774,9 @@ describe(`Plugin: ${PLUGIN_NAME}`, () => { "options": { "cwd": "libs/my-lib", }, - "outputs": [], + "outputs": [ + "{projectRoot}/dist", + ], "syncGenerators": [ "@nx/js:typescript-sync", ], @@ -781,6 +800,8 @@ describe(`Plugin: ${PLUGIN_NAME}`, () => { 'libs/my-lib/tsconfig.json': JSON.stringify({ extends: '../../tsconfig.foo.json', include: ['src/**/*.ts'], + // set this to keep outputs smaller + compilerOptions: { outDir: 'dist' }, }), 'libs/my-lib/package.json': `{}`, }); @@ -835,7 +856,9 @@ describe(`Plugin: ${PLUGIN_NAME}`, () => { "options": { "cwd": "libs/my-lib", }, - "outputs": [], + "outputs": [ + "{projectRoot}/dist", + ], "syncGenerators": [ "@nx/js:typescript-sync", ], @@ -859,23 +882,33 @@ describe(`Plugin: ${PLUGIN_NAME}`, () => { { path: './nested-project/tsconfig.json' }, // external project reference in a nested directory { path: '../other-lib' }, // external project reference, it causes `dependentTasksOutputFiles` to be set ], + // set this to keep outputs smaller + compilerOptions: { outDir: 'dist' }, }), 'libs/my-lib/tsconfig.lib.json': JSON.stringify({ include: ['src/**/*.ts'], exclude: ['src/**/*.spec.ts'], + // set this to keep outputs smaller + compilerOptions: { outDir: 'dist' }, }), 'libs/my-lib/tsconfig.spec.json': JSON.stringify({ include: ['src/**/*.spec.ts'], references: [{ path: './tsconfig.lib.json' }], + // set this to keep outputs smaller + compilerOptions: { outDir: 'dist' }, }), 'libs/my-lib/cypress/tsconfig.json': JSON.stringify({ include: ['**/*.ts', '../cypress.config.ts', '../**/*.cy.ts'], references: [{ path: '../tsconfig.lib.json' }], + // set this to keep outputs smaller + compilerOptions: { outDir: 'dist' }, }), 'libs/my-lib/package.json': `{}`, 'libs/my-lib/nested-project/package.json': `{}`, 'libs/my-lib/nested-project/tsconfig.json': JSON.stringify({ include: ['lib/**/*.ts'], // different pattern that should not be included in my-lib because it's an external project reference + // set this to keep outputs smaller + compilerOptions: { outDir: 'dist' }, }), 'libs/other-lib/tsconfig.json': JSON.stringify({ include: ['**/*.ts'], // different pattern that should not be included because it's an external project @@ -931,7 +964,10 @@ describe(`Plugin: ${PLUGIN_NAME}`, () => { "options": { "cwd": "libs/my-lib", }, - "outputs": [], + "outputs": [ + "{projectRoot}/dist", + "{projectRoot}/cypress/dist", + ], "syncGenerators": [ "@nx/js:typescript-sync", ], @@ -975,7 +1011,9 @@ describe(`Plugin: ${PLUGIN_NAME}`, () => { "options": { "cwd": "libs/my-lib/nested-project", }, - "outputs": [], + "outputs": [ + "{projectRoot}/dist", + ], "syncGenerators": [ "@nx/js:typescript-sync", ], @@ -2803,6 +2841,8 @@ describe(`Plugin: ${PLUGIN_NAME}`, () => { }), 'libs/my-lib/tsconfig.other.json': JSON.stringify({ include: ['other/**/*.ts', 'src/**/foo.ts'], + // set this to keep outputs smaller + compilerOptions: { outDir: 'dist' }, }), 'libs/other-lib/tsconfig.json': JSON.stringify({ include: ['**/*.ts'], // different pattern that should not be included because it's an external project diff --git a/packages/js/src/plugins/typescript/plugin.ts b/packages/js/src/plugins/typescript/plugin.ts index 7f113e68e9ea9..14bf285c6b5c9 100644 --- a/packages/js/src/plugins/typescript/plugin.ts +++ b/packages/js/src/plugins/typescript/plugin.ts @@ -1,4 +1,5 @@ import { + CreateNodesContextV2, createNodesFromFiles, detectPackageManager, getPackageManagerCommand, @@ -10,7 +11,6 @@ import { type CreateDependencies, type CreateNodes, type CreateNodesContext, - type CreateNodesResult, type CreateNodesV2, type NxJsonConfiguration, type ProjectConfiguration, @@ -29,12 +29,12 @@ import { resolve, sep, } from 'node:path'; +import * as posix from 'node:path/posix'; import { hashArray, hashFile, hashObject } from 'nx/src/hasher/file-hasher'; // eslint-disable-next-line @typescript-eslint/no-restricted-imports import { getLockFileName } from 'nx/src/plugins/js/lock-file/lock-file'; import { workspaceDataDirectory } from 'nx/src/utils/cache-directory'; import type { ParsedCommandLine } from 'typescript'; -import { readTsConfig } from '../../utils/typescript/ts-config'; import { addBuildAndWatchDepsTargets } from './util'; export interface TscPluginOptions { @@ -72,20 +72,32 @@ interface NormalizedPluginOptions { } type TscProjectResult = Pick; +type ParsedTsconfigData = Pick< + ParsedCommandLine, + 'options' | 'projectReferences' | 'raw' +>; +type TsconfigCacheData = { + data: ParsedTsconfigData; + timestamp: number; +}; const pmc = getPackageManagerCommand(); -function readTargetsCache(cachePath: string): Record { - return process.env.NX_CACHE_PROJECT_GRAPH !== 'false' && existsSync(cachePath) - ? readJsonFile(cachePath) - : {}; +let tsConfigCache: Record; +const tsConfigCachePath = join(workspaceDataDirectory, 'tsconfig-files.hash'); + +function readFromCache(cachePath: string): T { + try { + return process.env.NX_CACHE_PROJECT_GRAPH !== 'false' + ? readJsonFile(cachePath) + : ({} as T); + } catch { + return {} as T; + } } -function writeTargetsToCache( - cachePath: string, - results?: Record -) { - writeJsonFile(cachePath, results); +function writeToCache(cachePath: string, data: T) { + writeJsonFile(cachePath, data, { spaces: 0 }); } /** @@ -103,28 +115,65 @@ export const createNodesV2: CreateNodesV2 = [ tsConfigGlob, async (configFilePaths, options, context) => { const optionsHash = hashObject(options); - const cachePath = join(workspaceDataDirectory, `tsc-${optionsHash}.hash`); - const targetsCache = readTargetsCache(cachePath); + const targetsCachePath = join( + workspaceDataDirectory, + `tsc-${optionsHash}.hash` + ); + const targetsCache = + readFromCache>(targetsCachePath); + tsConfigCache = + readFromCache>(tsConfigCachePath); const normalizedOptions = normalizePluginOptions(options); - const lockFileName = getLockFileName( - detectPackageManager(context.workspaceRoot) + const lockFileHash = hashFile( + join( + context.workspaceRoot, + getLockFileName(detectPackageManager(context.workspaceRoot)) + ) + ); + + const { + configFilePaths: validConfigFilePaths, + hashes, + projectRoots, + } = await resolveValidConfigFilesAndHashes( + configFilePaths, + optionsHash, + lockFileHash, + context ); + try { return await createNodesFromFiles( - (configFile, options, context) => - createNodesInternal( - configFile, + (configFilePath, options, context, idx) => { + const projectRoot = projectRoots[idx]; + const hash = hashes[idx]; + const cacheKey = `${hash}_${configFilePath}`; + + targetsCache[cacheKey] ??= buildTscTargets( + join(context.workspaceRoot, configFilePath), + projectRoot, options, - context, - lockFileName, - targetsCache - ), - configFilePaths, + context + ); + + const { targets } = targetsCache[cacheKey]; + + return { + projects: { + [projectRoot]: { + projectType: 'library', + targets, + }, + }, + }; + }, + validConfigFilePaths, normalizedOptions, context ); } finally { - writeTargetsToCache(cachePath, targetsCache); + writeToCache(targetsCachePath, targetsCache); + writeToCache(tsConfigCachePath, tsConfigCache); } }, ]; @@ -135,36 +184,146 @@ export const createNodes: CreateNodes = [ logger.warn( '`createNodes` is deprecated. Update your plugin to utilize createNodesV2 instead. In Nx 20, this will change to the createNodesV2 API.' ); + + const projectRoot = dirname(configFilePath); + if ( + !checkIfConfigFileShouldBeProject(configFilePath, projectRoot, context) + ) { + return {}; + } + const normalizedOptions = normalizePluginOptions(options); - const lockFileName = getLockFileName( - detectPackageManager(context.workspaceRoot) - ); - return createNodesInternal( - configFilePath, + tsConfigCache = + readFromCache>(tsConfigCachePath); + + const { targets } = buildTscTargets( + join(context.workspaceRoot, configFilePath), + projectRoot, normalizedOptions, - context, - lockFileName, - {} + context ); + + return { + projects: { + [projectRoot]: { + projectType: 'library', + targets, + }, + }, + }; }, ]; -async function createNodesInternal( +async function resolveValidConfigFilesAndHashes( + configFilePaths: readonly string[], + optionsHash: string, + lockFileHash: string, + context: CreateNodesContext | CreateNodesContextV2 +): Promise<{ + configFilePaths: string[]; + hashes: string[]; + projectRoots: string[]; +}> { + const validConfigFilePaths: string[] = []; + const hashes: string[] = []; + const projectRoots: string[] = []; + const fileHashesCache: Record = {}; + + for await (const configFilePath of configFilePaths) { + const projectRoot = dirname(configFilePath); + if ( + !checkIfConfigFileShouldBeProject(configFilePath, projectRoot, context) + ) { + continue; + } + + projectRoots.push(projectRoot); + validConfigFilePaths.push(configFilePath); + hashes.push( + await getConfigFileHash( + configFilePath, + context.workspaceRoot, + projectRoot, + optionsHash, + lockFileHash, + fileHashesCache + ) + ); + } + + return { configFilePaths: validConfigFilePaths, hashes, projectRoots }; +} + +/** + * The cache key is composed by: + * - hashes of the content of the relevant files that can affect what's inferred by the plugin: + * - current config file + * - config files extended by the current config file (recursively up to the root config file) + * - referenced config files that are internal to the owning Nx project of the current config file, + * or is a shallow external reference of the owning Nx project + * - lock file + * - project's package.json + * - hash of the plugin options + * - current config file path + */ +async function getConfigFileHash( configFilePath: string, - options: NormalizedPluginOptions, - context: CreateNodesContext, - lockFileName: string, - targetsCache: Record -): Promise { - const projectRoot = dirname(configFilePath); - const fullConfigPath = joinPathFragments( - context.workspaceRoot, - configFilePath + workspaceRoot: string, + projectRoot: string, + optionsHash: string, + lockFileHash: string, + fileHashesCache: Record +): Promise { + const fullConfigPath = join(workspaceRoot, configFilePath); + + const tsConfig = readCachedTsConfig(fullConfigPath, workspaceRoot); + const extendedConfigFiles = getExtendedConfigFiles( + fullConfigPath, + tsConfig, + workspaceRoot + ); + const internalReferencedFiles = resolveInternalProjectReferences( + tsConfig, + workspaceRoot, + projectRoot + ); + const externalProjectReferences = resolveShallowExternalProjectReferences( + tsConfig, + workspaceRoot, + projectRoot ); + let packageJson = null; + try { + packageJson = readJsonFile( + join(workspaceRoot, projectRoot, 'package.json') + ); + } catch {} + + return hashArray([ + ...[ + fullConfigPath, + ...extendedConfigFiles.files, + ...Object.keys(internalReferencedFiles), + ...Object.keys(externalProjectReferences), + ].map((file) => { + fileHashesCache[file] ??= hashFile(file); + return fileHashesCache[file]; + }), + lockFileHash, + optionsHash, + ...(packageJson ? [hashObject(packageJson)] : []), + ]); +} + +function checkIfConfigFileShouldBeProject( + configFilePath: string, + projectRoot: string, + context: CreateNodesContext | CreateNodesContextV2 +): boolean { // Do not create a project for the workspace root tsconfig files. if (projectRoot === '.') { - return {}; + return false; } // Do not create a project if package.json and project.json isn't there. @@ -173,7 +332,7 @@ async function createNodesInternal( !siblingFiles.includes('package.json') && !siblingFiles.includes('project.json') ) { - return {}; + return false; } // Do not create a project if it's not a tsconfig.json and there is no tsconfig.json in the same directory @@ -181,7 +340,7 @@ async function createNodesInternal( basename(configFilePath) !== 'tsconfig.json' && !siblingFiles.includes('tsconfig.json') ) { - return {}; + return false; } // Do not create project for Next.js projects since they are not compatible with @@ -192,67 +351,10 @@ async function createNodesInternal( siblingFiles.includes('next.config.mjs') || siblingFiles.includes('next.config.ts') ) { - return {}; + return false; } - /** - * The cache key is composed by: - * - hashes of the content of the relevant files that can affect what's inferred by the plugin: - * - current config file - * - config files extended by the current config file (recursively up to the root config file) - * - referenced config files that are internal to the owning Nx project of the current config file, or is a shallow external reference of the owning Nx project - * - lock file - * - hash of the plugin options - * - current config file path - */ - const tsConfig = readCachedTsConfig(fullConfigPath); - const extendedConfigFiles = getExtendedConfigFiles(fullConfigPath, tsConfig); - const internalReferencedFiles = resolveInternalProjectReferences( - tsConfig, - context.workspaceRoot, - projectRoot - ); - const externalProjectReferences = resolveShallowExternalProjectReferences( - tsConfig, - context.workspaceRoot, - projectRoot - ); - - const packageJsonPath = joinPathFragments(projectRoot, 'package.json'); - const packageJson = existsSync(packageJsonPath) - ? readJsonFile(packageJsonPath) - : null; - - const nodeHash = hashArray([ - ...[ - fullConfigPath, - ...extendedConfigFiles.files, - ...Object.keys(internalReferencedFiles), - ...Object.keys(externalProjectReferences), - join(context.workspaceRoot, lockFileName), - ].map(hashFile), - hashObject(options), - ...(packageJson ? [hashObject(packageJson)] : []), - ]); - const cacheKey = `${nodeHash}_${configFilePath}`; - - targetsCache[cacheKey] ??= buildTscTargets( - fullConfigPath, - projectRoot, - options, - context - ); - - const { targets } = targetsCache[cacheKey]; - - return { - projects: { - [projectRoot]: { - projectType: 'library', - targets, - }, - }, - }; + return true; } function buildTscTargets( @@ -263,9 +365,9 @@ function buildTscTargets( ) { const targets: Record = {}; const namedInputs = getNamedInputs(projectRoot, context); - const tsConfig = readCachedTsConfig(configFilePath); + const tsConfig = readCachedTsConfig(configFilePath, context.workspaceRoot); - let internalProjectReferences: Record; + let internalProjectReferences: Record; // Typecheck target if (basename(configFilePath) === 'tsconfig.json' && options.typecheck) { internalProjectReferences = resolveInternalProjectReferences( @@ -397,15 +499,19 @@ function buildTscTargets( function getInputs( namedInputs: NxJsonConfiguration['namedInputs'], configFilePath: string, - tsConfig: ParsedCommandLine, - internalProjectReferences: Record, + tsConfig: ParsedTsconfigData, + internalProjectReferences: Record, workspaceRoot: string, projectRoot: string ): TargetConfiguration['inputs'] { const configFiles = new Set(); const externalDependencies = ['typescript']; - const extendedConfigFiles = getExtendedConfigFiles(configFilePath, tsConfig); + const extendedConfigFiles = getExtendedConfigFiles( + configFilePath, + tsConfig, + workspaceRoot + ); extendedConfigFiles.files.forEach((configPath) => { configFiles.add(configPath); }); @@ -413,7 +519,7 @@ function getInputs( const includePaths = new Set(); const excludePaths = new Set(); - const projectTsConfigFiles: [string, ParsedCommandLine][] = [ + const projectTsConfigFiles: [string, ParsedTsconfigData][] = [ [configFilePath, tsConfig], ...Object.entries(internalProjectReferences), ]; @@ -510,8 +616,8 @@ function getInputs( function getOutputs( configFilePath: string, - tsConfig: ParsedCommandLine, - internalProjectReferences: Record, + tsConfig: ParsedTsconfigData, + internalProjectReferences: Record, workspaceRoot: string, projectRoot: string ): string[] { @@ -603,7 +709,11 @@ function getOutputs( ) ); } - } else if (config.fileNames.length) { + } else if ( + config.raw?.include?.length || + config.raw?.files?.length || + (!config.raw?.include && !config.raw?.files) + ) { // tsc produce files in place when no outDir or outFile is set outputs.add(joinPathFragments('{projectRoot}', '**/*.js')); outputs.add(joinPathFragments('{projectRoot}', '**/*.cjs')); @@ -649,7 +759,7 @@ function getOutputs( * @returns `true` if the package has a valid build configuration; otherwise, `false`. */ function isValidPackageJsonBuildConfig( - tsConfig: ParsedCommandLine, + tsConfig: ParsedTsconfigData, workspaceRoot: string, projectRoot: string ): boolean { @@ -754,7 +864,8 @@ function pathToInputOrOutput( function getExtendedConfigFiles( tsConfigPath: string, - tsConfig: ParsedCommandLine + tsConfig: ParsedTsconfigData, + workspaceRoot: string ): { files: string[]; packages: string[]; @@ -777,7 +888,10 @@ function getExtendedConfigFiles( break; } extendedConfigFiles.add(extendedConfigPath.filePath); - currentConfig = readCachedTsConfig(extendedConfigPath.filePath); + currentConfig = readCachedTsConfig( + extendedConfigPath.filePath, + workspaceRoot + ); currentConfigPath = extendedConfigPath.filePath; } @@ -788,53 +902,56 @@ function getExtendedConfigFiles( } function resolveInternalProjectReferences( - tsConfig: ParsedCommandLine, + tsConfig: ParsedTsconfigData, workspaceRoot: string, projectRoot: string, - projectReferences: Record = {} -): Record { - walkProjectReferences( - tsConfig, - workspaceRoot, - projectRoot, - (configPath, config) => { - if (isExternalProjectReference(configPath, workspaceRoot, projectRoot)) { - return false; - } else { - projectReferences[configPath] = config; - } + projectReferences: Record = {} +): Record { + if (!tsConfig.projectReferences?.length) { + return {}; + } + + for (const ref of tsConfig.projectReferences) { + let refConfigPath = ref.path; + if (projectReferences[refConfigPath]) { + // Already resolved + continue; } - ); - return projectReferences; -} -function resolveShallowExternalProjectReferences( - tsConfig: ParsedCommandLine, - workspaceRoot: string, - projectRoot: string, - projectReferences: Record = {} -): Record { - walkProjectReferences( - tsConfig, - workspaceRoot, - projectRoot, - (configPath, config) => { - if (isExternalProjectReference(configPath, workspaceRoot, projectRoot)) { - projectReferences[configPath] = config; - } - return false; + if (!existsSync(refConfigPath)) { + // the referenced tsconfig doesn't exist, ignore it + continue; } - ); + + if (isExternalProjectReference(refConfigPath, workspaceRoot, projectRoot)) { + continue; + } + + if (!refConfigPath.endsWith('.json')) { + refConfigPath = join(refConfigPath, 'tsconfig.json'); + } + projectReferences[refConfigPath] = readCachedTsConfig( + refConfigPath, + workspaceRoot + ); + + resolveInternalProjectReferences( + projectReferences[refConfigPath], + workspaceRoot, + projectRoot, + projectReferences + ); + } + return projectReferences; } -function walkProjectReferences( - tsConfig: ParsedCommandLine, +function resolveShallowExternalProjectReferences( + tsConfig: ParsedTsconfigData, workspaceRoot: string, projectRoot: string, - visitor: (configPath: string, config: ParsedCommandLine) => void | false, // false stops recursion - projectReferences: Record = {} -): Record { + projectReferences: Record = {} +): Record { if (!tsConfig.projectReferences?.length) { return projectReferences; } @@ -851,13 +968,14 @@ function walkProjectReferences( continue; } - if (!refConfigPath.endsWith('.json')) { - refConfigPath = join(refConfigPath, 'tsconfig.json'); - } - const refTsConfig = readCachedTsConfig(refConfigPath); - const result = visitor(refConfigPath, refTsConfig); - if (result !== false) { - walkProjectReferences(refTsConfig, workspaceRoot, projectRoot, visitor); + if (isExternalProjectReference(refConfigPath, workspaceRoot, projectRoot)) { + if (!refConfigPath.endsWith('.json')) { + refConfigPath = join(refConfigPath, 'tsconfig.json'); + } + projectReferences[refConfigPath] = readCachedTsConfig( + refConfigPath, + workspaceRoot + ); } } @@ -866,7 +984,7 @@ function walkProjectReferences( function hasExternalProjectReferences( tsConfigPath: string, - tsConfig: ParsedCommandLine, + tsConfig: ParsedTsconfigData, workspaceRoot: string, projectRoot: string, seen = new Set() @@ -895,7 +1013,7 @@ function hasExternalProjectReferences( if (!refConfigPath.endsWith('.json')) { refConfigPath = join(refConfigPath, 'tsconfig.json'); } - const refTsConfig = readCachedTsConfig(refConfigPath); + const refTsConfig = readCachedTsConfig(refConfigPath, workspaceRoot); const result = hasExternalProjectReferences( refConfigPath, refTsConfig, @@ -947,24 +1065,44 @@ function getTsConfigDirName(tsConfigPath: string): string { : normalize(tsConfigPath); } -const tsConfigCache = new Map(); -function readCachedTsConfig(tsConfigPath: string): ParsedCommandLine { - const cacheKey = getTsConfigCacheKey(tsConfigPath); +function readCachedTsConfig( + tsConfigPath: string, + workspaceRoot: string +): ParsedTsconfigData { + const relativeTsConfigPath = posix.relative(workspaceRoot, tsConfigPath); + const timestamp = statSync(tsConfigPath).mtimeMs; - if (tsConfigCache.has(cacheKey)) { - return tsConfigCache.get(cacheKey)!; + if (tsConfigCache[relativeTsConfigPath]?.timestamp === timestamp) { + return tsConfigCache[relativeTsConfigPath].data; } const tsConfig = readTsConfig(tsConfigPath); - tsConfigCache.set(cacheKey, tsConfig); + tsConfigCache[relativeTsConfigPath] = { + data: { + options: tsConfig.options, + projectReferences: tsConfig.projectReferences, + raw: tsConfig.raw, + }, + timestamp, + }; return tsConfig; } -function getTsConfigCacheKey(tsConfigPath: string): string { - const timestamp = statSync(tsConfigPath).mtimeMs; - - return `${tsConfigPath}-${timestamp}`; +let ts: typeof import('typescript'); +function readTsConfig(tsConfigPath: string): ParsedCommandLine { + if (!ts) { + ts = require('typescript'); + } + const readResult = ts.readConfigFile(tsConfigPath, ts.sys.readFile); + + // read with a custom host that won't read directories which is only used + // to identify the filenames included in the program, which we won't use + return ts.parseJsonConfigFileContent( + readResult.config, + { ...ts.sys, readDirectory: () => [] }, + dirname(tsConfigPath) + ); } function normalizePluginOptions(