diff --git a/packages/rspack/src/plugins/utils/get-non-buildable-libs.spec.ts b/packages/rspack/src/plugins/utils/get-non-buildable-libs.spec.ts new file mode 100644 index 0000000000..cecc4c99a5 --- /dev/null +++ b/packages/rspack/src/plugins/utils/get-non-buildable-libs.spec.ts @@ -0,0 +1,154 @@ +import { logger } from '@nx/devkit'; +import { createAllowlistFromExports } from './get-non-buildable-libs'; + +describe('createAllowlistFromExports', () => { + beforeEach(() => { + jest.spyOn(logger, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should handle undefined exports', () => { + const result = createAllowlistFromExports('@test/lib', undefined); + expect(result).toEqual(['@test/lib']); + }); + + it('should handle string exports', () => { + const result = createAllowlistFromExports('@test/lib', './index.js'); + expect(result).toEqual(['@test/lib']); + }); + + it('should handle wildcard exports', () => { + const result = createAllowlistFromExports('@test/lib', { + './*': './src/*.ts', + }); + expect(result).toHaveLength(2); + expect(result[0]).toBe('@test/lib'); + expect(result[1]).toBeInstanceOf(RegExp); + + const regex = result[1] as RegExp; + expect(regex.test('@test/lib/utils')).toBe(true); + expect(regex.test('@test/lib/nested/path')).toBe(true); + expect(regex.test('@other/lib/utils')).toBe(false); + expect(regex.test('@test/lib')).toBe(false); + }); + + it('should handle exact subpath exports', () => { + const result = createAllowlistFromExports('@test/lib', { + './utils': './src/utils.ts', + './types': './src/types.ts', + }); + expect(result).toEqual(['@test/lib', '@test/lib/utils', '@test/lib/types']); + }); + + it('should handle conditional exports', () => { + const result = createAllowlistFromExports('@test/lib', { + './utils': { + import: './src/utils.mjs', + require: './src/utils.cjs', + default: './src/utils.js', + }, + }); + expect(result).toEqual(['@test/lib', '@test/lib/utils']); + }); + + it('should handle conditional exports with development priority', () => { + const result = createAllowlistFromExports('@test/lib', { + './utils': { + development: './src/utils.ts', + import: './src/utils.mjs', + require: './src/utils.cjs', + default: './src/utils.js', + }, + }); + expect(result).toEqual(['@test/lib', '@test/lib/utils']); + }); + + it('should handle mixed patterns', () => { + const result = createAllowlistFromExports('@test/lib', { + './utils': './src/utils.ts', + './*': './src/*.ts', + }); + expect(result).toHaveLength(3); + expect(result[0]).toBe('@test/lib'); + expect(result[1]).toBe('@test/lib/utils'); + expect(result[2]).toBeInstanceOf(RegExp); + + const regex = result[2] as RegExp; + expect(regex.test('@test/lib/helpers')).toBe(true); + expect(regex.test('@test/lib/utils')).toBe(true); // Also matches regex + }); + + it('should escape special characters in package names', () => { + const result = createAllowlistFromExports('@test/lib.name', { + './*': './src/*.ts', + }); + expect(result).toHaveLength(2); + expect(result[1]).toBeInstanceOf(RegExp); + + const regex = result[1] as RegExp; + expect(regex.test('@test/lib.name/utils')).toBe(true); + expect(regex.test('@test/lib-name/utils')).toBe(false); + }); + + it('should handle scoped package names with special characters', () => { + const result = createAllowlistFromExports('@my-org/my-lib.pkg', { + './*': './src/*.ts', + }); + expect(result).toHaveLength(2); + expect(result[1]).toBeInstanceOf(RegExp); + + const regex = result[1] as RegExp; + expect(regex.test('@my-org/my-lib.pkg/utils')).toBe(true); + expect(regex.test('@my-org/my-lib-pkg/utils')).toBe(false); + }); + + it('should handle complex wildcard patterns', () => { + const result = createAllowlistFromExports('@test/lib', { + './utils/*': './src/utils/*.ts', + './types/*': './src/types/*.ts', + }); + expect(result).toHaveLength(3); + expect(result[0]).toBe('@test/lib'); + expect(result[1]).toBeInstanceOf(RegExp); + expect(result[2]).toBeInstanceOf(RegExp); + + const utilsRegex = result[1] as RegExp; + const typesRegex = result[2] as RegExp; + + expect(utilsRegex.test('@test/lib/utils/helpers')).toBe(true); + expect(utilsRegex.test('@test/lib/types/common')).toBe(false); + expect(typesRegex.test('@test/lib/types/common')).toBe(true); + expect(typesRegex.test('@test/lib/utils/helpers')).toBe(false); + }); + + it('should ignore main export (.)', () => { + const result = createAllowlistFromExports('@test/lib', { + '.': './src/index.ts', + './utils': './src/utils.ts', + }); + expect(result).toEqual(['@test/lib', '@test/lib/utils']); + }); + + it('should handle invalid conditional exports gracefully', () => { + const result = createAllowlistFromExports('@test/lib', { + './utils': { + import: null, + require: undefined, + types: './src/types.d.ts', // Should be ignored + }, + './valid': './src/valid.ts', + }); + expect(result).toEqual(['@test/lib', '@test/lib/valid']); + }); + + it('should handle non-string export paths', () => { + const result = createAllowlistFromExports('@test/lib', { + 123: './src/invalid.ts', + './valid': './src/valid.ts', + } as any); + expect(result).toEqual(['@test/lib', '@test/lib/valid']); + }); +}); diff --git a/packages/rspack/src/plugins/utils/get-non-buildable-libs.ts b/packages/rspack/src/plugins/utils/get-non-buildable-libs.ts index f280c8f9f9..9654b8c4b8 100644 --- a/packages/rspack/src/plugins/utils/get-non-buildable-libs.ts +++ b/packages/rspack/src/plugins/utils/get-non-buildable-libs.ts @@ -1,7 +1,76 @@ -import { type ProjectGraph } from '@nx/devkit'; +import { type ProjectGraph, readJsonFile } from '@nx/devkit'; +import { join } from 'path'; import { getAllTransitiveDeps } from './get-transitive-deps'; import { isBuildableLibrary } from './is-lib-buildable'; +function escapePackageName(packageName: string): string { + return packageName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function escapeRegexAndConvertWildcard(pattern: string): string { + return pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\\\*/g, '.*'); +} + +function resolveConditionalExport(target: any): string | null { + if (typeof target === 'string') { + return target; + } + + if (typeof target === 'object' && target !== null) { + // Priority order for conditions + const conditions = ['development', 'import', 'require', 'default']; + for (const condition of conditions) { + if (target[condition] && typeof target[condition] === 'string') { + return target[condition]; + } + } + } + + return null; +} + +export function createAllowlistFromExports( + packageName: string, + exports: Record | string | undefined +): (string | RegExp)[] { + if (!exports) { + return [packageName]; + } + + const allowlist: (string | RegExp)[] = []; + allowlist.push(packageName); + + if (typeof exports === 'string') { + return allowlist; + } + + if (typeof exports === 'object') { + for (const [exportPath, target] of Object.entries(exports)) { + if (typeof exportPath !== 'string') continue; + + const resolvedTarget = resolveConditionalExport(target); + if (!resolvedTarget) continue; + + if (exportPath === '.') { + continue; + } else if (exportPath.startsWith('./')) { + const subpath = exportPath.slice(2); + + if (subpath.includes('*')) { + const regexPattern = escapeRegexAndConvertWildcard(subpath); + allowlist.push( + new RegExp(`^${escapePackageName(packageName)}/${regexPattern}$`) + ); + } else { + allowlist.push(`${packageName}/${subpath}`); + } + } + } + } + + return allowlist; +} + /** * Get all non-buildable libraries in the project graph for a given project. * This function retrieves all direct and transitive dependencies of a project, @@ -14,10 +83,10 @@ import { isBuildableLibrary } from './is-lib-buildable'; export function getNonBuildableLibs( graph: ProjectGraph, projectName: string -): string[] { +): (string | RegExp)[] { const deps = graph?.dependencies?.[projectName] ?? []; - const allNonBuildable = new Set(); + const allNonBuildable = new Set(); // First, find all direct non-buildable deps and add them App -> library const directNonBuildable = deps.filter((dep) => { @@ -28,12 +97,38 @@ export function getNonBuildableLibs( return !isBuildableLibrary(node); }); - // Add direct non-buildable dependencies + // Add direct non-buildable dependencies with expanded export patterns for (const dep of directNonBuildable) { - const packageName = - graph.nodes?.[dep.target]?.data?.metadata?.js?.packageName; + const node = graph.nodes?.[dep.target]; + const packageName = node?.data?.metadata?.js?.packageName; + if (packageName) { - allNonBuildable.add(packageName); + // Get exports from project metadata first (most reliable) + const packageExports = node?.data?.metadata?.js?.packageExports; + + if (packageExports) { + // Use metadata exports if available + const allowlistPatterns = createAllowlistFromExports( + packageName, + packageExports + ); + allowlistPatterns.forEach((pattern) => allNonBuildable.add(pattern)); + } else { + // Fallback: try to read package.json directly + try { + const projectRoot = node.data.root; + const packageJsonPath = join(projectRoot, 'package.json'); + const packageJson = readJsonFile(packageJsonPath); + const allowlistPatterns = createAllowlistFromExports( + packageName, + packageJson.exports + ); + allowlistPatterns.forEach((pattern) => allNonBuildable.add(pattern)); + } catch (error) { + // Final fallback: just add base package name + allNonBuildable.add(packageName); + } + } } // Get all transitive non-buildable dependencies App -> library1 -> library2 diff --git a/packages/webpack/src/plugins/nx-webpack-plugin/lib/utils.spec.ts b/packages/webpack/src/plugins/nx-webpack-plugin/lib/utils.spec.ts new file mode 100644 index 0000000000..76e0b71800 --- /dev/null +++ b/packages/webpack/src/plugins/nx-webpack-plugin/lib/utils.spec.ts @@ -0,0 +1,154 @@ +import { logger } from '@nx/devkit'; +import { createAllowlistFromExports } from './utils'; + +describe('createAllowlistFromExports', () => { + beforeEach(() => { + jest.spyOn(logger, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should handle undefined exports', () => { + const result = createAllowlistFromExports('@test/lib', undefined); + expect(result).toEqual(['@test/lib']); + }); + + it('should handle string exports', () => { + const result = createAllowlistFromExports('@test/lib', './index.js'); + expect(result).toEqual(['@test/lib']); + }); + + it('should handle wildcard exports', () => { + const result = createAllowlistFromExports('@test/lib', { + './*': './src/*.ts', + }); + expect(result).toHaveLength(2); + expect(result[0]).toBe('@test/lib'); + expect(result[1]).toBeInstanceOf(RegExp); + + const regex = result[1] as RegExp; + expect(regex.test('@test/lib/utils')).toBe(true); + expect(regex.test('@test/lib/nested/path')).toBe(true); + expect(regex.test('@other/lib/utils')).toBe(false); + expect(regex.test('@test/lib')).toBe(false); + }); + + it('should handle exact subpath exports', () => { + const result = createAllowlistFromExports('@test/lib', { + './utils': './src/utils.ts', + './types': './src/types.ts', + }); + expect(result).toEqual(['@test/lib', '@test/lib/utils', '@test/lib/types']); + }); + + it('should handle conditional exports', () => { + const result = createAllowlistFromExports('@test/lib', { + './utils': { + import: './src/utils.mjs', + require: './src/utils.cjs', + default: './src/utils.js', + }, + }); + expect(result).toEqual(['@test/lib', '@test/lib/utils']); + }); + + it('should handle conditional exports with development priority', () => { + const result = createAllowlistFromExports('@test/lib', { + './utils': { + development: './src/utils.ts', + import: './src/utils.mjs', + require: './src/utils.cjs', + default: './src/utils.js', + }, + }); + expect(result).toEqual(['@test/lib', '@test/lib/utils']); + }); + + it('should handle mixed patterns', () => { + const result = createAllowlistFromExports('@test/lib', { + './utils': './src/utils.ts', + './*': './src/*.ts', + }); + expect(result).toHaveLength(3); + expect(result[0]).toBe('@test/lib'); + expect(result[1]).toBe('@test/lib/utils'); + expect(result[2]).toBeInstanceOf(RegExp); + + const regex = result[2] as RegExp; + expect(regex.test('@test/lib/helpers')).toBe(true); + expect(regex.test('@test/lib/utils')).toBe(true); // Also matches regex + }); + + it('should escape special characters in package names', () => { + const result = createAllowlistFromExports('@test/lib.name', { + './*': './src/*.ts', + }); + expect(result).toHaveLength(2); + expect(result[1]).toBeInstanceOf(RegExp); + + const regex = result[1] as RegExp; + expect(regex.test('@test/lib.name/utils')).toBe(true); + expect(regex.test('@test/lib-name/utils')).toBe(false); + }); + + it('should handle scoped package names with special characters', () => { + const result = createAllowlistFromExports('@my-org/my-lib.pkg', { + './*': './src/*.ts', + }); + expect(result).toHaveLength(2); + expect(result[1]).toBeInstanceOf(RegExp); + + const regex = result[1] as RegExp; + expect(regex.test('@my-org/my-lib.pkg/utils')).toBe(true); + expect(regex.test('@my-org/my-lib-pkg/utils')).toBe(false); + }); + + it('should handle complex wildcard patterns', () => { + const result = createAllowlistFromExports('@test/lib', { + './utils/*': './src/utils/*.ts', + './types/*': './src/types/*.ts', + }); + expect(result).toHaveLength(3); + expect(result[0]).toBe('@test/lib'); + expect(result[1]).toBeInstanceOf(RegExp); + expect(result[2]).toBeInstanceOf(RegExp); + + const utilsRegex = result[1] as RegExp; + const typesRegex = result[2] as RegExp; + + expect(utilsRegex.test('@test/lib/utils/helpers')).toBe(true); + expect(utilsRegex.test('@test/lib/types/common')).toBe(false); + expect(typesRegex.test('@test/lib/types/common')).toBe(true); + expect(typesRegex.test('@test/lib/utils/helpers')).toBe(false); + }); + + it('should ignore main export (.)', () => { + const result = createAllowlistFromExports('@test/lib', { + '.': './src/index.ts', + './utils': './src/utils.ts', + }); + expect(result).toEqual(['@test/lib', '@test/lib/utils']); + }); + + it('should handle invalid conditional exports gracefully', () => { + const result = createAllowlistFromExports('@test/lib', { + './utils': { + import: null, + require: undefined, + types: './src/types.d.ts', // Should be ignored + }, + './valid': './src/valid.ts', + }); + expect(result).toEqual(['@test/lib', '@test/lib/valid']); + }); + + it('should handle non-string export paths', () => { + const result = createAllowlistFromExports('@test/lib', { + 123: './src/invalid.ts', + './valid': './src/valid.ts', + } as any); + expect(result).toEqual(['@test/lib', '@test/lib/valid']); + }); +}); diff --git a/packages/webpack/src/plugins/nx-webpack-plugin/lib/utils.ts b/packages/webpack/src/plugins/nx-webpack-plugin/lib/utils.ts index 89d799a6be..da666d2dd7 100644 --- a/packages/webpack/src/plugins/nx-webpack-plugin/lib/utils.ts +++ b/packages/webpack/src/plugins/nx-webpack-plugin/lib/utils.ts @@ -1,4 +1,74 @@ import type { ProjectGraph, ProjectGraphProjectNode } from '@nx/devkit'; +import { readJsonFile } from '@nx/devkit'; +import { join } from 'path'; + +function escapePackageName(packageName: string): string { + return packageName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function escapeRegexAndConvertWildcard(pattern: string): string { + return pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\\\*/g, '.*'); +} + +function resolveConditionalExport(target: any): string | null { + if (typeof target === 'string') { + return target; + } + + if (typeof target === 'object' && target !== null) { + // Priority order for conditions + const conditions = ['development', 'import', 'require', 'default']; + for (const condition of conditions) { + if (target[condition] && typeof target[condition] === 'string') { + return target[condition]; + } + } + } + + return null; +} + +export function createAllowlistFromExports( + packageName: string, + exports: Record | string | undefined +): (string | RegExp)[] { + if (!exports) { + return [packageName]; + } + + const allowlist: (string | RegExp)[] = []; + allowlist.push(packageName); + + if (typeof exports === 'string') { + return allowlist; + } + + if (typeof exports === 'object') { + for (const [exportPath, target] of Object.entries(exports)) { + if (typeof exportPath !== 'string') continue; + + const resolvedTarget = resolveConditionalExport(target); + if (!resolvedTarget) continue; + + if (exportPath === '.') { + continue; + } else if (exportPath.startsWith('./')) { + const subpath = exportPath.slice(2); + + if (subpath.includes('*')) { + const regexPattern = escapeRegexAndConvertWildcard(subpath); + allowlist.push( + new RegExp(`^${escapePackageName(packageName)}/${regexPattern}$`) + ); + } else { + allowlist.push(`${packageName}/${subpath}`); + } + } + } + } + + return allowlist; +} function isSourceFile(path: string): boolean { return ['.ts', '.tsx', '.mts', '.cts'].some((ext) => path.endsWith(ext)); @@ -122,10 +192,10 @@ export function getAllTransitiveDeps( export function getNonBuildableLibs( graph: ProjectGraph, projectName: string -): string[] { +): (string | RegExp)[] { const deps = graph?.dependencies?.[projectName] ?? []; - const allNonBuildable = new Set(); + const allNonBuildable = new Set(); // First, find all direct non-buildable deps and add them App -> library const directNonBuildable = deps.filter((dep) => { @@ -136,12 +206,38 @@ export function getNonBuildableLibs( return !isBuildableLibrary(node); }); - // Add direct non-buildable dependencies + // Add direct non-buildable dependencies with expanded export patterns for (const dep of directNonBuildable) { - const packageName = - graph.nodes?.[dep.target]?.data?.metadata?.js?.packageName; + const node = graph.nodes?.[dep.target]; + const packageName = node?.data?.metadata?.js?.packageName; + if (packageName) { - allNonBuildable.add(packageName); + // Get exports from project metadata first (most reliable) + const packageExports = node?.data?.metadata?.js?.packageExports; + + if (packageExports) { + // Use metadata exports if available + const allowlistPatterns = createAllowlistFromExports( + packageName, + packageExports + ); + allowlistPatterns.forEach((pattern) => allNonBuildable.add(pattern)); + } else { + // Fallback: try to read package.json directly + try { + const projectRoot = node.data.root; + const packageJsonPath = join(projectRoot, 'package.json'); + const packageJson = readJsonFile(packageJsonPath); + const allowlistPatterns = createAllowlistFromExports( + packageName, + packageJson.exports + ); + allowlistPatterns.forEach((pattern) => allNonBuildable.add(pattern)); + } catch (error) { + // Final fallback: just add base package name + allNonBuildable.add(packageName); + } + } } // Get all transitive non-buildable dependencies App -> library1 -> library2