Skip to content

Latest commit

 

History

History
1646 lines (1308 loc) · 45.6 KB

File metadata and controls

1646 lines (1308 loc) · 45.6 KB

Refactoring Phase 4: Extract Project Analysis Functions

Overview

This document provides a detailed implementation guide for Phase 4 of the refactoring plan. Phase 4 focuses on extracting project analysis functions from generator.ts into a dedicated project-analysis/ directory.

Phase 4 Status: ✅ COMPLETE

Completion Date: 2025-10-14

Results:

  • 13 project analysis functions extracted
  • 170 unit tests created (100% passing)
  • 276 lines removed from generator.ts
  • 100% generator test pass rate (94/94 tests)
  • All tests passing with proper cache management

Goals

  • Extract all project analysis functions to project-analysis/ directory
  • Create comprehensive unit tests for each function
  • One function per file (or tightly related helper functions)
  • Update imports in generator.ts
  • Zero functional changes
  • Maintain all existing test coverage

Prerequisites

✅ Phase 1 must be complete:

  • constants/file-extensions.ts created
  • types/move-context.ts created
  • All Phase 1 tests passing

✅ Phase 2 must be complete:

  • cache/ directory with 6 cache functions
  • All Phase 2 tests passing

✅ Phase 3 must be complete:

  • path-utils/ directory with 9 path utility functions
  • All Phase 3 tests passing

Project Analysis Functions to Extract

Phase 4 extracts 13 project analysis functions:

  1. findProjectForFile - Finds which project contains a given file
  2. isProjectEmpty - Checks if a project only contains index files
  3. getDependentProjectNames - Gets all projects that depend on a given project
  4. deriveProjectDirectoryFromSource - Derives target directory from source file path
  5. getProjectImportPath - Gets the TypeScript import alias for a project
  6. readCompilerPaths - Reads TypeScript compiler path mappings from tsconfig
  7. getProjectEntryPointPaths - Gets all possible entry point paths for a project
  8. getFallbackEntryPointPaths - Gets fallback entry points when tsconfig paths unavailable
  9. pointsToProjectIndex - Checks if a path points to a project's index file
  10. isIndexFilePath - Pattern-based check for index file paths
  11. isWildcardAlias - Checks if an alias is a wildcard pattern
  12. buildReverseDependencyMap - Builds map of reverse dependencies from project graph
  13. toFirstPath - Normalizes a path mapping entry to its first string value

Risk Level

Medium Risk - These functions involve complex logic for:

  • Project structure analysis
  • TypeScript path mapping resolution
  • Dependency graph traversal
  • Index file detection

Tasks

Task 4.1: Create project-analysis/find-project-for-file.ts

File: packages/workspace/src/generators/move-file/project-analysis/find-project-for-file.ts

import { ProjectConfiguration } from '@nx/devkit';

/**
 * Finds the project that contains the given file path.
 *
 * Searches through all projects to find one whose sourceRoot or root
 * is an ancestor of the given file path.
 *
 * @param projects - Map of all projects in the workspace
 * @param filePath - The file path to find the project for
 * @returns Project configuration and name, or null if not found
 */
export function findProjectForFile(
  projects: Map<string, ProjectConfiguration>,
  filePath: string,
): { project: ProjectConfiguration; name: string } | null {
  const entry = Array.from(projects.entries()).find(([, project]) => {
    const projectRoot = project.root;
    const sourceRoot = project.sourceRoot || project.root;

    // Check if file is within project's source root or project root
    return (
      filePath.startsWith(sourceRoot + '/') ||
      filePath.startsWith(projectRoot + '/')
    );
  });

  return entry ? { project: entry[1], name: entry[0] } : null;
}

Task 4.2: Create project-analysis/find-project-for-file.spec.ts

File: packages/workspace/src/generators/move-file/project-analysis/find-project-for-file.spec.ts

import { ProjectConfiguration } from '@nx/devkit';
import { findProjectForFile } from './find-project-for-file';

describe('findProjectForFile', () => {
  let projects: Map<string, ProjectConfiguration>;

  beforeEach(() => {
    projects = new Map<string, ProjectConfiguration>([
      [
        'lib1',
        {
          root: 'packages/lib1',
          sourceRoot: 'packages/lib1/src',
          projectType: 'library',
        },
      ],
      [
        'lib2',
        {
          root: 'packages/lib2',
          sourceRoot: 'packages/lib2/src',
          projectType: 'library',
        },
      ],
      [
        'app1',
        {
          root: 'apps/app1',
          sourceRoot: 'apps/app1/src',
          projectType: 'application',
        },
      ],
    ]);
  });

  it('should find project by source root', () => {
    const result = findProjectForFile(
      projects,
      'packages/lib1/src/lib/file.ts',
    );

    expect(result).not.toBeNull();
    expect(result?.name).toBe('lib1');
    expect(result?.project.root).toBe('packages/lib1');
  });

  it('should find project by project root', () => {
    const result = findProjectForFile(projects, 'packages/lib1/README.md');

    expect(result).not.toBeNull();
    expect(result?.name).toBe('lib1');
  });

  it('should return null for file not in any project', () => {
    const result = findProjectForFile(projects, 'packages/lib3/src/file.ts');

    expect(result).toBeNull();
  });

  it('should return null for empty file path', () => {
    const result = findProjectForFile(projects, '');

    expect(result).toBeNull();
  });

  it('should distinguish between different projects', () => {
    const result1 = findProjectForFile(projects, 'packages/lib1/src/index.ts');
    const result2 = findProjectForFile(projects, 'packages/lib2/src/index.ts');

    expect(result1?.name).toBe('lib1');
    expect(result2?.name).toBe('lib2');
    expect(result1?.name).not.toBe(result2?.name);
  });

  it('should handle application projects', () => {
    const result = findProjectForFile(projects, 'apps/app1/src/main.ts');

    expect(result).not.toBeNull();
    expect(result?.name).toBe('app1');
    expect(result?.project.projectType).toBe('application');
  });

  it('should handle projects without sourceRoot', () => {
    projects.set('lib3', {
      root: 'packages/lib3',
      projectType: 'library',
    });

    const result = findProjectForFile(projects, 'packages/lib3/index.ts');

    expect(result).not.toBeNull();
    expect(result?.name).toBe('lib3');
  });

  it('should match sourceRoot before projectRoot', () => {
    // File in source root
    const result = findProjectForFile(
      projects,
      'packages/lib1/src/lib/file.ts',
    );

    expect(result?.name).toBe('lib1');
  });
});

