Skip to content

Commit 1b1de90

Browse files
authored
feat(core): log out-of-sync details returned by sync generators when running nx sync:check (#32072)
## Current Behavior The `@nx/js:typescript-sync` generator does not provide details about the out-of-sync files. It only returns a generic message. <img width="1155" height="107" alt="image" src="https://github.com/user-attachments/assets/6fc170cc-6aab-49cd-b17d-764da57b840a" /> ## Expected Behavior The `nx sync:check` command should display additional details about the out-of-sync files if provided by the sync generators. The `@nx/js:typescript-sync` generator should provide details about the out-of-sync files. <img width="1140" height="214" alt="image" src="https://github.com/user-attachments/assets/3c74df6c-7dc3-4462-b0c9-75bce5bf81b4" /> Note: the `nx sync` command will still only display the generic message to avoid cluttering the logs and the details can be seen in the changes made to files.
1 parent b5b4cd5 commit 1b1de90

5 files changed

Lines changed: 148 additions & 80 deletions

File tree

packages/js/src/generators/typescript-sync/typescript-sync.spec.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,42 @@ describe('syncGenerator()', () => {
193193
).toStrictEqual(changesBeforeSyncing);
194194
});
195195

196+
it('should return an out of sync message and details when there are missing and stale references', async () => {
197+
writeJson(tree, 'tsconfig.json', {
198+
compilerOptions: { composite: true },
199+
references: [
200+
{ path: './packages/c' }, // non-existing reference
201+
],
202+
});
203+
writeJson(tree, 'packages/b/tsconfig.json', {
204+
compilerOptions: { composite: true },
205+
references: [
206+
{ path: './some/thing' },
207+
{ path: './another/one' },
208+
{ path: '../c' }, // non-existing reference
209+
],
210+
});
211+
writeJson(tree, `packages/b/tsconfig.lib.json`, {
212+
compilerOptions: { composite: true },
213+
});
214+
215+
const result = await syncGenerator(tree);
216+
217+
expect((result as any).outOfSyncMessage).toBe(
218+
'Some TypeScript configuration files are missing project references to the projects they depend on or contain stale project references.'
219+
);
220+
expect((result as any).outOfSyncDetails).toStrictEqual([
221+
'tsconfig.json:',
222+
' - Missing references: packages/a/tsconfig.json, packages/b/tsconfig.json',
223+
' - Stale references: packages/c/tsconfig.json',
224+
'packages/b/tsconfig.lib.json:',
225+
' - Missing references: packages/a/tsconfig.json',
226+
'packages/b/tsconfig.json:',
227+
' - Missing references: packages/a/tsconfig.json',
228+
' - Stale references: packages/c/tsconfig.json',
229+
]);
230+
});
231+
196232
describe('root tsconfig.json', () => {
197233
it('should sync project references to the tsconfig.json', async () => {
198234
expect(readJson(tree, 'tsconfig.json').references).toBeUndefined();

packages/js/src/generators/typescript-sync/typescript-sync.ts

Lines changed: 90 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import {
55
logger,
66
parseJson,
77
readNxJson,
8-
type ExpandedPluginConfiguration,
98
type ProjectGraph,
109
type ProjectGraphProjectNode,
1110
type Tree,
@@ -18,10 +17,6 @@ import {
1817
type SyncGeneratorResult,
1918
} from 'nx/src/utils/sync-generators';
2019
import * as ts from 'typescript';
21-
import {
22-
PLUGIN_NAME,
23-
type TscPluginOptions,
24-
} from '../../plugins/typescript/plugin';
2520

2621
interface Tsconfig {
2722
references?: Array<{ path: string }>;
@@ -55,8 +50,9 @@ type TsconfigInfoCaches = {
5550
composite: Map<string, boolean>;
5651
content: Map<string, string>;
5752
exists: Map<string, boolean>;
58-
isFile: Map<string, boolean>;
5953
};
54+
type ChangedFileDetails = { missing: Set<string>; stale: Set<string> };
55+
type ChangeType = keyof ChangedFileDetails;
6056

6157
export async function syncGenerator(tree: Tree): Promise<SyncGeneratorResult> {
6258
// Ensure that the plugin has been wired up in nx.json
@@ -66,7 +62,6 @@ export async function syncGenerator(tree: Tree): Promise<SyncGeneratorResult> {
6662
composite: new Map(),
6763
content: new Map(),
6864
exists: new Map(),
69-
isFile: new Map(),
7065
};
7166
// Root tsconfig containing project references for the whole workspace
7267
const rootTsconfigPath = 'tsconfig.json';
@@ -120,23 +115,26 @@ export async function syncGenerator(tree: Tree): Promise<SyncGeneratorResult> {
120115
// made by this generator to know if the TS config is out of sync with the
121116
// project graph. Therefore, we don't format the files if there were no changes
122117
// to avoid potential format-only changes that can lead to false positives.
123-
let hasChanges = false;
118+
const changedFiles = new Map<string, ChangedFileDetails>();
124119

125120
if (tsconfigProjectNodeValues.length > 0) {
126121
const referencesSet = new Set<string>();
127122
for (const ref of rootTsconfig.references ?? []) {
128123
// reference path is relative to the tsconfig file
129124
const resolvedRefPath = getTsConfigPathFromReferencePath(
130-
tree,
131125
rootTsconfigPath,
132-
ref.path,
133-
tsconfigInfoCaches
126+
ref.path
134127
);
135128
if (tsconfigExists(tree, tsconfigInfoCaches, resolvedRefPath)) {
136129
// we only keep the references that still exist
137130
referencesSet.add(normalizeReferencePath(ref.path));
138131
} else {
139-
hasChanges = true;
132+
addChangedFile(
133+
changedFiles,
134+
rootTsconfigPath,
135+
resolvedRefPath,
136+
'stale'
137+
);
140138
}
141139
}
142140

@@ -145,11 +143,16 @@ export async function syncGenerator(tree: Tree): Promise<SyncGeneratorResult> {
145143
// Skip the root tsconfig itself
146144
if (node.data.root !== '.' && !referencesSet.has(normalizedPath)) {
147145
referencesSet.add(normalizedPath);
148-
hasChanges = true;
146+
addChangedFile(
147+
changedFiles,
148+
rootTsconfigPath,
149+
toFullProjectReferencePath(node.data.root),
150+
'missing'
151+
);
149152
}
150153
}
151154

152-
if (hasChanges) {
155+
if (changedFiles.size > 0) {
153156
const updatedReferences = Array.from(referencesSet)
154157
// Check composite is true in the internal reference before proceeding
155158
.filter((ref) =>
@@ -181,7 +184,7 @@ export async function syncGenerator(tree: Tree): Promise<SyncGeneratorResult> {
181184
};
182185

183186
const collectedDependencies = new Map<string, ProjectGraphProjectNode[]>();
184-
for (const [projectName, data] of Object.entries(projectGraph.dependencies)) {
187+
for (const projectName of Object.keys(projectGraph.dependencies)) {
185188
if (
186189
!projectGraph.nodes[projectName] ||
187190
projectGraph.nodes[projectName].data.root === '.'
@@ -224,39 +227,55 @@ export async function syncGenerator(tree: Tree): Promise<SyncGeneratorResult> {
224227
}
225228

226229
// Update project references for the runtime tsconfig
227-
hasChanges =
228-
updateTsConfigReferences(
229-
tree,
230-
tsSysFromTree,
231-
tsconfigInfoCaches,
232-
runtimeTsConfigPath,
233-
dependencies,
234-
sourceProjectNode.data.root,
235-
projectRoots,
236-
runtimeTsConfigFileName,
237-
runtimeTsConfigFileNames
238-
) || hasChanges;
239-
}
240-
241-
// Update project references for the tsconfig.json file
242-
hasChanges =
243230
updateTsConfigReferences(
244231
tree,
245232
tsSysFromTree,
246233
tsconfigInfoCaches,
247-
sourceProjectTsconfigPath,
234+
runtimeTsConfigPath,
248235
dependencies,
249236
sourceProjectNode.data.root,
250-
projectRoots
251-
) || hasChanges;
237+
projectRoots,
238+
changedFiles,
239+
runtimeTsConfigFileName,
240+
runtimeTsConfigFileNames
241+
);
242+
}
243+
244+
// Update project references for the tsconfig.json file
245+
updateTsConfigReferences(
246+
tree,
247+
tsSysFromTree,
248+
tsconfigInfoCaches,
249+
sourceProjectTsconfigPath,
250+
dependencies,
251+
sourceProjectNode.data.root,
252+
projectRoots,
253+
changedFiles
254+
);
252255
}
253256

254-
if (hasChanges) {
257+
if (changedFiles.size > 0) {
255258
await formatFiles(tree);
256259

260+
const outOfSyncDetails: string[] = [];
261+
for (const [filePath, details] of changedFiles) {
262+
outOfSyncDetails.push(`${filePath}:`);
263+
if (details.missing.size > 0) {
264+
outOfSyncDetails.push(
265+
` - Missing references: ${Array.from(details.missing).join(', ')}`
266+
);
267+
}
268+
if (details.stale.size > 0) {
269+
outOfSyncDetails.push(
270+
` - Stale references: ${Array.from(details.stale).join(', ')}`
271+
);
272+
}
273+
}
274+
257275
return {
258276
outOfSyncMessage:
259-
'Some TypeScript configuration files are missing project references to the projects they depend on or contain outdated project references.',
277+
'Some TypeScript configuration files are missing project references to the projects they depend on or contain stale project references.',
278+
outOfSyncDetails,
260279
};
261280
}
262281
}
@@ -306,9 +325,10 @@ function updateTsConfigReferences(
306325
dependencies: ProjectGraphProjectNode[],
307326
projectRoot: string,
308327
projectRoots: Set<string>,
328+
changedFiles: Map<string, ChangedFileDetails>,
309329
runtimeTsConfigFileName?: string,
310330
possibleRuntimeTsConfigFileNames?: string[]
311-
): boolean {
331+
): void {
312332
const stringifiedJsonContents = readRawTsconfigContents(
313333
tree,
314334
tsconfigInfoCaches,
@@ -334,15 +354,11 @@ function updateTsConfigReferences(
334354

335355
// reference path is relative to the tsconfig file
336356
const resolvedRefPath = getTsConfigPathFromReferencePath(
337-
tree,
338357
tsConfigPath,
339-
ref.path,
340-
tsconfigInfoCaches
358+
ref.path
341359
);
342360
if (
343361
isProjectReferenceWithinNxProject(
344-
tree,
345-
tsconfigInfoCaches,
346362
resolvedRefPath,
347363
projectRoot,
348364
projectRoots
@@ -353,6 +369,10 @@ function updateTsConfigReferences(
353369
references.push(ref);
354370
newReferencesSet.add(normalizedPath);
355371
}
372+
373+
if (!newReferencesSet.has(normalizedPath)) {
374+
addChangedFile(changedFiles, tsConfigPath, resolvedRefPath, 'stale');
375+
}
356376
}
357377

358378
let hasChanges = false;
@@ -441,6 +461,12 @@ function updateTsConfigReferences(
441461
}
442462
if (!originalReferencesSet.has(relativePathToTargetRoot)) {
443463
hasChanges = true;
464+
addChangedFile(
465+
changedFiles,
466+
tsConfigPath,
467+
toFullProjectReferencePath(referencePath),
468+
'missing'
469+
);
444470
}
445471
}
446472

@@ -454,8 +480,6 @@ function updateTsConfigReferences(
454480
references
455481
);
456482
}
457-
458-
return hasChanges;
459483
}
460484

461485
// TODO(leo): follow up with the TypeScript team to confirm if we really need
@@ -522,18 +546,20 @@ function normalizeReferencePath(path: string): string {
522546
.replace(/^\.\//, '');
523547
}
524548

549+
function toFullProjectReferencePath(path: string): string {
550+
const normalizedPath = normalizeReferencePath(path);
551+
552+
return normalizedPath.endsWith('.json')
553+
? normalizedPath
554+
: joinPathFragments(normalizedPath, 'tsconfig.json');
555+
}
556+
525557
function isProjectReferenceWithinNxProject(
526-
tree: Tree,
527-
tsconfigInfoCaches: TsconfigInfoCaches,
528558
refTsConfigPath: string,
529559
projectRoot: string,
530560
projectRoots: Set<string>
531561
): boolean {
532-
let currentPath = getTsConfigDirName(
533-
tree,
534-
tsconfigInfoCaches,
535-
refTsConfigPath
536-
);
562+
let currentPath = getTsConfigDirName(refTsConfigPath);
537563

538564
if (relative(projectRoot, currentPath).startsWith('..')) {
539565
// it's outside of the project root, so it's an external project reference
@@ -568,28 +594,22 @@ function isProjectReferenceIgnored(
568594
return ig.ignores(refTsConfigPath);
569595
}
570596

571-
function getTsConfigDirName(
572-
tree: Tree,
573-
tsconfigInfoCaches: TsconfigInfoCaches,
574-
tsConfigPath: string
575-
): string {
576-
return tsconfigIsFile(tree, tsconfigInfoCaches, tsConfigPath)
597+
function getTsConfigDirName(tsConfigPath: string): string {
598+
return tsConfigPath.endsWith('.json')
577599
? dirname(tsConfigPath)
578600
: normalize(tsConfigPath);
579601
}
580602

581603
function getTsConfigPathFromReferencePath(
582-
tree: Tree,
583604
ownerTsConfigPath: string,
584-
referencePath: string,
585-
tsconfigInfoCaches: TsconfigInfoCaches
605+
referencePath: string
586606
): string {
587607
const resolvedRefPath = joinPathFragments(
588608
dirname(ownerTsConfigPath),
589609
referencePath
590610
);
591611

592-
return tsconfigIsFile(tree, tsconfigInfoCaches, resolvedRefPath)
612+
return resolvedRefPath.endsWith('.json')
593613
? resolvedRefPath
594614
: joinPathFragments(resolvedRefPath, 'tsconfig.json');
595615
}
@@ -640,22 +660,15 @@ function hasCompositeEnabled(
640660
return tsconfigInfoCaches.composite.get(tsconfigPath);
641661
}
642662

643-
function tsconfigIsFile(
644-
tree: Tree,
645-
tsconfigInfoCaches: TsconfigInfoCaches,
646-
tsconfigPath: string
647-
): boolean {
648-
if (tsconfigInfoCaches.isFile.has(tsconfigPath)) {
649-
return tsconfigInfoCaches.isFile.get(tsconfigPath);
650-
}
651-
652-
if (tsconfigInfoCaches.content.has(tsconfigPath)) {
653-
// if it has content, it's a file
654-
tsconfigInfoCaches.isFile.set(tsconfigPath, true);
655-
return true;
663+
function addChangedFile(
664+
changedFiles: Map<string, ChangedFileDetails>,
665+
filePath: string,
666+
referencePath: string,
667+
type: ChangeType
668+
) {
669+
if (!changedFiles.has(filePath)) {
670+
changedFiles.set(filePath, { missing: new Set(), stale: new Set() });
656671
}
657672

658-
tsconfigInfoCaches.isFile.set(tsconfigPath, tree.isFile(tsconfigPath));
659-
660-
return tsconfigInfoCaches.isFile.get(tsconfigPath);
673+
changedFiles.get(filePath)[type].add(referencePath);
661674
}

packages/nx/src/command-line/sync/sync.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,11 +78,19 @@ export function syncHandler(options: SyncOptions): Promise<number> {
7878
return 1;
7979
}
8080

81-
const resultBodyLines = getSyncGeneratorSuccessResultsMessageLines(results);
81+
const resultBodyLines = getSyncGeneratorSuccessResultsMessageLines(
82+
results,
83+
// log the out of sync details if the user is running `nx sync --check`
84+
options.check
85+
);
8286
if (options.check) {
8387
output.error({
8488
title: 'The workspace is out of sync',
85-
bodyLines: resultBodyLines,
89+
bodyLines: [
90+
...resultBodyLines,
91+
'',
92+
'Run `nx sync` to sync the workspace.',
93+
],
8694
});
8795

8896
if (anySyncGeneratorsFailed) {

0 commit comments

Comments
 (0)