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
- 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
✅ Phase 1 must be complete:
constants/file-extensions.tscreatedtypes/move-context.tscreated- 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
Phase 4 extracts 13 project analysis functions:
- findProjectForFile - Finds which project contains a given file
- isProjectEmpty - Checks if a project only contains index files
- getDependentProjectNames - Gets all projects that depend on a given project
- deriveProjectDirectoryFromSource - Derives target directory from source file path
- getProjectImportPath - Gets the TypeScript import alias for a project
- readCompilerPaths - Reads TypeScript compiler path mappings from tsconfig
- getProjectEntryPointPaths - Gets all possible entry point paths for a project
- getFallbackEntryPointPaths - Gets fallback entry points when tsconfig paths unavailable
- pointsToProjectIndex - Checks if a path points to a project's index file
- isIndexFilePath - Pattern-based check for index file paths
- isWildcardAlias - Checks if an alias is a wildcard pattern
- buildReverseDependencyMap - Builds map of reverse dependencies from project graph
- toFirstPath - Normalizes a path mapping entry to its first string value
Medium Risk - These functions involve complex logic for:
- Project structure analysis
- TypeScript path mapping resolution
- Dependency graph traversal
- Index file detection
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;
}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');
});
});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;
}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);
});
});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);
}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([]);
});
});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;
}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');
});
});
});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;
}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/);
});
});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;
}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'],
});
});
});Create the following files following the same pattern:
- get-project-entry-point-paths.ts and .spec.ts
- get-fallback-entry-point-paths.ts and .spec.ts
- points-to-project-index.ts and .spec.ts
- is-index-file-path.ts and .spec.ts
- is-wildcard-alias.ts and .spec.ts
- build-reverse-dependency-map.ts and .spec.ts
- to-first-path.ts and .spec.ts
Due to space constraints, I'm providing abbreviated versions. Follow the patterns from above.
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;
}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),
),
];
}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);
}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));
}/**
* 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('*');
}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;
}/**
* 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;
}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().
# Run only the new project-analysis tests
npx nx test workspace --testPathPattern=project-analysis/# Run all generator tests to ensure no regressions
npx nx test workspace --testPathPattern=generator.spec.ts# Run all tests
npx nx test workspace-
Create all project-analysis files and tests
-
Build succeeds:
npx nx build workspace
-
All new project-analysis tests pass:
npx nx test workspace --testPathPattern=project-analysis/ -
All existing tests still pass:
npx nx test workspace -
Linting passes:
npx nx lint workspace
-
No circular dependencies:
npx nx graph
generator.ts: ~1,800 lines (after Phase 3)- Project analysis functions inline in generator.ts
- No separate tests for project analysis functions
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
- Better organization: Project analysis logic separated from main generator
- Testability: Each function tested independently with mock data
- Reusability: Functions can be used by other modules
- Maintainability: Easier to understand and modify project analysis behavior
- Documentation: JSDoc on each function explains purpose and behavior
- Performance: Mock-based tests run faster than integration tests
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
If issues arise:
- Revert the commit
- All functionality returns to previous state
- Medium risk but well-tested with comprehensive unit tests
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.
The readCompilerPaths function maintains its own cache. This cache must be cleared by the main clearAllCaches function. The cache is exported via clearCompilerPathsCache().
Some project analysis functions depend on each other:
getProjectEntryPointPaths→readCompilerPaths,toFirstPath,pointsToProjectIndex,getFallbackEntryPointPathsgetProjectImportPath→readCompilerPaths,toFirstPath,pointsToProjectIndex,isWildcardAliasgetDependentProjectNames→buildReverseDependencyMapisProjectEmpty→getProjectEntryPointPathspointsToProjectIndex→isIndexFilePath,cachedTreeExists
These dependencies are handled through imports between the modules.
- 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
- 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