Task 4.3: Create project-analysis/is-project-empty.ts

File: packages/workspace/src/generators/move-file/project-analysis/is-project-empty.ts

import {
  ProjectConfiguration,
  Tree,
  visitNotIgnoredFiles,
  normalizePath,
} from '@nx/devkit';
import { posix as path } from 'node:path';
import { getProjectEntryPointPaths } from './get-project-entry-point-paths';
import { primaryEntryFilenames } from '../constants/file-extensions';
import { hasSourceFileExtension } from '../path-utils/has-source-file-extension';

/**
 * Checks if a project is empty (contains only index/entry point files).
 *
 * A project is considered empty if it only contains its entry point file(s)
 * and no other source files.
 *
 * @param tree - The virtual file system tree
 * @param project - The project to check
 * @returns True if the project only contains index files
 */
export function isProjectEmpty(
  tree: Tree,
  project: ProjectConfiguration,
): boolean {
  const sourceRoot = project.sourceRoot || project.root;
  const indexCandidates = new Set(
    getProjectEntryPointPaths(tree, project).map((candidate) =>
      normalizePath(candidate),
    ),
  );

  if (indexCandidates.size === 0) {
    indexCandidates.add(
      normalizePath(path.join(sourceRoot, primaryEntryFilenames[0])),
    );
  }

  // Don't use cache for isProjectEmpty check as we need the current state
  let hasNonIndexSourceFiles = false;

  visitNotIgnoredFiles(tree, sourceRoot, (filePath) => {
    if (hasNonIndexSourceFiles) {
      return; // Short-circuit if we already found a non-index file
    }

    const normalizedFilePath = normalizePath(filePath);
    const isSourceFile = hasSourceFileExtension(normalizedFilePath);

    if (!isSourceFile) {
      return;
    }

    if (indexCandidates.has(normalizedFilePath)) {
      return;
    }

    hasNonIndexSourceFiles = true;
  });

  return !hasNonIndexSourceFiles;
}

Task 4.4: Create project-analysis/is-project-empty.spec.ts

File: packages/workspace/src/generators/move-file/project-analysis/is-project-empty.spec.ts

import { Tree, ProjectConfiguration } from '@nx/devkit';
import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing';
import { isProjectEmpty } from './is-project-empty';

describe('isProjectEmpty', () => {
  let tree: Tree;
  let project: ProjectConfiguration;

  beforeEach(() => {
    tree = createTreeWithEmptyWorkspace();
    project = {
      root: 'packages/lib1',
      sourceRoot: 'packages/lib1/src',
      projectType: 'library',
    };
  });

  it('should return true when project only has index.ts', () => {
    tree.write('packages/lib1/src/index.ts', 'export const lib1 = "lib1";');

    const result = isProjectEmpty(tree, project);

    expect(result).toBe(true);
  });

  it('should return true when project only has public-api.ts', () => {
    tree.write(
      'packages/lib1/src/public-api.ts',
      'export const lib1 = "lib1";',
    );

    const result = isProjectEmpty(tree, project);

    expect(result).toBe(true);
  });

  it('should return false when project has additional source files', () => {
    tree.write('packages/lib1/src/index.ts', 'export * from "./lib/file";');
    tree.write('packages/lib1/src/lib/file.ts', 'export const file = "file";');

    const result = isProjectEmpty(tree, project);

    expect(result).toBe(false);
  });

  it('should return true when project only has non-source files', () => {
    tree.write('packages/lib1/src/index.ts', 'export const lib1 = "lib1";');
    tree.write('packages/lib1/README.md', '# Lib1');
    tree.write('packages/lib1/package.json', '{}');

    const result = isProjectEmpty(tree, project);

    expect(result).toBe(true);
  });

  it('should return true for completely empty project', () => {
    const result = isProjectEmpty(tree, project);

    expect(result).toBe(true);
  });

  it('should handle projects with nested directories', () => {
    tree.write('packages/lib1/src/index.ts', 'export * from "./lib/utils";');
    tree.write(
      'packages/lib1/src/lib/utils/helper.ts',
      'export const helper = () => {};',
    );

    const result = isProjectEmpty(tree, project);

    expect(result).toBe(false);
  });

  it('should ignore test files when checking emptiness', () => {
    tree.write('packages/lib1/src/index.ts', 'export const lib1 = "lib1";');
    tree.write(
      'packages/lib1/src/index.spec.ts',
      'describe("lib1", () => {});',
    );

    const result = isProjectEmpty(tree, project);

    // Should still be considered empty as spec files are source files
    // but only if they're the only files besides index
    expect(result).toBe(false);
  });

  it('should handle projects without sourceRoot', () => {
    const projectWithoutSourceRoot: ProjectConfiguration = {
      root: 'packages/lib2',
      projectType: 'library',
    };

    tree.write('packages/lib2/index.ts', 'export const lib2 = "lib2";');

    const result = isProjectEmpty(tree, projectWithoutSourceRoot);

    expect(result).toBe(true);
  });
});

