@nx/js
Version:
416 lines (415 loc) • 21.2 kB
JavaScript
"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);
}