UNPKG

@nx/js

Version:

The JS plugin for Nx contains executors and generators that provide the best experience for developing JavaScript and TypeScript projects.

416 lines (415 loc) • 21.2 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.syncGenerator = syncGenerator; const devkit_1 = require("@nx/devkit"); const ignore_1 = require("ignore"); const jsonc_parser_1 = require("jsonc-parser"); const posix_1 = require("node:path/posix"); const sync_generators_1 = require("nx/src/utils/sync-generators"); const ts = require("typescript"); const COMMON_RUNTIME_TS_CONFIG_FILE_NAMES = [ 'tsconfig.app.json', 'tsconfig.lib.json', 'tsconfig.build.json', 'tsconfig.cjs.json', 'tsconfig.esm.json', 'tsconfig.runtime.json', ]; async function syncGenerator(tree) { // Ensure that the plugin has been wired up in nx.json const nxJson = (0, devkit_1.readNxJson)(tree); const tsconfigInfoCaches = { composite: new Map(), content: new Map(), exists: new Map(), }; // Root tsconfig containing project references for the whole workspace const rootTsconfigPath = 'tsconfig.json'; if (!tsconfigExists(tree, tsconfigInfoCaches, rootTsconfigPath)) { throw new sync_generators_1.SyncError('Missing root "tsconfig.json"', [ `A "tsconfig.json" file must exist in the workspace root in order to sync the project graph information to the TypeScript configuration files.`, ]); } const stringifiedRootJsonContents = readRawTsconfigContents(tree, tsconfigInfoCaches, rootTsconfigPath); const rootTsconfig = (0, devkit_1.parseJson)(stringifiedRootJsonContents); const projectGraph = await (0, devkit_1.createProjectGraphAsync)(); const projectRoots = new Set(); const tsconfigProjectNodeValues = Object.values(projectGraph.nodes).filter((node) => { projectRoots.add(node.data.root); const projectTsconfigPath = (0, devkit_1.joinPathFragments)(node.data.root, 'tsconfig.json'); return tsconfigExists(tree, tsconfigInfoCaches, projectTsconfigPath); }); const tsSysFromTree = { ...ts.sys, fileExists(path) { // Given ts.System.resolve resolve full path for tsconfig within node_modules // We need to remove the workspace root to ensure we don't have double workspace root within the Tree const correctPath = path.startsWith(tree.root) ? (0, posix_1.relative)(tree.root, path) : path; return tsconfigExists(tree, tsconfigInfoCaches, correctPath); }, readFile(path) { // Given ts.System.resolve resolve full path for tsconfig within node_modules // We need to remove the workspace root to ensure we don't have double workspace root within the Tree const correctPath = path.startsWith(tree.root) ? (0, posix_1.relative)(tree.root, path) : path; return readRawTsconfigContents(tree, tsconfigInfoCaches, correctPath); }, }; // Track if any changes were made to the tsconfig files. We check the changes // made by this generator to know if the TS config is out of sync with the // project graph. Therefore, we don't format the files if there were no changes // to avoid potential format-only changes that can lead to false positives. const changedFiles = new Map(); if (tsconfigProjectNodeValues.length > 0) { const referencesSet = new Set(); const rootPathCounts = new Map(); for (const ref of rootTsconfig.references ?? []) { // reference path is relative to the tsconfig file const resolvedRefPath = getTsConfigPathFromReferencePath(rootTsconfigPath, ref.path); const normalizedPath = normalizeReferencePath(ref.path); // Track duplicates const currentCount = (rootPathCounts.get(normalizedPath) || 0) + 1; rootPathCounts.set(normalizedPath, currentCount); if (currentCount === 2) { addChangedFile(changedFiles, rootTsconfigPath, resolvedRefPath, 'duplicates'); } if (currentCount > 1) { // Skip duplicate processing - only process first occurrence continue; } if (tsconfigExists(tree, tsconfigInfoCaches, resolvedRefPath)) { // we only keep the references that still exist referencesSet.add(normalizedPath); } else { addChangedFile(changedFiles, rootTsconfigPath, resolvedRefPath, 'stale'); } } for (const node of tsconfigProjectNodeValues) { const normalizedPath = normalizeReferencePath(node.data.root); // Skip the root tsconfig itself if (node.data.root !== '.' && !referencesSet.has(normalizedPath)) { referencesSet.add(normalizedPath); addChangedFile(changedFiles, rootTsconfigPath, toFullProjectReferencePath(node.data.root), 'missing'); } } if (changedFiles.size > 0) { const updatedReferences = Array.from(referencesSet) // Check composite is true in the internal reference before proceeding .filter((ref) => hasCompositeEnabled(tsSysFromTree, tsconfigInfoCaches, (0, devkit_1.joinPathFragments)(ref, 'tsconfig.json'))) .map((ref) => ({ path: `./${ref}`, })); patchTsconfigJsonReferences(tree, tsconfigInfoCaches, rootTsconfigPath, updatedReferences); } } const userOptions = nxJson.sync?.generatorOptions?.['@nx/js:typescript-sync']; const { runtimeTsConfigFileNames } = { runtimeTsConfigFileNames: userOptions?.runtimeTsConfigFileNames ?? COMMON_RUNTIME_TS_CONFIG_FILE_NAMES, }; const collectedDependencies = new Map(); for (const projectName of Object.keys(projectGraph.dependencies)) { if (!projectGraph.nodes[projectName] || projectGraph.nodes[projectName].data.root === '.') { continue; } // Get the source project nodes for the source and target const sourceProjectNode = projectGraph.nodes[projectName]; // Find the relevant tsconfig file for the source project const sourceProjectTsconfigPath = (0, devkit_1.joinPathFragments)(sourceProjectNode.data.root, 'tsconfig.json'); if (!tsconfigExists(tree, tsconfigInfoCaches, sourceProjectTsconfigPath)) { if (process.env.NX_VERBOSE_LOGGING === 'true') { devkit_1.logger.warn(`Skipping project "${projectName}" as there is no tsconfig.json file found in the project root "${sourceProjectNode.data.root}".`); } continue; } // Collect the dependencies of the source project const dependencies = collectProjectDependencies(tree, projectName, projectGraph, collectedDependencies); let foundRuntimeTsConfig = false; for (const runtimeTsConfigFileName of runtimeTsConfigFileNames) { const runtimeTsConfigPath = (0, devkit_1.joinPathFragments)(sourceProjectNode.data.root, runtimeTsConfigFileName); if (!tsconfigExists(tree, tsconfigInfoCaches, runtimeTsConfigPath)) { continue; } foundRuntimeTsConfig = true; // Update project references for the runtime tsconfig updateTsConfigReferences(tree, tsSysFromTree, tsconfigInfoCaches, runtimeTsConfigPath, dependencies, sourceProjectNode.data.root, projectRoots, changedFiles, runtimeTsConfigFileName, runtimeTsConfigFileNames); } // We keep the project references in the tsconfig.json file if it has files // or if we don't find a runtime tsconfig file, otherwise we don't need to // duplicate the project references in the tsconfig.json file let keepReferencesInTsconfigJson = true; if (foundRuntimeTsConfig) { const sourceProjectTsconfig = parseTsconfig(sourceProjectTsconfigPath, tsSysFromTree); keepReferencesInTsconfigJson = sourceProjectTsconfig.fileNames.length > 0; } updateTsConfigReferences(tree, tsSysFromTree, tsconfigInfoCaches, sourceProjectTsconfigPath, keepReferencesInTsconfigJson ? dependencies : [], sourceProjectNode.data.root, projectRoots, changedFiles); } if (changedFiles.size > 0) { await (0, devkit_1.formatFiles)(tree); const outOfSyncDetails = []; for (const [filePath, details] of changedFiles) { outOfSyncDetails.push(`${filePath}:`); if (details.missing.size > 0) { outOfSyncDetails.push(` - Missing references: ${Array.from(details.missing).join(', ')}`); } if (details.stale.size > 0) { outOfSyncDetails.push(` - Stale references: ${Array.from(details.stale).join(', ')}`); } if (details.duplicates.size > 0) { outOfSyncDetails.push(` - Duplicate references: ${Array.from(details.duplicates).join(', ')}`); } } return { outOfSyncMessage: 'Some TypeScript configuration files are missing project references to the projects they depend on, contain stale project references, or have duplicate project references.', outOfSyncDetails, }; } } exports.default = syncGenerator; /** * Within the context of a sync generator, performance is a key concern, * so avoid FS interactions whenever possible. */ function readRawTsconfigContents(tree, tsconfigInfoCaches, tsconfigPath) { if (!tsconfigInfoCaches.content.has(tsconfigPath)) { tsconfigInfoCaches.content.set(tsconfigPath, tree.read(tsconfigPath, 'utf-8')); } return tsconfigInfoCaches.content.get(tsconfigPath); } function parseTsconfig(tsconfigPath, sys) { return ts.parseJsonConfigFileContent(ts.readConfigFile(tsconfigPath, sys.readFile).config, sys, (0, posix_1.dirname)(tsconfigPath)); } /** * Within the context of a sync generator, performance is a key concern, * so avoid FS interactions whenever possible. */ function tsconfigExists(tree, tsconfigInfoCaches, tsconfigPath) { if (!tsconfigInfoCaches.exists.has(tsconfigPath)) { tsconfigInfoCaches.exists.set(tsconfigPath, tree.exists(tsconfigPath)); } return tsconfigInfoCaches.exists.get(tsconfigPath); } function updateTsConfigReferences(tree, tsSysFromTree, tsconfigInfoCaches, tsConfigPath, dependencies, projectRoot, projectRoots, changedFiles, runtimeTsConfigFileName, possibleRuntimeTsConfigFileNames) { const stringifiedJsonContents = readRawTsconfigContents(tree, tsconfigInfoCaches, tsConfigPath); const tsConfig = (0, devkit_1.parseJson)(stringifiedJsonContents); const ignoredReferences = new Set(tsConfig.nx?.sync?.ignoredReferences ?? []); // We have at least one dependency so we can safely set it to an empty array if not already set const references = []; const originalReferencesSet = new Set(); const newReferencesSet = new Set(); const pathCounts = new Map(); let hasChanges = false; for (const ref of tsConfig.references ?? []) { const normalizedPath = normalizeReferencePath(ref.path); originalReferencesSet.add(normalizedPath); // Track duplicates const currentCount = (pathCounts.get(normalizedPath) || 0) + 1; pathCounts.set(normalizedPath, currentCount); if (currentCount === 2) { const resolvedRefPath = getTsConfigPathFromReferencePath(tsConfigPath, normalizedPath); addChangedFile(changedFiles, tsConfigPath, resolvedRefPath, 'duplicates'); hasChanges = true; } if (currentCount > 1) { // Skip duplicate processing - only process first occurrence continue; } if (ignoredReferences.has(ref.path)) { // we keep the user-defined ignored references references.push(ref); newReferencesSet.add(normalizedPath); continue; } // reference path is relative to the tsconfig file const resolvedRefPath = getTsConfigPathFromReferencePath(tsConfigPath, ref.path); if (isProjectReferenceWithinNxProject(resolvedRefPath, projectRoot, projectRoots) || isProjectReferenceIgnored(tree, resolvedRefPath)) { // we keep all references within the current Nx project or that are ignored references.push(ref); newReferencesSet.add(normalizedPath); } } for (const dep of dependencies) { // Ensure the project reference for the target is set if we can find the // relevant tsconfig file let referencePath; if (runtimeTsConfigFileName) { const runtimeTsConfigPath = (0, devkit_1.joinPathFragments)(dep.data.root, runtimeTsConfigFileName); if (tsconfigExists(tree, tsconfigInfoCaches, runtimeTsConfigPath)) { // Check composite is true in the dependency runtime tsconfig file before proceeding if (!hasCompositeEnabled(tsSysFromTree, tsconfigInfoCaches, runtimeTsConfigPath)) { continue; } referencePath = runtimeTsConfigPath; } else { // Check for other possible runtime tsconfig file names // TODO(leo): should we check if there are more than one runtime tsconfig files and throw an error? for (const possibleRuntimeTsConfigFileName of possibleRuntimeTsConfigFileNames ?? []) { const possibleRuntimeTsConfigPath = (0, devkit_1.joinPathFragments)(dep.data.root, possibleRuntimeTsConfigFileName); if (tsconfigExists(tree, tsconfigInfoCaches, possibleRuntimeTsConfigPath)) { // Check composite is true in the dependency runtime tsconfig file before proceeding if (!hasCompositeEnabled(tsSysFromTree, tsconfigInfoCaches, possibleRuntimeTsConfigPath)) { continue; } referencePath = possibleRuntimeTsConfigPath; break; } } } } else { // Check composite is true in the dependency tsconfig.json file before proceeding if (!hasCompositeEnabled(tsSysFromTree, tsconfigInfoCaches, (0, devkit_1.joinPathFragments)(dep.data.root, 'tsconfig.json'))) { continue; } } if (!referencePath) { if (tsconfigExists(tree, tsconfigInfoCaches, (0, devkit_1.joinPathFragments)(dep.data.root, 'tsconfig.json'))) { referencePath = dep.data.root; } else { continue; } } const relativePathToTargetRoot = (0, posix_1.relative)(projectRoot, referencePath); if (!newReferencesSet.has(relativePathToTargetRoot)) { newReferencesSet.add(relativePathToTargetRoot); // Make sure we unshift rather than push so that dependencies are built in the right order by TypeScript when it is run directly from the root of the workspace references.unshift({ path: relativePathToTargetRoot }); } if (!originalReferencesSet.has(relativePathToTargetRoot)) { hasChanges = true; addChangedFile(changedFiles, tsConfigPath, toFullProjectReferencePath(referencePath), 'missing'); } } for (const ref of originalReferencesSet) { if (!newReferencesSet.has(ref)) { addChangedFile(changedFiles, tsConfigPath, toFullProjectReferencePath((0, devkit_1.joinPathFragments)(projectRoot, ref)), 'stale'); } } hasChanges ||= newReferencesSet.size !== originalReferencesSet.size; if (hasChanges) { patchTsconfigJsonReferences(tree, tsconfigInfoCaches, tsConfigPath, references); } } // TODO(leo): follow up with the TypeScript team to confirm if we really need // to reference transitive dependencies. // Collect the dependencies of a project recursively sorted from root to leaf function collectProjectDependencies(tree, projectName, projectGraph, collectedDependencies) { if (collectedDependencies.has(projectName)) { // We've already collected the dependencies for this project return collectedDependencies.get(projectName); } collectedDependencies.set(projectName, []); for (const dep of projectGraph.dependencies[projectName]) { const targetProjectNode = projectGraph.nodes[dep.target]; if (!targetProjectNode || dep.type === 'implicit') { // It's an npm or an implicit dependency continue; } // Add the target project node to the list of dependencies for the current project if (!collectedDependencies .get(projectName) .some((d) => d.name === targetProjectNode.name)) { collectedDependencies.get(projectName).push(targetProjectNode); } if (process.env.NX_ENABLE_TS_SYNC_TRANSITIVE_DEPENDENCIES !== 'true') { continue; } // Recursively get the dependencies of the target project const transitiveDependencies = collectProjectDependencies(tree, dep.target, projectGraph, collectedDependencies); for (const transitiveDep of transitiveDependencies) { if (!collectedDependencies .get(projectName) .some((d) => d.name === transitiveDep.name)) { collectedDependencies.get(projectName).push(transitiveDep); } } } return collectedDependencies.get(projectName); } // Normalize the paths to strip leading `./` and trailing `/tsconfig.json` function normalizeReferencePath(path) { return (0, posix_1.normalize)(path) .replace(/\/tsconfig.json$/, '') .replace(/^\.\//, ''); } function toFullProjectReferencePath(path) { const normalizedPath = normalizeReferencePath(path); return normalizedPath.endsWith('.json') ? normalizedPath : (0, devkit_1.joinPathFragments)(normalizedPath, 'tsconfig.json'); } function isProjectReferenceWithinNxProject(refTsConfigPath, projectRoot, projectRoots) { let currentPath = getTsConfigDirName(refTsConfigPath); if ((0, posix_1.relative)(projectRoot, currentPath).startsWith('..')) { // it's outside of the project root, so it's an external project reference return false; } while (currentPath !== projectRoot) { if (projectRoots.has(currentPath)) { // it's inside a nested project root, so it's and external project reference return false; } currentPath = (0, posix_1.dirname)(currentPath); } // it's inside the project root, so it's an internal project reference return true; } function isProjectReferenceIgnored(tree, refTsConfigPath) { const ig = (0, ignore_1.default)(); if (tree.exists('.gitignore')) { ig.add('.git'); ig.add(tree.read('.gitignore', 'utf-8')); } if (tree.exists('.nxignore')) { ig.add(tree.read('.nxignore', 'utf-8')); } return ig.ignores(refTsConfigPath); } function getTsConfigDirName(tsConfigPath) { return tsConfigPath.endsWith('.json') ? (0, posix_1.dirname)(tsConfigPath) : (0, posix_1.normalize)(tsConfigPath); } function getTsConfigPathFromReferencePath(ownerTsConfigPath, referencePath) { const resolvedRefPath = (0, devkit_1.joinPathFragments)((0, posix_1.dirname)(ownerTsConfigPath), referencePath); return resolvedRefPath.endsWith('.json') ? resolvedRefPath : (0, devkit_1.joinPathFragments)(resolvedRefPath, 'tsconfig.json'); } /** * Minimally patch just the "references" property within the tsconfig file at a given path. * This allows comments in other sections of the file to remain intact when syncing is run. */ function patchTsconfigJsonReferences(tree, tsconfigInfoCaches, tsconfigPath, updatedReferences) { const stringifiedJsonContents = readRawTsconfigContents(tree, tsconfigInfoCaches, tsconfigPath); const edits = (0, jsonc_parser_1.modify)(stringifiedJsonContents, ['references'], updatedReferences, { formattingOptions: { keepLines: true, insertSpaces: true, tabSize: 2 } }); const updatedJsonContents = (0, jsonc_parser_1.applyEdits)(stringifiedJsonContents, edits); // The final contents will be formatted by formatFiles() later tree.write(tsconfigPath, updatedJsonContents); } function hasCompositeEnabled(tsSysFromTree, tsconfigInfoCaches, tsconfigPath) { if (!tsconfigInfoCaches.composite.has(tsconfigPath)) { const parsed = parseTsconfig(tsconfigPath, tsSysFromTree); tsconfigInfoCaches.composite.set(tsconfigPath, parsed.options.composite === true); } return tsconfigInfoCaches.composite.get(tsconfigPath); } function addChangedFile(changedFiles, filePath, referencePath, type) { if (!changedFiles.has(filePath)) { changedFiles.set(filePath, { duplicates: new Set(), missing: new Set(), stale: new Set(), }); } changedFiles.get(filePath)[type].add(referencePath); }