Task 4.5: Create project-analysis/get-dependent-project-names.ts

File: packages/workspace/src/generators/move-file/project-analysis/get-dependent-project-names.ts

import { ProjectGraph } from '@nx/devkit';
import { buildReverseDependencyMap } from './build-reverse-dependency-map';

/**
 * Gets all projects that depend on the given project (transitively).
 *
 * This function traverses the dependency graph to find all projects
 * that directly or indirectly depend on the specified project.
 *
 * @param projectGraph - The project dependency graph
 * @param projectName - Name of the project to find dependents for
 * @returns Array of dependent project names
 */
export function getDependentProjectNames(
  projectGraph: ProjectGraph,
  projectName: string,
): string[] {
  const reverseMap = buildReverseDependencyMap(projectGraph);
  const dependents = new Set<string>();
  const queue: string[] = [projectName];

  while (queue.length > 0) {
    const current = queue.shift();
    if (!current) {
      continue;
    }
    const directDependents = reverseMap.get(current);
    if (!directDependents) {
      continue;
    }

    directDependents.forEach((dependent) => {
      if (!dependents.has(dependent)) {
        dependents.add(dependent);
        queue.push(dependent);
      }
    });
  }

  dependents.delete(projectName);
  return Array.from(dependents);
}

Task 4.6: Create project-analysis/get-dependent-project-names.spec.ts

File: packages/workspace/src/generators/move-file/project-analysis/get-dependent-project-names.spec.ts

import { ProjectGraph } from '@nx/devkit';
import { getDependentProjectNames } from './get-dependent-project-names';

describe('getDependentProjectNames', () => {
  let projectGraph: ProjectGraph;

  beforeEach(() => {
    projectGraph = {
      nodes: {},
      dependencies: {
        lib1: [],
        lib2: [{ source: 'lib2', target: 'lib1', type: 'static' }],
        lib3: [{ source: 'lib3', target: 'lib2', type: 'static' }],
        app1: [
          { source: 'app1', target: 'lib2', type: 'static' },
          { source: 'app1', target: 'lib3', type: 'static' },
        ],
      },
    };
  });

  it('should find direct dependents', () => {
    const result = getDependentProjectNames(projectGraph, 'lib1');

    expect(result).toContain('lib2');
    expect(result.length).toBeGreaterThan(0);
  });

  it('should find transitive dependents', () => {
    const result = getDependentProjectNames(projectGraph, 'lib1');

    expect(result).toContain('lib2');
    expect(result).toContain('lib3');
  });

  it('should return empty array for project with no dependents', () => {
    const result = getDependentProjectNames(projectGraph, 'app1');

    expect(result).toEqual([]);
  });

  it('should not include the project itself', () => {
    const result = getDependentProjectNames(projectGraph, 'lib1');

    expect(result).not.toContain('lib1');
  });

  it('should handle cyclic dependencies gracefully', () => {
    const cyclicGraph: ProjectGraph = {
      nodes: {},
      dependencies: {
        lib1: [{ source: 'lib1', target: 'lib2', type: 'static' }],
        lib2: [{ source: 'lib2', target: 'lib1', type: 'static' }],
      },
    };

    const result = getDependentProjectNames(cyclicGraph, 'lib1');

    expect(result).toContain('lib2');
    expect(result).not.toContain('lib1');
  });

  it('should handle complex dependency chains', () => {
    const complexGraph: ProjectGraph = {
      nodes: {},
      dependencies: {
        base: [],
        mid1: [{ source: 'mid1', target: 'base', type: 'static' }],
        mid2: [{ source: 'mid2', target: 'base', type: 'static' }],
        top1: [
          { source: 'top1', target: 'mid1', type: 'static' },
          { source: 'top1', target: 'mid2', type: 'static' },
        ],
        top2: [{ source: 'top2', target: 'mid1', type: 'static' }],
      },
    };

    const result = getDependentProjectNames(complexGraph, 'base');

    expect(result).toContain('mid1');
    expect(result).toContain('mid2');
    expect(result).toContain('top1');
    expect(result).toContain('top2');
    expect(result.length).toBe(4);
  });

  it('should handle empty project graph', () => {
    const emptyGraph: ProjectGraph = {
      nodes: {},
      dependencies: {},
    };

    const result = getDependentProjectNames(emptyGraph, 'lib1');

    expect(result).toEqual([]);
  });
});

Task 4.7: Create project-analysis/derive-project-directory-from-source.ts

File: packages/workspace/src/generators/move-file/project-analysis/derive-project-directory-from-source.ts

import { ProjectConfiguration } from '@nx/devkit';
import { posix as path } from 'node:path';

/**
 * Derives the target project directory from the source file path.
 *
 * Extracts the directory structure relative to the project's base directory
 * (lib/ or app/). Returns undefined if the file is not in the expected structure.
 *
 * @param sourceFilePath - The source file path
 * @param sourceProject - The source project configuration
 * @returns The relative directory path, or undefined if not derivable
 */
