diff --git a/astro-docs/src/content/docs/features/CI Features/affected.mdoc b/astro-docs/src/content/docs/features/CI Features/affected.mdoc index 0935049c71e..6dcc507b903 100644 --- a/astro-docs/src/content/docs/features/CI Features/affected.mdoc +++ b/astro-docs/src/content/docs/features/CI Features/affected.mdoc @@ -284,6 +284,30 @@ By default, Nx will mark all projects as affected whenever your package manager' The flag `projectsAffectedByDependencyUpdates` can be set to `auto`, `all`, or an array that contains project specifiers. The default value is `all`. +## Reduce affected fan-out for JavaScript and TypeScript projects + +If your workspace uses `@nx/js` source analysis, you can also enable dependency narrowing to reduce affected fan-out from source-level imports. + +```json +// nx.json +{ + "pluginsConfig": { + "@nx/js": { + "dependencyNarrowing": { + "affectedNarrowing": true + } + } + } +} +``` + +This is different from `projectsAffectedByDependencyUpdates`. + +- `projectsAffectedByDependencyUpdates` controls what happens when the lock file changes. +- `dependencyNarrowing.affectedNarrowing` makes `nx affected` more precise for JavaScript and TypeScript source changes. + +See [Narrow project graph dependencies](/docs/technologies/typescript/guides/dependency-narrowing) for the full workflow. + ## Not using git If you aren't using Git, you can pass `--files` to any affected command to indicate what files have been changed. diff --git a/astro-docs/src/content/docs/guides/Tips-n-Tricks/analyze-source-files.mdoc b/astro-docs/src/content/docs/guides/Tips-n-Tricks/analyze-source-files.mdoc index b3d693610e0..953657d5d03 100644 --- a/astro-docs/src/content/docs/guides/Tips-n-Tricks/analyze-source-files.mdoc +++ b/astro-docs/src/content/docs/guides/Tips-n-Tricks/analyze-source-files.mdoc @@ -20,3 +20,5 @@ If you want to disable detecting dependencies from source code and want to only ## Default The default setting for Nx repos is `"analyzeSourceFiles": true`. The assumption is that if there is a real link in the code between projects, you want to know about it. For Lerna repos, the default value is `false` in order to maintain backward compatibility with the way Lerna has always calculated dependencies. + +If you still want Nx to analyze source files but you want the project graph to keep fewer edges, enable [`@nx/js` dependency narrowing](/docs/technologies/typescript/guides/dependency-narrowing) instead of turning source analysis off entirely. diff --git a/astro-docs/src/content/docs/reference/nx-json.mdoc b/astro-docs/src/content/docs/reference/nx-json.mdoc index a7ab5096eeb..702d6061c34 100644 --- a/astro-docs/src/content/docs/reference/nx-json.mdoc +++ b/astro-docs/src/content/docs/reference/nx-json.mdoc @@ -181,6 +181,29 @@ This will exclude all e2e projects except `toolkit-workspace-e2e`. - The last matching pattern determines if a file is included - If the first pattern is a negation, all files are matched initially +### Plugin-specific configuration with `pluginsConfig` + +Use `pluginsConfig` for plugin-specific settings that Nx reads from `nx.json` outside of the `plugins` array. + +Keys are plugin package names such as `@nx/js`. + +For example: + +```json +// nx.json +{ + "pluginsConfig": { + "@nx/js": { + "dependencyNarrowing": { + "affectedNarrowing": true + } + } + } +} +``` + +For `@nx/js` dependency narrowing behavior and options, see [Narrow project graph dependencies](/docs/technologies/typescript/guides/dependency-narrowing). + ## Task options The following properties affect the way Nx runs tasks and can be set at the root of `nx.json`. diff --git a/astro-docs/src/content/docs/technologies/typescript/Guides/dependency-narrowing.mdoc b/astro-docs/src/content/docs/technologies/typescript/Guides/dependency-narrowing.mdoc new file mode 100644 index 00000000000..757eb62e088 --- /dev/null +++ b/astro-docs/src/content/docs/technologies/typescript/Guides/dependency-narrowing.mdoc @@ -0,0 +1,101 @@ +--- +title: Narrow project graph dependencies +description: Use @nx/js dependency narrowing to remove safe project graph edges and reduce the number of projects marked as affected by a change. +sidebar: + label: Narrow project graph dependencies +filter: 'type:Guides' +--- + +If you use `@nx/js` to analyze source imports, you can ask Nx to remove project graph edges that are not needed at runtime. + +This is useful when a project imports a symbol from another project but never uses that symbol in emitted code. In that case, keeping the edge makes the project graph denser than the runtime dependency graph, which can also make `nx affected` less precise. + +## Enable dependency narrowing + +Add `dependencyNarrowing` under `pluginsConfig["@nx/js"]` in `nx.json`: + +```json +// nx.json +{ + "pluginsConfig": { + "@nx/js": { + "dependencyNarrowing": {} + } + } +} +``` + +An empty object enables the feature with the default settings. + +If you want to be explicit about the main behavior switches, start with this shape: + +```json +// nx.json +{ + "pluginsConfig": { + "@nx/js": { + "dependencyNarrowing": { + "respectSideEffects": true, + "removeTypeOnlyEdges": true, + "fallbackToStaticGraph": true, + "affectedNarrowing": true + } + } + } +} +``` + +## What changes when you enable it + +Dependency narrowing changes the computed project graph for JavaScript and TypeScript imports discovered by `@nx/js` source analysis. + +- `nx graph` shows the narrowed graph because it renders the computed project graph. +- `nx affected` can mark fewer downstream projects as affected when a change only touches exports that consumers do not use. +- Task pipelines that depend on the project graph use the narrowed edges too. + +That is the intended behavior. Nx is not hiding edges in the UI. It is building a more precise graph. + +## When Nx keeps an edge + +Nx only removes an edge when it can do so conservatively. + +It keeps edges for cases such as: + +- side-effect imports +- dynamic imports +- namespace imports unless you opt into resolving accessed properties +- re-export cases that still matter to downstream consumers +- targets that may have side effects when `respectSideEffects` is enabled + +If Nx cannot prove that removing an edge is safe, it keeps the edge. + +## Common options + +These are the settings you are most likely to tune first: + +| Property | Default | What it changes | +| ----------------------------------------- | ------- | ------------------------------------------------------------------------------------------------- | +| `concurrency` | `50` | Number of files Nx analyzes in parallel. | +| `respectSideEffects` | `true` | Keeps edges for projects that may have side effects. | +| `removeTypeOnlyEdges` | `true` | Allows type-only imports to be removed from the project graph. | +| `treatMissingPackageJsonAsSideEffectFree` | `false` | Treats projects without a `package.json` as side-effect-free when side effects are being checked. | +| `resolveNamespaceImports` | `false` | Tracks accessed properties on namespace imports instead of keeping the whole edge by default. | +| `fallbackToStaticGraph` | `true` | Falls back to the normal static graph when aggressive narrowing cannot use bundler signals. | +| `affectedNarrowing` | `true` | Reduces affected fan-out when a change only touches exports that consumers do not use. | + +The `dependencyNarrowing` object also includes advanced fields such as `mode`, `bundlerAdapters`, and `debug`. Leave those at their defaults unless you are testing a specific narrowing workflow. + +## Compare it with disabling source analysis + +Dependency narrowing is different from disabling source analysis. + +- If you set `analyzeSourceFiles` to `false`, Nx stops creating source-based edges from JavaScript and TypeScript imports. +- If you enable dependency narrowing, Nx still analyzes source imports but removes only the edges it can prove are unnecessary. + +Use dependency narrowing when you still want the project graph to reflect real source relationships, but you want that graph to be closer to runtime behavior. + +## Use it with affected + +Dependency narrowing is especially useful on CI because it can reduce the number of downstream projects that `nx affected` needs to run. + +See [Run Only Tasks Affected by a PR](/docs/features/ci-features/affected) for the `affected` workflow, and see [nx.json Reference](/docs/reference/nx-json#plugin-specific-configuration-with-pluginsconfig) for the configuration shape. diff --git a/astro-docs/src/content/docs/technologies/typescript/introduction.mdoc b/astro-docs/src/content/docs/technologies/typescript/introduction.mdoc index 4332adb40de..6880901132b 100644 --- a/astro-docs/src/content/docs/technologies/typescript/introduction.mdoc +++ b/astro-docs/src/content/docs/technologies/typescript/introduction.mdoc @@ -323,6 +323,8 @@ nx affected -t build test typecheck lint This uses the [project graph](/docs/features/explore-graph) to determine which projects are affected by your changes and only runs tasks for those. Read more about [the benefits of `nx affected`](/docs/features/ci-features/affected). +If you want Nx to keep the project graph closer to runtime behavior, enable [dependency narrowing](/docs/technologies/typescript/guides/dependency-narrowing). It can remove safe edges from the `@nx/js` project graph and reduce affected fan-out for JavaScript and TypeScript projects. + ### Remote caching Share build and typecheck cache results across your team and CI with [remote caching](/docs/features/ci-features/remote-cache): @@ -341,6 +343,7 @@ For large monorepos with many TypeScript projects, [TSC batch mode](/docs/techno {% linkcard title="Learn Nx Tutorial" description="Build a TypeScript monorepo step-by-step with Nx." href="/docs/getting-started/tutorials/crafting-your-workspace" /%} {% linkcard title="Switch to Workspaces & Project References" description="Migrate from path aliases to the modern monorepo setup." href="/docs/technologies/typescript/guides/switch-to-workspaces-project-references" /%} {% linkcard title="Compile to Multiple Formats" description="Build libraries to both ESM and CommonJS with Rollup." href="/docs/technologies/typescript/guides/compile-multiple-formats" /%} +{% linkcard title="Narrow project graph dependencies" description="Remove safe `@nx/js` project graph edges and reduce affected fan-out." href="/docs/technologies/typescript/guides/dependency-narrowing" /%} {% linkcard title="Generators Reference" description="Full API reference for @nx/js generators." href="/docs/technologies/typescript/generators" /%} {% linkcard title="Executors Reference" description="Full API reference for @nx/js executors." href="/docs/technologies/typescript/executors" /%} {% linkcard title="Migrations Reference" description="Full reference for @nx/js migrations." href="/docs/technologies/typescript/migrations" /%} diff --git a/e2e/nx/src/affected-graph.test.ts b/e2e/nx/src/affected-graph.test.ts index 0f328b8c041..c246bc64f33 100644 --- a/e2e/nx/src/affected-graph.test.ts +++ b/e2e/nx/src/affected-graph.test.ts @@ -4,6 +4,7 @@ import { newProject, readFile, readJson, + updateJson, cleanupProject, runCLI, runCLIAsync, @@ -606,6 +607,72 @@ describe('show projects --affected', () => { }); }, 120000); + it('should reduce affected fan-out for imports not used in emitted code when dependency narrowing is enabled', async () => { + const unusedConsumer = uniq('unused-consumer'); + const usedConsumer = uniq('used-consumer'); + const sharedLib = uniq('shared-lib'); + + updateJson('nx.json', (json) => ({ + ...json, + pluginsConfig: { + ...json.pluginsConfig, + '@nx/js': { + ...json.pluginsConfig?.['@nx/js'], + dependencyNarrowing: { + ...json.pluginsConfig?.['@nx/js']?.dependencyNarrowing, + affectedNarrowing: true, + respectSideEffects: false, + }, + }, + }, + })); + runCLI('reset'); + + runCLI( + `generate @nx/web:app ${unusedConsumer} --directory=apps/${unusedConsumer} --unitTestRunner=vitest` + ); + runCLI( + `generate @nx/web:app ${usedConsumer} --directory=apps/${usedConsumer} --unitTestRunner=vitest` + ); + runCLI(`generate @nx/js:lib ${sharedLib} --directory=libs/${sharedLib}`); + + updateFile( + `libs/${sharedLib}/src/index.ts`, + `export const unusedValue = 'unused';\nexport const usedValue = 'used';\n` + ); + + updateFile( + `apps/${unusedConsumer}/src/app/app.element.spec.ts`, + `import { unusedValue } from '@${proj}/${sharedLib}';\n\n` + + `describe('unused import consumer', () => {\n` + + ` it('uses the import only in a type position', () => {\n` + + ` type ImportedValue = typeof unusedValue;\n` + + ` const value: ImportedValue = 'unused';\n` + + ` expect(value).toEqual('unused');\n` + + ` });\n` + + `});\n` + ); + + updateFile( + `apps/${usedConsumer}/src/app/app.element.spec.ts`, + `import { usedValue } from '@${proj}/${sharedLib}';\n\n` + + `describe('used import consumer', () => {\n` + + ` it('uses the imported value', () => {\n` + + ` expect(usedValue).toEqual('used');\n` + + ` });\n` + + `});\n` + ); + + const { stdout } = await runCLIAsync( + `show projects --affected --files=libs/${sharedLib}/src/index.ts --exclude=${unusedConsumer}-e2e,${usedConsumer}-e2e` + ); + + const affectedProjects = stdout.split('\n').filter(Boolean); + expect(affectedProjects).toContain(sharedLib); + expect(affectedProjects).toContain(usedConsumer); + expect(affectedProjects).not.toContain(unusedConsumer); + }, 120000); + function compareTwoArrays(a: string[], b: string[]) { expect(a.sort((x, y) => x.localeCompare(y))).toEqual( b.sort((x, y) => x.localeCompare(y)) diff --git a/packages/node/src/generators/init/init.spec.ts b/packages/node/src/generators/init/init.spec.ts index 20d8aed72b4..e92bb93ff4d 100644 --- a/packages/node/src/generators/init/init.spec.ts +++ b/packages/node/src/generators/init/init.spec.ts @@ -1,6 +1,7 @@ import { addDependenciesToPackageJson, readJson, + readNxJson, Tree, updateJson, } from '@nx/devkit'; @@ -33,10 +34,21 @@ describe('init', () => { await initGenerator(tree, {}); const packageJson = readJson(tree, 'package.json'); + const nxJson = readNxJson(tree); + expect(packageJson.dependencies['@nx/node']).toBeUndefined(); expect(packageJson.dependencies[existing]).toBeDefined(); expect(packageJson.devDependencies['@nx/node']).toBeDefined(); expect(packageJson.devDependencies[existing]).toBeDefined(); + expect(nxJson.plugins).toBeUndefined(); + expect(nxJson.pluginsConfig?.['@nx/js']).toMatchObject({ + dependencyNarrowing: { + respectSideEffects: true, + removeTypeOnlyEdges: true, + fallbackToStaticGraph: true, + affectedNarrowing: true, + }, + }); }); it('should not fail when dependencies is missing from package.json and no other init generators are invoked', async () => { @@ -47,4 +59,31 @@ describe('init', () => { await expect(initGenerator(tree, {})).resolves.toBeTruthy(); }); + + it('should preserve existing @nx/js plugin config when configuring dependency narrowing', async () => { + updateJson(tree, 'nx.json', (json) => { + json.pluginsConfig = { + '@nx/js': { + analyzeLockfile: true, + dependencyNarrowing: { + debug: true, + }, + }, + }; + return json; + }); + + await initGenerator(tree, {}); + + expect(readNxJson(tree).pluginsConfig?.['@nx/js']).toMatchObject({ + analyzeLockfile: true, + dependencyNarrowing: { + debug: true, + respectSideEffects: true, + removeTypeOnlyEdges: true, + fallbackToStaticGraph: true, + affectedNarrowing: true, + }, + }); + }); }); diff --git a/packages/node/src/generators/init/init.ts b/packages/node/src/generators/init/init.ts index a7d1dce3122..71e349502e5 100644 --- a/packages/node/src/generators/init/init.ts +++ b/packages/node/src/generators/init/init.ts @@ -2,9 +2,11 @@ import { addDependenciesToPackageJson, formatFiles, GeneratorCallback, + readNxJson, removeDependenciesFromPackageJson, runTasksInSerial, Tree, + updateNxJson, } from '@nx/devkit'; import { nxVersion } from '../../utils/versions'; import { Schema } from './schema'; @@ -25,12 +27,37 @@ function updateDependencies(tree: Tree, options: Schema) { return runTasksInSerial(...tasks); } +function addProjectGraphPlugin(tree: Tree) { + const nxJson = readNxJson(tree); + nxJson.pluginsConfig ??= {}; + const jsPluginConfig = + (nxJson.pluginsConfig['@nx/js'] as Record | undefined) ?? + {}; + + nxJson.pluginsConfig['@nx/js'] = { + ...jsPluginConfig, + dependencyNarrowing: { + respectSideEffects: true, + removeTypeOnlyEdges: true, + fallbackToStaticGraph: true, + affectedNarrowing: true, + ...(jsPluginConfig.dependencyNarrowing as + | Record + | undefined), + }, + }; + + updateNxJson(tree, nxJson); +} + export async function initGenerator(tree: Tree, options: Schema) { let installTask: GeneratorCallback = () => {}; if (!options.skipPackageJson) { installTask = updateDependencies(tree, options); } + addProjectGraphPlugin(tree); + if (!options.skipFormat) { await formatFiles(tree); } diff --git a/packages/nx/src/config/nx-json.ts b/packages/nx/src/config/nx-json.ts index 0d49026cee4..ad010280888 100644 --- a/packages/nx/src/config/nx-json.ts +++ b/packages/nx/src/config/nx-json.ts @@ -82,6 +82,25 @@ export interface NrwlJsPluginConfig { analyzePackageJson?: boolean; analyzeLockfile?: boolean; projectsAffectedByDependencyUpdates?: 'all' | 'auto' | string[]; + dependencyNarrowing?: { + mode?: 'semantic' | 'strict' | 'aggressive'; + respectSideEffects?: boolean; + removeTypeOnlyEdges?: boolean; + treatMissingPackageJsonAsSideEffectFree?: boolean; + resolveNamespaceImports?: boolean; + bundlerAdapters?: ( + | 'esbuild' + | 'swc' + | 'babel' + | 'webpack' + | 'rollup' + | 'vite' + )[]; + fallbackToStaticGraph?: boolean; + passthrough?: boolean; + affectedNarrowing?: boolean; + debug?: boolean; + }; } interface NxInstallationConfiguration { diff --git a/packages/nx/src/plugins/js/index.ts b/packages/nx/src/plugins/js/index.ts index 252da253489..66c8868fe61 100644 --- a/packages/nx/src/plugins/js/index.ts +++ b/packages/nx/src/plugins/js/index.ts @@ -29,7 +29,10 @@ import { lockFileExists, LOCKFILES, } from './lock-file/lock-file'; +import { narrowDependencies } from './project-graph/narrow-dependencies'; import { buildExplicitDependencies } from './project-graph/build-dependencies/build-dependencies'; +import { getJsPluginDependencyNarrowingOptions } from './project-graph/narrowing-options'; +import type { RawDependency } from './project-graph/types'; import { jsPluginConfig } from './utils/config'; export const name = 'nx/js/dependencies-and-lockfile'; @@ -95,11 +98,14 @@ function internalCreateNodes( }; } -export const createDependencies: CreateDependencies = ( +export const createDependencies: CreateDependencies = async ( _, ctx: CreateDependenciesContext ) => { const pluginConfig = jsPluginConfig(ctx.nxJsonConfiguration); + const dependencyNarrowingOptions = getJsPluginDependencyNarrowingOptions( + ctx.nxJsonConfiguration + ); const packageManager = detectPackageManager(workspaceRoot); @@ -143,7 +149,16 @@ export const createDependencies: CreateDependencies = ( 'build typescript dependencies - start', 'build typescript dependencies - end' ); - return lockfileDependencies.concat(explicitProjectDependencies); + + const narrowedProjectDependencies = dependencyNarrowingOptions + ? await narrowDependencies( + explicitProjectDependencies, + ctx, + dependencyNarrowingOptions + ) + : explicitProjectDependencies; + + return lockfileDependencies.concat(narrowedProjectDependencies); }; function getLockFileHash(lockFileContents: string) { diff --git a/packages/nx/src/plugins/js/project-graph/bundlers.ts b/packages/nx/src/plugins/js/project-graph/bundlers.ts new file mode 100644 index 00000000000..5683b6bcd3d --- /dev/null +++ b/packages/nx/src/plugins/js/project-graph/bundlers.ts @@ -0,0 +1,33 @@ +import type { ProjectConfiguration } from '@nx/devkit'; +import type { BundlerKind } from './types'; + +const BUNDLER_EXECUTOR_MATCHERS: Record = { + esbuild: /esbuild/i, + swc: /swc|rspack/i, + babel: /babel/i, + webpack: /webpack/i, + rollup: /rollup/i, + vite: /vite/i, +}; + +export function detectBundlersForProject( + project: ProjectConfiguration | undefined +): BundlerKind[] { + if (!project?.targets) { + return []; + } + + const detected = new Set(); + + for (const target of Object.values(project.targets)) { + const executor = (target.executor ?? '').toString(); + + for (const [kind, matcher] of Object.entries(BUNDLER_EXECUTOR_MATCHERS)) { + if (matcher.test(executor)) { + detected.add(kind as BundlerKind); + } + } + } + + return [...detected]; +} diff --git a/packages/nx/src/plugins/js/project-graph/narrow-dependencies.spec.ts b/packages/nx/src/plugins/js/project-graph/narrow-dependencies.spec.ts new file mode 100644 index 00000000000..28ef2771c3f --- /dev/null +++ b/packages/nx/src/plugins/js/project-graph/narrow-dependencies.spec.ts @@ -0,0 +1,148 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { CreateDependenciesContext } from '../../../project-graph/plugins'; +import { narrowDependencies } from './narrow-dependencies'; +import { + getJsPluginDependencyNarrowingOptions, + normalizeDependencyNarrowingOptions, +} from './narrowing-options'; +import type { RawDependency } from './types'; + +describe('@nx/js project graph narrowing', () => { + let workspaceRoot: string; + + beforeEach(() => { + workspaceRoot = mkdtempSync(join(tmpdir(), 'nx-js-graph-')); + + mkdirSync(join(workspaceRoot, 'apps/app/src'), { recursive: true }); + mkdirSync(join(workspaceRoot, 'libs/side-effect-free/src'), { + recursive: true, + }); + + writeFileSync( + join(workspaceRoot, 'tsconfig.base.json'), + JSON.stringify( + { + compilerOptions: { + paths: { + '@fixtures/side-effect-free': [ + 'libs/side-effect-free/src/index.ts', + ], + }, + }, + }, + null, + 2 + ) + ); + + writeFileSync( + join(workspaceRoot, 'libs/side-effect-free/package.json'), + JSON.stringify( + { + name: '@fixtures/side-effect-free', + sideEffects: false, + }, + null, + 2 + ) + ); + }); + + afterEach(() => { + rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + it('removes an unused import edge to a side-effect-free target', async () => { + writeFileSync( + join(workspaceRoot, 'apps/app/src/main.ts'), + "import { unusedValue } from '@fixtures/side-effect-free';\nconsole.log('hello');\n" + ); + + const deps: RawDependency[] = [ + { + source: 'app', + target: 'side-effect-free', + type: 'static', + sourceFile: 'apps/app/src/main.ts', + }, + ]; + + const narrowed = await narrowDependencies( + deps, + mockContext(workspaceRoot), + normalizeDependencyNarrowingOptions({ mode: 'semantic' }) + ); + + expect(narrowed).toHaveLength(0); + }); + + it('reads dependency narrowing options from the @nx/js plugin config', () => { + expect( + getJsPluginDependencyNarrowingOptions({ + pluginsConfig: { + '@nx/js': { + dependencyNarrowing: { + debug: true, + affectedNarrowing: false, + }, + }, + }, + } as CreateDependenciesContext['nxJsonConfiguration']) + ).toMatchObject({ + debug: true, + affectedNarrowing: false, + concurrency: 50, + respectSideEffects: true, + removeTypeOnlyEdges: true, + fallbackToStaticGraph: true, + }); + }); + + it('normalizes explicit concurrency values', () => { + expect( + normalizeDependencyNarrowingOptions({ concurrency: 7 }).concurrency + ).toBe(7); + expect( + normalizeDependencyNarrowingOptions({ concurrency: 0 }).concurrency + ).toBe(50); + expect( + normalizeDependencyNarrowingOptions({ concurrency: 3.9 }).concurrency + ).toBe(3); + }); +}); + +function mockContext(workspaceRoot: string): CreateDependenciesContext { + return { + workspaceRoot, + externalNodes: {}, + nxJsonConfiguration: {}, + fileMap: { + projectFileMap: {}, + nonProjectFiles: [], + }, + filesToProcess: { + projectFileMap: {}, + nonProjectFiles: [], + }, + projects: { + app: { + name: 'app', + root: 'apps/app', + sourceRoot: 'apps/app/src', + projectType: 'application', + targets: { + build: { executor: '@nx/esbuild:esbuild' }, + }, + }, + 'side-effect-free': { + name: 'side-effect-free', + root: 'libs/side-effect-free', + sourceRoot: 'libs/side-effect-free/src', + projectType: 'library', + targets: {}, + }, + }, + } as CreateDependenciesContext; +} diff --git a/packages/nx/src/plugins/js/project-graph/narrow-dependencies.ts b/packages/nx/src/plugins/js/project-graph/narrow-dependencies.ts new file mode 100644 index 00000000000..313135ca8d5 --- /dev/null +++ b/packages/nx/src/plugins/js/project-graph/narrow-dependencies.ts @@ -0,0 +1,12 @@ +import type { CreateDependenciesContext } from '@nx/devkit'; +import { DependencyNarrower } from './narrow-dependencies/dependency-narrower'; +import type { NormalizedOptions } from './narrowing-options'; +import type { RawDependency } from './types'; + +export async function narrowDependencies( + dependencies: RawDependency[], + context: CreateDependenciesContext, + options: NormalizedOptions +): Promise { + return new DependencyNarrower(context, options).narrow(dependencies); +} diff --git a/packages/nx/src/plugins/js/project-graph/narrow-dependencies/affected-dependency-analyzer.ts b/packages/nx/src/plugins/js/project-graph/narrow-dependencies/affected-dependency-analyzer.ts new file mode 100644 index 00000000000..ced3d114266 --- /dev/null +++ b/packages/nx/src/plugins/js/project-graph/narrow-dependencies/affected-dependency-analyzer.ts @@ -0,0 +1,220 @@ +import { execFile } from 'node:child_process'; +import { join } from 'node:path'; +import { promisify } from 'node:util'; +import type { CreateDependenciesContext } from '@nx/devkit'; +import type { NormalizedOptions } from '../narrowing-options'; +import type { RawDependency, TargetMatchData } from '../types'; +import { ExportGraphResolver } from './export-graph-resolver'; +import { FileCache } from './file-cache'; +import { ImportUsageAnalyzer } from './import-usage-analyzer'; +import { TaskRunner } from './task-runner'; + +const execFileAsync = promisify(execFile); + +export class AffectedDependencyAnalyzer { + constructor( + private readonly workspaceRoot: string, + private readonly cache: FileCache, + private readonly importUsageAnalyzer: ImportUsageAnalyzer, + private readonly exportGraphResolver: ExportGraphResolver, + private readonly taskRunner: TaskRunner + ) {} + + async applyAffectedNarrowing( + keptEdges: RawDependency[], + context: CreateDependenciesContext, + targetMatchMap: Map, + options: NormalizedOptions + ): Promise { + const base = process.env['NX_BASE']; + if (!base) { + return keptEdges; + } + + const head = process.env['NX_HEAD'] ?? ''; + const changedFiles = await this.getGitDiffFiles(base, head); + if (changedFiles.length === 0) { + return keptEdges; + } + + const touchedProjects = this.mapFilesToProjects(changedFiles, context.projects); + if (touchedProjects.size === 0) { + return keptEdges; + } + + const changedFilesByProject = new Map>(); + for (const [projectName, projectFiles] of touchedProjects) { + changedFilesByProject.set(projectName, new Set(projectFiles)); + } + + const importedNamesCache = new Map>>(); + const originMapEntries = await this.taskRunner.map( + [...touchedProjects.keys()], + async (projectName) => { + const targetMatch = targetMatchMap.get(projectName); + if (!targetMatch) { + return; + } + + const entryPath = await this.exportGraphResolver.findEntryPoint( + targetMatch.root + ); + if (!entryPath) { + return; + } + + const originMap = await this.exportGraphResolver.resolveExportOriginsFromFile( + entryPath, + new Set() + ); + if (originMap.size === 0) { + return; + } + + return { projectName, originMap } as const; + } + ); + + const exportOriginMaps = new Map>>(); + for (const entry of originMapEntries) { + if (entry) { + exportOriginMaps.set(entry.projectName, entry.originMap); + } + } + + const edgesToCheck: RawDependency[] = []; + const unconditionallyKept: RawDependency[] = []; + + for (const edge of keptEdges) { + if (edge.type === 'static' && touchedProjects.has(edge.target)) { + edgesToCheck.push(edge); + } else { + unconditionallyKept.push(edge); + } + } + + const decisions = await this.taskRunner.map(edgesToCheck, async (edge) => { + const changedFilesInTarget = changedFilesByProject.get(edge.target); + if (!changedFilesInTarget || changedFilesInTarget.size === 0) { + return { edge, keep: true }; + } + + const targetMatch = targetMatchMap.get(edge.target); + if (!targetMatch || !edge.sourceFile) { + return { edge, keep: true }; + } + + const sourceFilePath = join(this.workspaceRoot, edge.sourceFile); + if (!(await this.cache.exists(sourceFilePath))) { + return { edge, keep: true }; + } + + const importedNames = await this.importUsageAnalyzer.getImportedNamesForEdgeSourceTarget( + sourceFilePath, + targetMatch, + options.resolveNamespaceImports, + importedNamesCache + ); + + if (importedNames.has('*') || importedNames.size === 0) { + return { edge, keep: true }; + } + + const exportOriginMap = exportOriginMaps.get(edge.target); + if (!exportOriginMap) { + return { edge, keep: true }; + } + + for (const symbolName of importedNames) { + const originFiles = exportOriginMap.get(symbolName); + if (!originFiles) { + return { edge, keep: true }; + } + + for (const originFile of originFiles) { + if (changedFilesInTarget.has(originFile)) { + return { edge, keep: true }; + } + } + } + + return { edge, keep: false }; + }); + + const result = [...unconditionallyKept]; + for (const decision of decisions) { + if (decision.keep) { + result.push(decision.edge); + } + } + + return result; + } + + private async getGitDiffFiles(base: string, head: string): Promise { + try { + const resolvedBase = head ? await this.resolveMergeBase(base, head) : base; + const args = ['diff', '--name-only', '--no-renames', '--relative', resolvedBase]; + if (head) { + args.push(head); + } + + const { stdout } = await execFileAsync('git', args, { + cwd: this.workspaceRoot, + maxBuffer: 10 * 1024 * 1024, + }); + + return stdout + .trim() + .split('\n') + .filter((line) => line.length > 0); + } catch { + return []; + } + } + + private mapFilesToProjects( + changedFiles: string[], + projects: CreateDependenciesContext['projects'] + ): Map { + const result = new Map(); + const sortedProjects = Object.entries(projects).sort( + ([, left], [, right]) => right.root.length - left.root.length + ); + + for (const file of changedFiles) { + const normalized = file.split('\\').join('/'); + for (const [projectName, project] of sortedProjects) { + const root = project.root.split('\\').join('/'); + if (normalized.startsWith(root + '/') || normalized === root) { + const existing = result.get(projectName); + if (existing) { + existing.push(normalized); + } else { + result.set(projectName, [normalized]); + } + break; + } + } + } + + return result; + } + + private async resolveMergeBase(base: string, head: string): Promise { + try { + const { stdout } = await execFileAsync( + 'git', + ['merge-base', '--fork-point', base, head], + { + cwd: this.workspaceRoot, + maxBuffer: 10 * 1024 * 1024, + } + ); + + return stdout.trim() || base; + } catch { + return base; + } + } +} \ No newline at end of file diff --git a/packages/nx/src/plugins/js/project-graph/narrow-dependencies/dependency-narrower.ts b/packages/nx/src/plugins/js/project-graph/narrow-dependencies/dependency-narrower.ts new file mode 100644 index 00000000000..db2c52dc8d8 --- /dev/null +++ b/packages/nx/src/plugins/js/project-graph/narrow-dependencies/dependency-narrower.ts @@ -0,0 +1,298 @@ +import { join } from 'node:path'; +import type { CreateDependenciesContext } from '@nx/devkit'; +import { detectBundlersForProject } from '../bundlers'; +import type { NormalizedOptions } from '../narrowing-options'; +import type { RawDependency, TargetMatchData } from '../types'; +import { AffectedDependencyAnalyzer } from './affected-dependency-analyzer'; +import { ExportGraphResolver } from './export-graph-resolver'; +import { FileCache } from './file-cache'; +import { ImportUsageAnalyzer } from './import-usage-analyzer'; +import { TargetMatchResolver } from './target-match-resolver'; +import { TaskRunner } from './task-runner'; + +type ReexportEdge = { + dep: RawDependency; + reexportedNames: string[]; + isStarReexport: boolean; + targetMatch: TargetMatchData; +}; + +type Phase1Result = { + dep: RawDependency; + keep: boolean | 'reexport-pending'; + reexport?: ReexportEdge; +}; + +export class DependencyNarrower { + private readonly cache: FileCache; + private readonly taskRunner: TaskRunner; + private readonly targetMatchResolver: TargetMatchResolver; + private readonly exportGraphResolver: ExportGraphResolver; + private readonly importUsageAnalyzer: ImportUsageAnalyzer; + private readonly affectedDependencyAnalyzer: AffectedDependencyAnalyzer; + + constructor( + private readonly context: CreateDependenciesContext, + private readonly options: NormalizedOptions + ) { + this.cache = new FileCache(); + this.taskRunner = new TaskRunner(this.options.concurrency); + this.targetMatchResolver = new TargetMatchResolver(this.context.workspaceRoot); + this.exportGraphResolver = new ExportGraphResolver( + this.context.workspaceRoot, + this.cache + ); + this.importUsageAnalyzer = new ImportUsageAnalyzer( + this.cache, + this.exportGraphResolver + ); + this.affectedDependencyAnalyzer = new AffectedDependencyAnalyzer( + this.context.workspaceRoot, + this.cache, + this.importUsageAnalyzer, + this.exportGraphResolver, + this.taskRunner + ); + } + + async narrow(dependencies: RawDependency[]): Promise { + const targetMatchMap = await this.targetMatchResolver.buildTargetMatchMap( + this.context, + this.options + ); + + const phase1Results = await this.taskRunner.map( + dependencies, + async (dep): Promise => { + if (dep.type !== 'static' || !dep.sourceFile) { + return { dep, keep: true }; + } + + if (dep.sourceFile.endsWith('package.json')) { + return { dep, keep: false }; + } + + const targetMatch = targetMatchMap.get(dep.target); + if (!targetMatch) { + return { dep, keep: true }; + } + + if (this.options.respectSideEffects && targetMatch.sideEffects) { + return { dep, keep: true }; + } + + const sourceProject = this.context.projects[dep.source]; + if (!sourceProject) { + return { dep, keep: true }; + } + + const sourceFilePath = join(this.context.workspaceRoot, dep.sourceFile); + if (!(await this.cache.exists(sourceFilePath))) { + return { dep, keep: true }; + } + + const { ast: sourceAst, text: sourceText } = + await this.cache.getOrParse(sourceFilePath); + + const importInsight = await this.importUsageAnalyzer.analyzeImportsForTarget({ + sourceAst, + sourceText, + sourceFilePath, + targetMatch, + removeTypeOnlyEdges: this.options.removeTypeOnlyEdges, + resolveNamespaceImports: this.options.resolveNamespaceImports, + }); + + if (!importInsight.matched || importInsight.hasDynamicImport) { + return { dep, keep: true }; + } + + if (this.options.mode === 'aggressive') { + const bundlers = detectBundlersForProject(sourceProject); + if (bundlers.length === 0 && !this.options.fallbackToStaticGraph) { + return { dep, keep: true }; + } + } + + if (importInsight.onlyReexports) { + return { + dep, + keep: 'reexport-pending', + reexport: { + dep, + reexportedNames: importInsight.reexportedNames, + isStarReexport: importInsight.isStarReexport, + targetMatch, + }, + }; + } + + return { dep, keep: !importInsight.removable }; + } + ); + + const reexportEdges = phase1Results + .map((result) => result.reexport) + .filter((result): result is ReexportEdge => result !== undefined); + + const consumerImports = await this.buildConsumerImports( + dependencies, + reexportEdges, + targetMatchMap + ); + const reexportDecisions = await this.resolveReexportEdges( + reexportEdges, + dependencies, + consumerImports, + targetMatchMap + ); + + const keptEdges = phase1Results + .filter(({ dep, keep }) => { + if (keep === 'reexport-pending') { + return reexportDecisions.get(dep) ?? true; + } + + return keep; + }) + .map(({ dep }) => dep); + + if (!this.options.affectedNarrowing) { + return keptEdges; + } + + return this.affectedDependencyAnalyzer.applyAffectedNarrowing( + keptEdges, + this.context, + targetMatchMap, + this.options + ); + } + + private async buildConsumerImports( + dependencies: RawDependency[], + reexportEdges: ReexportEdge[], + targetMatchMap: Map + ): Promise>>> { + const consumerImports = new Map>>(); + + if (reexportEdges.length === 0) { + return consumerImports; + } + + const consumerResults = await this.taskRunner.map( + dependencies.filter( + (dep) => + dep.type === 'static' && + dep.sourceFile && + !dep.sourceFile.endsWith('package.json') + ), + async (dep) => { + const sourceFile = dep.sourceFile; + if (!sourceFile) { + return; + } + + const sourceFilePath = join(this.context.workspaceRoot, sourceFile); + if (!(await this.cache.exists(sourceFilePath))) { + return; + } + + const targetMatch = targetMatchMap.get(dep.target); + if (!targetMatch) { + return; + } + + const { ast: sourceAst } = await this.cache.getOrParse(sourceFilePath); + const importedNames = this.importUsageAnalyzer.collectImportedNamesForTarget( + sourceAst, + targetMatch, + this.options.resolveNamespaceImports + ); + + if (importedNames.size === 0) { + return; + } + + return { source: dep.source, target: dep.target, importedNames }; + } + ); + + for (const result of consumerResults) { + if (!result) { + continue; + } + + const projectMap = + consumerImports.get(result.source) ?? new Map>(); + consumerImports.set(result.source, projectMap); + + const names = projectMap.get(result.target) ?? new Set(); + projectMap.set(result.target, names); + + for (const name of result.importedNames) { + names.add(name); + } + } + + return consumerImports; + } + + private async resolveReexportEdges( + reexportEdges: ReexportEdge[], + allDependencies: RawDependency[], + consumerImports: Map>>, + targetMatchMap: Map + ): Promise> { + const decisions = new Map(); + if (reexportEdges.length === 0) { + return decisions; + } + + const consumersOf = new Map>(); + for (const dep of allDependencies) { + if (dep.type !== 'static') { + continue; + } + + const consumers = consumersOf.get(dep.target) ?? new Set(); + consumers.add(dep.source); + consumersOf.set(dep.target, consumers); + } + + const results = await this.taskRunner.map(reexportEdges, async (edge) => { + const consumers = consumersOf.get(edge.dep.source); + if (!consumers || consumers.size === 0) { + return { dep: edge.dep, keep: false }; + } + + const relevantSymbols = edge.isStarReexport + ? await this.exportGraphResolver.resolveTargetExportedSymbols( + edge.targetMatch, + targetMatchMap + ) + : new Set(edge.reexportedNames); + + if (!relevantSymbols) { + return { dep: edge.dep, keep: true }; + } + + return { + dep: edge.dep, + keep: this.exportGraphResolver.anyConsumerUsesSymbols( + edge.dep.source, + relevantSymbols, + consumersOf, + consumerImports, + new Set() + ), + }; + }); + + for (const result of results) { + decisions.set(result.dep, result.keep); + } + + return decisions; + } +} \ No newline at end of file diff --git a/packages/nx/src/plugins/js/project-graph/narrow-dependencies/export-graph-resolver.ts b/packages/nx/src/plugins/js/project-graph/narrow-dependencies/export-graph-resolver.ts new file mode 100644 index 00000000000..142b7672def --- /dev/null +++ b/packages/nx/src/plugins/js/project-graph/narrow-dependencies/export-graph-resolver.ts @@ -0,0 +1,477 @@ +import { join, relative, resolve, dirname } from 'node:path'; +import ts from 'typescript'; +import type { TargetMatchData } from '../types'; +import { FileCache } from './file-cache'; + +export class ExportGraphResolver { + constructor( + private readonly workspaceRoot: string, + private readonly cache: FileCache + ) {} + + async resolveRelativeImport( + sourceFilePath: string, + specifier: string + ): Promise { + const fromDir = dirname(sourceFilePath); + const baseCandidate = resolve(fromDir, specifier); + + const candidates = [ + baseCandidate, + `${baseCandidate}.ts`, + `${baseCandidate}.tsx`, + `${baseCandidate}.mts`, + `${baseCandidate}.cts`, + `${baseCandidate}.js`, + `${baseCandidate}.mjs`, + `${baseCandidate}.cjs`, + join(baseCandidate, 'index.ts'), + join(baseCandidate, 'index.tsx'), + join(baseCandidate, 'index.js'), + join(baseCandidate, 'index.mjs'), + join(baseCandidate, 'index.cjs'), + ]; + + const match = await this.findFirstExistingPath(candidates); + return match ? this.toWorkspacePath(match) : undefined; + } + + async findEntryPoint(projectRoot: string): Promise { + const entryPoints = [ + join(this.workspaceRoot, projectRoot, 'src', 'index.ts'), + join(this.workspaceRoot, projectRoot, 'src', 'index.tsx'), + join(this.workspaceRoot, projectRoot, 'src', 'index.js'), + join(this.workspaceRoot, projectRoot, 'src', 'index.mjs'), + join(this.workspaceRoot, projectRoot, 'index.ts'), + join(this.workspaceRoot, projectRoot, 'index.js'), + ]; + + return this.findFirstExistingPath(entryPoints); + } + + async resolveTargetExportedSymbols( + targetMatch: TargetMatchData, + targetMatchMap?: Map, + visited?: Set, + maxDepth = 10 + ): Promise | undefined> { + if (maxDepth <= 0) { + return undefined; + } + + const tracked = visited ?? new Set(); + if (tracked.has(targetMatch.target)) { + return new Set(); + } + tracked.add(targetMatch.target); + + const entryPath = await this.findEntryPoint(targetMatch.root); + if (!entryPath) { + return undefined; + } + + try { + return await this.resolveExportsFromFile( + entryPath, + targetMatchMap, + tracked, + maxDepth + ); + } catch { + return undefined; + } + } + + anyConsumerUsesSymbols( + project: string, + relevantSymbols: Set, + consumersOf: Map>, + consumerImports: Map>>, + visited: Set + ): boolean { + if (visited.has(project)) { + return false; + } + visited.add(project); + + const consumers = consumersOf.get(project); + if (!consumers || consumers.size === 0) { + return false; + } + + for (const consumer of consumers) { + const projectMap = consumerImports.get(consumer); + if (!projectMap) { + continue; + } + + const importedNames = projectMap.get(project); + if (!importedNames) { + continue; + } + + for (const name of importedNames) { + if (name !== '*' && relevantSymbols.has(name)) { + return true; + } + } + + if ( + importedNames.has('*') && + this.anyConsumerUsesSymbols( + consumer, + relevantSymbols, + consumersOf, + consumerImports, + visited + ) + ) { + return true; + } + } + + return false; + } + + async resolveExportOriginsFromFile( + filePath: string, + visited: Set + ): Promise>> { + const origins = new Map>(); + + if (visited.has(filePath) || !(await this.cache.exists(filePath))) { + return origins; + } + visited.add(filePath); + + const { ast: sourceAst } = await this.cache.getOrParse(filePath); + const workspaceRelative = relative(this.workspaceRoot, filePath) + .split('\\') + .join('/'); + + type NamedReexport = { + spec: string; + elements: ts.ExportSpecifier[]; + }; + + const starReexportSpecs: string[] = []; + const namedReexportSpecs: NamedReexport[] = []; + + for (const statement of sourceAst.statements) { + if (ts.isExportDeclaration(statement)) { + if (!statement.exportClause && statement.moduleSpecifier) { + const spec = statement.moduleSpecifier + .getText(sourceAst) + .slice(1, -1); + if (spec.startsWith('.')) { + starReexportSpecs.push(spec); + } + } else if ( + statement.exportClause && + ts.isNamedExports(statement.exportClause) + ) { + if (statement.moduleSpecifier) { + const spec = statement.moduleSpecifier + .getText(sourceAst) + .slice(1, -1); + if (spec.startsWith('.')) { + namedReexportSpecs.push({ + spec, + elements: [...statement.exportClause.elements], + }); + } else { + for (const element of statement.exportClause.elements) { + this.addOrigin(origins, element.name.text, workspaceRelative); + } + } + } else { + for (const element of statement.exportClause.elements) { + this.addOrigin(origins, element.name.text, workspaceRelative); + } + } + } + continue; + } + + if (this.hasExportModifier(statement)) { + const names = new Set(); + this.collectDeclaredNames(statement, names); + for (const name of names) { + this.addOrigin(origins, name, workspaceRelative); + } + } + + if (ts.isExportAssignment(statement)) { + this.addOrigin(origins, 'default', workspaceRelative); + } + } + + const namedReexports = await this.resolveReexportOrigins( + filePath, + namedReexportSpecs.map(({ spec }) => spec), + visited + ); + + for (const [index, { elements }] of namedReexportSpecs.entries()) { + const { resolvedPath, subOrigins } = namedReexports[index]; + if (!resolvedPath) { + continue; + } + + for (const element of elements) { + const originalName = element.propertyName?.text ?? element.name.text; + const exportName = element.name.text; + const sub = subOrigins.get(originalName); + + if (sub) { + this.addOriginSet(origins, exportName, sub); + } else { + this.addOrigin(origins, exportName, resolvedPath); + } + } + } + + const starReexports = await this.resolveReexportOrigins( + filePath, + starReexportSpecs, + visited + ); + + for (const { subOrigins } of starReexports) { + for (const [name, files] of subOrigins) { + this.addOriginSet(origins, name, files); + } + } + + return origins; + } + + private async resolveExportsFromFile( + filePath: string, + targetMatchMap: Map | undefined, + visited: Set, + depth: number + ): Promise | undefined> { + const { ast: sourceAst } = await this.cache.getOrParse(filePath); + const exported = new Set(); + const starExportStatements: ts.ExportDeclaration[] = []; + + for (const statement of sourceAst.statements) { + if (ts.isExportDeclaration(statement)) { + if (!statement.exportClause) { + starExportStatements.push(statement); + } else if (ts.isNamedExports(statement.exportClause)) { + for (const element of statement.exportClause.elements) { + exported.add(element.name.text); + } + } + continue; + } + + if (this.hasExportModifier(statement)) { + this.collectDeclaredNames(statement, exported); + } + } + + const starResults = await Promise.all( + starExportStatements.map((statement) => + this.resolveStarReexportSymbols( + statement, + filePath, + targetMatchMap, + new Set(visited), + depth + ) + ) + ); + + for (const resolved of starResults) { + if (!resolved) { + return undefined; + } + + for (const name of resolved) { + exported.add(name); + } + } + + return exported.size > 0 ? exported : undefined; + } + + private async resolveStarReexportSymbols( + statement: ts.ExportDeclaration, + currentFile: string, + targetMatchMap: Map | undefined, + visited: Set, + depth: number + ): Promise | undefined> { + if (!statement.moduleSpecifier) { + return undefined; + } + + const specifier = statement.moduleSpecifier + .getText(statement.getSourceFile()) + .slice(1, -1); + + if (specifier.startsWith('.')) { + const resolvedPath = await this.resolveRelativeImport( + currentFile, + specifier + ); + if (!resolvedPath) { + return undefined; + } + + const absolutePath = join(this.workspaceRoot, resolvedPath); + if (!(await this.cache.exists(absolutePath))) { + return undefined; + } + + return this.resolveExportsFromFile( + absolutePath, + targetMatchMap, + visited, + depth - 1 + ); + } + + if (targetMatchMap) { + const matchingEntry = [...targetMatchMap.values()].find( + (match) => + (match.packageName && specifier === match.packageName) || + match.aliases.some( + (alias) => specifier === alias || specifier.startsWith(alias) + ) + ); + + if (matchingEntry) { + return this.resolveTargetExportedSymbols( + matchingEntry, + targetMatchMap, + visited, + depth - 1 + ); + } + } + + return undefined; + } + + private toWorkspacePath(absolutePath: string): string { + const relativePath = absolutePath.replace(this.workspaceRoot, ''); + return relativePath.split(/[\\/]/).filter(Boolean).join('/'); + } + + private async findFirstExistingPath( + candidates: string[] + ): Promise { + const results = await Promise.all( + candidates.map((candidate) => this.cache.isFile(candidate)) + ); + const index = results.findIndex(Boolean); + + return index >= 0 ? candidates[index] : undefined; + } + + private async resolveReexportOrigins( + filePath: string, + specs: string[], + visited: Set + ): Promise< + Array<{ + resolvedPath: string | undefined; + subOrigins: Map>; + }> + > { + const resolvedPaths = await Promise.all( + specs.map((spec) => this.resolveRelativeImport(filePath, spec)) + ); + const subOrigins = await Promise.all( + resolvedPaths.map((resolvedPath) => + this.resolveOriginsForResolvedPath(resolvedPath, visited) + ) + ); + + return resolvedPaths.map((resolvedPath, index) => ({ + resolvedPath, + subOrigins: subOrigins[index], + })); + } + + private resolveOriginsForResolvedPath( + resolvedPath: string | undefined, + visited: Set + ): Promise>> { + if (!resolvedPath) { + return Promise.resolve(new Map>()); + } + + return this.resolveExportOriginsFromFile( + join(this.workspaceRoot, resolvedPath), + new Set(visited) + ); + } + + private collectDeclaredNames( + statement: ts.Statement, + into: Set + ): void { + if (ts.isFunctionDeclaration(statement) && statement.name) { + into.add(statement.name.text); + } else if (ts.isClassDeclaration(statement) && statement.name) { + into.add(statement.name.text); + } else if (ts.isVariableStatement(statement)) { + for (const declaration of statement.declarationList.declarations) { + if (ts.isIdentifier(declaration.name)) { + into.add(declaration.name.text); + } + } + } else if (ts.isEnumDeclaration(statement)) { + into.add(statement.name.text); + } else if (ts.isInterfaceDeclaration(statement)) { + into.add(statement.name.text); + } else if (ts.isTypeAliasDeclaration(statement)) { + into.add(statement.name.text); + } + } + + private hasExportModifier(node: ts.Statement): boolean { + const modifiers: readonly ts.Modifier[] | undefined = + (node as { modifiers?: readonly ts.Modifier[] }).modifiers ?? + (ts.canHaveModifiers?.(node) ? ts.getModifiers?.(node) : undefined); + + return ( + modifiers?.some( + (modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword + ) ?? false + ); + } + + private addOrigin( + map: Map>, + name: string, + file: string + ): void { + const existing = map.get(name); + if (existing) { + existing.add(file); + } else { + map.set(name, new Set([file])); + } + } + + private addOriginSet( + map: Map>, + name: string, + files: Set + ): void { + const existing = map.get(name); + if (existing) { + for (const file of files) { + existing.add(file); + } + } else { + map.set(name, new Set(files)); + } + } +} diff --git a/packages/nx/src/plugins/js/project-graph/narrow-dependencies/file-cache.ts b/packages/nx/src/plugins/js/project-graph/narrow-dependencies/file-cache.ts new file mode 100644 index 00000000000..2df6180ebbc --- /dev/null +++ b/packages/nx/src/plugins/js/project-graph/narrow-dependencies/file-cache.ts @@ -0,0 +1,97 @@ +import { access, readFile, stat } from 'node:fs/promises'; +import ts from 'typescript'; + +export type ParsedFile = { + text: string; + ast: ts.SourceFile; +}; + +const DEFAULT_MAX_PARSED_ENTRIES = 200; + +export class FileCache { + private readonly parsed = new Map>(); + private readonly existence = new Map>(); + private readonly files = new Map>(); + + constructor( + private readonly maxParsedEntries = DEFAULT_MAX_PARSED_ENTRIES + ) {} + + getOrParse(filePath: string): Promise { + const existing = this.parsed.get(filePath); + if (existing) { + this.touchParsedEntry(filePath, existing); + return existing; + } + + const promise = readFile(filePath, 'utf8').then((text) => { + const ast = ts.createSourceFile( + filePath, + text, + ts.ScriptTarget.Latest, + true, + scriptKindFromPath(filePath) + ); + return { text, ast }; + }); + this.touchParsedEntry(filePath, promise); + this.evictParsedEntries(); + return promise; + } + + exists(filePath: string): Promise { + const cached = this.existence.get(filePath); + if (cached !== undefined) return cached; + const promise = access(filePath).then( + () => true, + () => false + ); + this.existence.set(filePath, promise); + return promise; + } + + isFile(filePath: string): Promise { + const cached = this.files.get(filePath); + if (cached !== undefined) return cached; + const promise = stat(filePath).then( + (fileStat) => fileStat.isFile(), + () => false + ); + this.files.set(filePath, promise); + return promise; + } + + private touchParsedEntry( + filePath: string, + parsedFile: Promise + ): void { + this.parsed.delete(filePath); + this.parsed.set(filePath, parsedFile); + } + + private evictParsedEntries(): void { + while (this.parsed.size > this.maxParsedEntries) { + const oldestPath = this.parsed.keys().next().value; + if (oldestPath === undefined) { + break; + } + this.parsed.delete(oldestPath); + } + } +} + +function scriptKindFromPath(filePath: string): ts.ScriptKind { + if (filePath.endsWith('.tsx')) { + return ts.ScriptKind.TSX; + } + if (filePath.endsWith('.jsx')) { + return ts.ScriptKind.JSX; + } + if (filePath.endsWith('.mts')) { + return ts.ScriptKind.TS; + } + if (filePath.endsWith('.cts')) { + return ts.ScriptKind.TS; + } + return ts.ScriptKind.TS; +} diff --git a/packages/nx/src/plugins/js/project-graph/narrow-dependencies/import-usage-analyzer.ts b/packages/nx/src/plugins/js/project-graph/narrow-dependencies/import-usage-analyzer.ts new file mode 100644 index 00000000000..4fecfcd9bf8 --- /dev/null +++ b/packages/nx/src/plugins/js/project-graph/narrow-dependencies/import-usage-analyzer.ts @@ -0,0 +1,568 @@ +import ts from 'typescript'; +import type { ImportInsight, TargetMatchData } from '../types'; +import { FileCache } from './file-cache'; +import { ExportGraphResolver } from './export-graph-resolver'; + +type AnalyzeImportsParams = { + sourceAst: ts.SourceFile; + sourceText: string; + sourceFilePath: string; + targetMatch: TargetMatchData; + removeTypeOnlyEdges: boolean; + resolveNamespaceImports: boolean; +}; + +export class ImportUsageAnalyzer { + constructor( + private readonly cache: FileCache, + private readonly exportGraphResolver: ExportGraphResolver + ) {} + + async analyzeImportsForTarget( + params: AnalyzeImportsParams + ): Promise { + const relativeSpecifiers = new Set( + this.collectRelativeModuleStatements(params.sourceAst).map( + ({ specifier }) => specifier + ) + ); + + ts.forEachChild(params.sourceAst, function collectDynamic(node) { + if ( + ts.isCallExpression(node) && + node.expression.kind === ts.SyntaxKind.ImportKeyword && + node.arguments.length === 1 && + ts.isStringLiteral(node.arguments[0]) + ) { + const specifier = node.arguments[0].text; + if (specifier.startsWith('.')) { + relativeSpecifiers.add(specifier); + } + } + + ts.forEachChild(node, collectDynamic); + }); + + const resolvedRelativeImports = new Map(); + if (relativeSpecifiers.size > 0) { + const specifiers = [...relativeSpecifiers]; + const resolved = await Promise.all( + specifiers.map((specifier) => + this.exportGraphResolver.resolveRelativeImport( + params.sourceFilePath, + specifier + ) + ) + ); + + specifiers.forEach((specifier, index) => { + resolvedRelativeImports.set(specifier, resolved[index]); + }); + } + + let matched = false; + let hasRetainedUsage = false; + let hasDynamicImport = false; + let hasReexport = false; + let isStarReexport = false; + const reexportedNames: string[] = []; + let namespaceAccessedProps: Set | undefined; + const deferredUsageNames: string[] = []; + + for (const statement of params.sourceAst.statements) { + if (ts.isImportDeclaration(statement)) { + const specifier = statement.moduleSpecifier + .getText(params.sourceAst) + .slice(1, -1); + if ( + !this.matchesTargetImport( + specifier, + params.targetMatch, + resolvedRelativeImports + ) + ) { + continue; + } + + matched = true; + + if (!statement.importClause) { + hasRetainedUsage = true; + continue; + } + + if (statement.importClause.isTypeOnly) { + if (!params.removeTypeOnlyEdges) { + hasRetainedUsage = true; + } + continue; + } + + if ( + statement.importClause.namedBindings && + ts.isNamespaceImport(statement.importClause.namedBindings) + ) { + if (!params.resolveNamespaceImports) { + hasRetainedUsage = true; + continue; + } + + const namespaceName = statement.importClause.namedBindings.name.text; + const accessed = this.collectNamespacePropertyAccesses( + params.sourceAst, + namespaceName + ); + + if (accessed === undefined) { + hasRetainedUsage = true; + } else if (accessed.size > 0) { + namespaceAccessedProps = accessed; + hasRetainedUsage = true; + } + continue; + } + + const localNames = this.collectRuntimeImportedNames( + statement.importClause + ); + if (localNames.length === 0) { + hasRetainedUsage = true; + continue; + } + + deferredUsageNames.push(...localNames); + continue; + } + + if (ts.isExportDeclaration(statement) && statement.moduleSpecifier) { + const specifier = statement.moduleSpecifier + .getText(params.sourceAst) + .slice(1, -1); + + if ( + this.matchesTargetImport( + specifier, + params.targetMatch, + resolvedRelativeImports + ) + ) { + matched = true; + hasReexport = true; + + if (!statement.exportClause) { + isStarReexport = true; + } else if (ts.isNamedExports(statement.exportClause)) { + for (const element of statement.exportClause.elements) { + if (!element.isTypeOnly) { + reexportedNames.push(element.name.text); + } + } + } + } + } + } + + const matchesTargetImmediate = (specifier: string) => + this.matchesTargetImport( + specifier, + params.targetMatch, + resolvedRelativeImports + ); + + ts.forEachChild(params.sourceAst, function visit(node) { + if ( + ts.isCallExpression(node) && + node.expression.kind === ts.SyntaxKind.ImportKeyword && + node.arguments.length === 1 && + ts.isStringLiteral(node.arguments[0]) + ) { + const specifier = node.arguments[0].text; + if (matchesTargetImmediate(specifier)) { + matched = true; + hasRetainedUsage = true; + hasDynamicImport = true; + } + } + + ts.forEachChild(node, visit); + }); + + if (deferredUsageNames.length > 0 && !hasRetainedUsage) { + const counts = this.countIdentifierOccurrencesBatch( + params.sourceText, + deferredUsageNames + ); + const allUnused = deferredUsageNames.every( + (name) => (counts.get(name) ?? 0) <= 1 + ); + + if (!allUnused) { + hasRetainedUsage = true; + } + } + + return { + matched, + removable: matched && !hasRetainedUsage && !hasReexport, + hasDynamicImport, + onlyReexports: hasReexport && !hasRetainedUsage && !hasDynamicImport, + reexportedNames, + isStarReexport, + namespaceAccessedProps, + }; + } + + collectImportedNamesForTarget( + sourceAst: ts.SourceFile, + targetMatch: TargetMatchData, + resolveNamespaceImports = false + ): Set { + const names = new Set(); + + this.forEachMatchedModuleStatement( + sourceAst, + (specifier) => this.matchesAlias(specifier, targetMatch), + (statement) => { + this.collectImportedNamesFromMatchedStatement( + names, + statement, + sourceAst, + resolveNamespaceImports + ); + } + ); + + return names; + } + + async getImportedNamesForEdgeSourceTarget( + sourceFilePath: string, + targetMatch: TargetMatchData, + resolveNamespaceImports: boolean, + importedNamesCache: Map>> + ): Promise> { + const cacheKey = `${sourceFilePath}::${targetMatch.target}::${ + resolveNamespaceImports ? '1' : '0' + }`; + const existing = importedNamesCache.get(cacheKey); + if (existing) { + return existing; + } + + const pending = (async () => { + const { ast: sourceAst } = await this.cache.getOrParse(sourceFilePath); + let importedNames = this.collectImportedNamesForTarget( + sourceAst, + targetMatch, + resolveNamespaceImports + ); + + if (importedNames.size === 0) { + importedNames = await this.collectImportedNamesViaRelativePaths( + sourceAst, + sourceFilePath, + targetMatch, + resolveNamespaceImports + ); + } + + return importedNames; + })(); + + importedNamesCache.set(cacheKey, pending); + return pending; + } + + private extractModuleSpecifier( + statement: ts.Statement, + sourceFile: ts.SourceFile + ): string | undefined { + if (ts.isImportDeclaration(statement)) { + return statement.moduleSpecifier.getText(sourceFile).slice(1, -1); + } + if (ts.isExportDeclaration(statement) && statement.moduleSpecifier) { + return statement.moduleSpecifier.getText(sourceFile).slice(1, -1); + } + return undefined; + } + + private matchesTargetImport( + specifier: string, + targetMatch: TargetMatchData, + resolvedRelativeImports: Map + ): boolean { + if (targetMatch.packageName && specifier === targetMatch.packageName) { + return true; + } + + if (targetMatch.aliases.includes(specifier)) { + return true; + } + + if (specifier.startsWith('.')) { + const resolvedImport = resolvedRelativeImports.get(specifier); + if (resolvedImport?.startsWith(targetMatch.root)) { + return true; + } + } + + return false; + } + + private matchesAlias( + specifier: string, + targetMatch: TargetMatchData + ): boolean { + return ( + (!!targetMatch.packageName && specifier === targetMatch.packageName) || + targetMatch.aliases.some( + (alias) => specifier === alias || specifier.startsWith(alias) + ) + ); + } + + private collectNamespacePropertyAccesses( + sourceAst: ts.SourceFile, + namespaceName: string + ): Set | undefined { + const props = new Set(); + let unsafeUsage = false; + + const visit = (node: ts.Node): void => { + if (unsafeUsage) { + return; + } + + if (ts.isIdentifier(node) && node.text === namespaceName) { + const parent = node.parent; + + if ( + parent && + ts.isPropertyAccessExpression(parent) && + parent.expression === node + ) { + props.add(parent.name.text); + return; + } + + if ( + parent && + ts.isElementAccessExpression(parent) && + parent.expression === node && + ts.isStringLiteral(parent.argumentExpression) + ) { + props.add(parent.argumentExpression.text); + return; + } + + if (parent && ts.isNamespaceImport(parent)) { + return; + } + + unsafeUsage = true; + return; + } + + ts.forEachChild(node, visit); + }; + + ts.forEachChild(sourceAst, visit); + return unsafeUsage ? undefined : props; + } + + private collectRuntimeImportedNames(importClause: ts.ImportClause): string[] { + const names: string[] = []; + + if (importClause.name) { + names.push(importClause.name.text); + } + + if ( + importClause.namedBindings && + ts.isNamedImports(importClause.namedBindings) + ) { + for (const element of importClause.namedBindings.elements) { + if (!element.isTypeOnly) { + names.push(element.name.text); + } + } + } + + return names; + } + + private countIdentifierOccurrencesBatch( + sourceText: string, + identifiers: string[] + ): Map { + const counts = new Map(); + if (identifiers.length === 0) { + return counts; + } + + for (const identifier of identifiers) { + counts.set(identifier, 0); + } + + const escaped = identifiers.map((identifier) => + identifier.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ); + const matcher = new RegExp(`\\b(?:${escaped.join('|')})\\b`, 'g'); + + let match: RegExpExecArray | null; + while ((match = matcher.exec(sourceText)) !== null) { + const current = counts.get(match[0]); + if (current !== undefined) { + counts.set(match[0], current + 1); + } + } + + return counts; + } + + private async collectImportedNamesViaRelativePaths( + sourceAst: ts.SourceFile, + sourceFilePath: string, + targetMatch: TargetMatchData, + resolveNamespaceImports: boolean + ): Promise> { + const names = new Set(); + const relativeSpecs = this.collectRelativeModuleStatements(sourceAst); + + if (relativeSpecs.length === 0) { + return names; + } + + const resolved = await Promise.all( + relativeSpecs.map(({ specifier }) => + this.exportGraphResolver.resolveRelativeImport( + sourceFilePath, + specifier + ) + ) + ); + const normalizedRoot = targetMatch.root.split('\\').join('/'); + + for (let index = 0; index < relativeSpecs.length; index += 1) { + const workspaceRelative = resolved[index]; + if ( + !workspaceRelative || + !workspaceRelative.startsWith(normalizedRoot + '/') + ) { + continue; + } + + this.collectImportedNamesFromMatchedStatement( + names, + relativeSpecs[index].statement, + sourceAst, + resolveNamespaceImports + ); + } + + return names; + } + + private collectRelativeModuleStatements( + sourceAst: ts.SourceFile + ): Array<{ specifier: string; statement: ts.Statement }> { + const relativeStatements: Array<{ + specifier: string; + statement: ts.Statement; + }> = []; + + this.forEachModuleStatement(sourceAst, (statement, specifier) => { + if (specifier.startsWith('.')) { + relativeStatements.push({ specifier, statement }); + } + }); + + return relativeStatements; + } + + private forEachMatchedModuleStatement( + sourceAst: ts.SourceFile, + matches: (specifier: string) => boolean, + visitor: (statement: ts.Statement) => void + ): void { + this.forEachModuleStatement(sourceAst, (statement, specifier) => { + if (matches(specifier)) { + visitor(statement); + } + }); + } + + private forEachModuleStatement( + sourceAst: ts.SourceFile, + visitor: (statement: ts.Statement, specifier: string) => void + ): void { + for (const statement of sourceAst.statements) { + const specifier = this.extractModuleSpecifier(statement, sourceAst); + if (specifier !== undefined) { + visitor(statement, specifier); + } + } + } + + private collectImportedNamesFromMatchedStatement( + names: Set, + statement: ts.Statement, + sourceAst: ts.SourceFile, + resolveNamespaceImports: boolean + ): void { + if (ts.isImportDeclaration(statement)) { + if (!statement.importClause) { + names.add('*'); + return; + } + + if ( + statement.importClause.namedBindings && + ts.isNamespaceImport(statement.importClause.namedBindings) + ) { + if (!resolveNamespaceImports) { + names.add('*'); + return; + } + + const namespaceName = statement.importClause.namedBindings.name.text; + const accessed = this.collectNamespacePropertyAccesses( + sourceAst, + namespaceName + ); + if (accessed === undefined || accessed.size === 0) { + names.add('*'); + } else { + for (const prop of accessed) { + names.add(prop); + } + } + return; + } + + if (statement.importClause.name) { + names.add('default'); + } + + if ( + statement.importClause.namedBindings && + ts.isNamedImports(statement.importClause.namedBindings) + ) { + for (const element of statement.importClause.namedBindings.elements) { + names.add(element.propertyName?.text ?? element.name.text); + } + } + return; + } + + if (ts.isExportDeclaration(statement) && statement.moduleSpecifier) { + if (!statement.exportClause) { + names.add('*'); + } else if (ts.isNamedExports(statement.exportClause)) { + for (const element of statement.exportClause.elements) { + names.add(element.propertyName?.text ?? element.name.text); + } + } + } + } +} diff --git a/packages/nx/src/plugins/js/project-graph/narrow-dependencies/target-match-resolver.ts b/packages/nx/src/plugins/js/project-graph/narrow-dependencies/target-match-resolver.ts new file mode 100644 index 00000000000..eda4c3b5e88 --- /dev/null +++ b/packages/nx/src/plugins/js/project-graph/narrow-dependencies/target-match-resolver.ts @@ -0,0 +1,117 @@ +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import type { CreateDependenciesContext } from '@nx/devkit'; +import type { NormalizedOptions } from '../narrowing-options'; +import type { TargetMatchData } from '../types'; + +export class TargetMatchResolver { + constructor(private readonly workspaceRoot: string) {} + + async buildTargetMatchMap( + context: CreateDependenciesContext, + options: NormalizedOptions + ): Promise> { + const tsConfigPaths = await this.readTsConfigPaths(); + + const entries = await Promise.all( + Object.entries(context.projects).map(async ([projectName, project]) => { + const normalizedRoot = project.root.split('\\').join('/'); + const [packageName, sideEffects] = await Promise.all([ + this.readPackageName(project.root), + this.readSideEffects( + project.root, + options.respectSideEffects, + options.treatMissingPackageJsonAsSideEffectFree + ), + ]); + + return [ + projectName, + { + target: projectName, + root: normalizedRoot, + packageName, + aliases: this.aliasesForProject(tsConfigPaths, normalizedRoot), + sideEffects, + }, + ] as [string, TargetMatchData]; + }) + ); + + return new Map(entries); + } + + private async readTsConfigPaths(): Promise> { + const tsConfigPath = join(this.workspaceRoot, 'tsconfig.base.json'); + + try { + const content = await readFile(tsConfigPath, 'utf8'); + const parsed = JSON.parse(content) as { + compilerOptions?: { paths?: Record }; + }; + + return parsed.compilerOptions?.paths ?? {}; + } catch { + return {}; + } + } + + private async readPackageName( + projectRoot: string + ): Promise { + const packageJsonPath = join(this.workspaceRoot, projectRoot, 'package.json'); + + try { + const content = await readFile(packageJsonPath, 'utf8'); + const parsed = JSON.parse(content) as { name?: string }; + return parsed.name; + } catch { + return undefined; + } + } + + private async readSideEffects( + projectRoot: string, + respectSideEffects: boolean, + treatMissingPackageJsonAsSideEffectFree: boolean + ): Promise { + if (!respectSideEffects) { + return false; + } + + const packageJsonPath = join(this.workspaceRoot, projectRoot, 'package.json'); + + let content: string; + try { + content = await readFile(packageJsonPath, 'utf8'); + } catch { + return !treatMissingPackageJsonAsSideEffectFree; + } + + try { + const parsed = JSON.parse(content) as { sideEffects?: boolean | string[] }; + return parsed.sideEffects !== false; + } catch { + return true; + } + } + + private aliasesForProject( + tsConfigPaths: Record, + projectRoot: string + ): string[] { + const aliases: string[] = []; + + for (const [alias, mappedPaths] of Object.entries(tsConfigPaths)) { + const belongsToProject = mappedPaths + .map((mappedPath) => mappedPath.split('\\').join('/').replace(/\*$/, '')) + .some((mappedPath) => mappedPath.startsWith(projectRoot)); + + if (belongsToProject) { + aliases.push(alias.replace(/\*$/, '')); + } + } + + return aliases; + } +} \ No newline at end of file diff --git a/packages/nx/src/plugins/js/project-graph/narrow-dependencies/task-runner.ts b/packages/nx/src/plugins/js/project-graph/narrow-dependencies/task-runner.ts new file mode 100644 index 00000000000..901aad1aec2 --- /dev/null +++ b/packages/nx/src/plugins/js/project-graph/narrow-dependencies/task-runner.ts @@ -0,0 +1,34 @@ +export class TaskRunner { + constructor(private readonly concurrency: number) {} + + async map( + items: readonly T[], + fn: (item: T, index: number) => Promise + ): Promise { + if (items.length === 0) { + return []; + } + + const results = new Array(items.length); + let nextIndex = 0; + + const worker = async () => { + while (true) { + const index = nextIndex++; + if (index >= items.length) { + return; + } + + results[index] = await fn(items[index], index); + } + }; + + await Promise.all( + Array.from({ length: Math.min(this.concurrency, items.length) }, () => + worker() + ) + ); + + return results; + } +} \ No newline at end of file diff --git a/packages/nx/src/plugins/js/project-graph/narrowing-options.ts b/packages/nx/src/plugins/js/project-graph/narrowing-options.ts new file mode 100644 index 00000000000..f80076bb19a --- /dev/null +++ b/packages/nx/src/plugins/js/project-graph/narrowing-options.ts @@ -0,0 +1,82 @@ +import type { NxJsonConfiguration } from '../../../config/nx-json'; +import type { BundlerKind } from './types'; + +export type DependencyNarrowingOptions = { + mode?: 'semantic' | 'strict' | 'aggressive'; + concurrency?: number; + respectSideEffects?: boolean; + removeTypeOnlyEdges?: boolean; + treatMissingPackageJsonAsSideEffectFree?: boolean; + resolveNamespaceImports?: boolean; + bundlerAdapters?: BundlerKind[]; + fallbackToStaticGraph?: boolean; + passthrough?: boolean; + affectedNarrowing?: boolean; + debug?: boolean; +}; + +export type NormalizedDependencyNarrowingOptions = { + mode: 'semantic' | 'strict' | 'aggressive'; + concurrency: number; + respectSideEffects: boolean; + removeTypeOnlyEdges: boolean; + treatMissingPackageJsonAsSideEffectFree: boolean; + resolveNamespaceImports: boolean; + bundlerAdapters: BundlerKind[]; + fallbackToStaticGraph: boolean; + passthrough: boolean; + affectedNarrowing: boolean; + debug: boolean; +}; + +const DEFAULT_OPTIONS: NormalizedDependencyNarrowingOptions = { + mode: 'semantic', + concurrency: 50, + respectSideEffects: true, + removeTypeOnlyEdges: true, + treatMissingPackageJsonAsSideEffectFree: false, + resolveNamespaceImports: false, + bundlerAdapters: ['esbuild', 'swc', 'babel', 'webpack', 'rollup', 'vite'], + fallbackToStaticGraph: true, + passthrough: false, + affectedNarrowing: true, + debug: false, +}; + +export type NormalizedOptions = NormalizedDependencyNarrowingOptions; + +export function normalizeDependencyNarrowingOptions( + options?: DependencyNarrowingOptions +): NormalizedDependencyNarrowingOptions { + return { + ...DEFAULT_OPTIONS, + ...options, + concurrency: normalizeConcurrency(options?.concurrency), + bundlerAdapters: + options?.bundlerAdapters && options.bundlerAdapters.length > 0 + ? options.bundlerAdapters + : DEFAULT_OPTIONS.bundlerAdapters, + }; +} + +function normalizeConcurrency(concurrency: number | undefined): number { + if (!Number.isFinite(concurrency) || concurrency === undefined) { + return DEFAULT_OPTIONS.concurrency; + } + + const normalized = Math.floor(concurrency); + return normalized > 0 ? normalized : DEFAULT_OPTIONS.concurrency; +} + +export function getJsPluginDependencyNarrowingOptions( + nxJson: NxJsonConfiguration | undefined +): NormalizedDependencyNarrowingOptions | undefined { + const dependencyNarrowing = + nxJson?.pluginsConfig?.['@nx/js']?.dependencyNarrowing; + + if (!dependencyNarrowing) { + return undefined; + } + + return normalizeDependencyNarrowingOptions(dependencyNarrowing); +} diff --git a/packages/nx/src/plugins/js/project-graph/types.ts b/packages/nx/src/plugins/js/project-graph/types.ts new file mode 100644 index 00000000000..069985a31fb --- /dev/null +++ b/packages/nx/src/plugins/js/project-graph/types.ts @@ -0,0 +1,29 @@ +import type { RawProjectGraphDependency } from '../../../project-graph/project-graph-builder'; + +export type RawDependency = RawProjectGraphDependency; + +export type BundlerKind = + | 'esbuild' + | 'swc' + | 'babel' + | 'webpack' + | 'rollup' + | 'vite'; + +export type ImportInsight = { + matched: boolean; + removable: boolean; + hasDynamicImport: boolean; + onlyReexports: boolean; + reexportedNames: string[]; + isStarReexport: boolean; + namespaceAccessedProps?: Set; +}; + +export type TargetMatchData = { + target: string; + root: string; + packageName?: string; + aliases: string[]; + sideEffects: boolean; +};