diff --git a/packages/js/src/generators/typescript-sync/typescript-sync.spec.ts b/packages/js/src/generators/typescript-sync/typescript-sync.spec.ts index 706074515c2..ee90c6a430b 100644 --- a/packages/js/src/generators/typescript-sync/typescript-sync.spec.ts +++ b/packages/js/src/generators/typescript-sync/typescript-sync.spec.ts @@ -206,6 +206,78 @@ describe('syncGenerator()', () => { ]); }); + describe('@nx/js/typescript plugin filters', () => { + it('should not reference a project the plugin excludes', async () => { + writeJson(tree, 'nx.json', { + plugins: [{ plugin: '@nx/js/typescript', exclude: ['examples/**/*'] }], + }); + addProject('excluded-example', [], [], 'examples/basic'); + + await syncGenerator(tree); + + expect(readJson(tree, 'tsconfig.json').references).toEqual([ + { path: './packages/a' }, + { path: './packages/b' }, + ]); + }); + + it('should reference a project claimed by a second registration', async () => { + // The first registration excludes e2e, the second includes it. The + // plugin runs for the union, so the reference must be kept. + writeJson(tree, 'nx.json', { + plugins: [ + { plugin: '@nx/js/typescript', exclude: ['e2e/**/*'] }, + { plugin: '@nx/js/typescript', include: ['e2e/**/*'] }, + ], + }); + addProject('e2e-app', [], [], 'e2e/app'); + + await syncGenerator(tree); + + expect(readJson(tree, 'tsconfig.json').references).toContainEqual({ + path: './e2e/app', + }); + }); + + it('should not filter anything for a bare string registration', async () => { + writeJson(tree, 'nx.json', { plugins: ['@nx/js/typescript'] }); + addProject('anywhere', [], [], 'examples/basic'); + + await syncGenerator(tree); + + expect(readJson(tree, 'tsconfig.json').references).toContainEqual({ + path: './examples/basic', + }); + }); + + it('should not filter anything when the plugin is not registered', async () => { + writeJson(tree, 'nx.json', { plugins: [] }); + addProject('anywhere', [], [], 'examples/basic'); + + await syncGenerator(tree); + + expect(readJson(tree, 'tsconfig.json').references).toContainEqual({ + path: './examples/basic', + }); + }); + + it('should not report a non-composite project as out of sync', async () => { + addProject('not-composite', [], [], 'packages/not-composite'); + writeJson(tree, 'packages/not-composite/tsconfig.json', { + compilerOptions: {}, + }); + + const result = await syncGenerator(tree); + + // It is filtered out before writing, so it must never be named as the + // reason the workspace is out of sync. + expect(JSON.stringify(result ?? {})).not.toContain('not-composite'); + expect(readJson(tree, 'tsconfig.json').references).not.toContainEqual({ + path: './packages/not-composite', + }); + }); + }); + describe('root tsconfig.json', () => { it('should sync project references to the tsconfig.json', async () => { expect(readJson(tree, 'tsconfig.json').references).toBeUndefined(); diff --git a/packages/js/src/generators/typescript-sync/typescript-sync.ts b/packages/js/src/generators/typescript-sync/typescript-sync.ts index 8bf3e9e5503..5a4c1b39f0f 100644 --- a/packages/js/src/generators/typescript-sync/typescript-sync.ts +++ b/packages/js/src/generators/typescript-sync/typescript-sync.ts @@ -5,6 +5,8 @@ import { logger, parseJson, readNxJson, + type ExpandedPluginConfiguration, + type NxJsonConfiguration, type ProjectGraph, type ProjectGraphProjectNode, type Tree, @@ -12,6 +14,7 @@ import { import ignore from 'ignore'; import { applyEdits, modify } from 'jsonc-parser'; import { dirname, normalize, relative } from 'node:path/posix'; +import { findMatchingConfigFiles } from 'nx/src/project-graph/utils/project-configuration-utils'; import { SyncError, type SyncGeneratorResult, @@ -48,6 +51,50 @@ type GeneratorOptions = { }; type NormalizedGeneratorOptions = Required; + +const TS_PLUGIN_NAME = '@nx/js/typescript'; + +/** + * Builds a predicate for whether a tsconfig is managed by `@nx/js/typescript`, + * honoring the `include`/`exclude` filters on its `nx.json` registrations. + * + * A project the plugin was told to skip must not be pulled into the root + * tsconfig's references — nested standalone workspaces are excluded there + * precisely because they are not part of this workspace's TypeScript build. + * + * Fails open: when the plugin is registered without filters, or not registered + * at all, nothing is filtered out. + */ +function createTypeScriptPluginFilter( + nxJson: NxJsonConfiguration +): (tsconfigPath: string) => boolean { + const plugins = nxJson.plugins ?? []; + + // A bare string registration carries no filters, so it claims everything. + if (plugins.some((p) => p === TS_PLUGIN_NAME)) { + return () => true; + } + + const registrations = plugins + .filter( + (p): p is ExpandedPluginConfiguration => + typeof p !== 'string' && p.plugin === TS_PLUGIN_NAME + ) + .map((p) => ({ include: p.include ?? [], exclude: p.exclude ?? [] })); + + if (registrations.length === 0) { + return () => true; + } + + // Registrations are additive: one may exclude a directory that another + // includes, and the plugin runs for the union of what they claim. + return (tsconfigPath) => + registrations.some( + ({ include, exclude }) => + findMatchingConfigFiles([tsconfigPath], include, exclude).length > 0 + ); +} + type TsconfigInfoCaches = { composite: Map; content: Map; @@ -88,6 +135,7 @@ export async function syncGenerator(tree: Tree): Promise { const projectGraph = await createProjectGraphAsync(); const projectRoots = new Set(); + const isManagedByTypeScriptPlugin = createTypeScriptPluginFilter(nxJson); const tsconfigProjectNodeValues = Object.values(projectGraph.nodes).filter( (node) => { projectRoots.add(node.data.root); @@ -95,7 +143,10 @@ export async function syncGenerator(tree: Tree): Promise { node.data.root, 'tsconfig.json' ); - return tsconfigExists(tree, tsconfigInfoCaches, projectTsconfigPath); + return ( + tsconfigExists(tree, tsconfigInfoCaches, projectTsconfigPath) && + isManagedByTypeScriptPlugin(projectTsconfigPath) + ); } ); @@ -170,6 +221,18 @@ export async function syncGenerator(tree: Tree): Promise { const normalizedPath = normalizeReferencePath(node.data.root); // Skip the root tsconfig itself if (node.data.root !== '.' && !referencesSet.has(normalizedPath)) { + // Check composite here rather than only when writing, so a reference + // that would be filtered out below never reports the workspace as out + // of sync — it can never be satisfied. + if ( + !hasCompositeEnabled( + tsSysFromTree, + tsconfigInfoCaches, + joinPathFragments(normalizedPath, 'tsconfig.json') + ) + ) { + continue; + } referencesSet.add(normalizedPath); addChangedFile( changedFiles, @@ -182,7 +245,7 @@ export async function syncGenerator(tree: Tree): Promise { if (changedFiles.size > 0) { const updatedReferences = Array.from(referencesSet) - // Check composite is true in the internal reference before proceeding + // Existing references are not re-checked above, so still filter here. .filter((ref) => hasCompositeEnabled( tsSysFromTree,