export function deriveProjectDirectoryFromSource(
  sourceFilePath: string,
  sourceProject: ProjectConfiguration,
): string | undefined {
  const sourceRoot = sourceProject.sourceRoot || sourceProject.root;
  const baseDir = sourceProject.projectType === 'application' ? 'app' : 'lib';

  // Get the path relative to source root
  const relativeToSourceRoot = path.relative(sourceRoot, sourceFilePath);

  // Check if the file is within the base directory (lib or app)
  const baseDirPrefix = baseDir + '/';
  if (!relativeToSourceRoot.startsWith(baseDirPrefix)) {
    // File is not in the expected base directory, return undefined
    return undefined;
  }

  // Remove the base directory prefix
  const afterBaseDir = relativeToSourceRoot.substring(baseDirPrefix.length);

  // Get the directory part (without the filename)
  const dirPath = path.dirname(afterBaseDir);

  // If dirPath is '.' it means the file is directly in the base directory
  if (dirPath === '.') {
    return undefined;
  }

  return dirPath;
}

Task 4.8: Create project-analysis/derive-project-directory-from-source.spec.ts

File: packages/workspace/src/generators/move-file/project-analysis/derive-project-directory-from-source.spec.ts

import { ProjectConfiguration } from '@nx/devkit';
import { deriveProjectDirectoryFromSource } from './derive-project-directory-from-source';

describe('deriveProjectDirectoryFromSource', () => {
  let libraryProject: ProjectConfiguration;
  let appProject: ProjectConfiguration;

  beforeEach(() => {
    libraryProject = {
      root: 'packages/lib1',
      sourceRoot: 'packages/lib1/src',
      projectType: 'library',
    };

    appProject = {
      root: 'apps/app1',
      sourceRoot: 'apps/app1/src',
      projectType: 'application',
    };
  });

  describe('library projects', () => {
    it('should derive directory from nested lib path', () => {
      const result = deriveProjectDirectoryFromSource(
        'packages/lib1/src/lib/utils/helper.ts',
        libraryProject,
      );

      expect(result).toBe('utils');
    });

    it('should derive deeply nested directory', () => {
      const result = deriveProjectDirectoryFromSource(
        'packages/lib1/src/lib/features/auth/services/user.service.ts',
        libraryProject,
      );

      expect(result).toBe('features/auth/services');
    });

    it('should return undefined for file directly in lib/', () => {
      const result = deriveProjectDirectoryFromSource(
        'packages/lib1/src/lib/file.ts',
        libraryProject,
      );

      expect(result).toBeUndefined();
    });

    it('should return undefined for file not in lib/', () => {
      const result = deriveProjectDirectoryFromSource(
        'packages/lib1/src/index.ts',
        libraryProject,
      );

      expect(result).toBeUndefined();
    });

    it('should return undefined for file in different subdirectory', () => {
      const result = deriveProjectDirectoryFromSource(
        'packages/lib1/src/other/file.ts',
        libraryProject,
      );

      expect(result).toBeUndefined();
    });
  });

  describe('application projects', () => {
    it('should derive directory from nested app path', () => {
      const result = deriveProjectDirectoryFromSource(
        'apps/app1/src/app/components/button.tsx',
        appProject,
      );

      expect(result).toBe('components');
    });

    it('should derive deeply nested directory', () => {
      const result = deriveProjectDirectoryFromSource(
        'apps/app1/src/app/features/users/components/user-list.tsx',
        appProject,
      );

      expect(result).toBe('features/users/components');
    });

    it('should return undefined for file directly in app/', () => {
      const result = deriveProjectDirectoryFromSource(
        'apps/app1/src/app/main.ts',
        appProject,
      );

      expect(result).toBeUndefined();
    });

    it('should return undefined for file not in app/', () => {
      const result = deriveProjectDirectoryFromSource(
        'apps/app1/src/main.ts',
        appProject,
      );

      expect(result).toBeUndefined();
    });
  });

  describe('projects without sourceRoot', () => {
    it('should use root as sourceRoot fallback', () => {
      const projectWithoutSourceRoot: ProjectConfiguration = {
        root: 'packages/lib2',
        projectType: 'library',
      };

      const result = deriveProjectDirectoryFromSource(
        'packages/lib2/lib/utils/helper.ts',
        projectWithoutSourceRoot,
      );

      expect(result).toBe('utils');
    });
  });

  describe('edge cases', () => {
    it('should handle windows-style paths', () => {
      const result = deriveProjectDirectoryFromSource(
        'packages/lib1/src/lib/utils/helper.ts',
        libraryProject,
      );

      expect(result).toBe('utils');
    });

    it('should preserve directory case', () => {
      const result = deriveProjectDirectoryFromSource(
        'packages/lib1/src/lib/MyUtils/Helper.ts',
        libraryProject,
      );

      expect(result).toBe('MyUtils');
    });
  });
});

Task 4.9: Create project-analysis/get-project-import-path.ts

File: packages/workspace/src/generators/move-file/project-analysis/get-project-import-path.ts

import { Tree, ProjectConfiguration } from '@nx/devkit';
import { readCompilerPaths } from './read-compiler-paths';
import { toFirstPath } from './to-first-path';
import { pointsToProjectIndex } from './points-to-project-index';
import { isWildcardAlias } from './is-wildcard-alias';

/**
 * Resolves a wildcard alias to the project-specific alias string.
 *
 * @param alias - The alias key from tsconfig paths
 * @param sourceRoot - Source root of the project
 * @param projectName - Fallback project name when the directory name is missing
 * @returns The resolved alias string
 */
function resolveWildcardAlias(
  alias: string,
  sourceRoot: string,
  projectName: string,
): string {
  const projectDirName = sourceRoot.split('/').pop();
  return alias.replace(/\*/g, projectDirName || projectName);
}

/**
 * Gets the TypeScript import path for a project from tsconfig.base.json.
 *
 * Searches through the compiler path mappings to find the alias that
 * points to the project's entry point.
 *
 * @param tree - The virtual file system tree
 * @param projectName - Name of the project
 * @param project - The project configuration
 * @returns The import path alias, or null if not found
 */
export function getProjectImportPath(
  tree: Tree,
  projectName: string,
  project: ProjectConfiguration,
): string | null {
  const paths = readCompilerPaths(tree);
  if (!paths) {
    return null;
  }

  const sourceRoot = project.sourceRoot || project.root;

  for (const [alias, pathEntry] of Object.entries(paths)) {
    const pathStr = toFirstPath(pathEntry);
    if (!pathStr) {
      continue;
    }

    if (!pointsToProjectIndex(tree, pathStr, sourceRoot)) {
      continue;
    }

    if (isWildcardAlias(alias, pathStr)) {
      return resolveWildcardAlias(alias, sourceRoot, projectName);
    }

    return alias;
  }

  return null;
}

Task 4.10: Create project-analysis/get-project-import-path.spec.ts

File: packages/workspace/src/generators/move-file/project-analysis/get-project-import-path.spec.ts

import { Tree, ProjectConfiguration } from '@nx/devkit';
import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing';
import { getProjectImportPath } from './get-project-import-path';

describe('getProjectImportPath', () => {
  let tree: Tree;
  let project: ProjectConfiguration;

  beforeEach(() => {
    tree = createTreeWithEmptyWorkspace();
    project = {
      root: 'packages/lib1',
      sourceRoot: 'packages/lib1/src',
      projectType: 'library',
    };

    // Create the index file
    tree.write('packages/lib1/src/index.ts', 'export const lib1 = "lib1";');
  });

  it('should return the alias when found in tsconfig paths', () => {
    tree.write(
      'tsconfig.base.json',
      JSON.stringify({
        compilerOptions: {
          paths: {
            '@test/lib1': ['packages/lib1/src/index.ts'],
          },
        },
      }),
    );

    const result = getProjectImportPath(tree, 'lib1', project);

    expect(result).toBe('@test/lib1');
  });

  it('should return null when tsconfig.base.json does not exist', () => {
    const result = getProjectImportPath(tree, 'lib1', project);

    expect(result).toBeNull();
  });

  it('should return null when paths not in tsconfig', () => {
    tree.write(
      'tsconfig.base.json',
      JSON.stringify({
        compilerOptions: {},
      }),
    );

    const result = getProjectImportPath(tree, 'lib1', project);

    expect(result).toBeNull();
  });

  it('should return null when project not in paths', () => {
    tree.write(
      'tsconfig.base.json',
      JSON.stringify({
        compilerOptions: {
          paths: {
            '@test/other': ['packages/other/src/index.ts'],
          },
        },
      }),
    );

    const result = getProjectImportPath(tree, 'lib1', project);

    expect(result).toBeNull();
  });

  it('should handle wildcard aliases', () => {
    tree.write(
      'tsconfig.base.json',
      JSON.stringify({
        compilerOptions: {
          paths: {
            '@test/*': ['packages/*/src/index.ts'],
          },
        },
      }),
    );

    const result = getProjectImportPath(tree, 'lib1', project);

    expect(result).toBe('@test/lib1');
  });

  it('should handle array of paths', () => {
    tree.write(
      'tsconfig.base.json',
      JSON.stringify({
        compilerOptions: {
          paths: {
            '@test/lib1': [
              'packages/lib1/src/index.ts',
              'packages/lib1/src/public-api.ts',
            ],
          },
        },
      }),
    );

    const result = getProjectImportPath(tree, 'lib1', project);

    expect(result).toBe('@test/lib1');
  });

  it('should handle projects without sourceRoot', () => {
    const projectWithoutSourceRoot: ProjectConfiguration = {
      root: 'packages/lib2',
      projectType: 'library',
    };

    tree.write('packages/lib2/index.ts', 'export const lib2 = "lib2";');
    tree.write(
      'tsconfig.base.json',
      JSON.stringify({
        compilerOptions: {
          paths: {
            '@test/lib2': ['packages/lib2/index.ts'],
          },
        },
      }),
    );

    const result = getProjectImportPath(tree, 'lib2', projectWithoutSourceRoot);

    expect(result).toBe('@test/lib2');
  });

  it('should prefer exact match over wildcard', () => {
    tree.write(
      'tsconfig.base.json',
      JSON.stringify({
        compilerOptions: {
          paths: {
            '@exact/lib1': ['packages/lib1/src/index.ts'],
            '@test/*': ['packages/*/src/index.ts'],
          },
        },
      }),
    );

    const result = getProjectImportPath(tree, 'lib1', project);

    // Should return first match (order depends on object iteration)
    expect(result).toMatch(/@(exact|test)\/lib1/);
  });
});

Task 4.11: Create project-analysis/read-compiler-paths.ts

File: packages/workspace/src/generators/move-file/project-analysis/read-compiler-paths.ts

import { Tree, logger } from '@nx/devkit';
import { cachedTreeExists } from '../cache/cached-tree-exists';
import { treeReadCache } from '../tree-cache';

// Module-level cache for compiler paths
// This will be managed by clearAllCaches in generator.ts
let compilerPathsCache: Record<string, unknown> | null | undefined = undefined;

/**
 * Clears the compiler paths cache.
 * Should be called from clearAllCaches.
 */
export function clearCompilerPathsCache(): void {
  compilerPathsCache = undefined;
}

/**
 * Reads the TypeScript compiler path mappings from tsconfig files at the workspace root.
 *
 * Tries tsconfig.base.json, tsconfig.json, and any tsconfig.*.json files.
 * Results are cached for performance.
 *
 * @param tree - The virtual file system tree
 * @returns The paths object or null if unavailable
 */
export function readCompilerPaths(tree: Tree): Record<string, unknown> | null {
  // Return cached value if available
  if (compilerPathsCache !== undefined) {
    return compilerPathsCache;
  }

  // Try common tsconfig files in order of preference
  const tsconfigFiles = ['tsconfig.base.json', 'tsconfig.json'];

  // Add any tsconfig.*.json files found at the root
  const rootFiles = treeReadCache.children(tree, '');
  const additionalTsconfigFiles = rootFiles
    .filter((file) => file.startsWith('tsconfig.') && file.endsWith('.json'))
    .filter((file) => !tsconfigFiles.includes(file));

  const allTsconfigFiles = [...tsconfigFiles, ...additionalTsconfigFiles];

  for (const tsconfigPath of allTsconfigFiles) {
    if (!cachedTreeExists(tree, tsconfigPath)) {
      continue;
    }

    try {
      const tsconfigContent = treeReadCache.read(tree, tsconfigPath, 'utf-8');
      if (!tsconfigContent) {
        continue;
      }

      const tsconfig = JSON.parse(tsconfigContent);
      const paths = tsconfig.compilerOptions?.paths;

      if (typeof paths === 'object' && paths) {
        compilerPathsCache = paths;
        return paths;
      }
    } catch (error) {
      logger.warn(`Could not parse ${tsconfigPath}: ${error}`);
    }
  }

  compilerPathsCache = null;
  return null;
}

Task 4.12: Create project-analysis/read-compiler-paths.spec.ts

File: packages/workspace/src/generators/move-file/project-analysis/read-compiler-paths.spec.ts

import { Tree } from '@nx/devkit';
import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing';
import {
  readCompilerPaths,
  clearCompilerPathsCache,
} from './read-compiler-paths';

describe('readCompilerPaths', () => {
  let tree: Tree;

  beforeEach(() => {
    tree = createTreeWithEmptyWorkspace();
    // Clear cache before each test
    clearCompilerPathsCache();
  });

  afterEach(() => {
    // Clear cache after each test
    clearCompilerPathsCache();
  });

  it('should read paths from tsconfig.base.json', () => {
    tree.write(
      'tsconfig.base.json',
      JSON.stringify({
        compilerOptions: {
          paths: {
            '@test/lib1': ['packages/lib1/src/index.ts'],
          },
        },
      }),
    );

    const result = readCompilerPaths(tree);

    expect(result).toEqual({
      '@test/lib1': ['packages/lib1/src/index.ts'],
    });
  });

  it('should read paths from tsconfig.json if tsconfig.base.json missing', () => {
    tree.write(
      'tsconfig.json',
      JSON.stringify({
        compilerOptions: {
          paths: {
            '@test/lib1': ['packages/lib1/src/index.ts'],
          },
        },
      }),
    );

    const result = readCompilerPaths(tree);

    expect(result).toEqual({
      '@test/lib1': ['packages/lib1/src/index.ts'],
    });
  });

  it('should prefer tsconfig.base.json over tsconfig.json', () => {
    tree.write(
      'tsconfig.base.json',
      JSON.stringify({
        compilerOptions: {
          paths: {
            '@base/lib1': ['packages/lib1/src/index.ts'],
          },
        },
      }),
    );

    tree.write(
      'tsconfig.json',
      JSON.stringify({
        compilerOptions: {
          paths: {
            '@regular/lib1': ['packages/lib1/src/index.ts'],
          },
        },
      }),
    );

    const result = readCompilerPaths(tree);

    expect(result).toEqual({
      '@base/lib1': ['packages/lib1/src/index.ts'],
    });
  });

  it('should return null when no tsconfig files exist', () => {
    const result = readCompilerPaths(tree);

    expect(result).toBeNull();
  });

  it('should return null when paths not in compilerOptions', () => {
    tree.write(
      'tsconfig.base.json',
      JSON.stringify({
        compilerOptions: {},
      }),
    );

    const result = readCompilerPaths(tree);

    expect(result).toBeNull();
  });

  it('should cache the result', () => {
    tree.write(
      'tsconfig.base.json',
      JSON.stringify({
        compilerOptions: {
          paths: {
            '@test/lib1': ['packages/lib1/src/index.ts'],
          },
        },
      }),
    );

    const result1 = readCompilerPaths(tree);
    const result2 = readCompilerPaths(tree);

    expect(result1).toBe(result2); // Same reference
  });

  it('should handle malformed JSON gracefully', () => {
    tree.write('tsconfig.base.json', '{ invalid json }');

    const result = readCompilerPaths(tree);

    expect(result).toBeNull();
  });

  it('should handle missing compilerOptions', () => {
    tree.write('tsconfig.base.json', JSON.stringify({}));

    const result = readCompilerPaths(tree);

    expect(result).toBeNull();
  });

  it('should discover tsconfig.*.json files', () => {
    tree.write(
      'tsconfig.custom.json',
      JSON.stringify({
        compilerOptions: {
          paths: {
            '@custom/lib1': ['packages/lib1/src/index.ts'],
          },
        },
      }),
    );

    const result = readCompilerPaths(tree);

    expect(result).toEqual({
      '@custom/lib1': ['packages/lib1/src/index.ts'],
    });
  });
});

Task 4.13: Create remaining project analysis functions

Create the following files following the same pattern:

  1. get-project-entry-point-paths.ts and .spec.ts
  2. get-fallback-entry-point-paths.ts and .spec.ts
  3. points-to-project-index.ts and .spec.ts
  4. is-index-file-path.ts and .spec.ts
  5. is-wildcard-alias.ts and .spec.ts
  6. build-reverse-dependency-map.ts and .spec.ts
  7. to-first-path.ts and .spec.ts

Due to space constraints, I'm providing abbreviated versions. Follow the patterns from above.

project-analysis/get-project-entry-point-paths.ts

import { Tree, ProjectConfiguration, normalizePath } from '@nx/devkit';
import { readCompilerPaths } from './read-compiler-paths';
import { toFirstPath } from './to-first-path';
import { pointsToProjectIndex } from './points-to-project-index';
import { getFallbackEntryPointPaths } from './get-fallback-entry-point-paths';

/**
 * Gets all possible entry point paths for a project.
 *
 * Combines paths from tsconfig compiler paths and fallback locations.
 *
 * @param tree - The virtual file system tree
 * @param project - The project configuration
 * @returns Array of normalized entry point paths
 */
export function getProjectEntryPointPaths(
  tree: Tree,
  project: ProjectConfiguration,
): string[] {
  const sourceRoot = project.sourceRoot || project.root;
  const seen = new Set<string>();
  const candidates: string[] = [];

  const addCandidate = (value: string | null | undefined) => {
    if (!value) {
      return;
    }
    const normalized = normalizePath(value);
    if (seen.has(normalized)) {
      return;
    }
    seen.add(normalized);
    candidates.push(normalized);
  };

  const compilerPaths = readCompilerPaths(tree);
  if (compilerPaths) {
    for (const [, pathEntry] of Object.entries(compilerPaths)) {
      const pathStr = toFirstPath(pathEntry);
      if (!pathStr) {
        continue;
      }

      if (pointsToProjectIndex(tree, pathStr, sourceRoot)) {
        addCandidate(pathStr);
      }
    }
  }

  getFallbackEntryPointPaths(project).forEach(addCandidate);

  return candidates;
}

project-analysis/get-fallback-entry-point-paths.ts

import { ProjectConfiguration } from '@nx/devkit';
import { posix as path } from 'node:path';
import { primaryEntryFilenames } from '../constants/file-extensions';

/**
 * Gets fallback entry point paths when tsconfig paths are unavailable.
 *
 * Returns common entry point locations based on project structure.
 *
 * @param project - The project configuration
 * @returns Array of fallback entry point paths
 */
export function getFallbackEntryPointPaths(
  project: ProjectConfiguration,
): string[] {
  const sourceRoot = project.sourceRoot || project.root;

  return [
    ...primaryEntryFilenames.map((fileName) => path.join(sourceRoot, fileName)),
    ...primaryEntryFilenames.map((fileName) =>
      path.join(project.root, 'src', fileName),
    ),
  ];
}

project-analysis/points-to-project-index.ts

import { Tree, normalizePath } from '@nx/devkit';
import { cachedTreeExists } from '../cache/cached-tree-exists';
import { isIndexFilePath } from './is-index-file-path';

/**
 * Checks whether the provided path string points to the project's index file.
 *
 * @param tree - The virtual file system tree
 * @param pathStr - Path value from the tsconfig mapping
 * @param sourceRoot - Source root of the project
 * @returns True when the path targets the project's index
 */
export function pointsToProjectIndex(
  tree: Tree,
  pathStr: string,
  sourceRoot: string,
): boolean {
  const normalizedPathStr = normalizePath(pathStr);
  const normalizedSourceRoot = normalizePath(sourceRoot);

  // First, check if path is within the project's source root
  if (
    normalizedPathStr !== normalizedSourceRoot &&
    !normalizedPathStr.startsWith(`${normalizedSourceRoot}/`)
  ) {
    return false;
  }

  // Try dynamic verification: check if the file actually exists
  if (cachedTreeExists(tree, normalizedPathStr)) {
    return true;
  }

  // Fallback to hard-coded pattern matching for common index file patterns
  return isIndexFilePath(normalizedPathStr);
}

project-analysis/is-index-file-path.ts

import {
  entrypointPatterns,
  mainEntryPatterns,
} from '../constants/file-extensions';

/**
 * Determines if a path string references a supported index file using pattern matching.
 *
 * This is a fallback when we can't dynamically verify the file exists.
 *
 * @param pathStr - Path value from the tsconfig mapping
 * @returns True if the path matches common index file patterns
 */
export function isIndexFilePath(pathStr: string): boolean {
  const indexPatterns = [...entrypointPatterns, ...mainEntryPatterns];

  return indexPatterns.some((pattern) => pathStr.endsWith(pattern));
}

project-analysis/is-wildcard-alias.ts

/**
 * Checks whether both alias and path represent wildcard mappings.
 *
 * @param alias - The alias key from tsconfig paths
 * @param pathStr - The resolved path string
 * @returns True when both contain wildcard tokens
 */
export function isWildcardAlias(alias: string, pathStr: string): boolean {
  return alias.includes('*') && pathStr.includes('*');
}

project-analysis/build-reverse-dependency-map.ts

import { ProjectGraph } from '@nx/devkit';

/**
 * Builds a reverse dependency map from the project graph.
 *
 * Maps each project to the set of projects that depend on it.
 *
 * @param projectGraph - The project dependency graph
 * @returns Map from project name to set of dependent project names
 */
export function buildReverseDependencyMap(
  projectGraph: ProjectGraph,
): Map<string, Set<string>> {
  const reverse = new Map<string, Set<string>>();

  Object.entries(projectGraph.dependencies || {}).forEach(
    ([source, dependencies]) => {
      dependencies.forEach((dependency) => {
        const dependents = reverse.get(dependency.target);
        if (dependents) {
          dependents.add(source);
        } else {
          reverse.set(dependency.target, new Set([source]));
        }
      });
    },
  );

  return reverse;
}

project-analysis/to-first-path.ts

/**
 * Normalizes a path mapping entry to its first string value.
 *
 * TypeScript path mappings can be a string or an array of strings.
 * This function normalizes to a single string.
 *
 * @param pathEntry - Single string or string array entry from tsconfig paths
 * @returns The first path string or null when not resolvable
 */
export function toFirstPath(pathEntry: unknown): string | null {
  if (typeof pathEntry === 'string') {
    return pathEntry;
  }

  if (Array.isArray(pathEntry) && typeof pathEntry[0] === 'string') {
    return pathEntry[0];
  }

  return null;
}

Task 4.14: Update generator.ts imports

Update the imports in generator.ts to use the new project-analysis functions:

// Add to imports at top of file
import { findProjectForFile } from './project-analysis/find-project-for-file';
import { isProjectEmpty } from './project-analysis/is-project-empty';
import { getDependentProjectNames } from './project-analysis/get-dependent-project-names';
import { deriveProjectDirectoryFromSource } from './project-analysis/derive-project-directory-from-source';
import { getProjectImportPath } from './project-analysis/get-project-import-path';
import { readCompilerPaths } from './project-analysis/read-compiler-paths';
import { getProjectEntryPointPaths } from './project-analysis/get-project-entry-point-paths';
import { getFallbackEntryPointPaths } from './project-analysis/get-fallback-entry-point-paths';
import { pointsToProjectIndex } from './project-analysis/points-to-project-index';
import { isIndexFilePath } from './project-analysis/is-index-file-path';
import { isWildcardAlias } from './project-analysis/is-wildcard-alias';
import { buildReverseDependencyMap } from './project-analysis/build-reverse-dependency-map';
import { toFirstPath } from './project-analysis/to-first-path';
import { clearCompilerPathsCache } from './project-analysis/read-compiler-paths';

Remove the corresponding function implementations from generator.ts.

Update the clearAllCaches function to call clearCompilerPathsCache().

Testing Strategy

Unit Tests

# Run only the new project-analysis tests
npx nx test workspace --testPathPattern=project-analysis/

Integration Tests

# Run all generator tests to ensure no regressions
npx nx test workspace --testPathPattern=generator.spec.ts

Full Test Suite

# Run all tests
npx nx test workspace

Verification Steps

  1. Create all project-analysis files and tests

  2. Build succeeds:

    npx nx build workspace
  3. All new project-analysis tests pass:

    npx nx test workspace --testPathPattern=project-analysis/
  4. All existing tests still pass:

    npx nx test workspace
  5. Linting passes:

    npx nx lint workspace
  6. No circular dependencies:

    npx nx graph

Expected Outcomes

Before

  • generator.ts: ~1,800 lines (after Phase 3)
  • Project analysis functions inline in generator.ts
  • No separate tests for project analysis functions

After

  • generator.ts: ~1,550 lines (250 lines removed)
  • project-analysis/ directory with 13 function files (~400 lines total)
  • project-analysis/ directory with 13 test files (~1,300 lines total)
  • All 301 existing tests still pass
  • 80+ new tests for project analysis functions

Benefits

  1. Better organization: Project analysis logic separated from main generator
  2. Testability: Each function tested independently with mock data
  3. Reusability: Functions can be used by other modules
  4. Maintainability: Easier to understand and modify project analysis behavior
  5. Documentation: JSDoc on each function explains purpose and behavior
  6. Performance: Mock-based tests run faster than integration tests

Next Steps

After Phase 4 is complete:

  • Move to Phase 5: Extract import update functions
  • Continue using the same pattern: extract → test → verify → commit
  • Continue reducing size of generator.ts

Rollback Plan

If issues arise:

  1. Revert the commit
  2. All functionality returns to previous state
  3. Medium risk but well-tested with comprehensive unit tests

Commit Message

refactor(move-file): extract project analysis functions to dedicated modules (Phase 4)

- Create project-analysis/ directory with 13 analysis functions
- Add comprehensive unit tests for all project analysis functions
- Update generator.ts to use extracted functions
- No functional changes, purely organizational

Project analysis functions extracted:
- findProjectForFile
- isProjectEmpty
- getDependentProjectNames
- deriveProjectDirectoryFromSource
- getProjectImportPath
- readCompilerPaths
- getProjectEntryPointPaths
- getFallbackEntryPointPaths
- pointsToProjectIndex
- isIndexFilePath
- isWildcardAlias
- buildReverseDependencyMap
- toFirstPath

Part of refactoring plan Phase 4.
See REFACTORING_PLAN.md for full details.

Additional Notes

Cache Management

The readCompilerPaths function maintains its own cache. This cache must be cleared by the main clearAllCaches function. The cache is exported via clearCompilerPathsCache().

Dependencies Between Functions

Some project analysis functions depend on each other:

  • getProjectEntryPointPathsreadCompilerPaths, toFirstPath, pointsToProjectIndex, getFallbackEntryPointPaths
  • getProjectImportPathreadCompilerPaths, toFirstPath, pointsToProjectIndex, isWildcardAlias
  • getDependentProjectNamesbuildReverseDependencyMap
  • isProjectEmptygetProjectEntryPointPaths
  • pointsToProjectIndexisIndexFilePath, cachedTreeExists

These dependencies are handled through imports between the modules.

Test Coverage Goals

  • Aim for >95% code coverage on all new functions
  • Include edge cases: empty projects, missing tsconfig, malformed data
  • Test with realistic project structures
  • Use mock data to avoid file system dependencies

Performance Considerations

  • All tests use mock data and should run quickly
  • No file system access in unit tests (except through Tree mock)
  • Caching tested to ensure it works correctly
  • Test that cache improves performance on repeated calls