import * as path from 'path'; import { join } from 'path'; import { DependencyType, joinPathFragments, normalizePath, parseJson, ProjectGraph, ProjectGraphDependency, ProjectGraphExternalNode, ProjectGraphProjectNode, workspaceRoot, } from '@nx/devkit'; import { getPath, pathExists } from './graph-utils'; import { readFileIfExisting } from 'nx/src/utils/fileutils'; import { findProjectForPath, ProjectRootMappings, } from 'nx/src/project-graph/utils/find-project-for-path'; import { getRootTsConfigFileName } from '@nx/js'; import { resolveModuleByImport, TargetProjectLocator, } from '@nx/js/src/internal'; export type Deps = { [projectName: string]: ProjectGraphDependency[] }; type SingleSourceTagConstraint = { sourceTag: string; onlyDependOnLibsWithTags?: string[]; notDependOnLibsWithTags?: string[]; allowedExternalImports?: string[]; bannedExternalImports?: string[]; }; type ComboSourceTagConstraint = { allSourceTags: string[]; onlyDependOnLibsWithTags?: string[]; notDependOnLibsWithTags?: string[]; allowedExternalImports?: string[]; bannedExternalImports?: string[]; }; export type DepConstraint = | SingleSourceTagConstraint | ComboSourceTagConstraint; export function stringifyTags(tags: string[]): string { return tags.map((t) => `"${t}"`).join(', '); } export function hasNoneOfTheseTags( proj: ProjectGraphProjectNode, tags: string[] ): boolean { return tags.filter((tag) => hasTag(proj, tag)).length === 0; } export function isComboDepConstraint( depConstraint: DepConstraint ): depConstraint is ComboSourceTagConstraint { return !!(depConstraint as ComboSourceTagConstraint).allSourceTags; } /** * Check if any of the given tags is included in the project * @param proj ProjectGraphProjectNode * @param tags * @returns */ export function findDependenciesWithTags( targetProject: ProjectGraphProjectNode, tags: string[], graph: ProjectGraph ): ProjectGraphProjectNode[][] { // find all reachable projects that have one of the tags and // are reacheable from the targetProject (including self) const allReachableProjects = Object.keys(graph.nodes).filter( (projectName) => pathExists(graph, targetProject.name, projectName) && tags.some((tag) => hasTag(graph.nodes[projectName], tag)) ); // return path from targetProject to reachable project return allReachableProjects.map((project) => targetProject.name === project ? [targetProject] : getPath(graph, targetProject.name, project) ); } const regexMap = new Map(); function hasTag(proj: ProjectGraphProjectNode, tag: string): boolean { if (tag === '*') return true; // if the tag is a regex, check if the project matches the regex if (tag.startsWith('/') && tag.endsWith('/')) { let regex; if (regexMap.has(tag)) { regex = regexMap.get(tag); } else { regex = new RegExp(tag.substring(1, tag.length - 1)); regexMap.set(tag, regex); } return (proj.data.tags || []).some((t) => regex.test(t)); } // if the tag is a glob, check if the project matches the glob prefix if (tag.includes('*')) { const regex = mapGlobToRegExp(tag); return (proj.data.tags || []).some((t) => regex.test(t)); } return (proj.data.tags || []).indexOf(tag) > -1; } export function matchImportWithWildcard( // This may or may not contain wildcards ("*") allowableImport: string, extractedImport: string ): boolean { if (allowableImport.endsWith('/**')) { const prefix = allowableImport.substring(0, allowableImport.length - 2); return extractedImport.startsWith(prefix); } else if (allowableImport.endsWith('/*')) { const prefix = allowableImport.substring(0, allowableImport.length - 1); if (!extractedImport.startsWith(prefix)) return false; return extractedImport.substring(prefix.length).indexOf('/') === -1; } else if (allowableImport.indexOf('/**/') > -1) { const [prefix, suffix] = allowableImport.split('/**/'); return ( extractedImport.startsWith(prefix) && extractedImport.endsWith(suffix) ); } else { return new RegExp(allowableImport).test(extractedImport); } } export function isRelative(s: string) { return s.startsWith('./') || s.startsWith('../'); } export function getTargetProjectBasedOnRelativeImport( imp: string, projectPath: string, projectGraph: ProjectGraph, projectRootMappings: ProjectRootMappings, sourceFilePath: string ): ProjectGraphProjectNode | undefined { if (!isRelative(imp)) { return undefined; } const sourceDir = path.join(projectPath, path.dirname(sourceFilePath)); const targetFile = normalizePath(path.resolve(sourceDir, imp)).substring( projectPath.length + 1 ); return findProject(projectGraph, projectRootMappings, targetFile); } export function findProject( projectGraph: ProjectGraph, projectRootMappings: ProjectRootMappings, sourceFilePath: string ) { return projectGraph.nodes[ findProjectForPath(sourceFilePath, projectRootMappings) ]; } export function isAbsoluteImportIntoAnotherProject( imp: string, workspaceLayout = { libsDir: 'libs', appsDir: 'apps' } ) { return ( imp.startsWith(`${workspaceLayout.libsDir}/`) || imp.startsWith(`/${workspaceLayout.libsDir}/`) || imp.startsWith(`${workspaceLayout.appsDir}/`) || imp.startsWith(`/${workspaceLayout.appsDir}/`) ); } export function findProjectUsingImport( projectGraph: ProjectGraph, targetProjectLocator: TargetProjectLocator, filePath: string, imp: string ): ProjectGraphProjectNode | ProjectGraphExternalNode { const target = targetProjectLocator.findProjectWithImport(imp, filePath); return projectGraph.nodes[target] || projectGraph.externalNodes?.[target]; } export function findConstraintsFor( depConstraints: DepConstraint[], sourceProject: ProjectGraphProjectNode ) { return depConstraints.filter((f) => { if (isComboDepConstraint(f)) { return f.allSourceTags.every((tag) => hasTag(sourceProject, tag)); } else { return hasTag(sourceProject, f.sourceTag); } }); } export function onlyLoadChildren( graph: ProjectGraph, sourceProjectName: string, targetProjectName: string, visited: string[] ) { if (visited.indexOf(sourceProjectName) > -1) return false; return ( (graph.dependencies[sourceProjectName] || []).filter((d) => { if (d.type !== DependencyType.dynamic) return false; if (d.target === targetProjectName) return true; return onlyLoadChildren(graph, d.target, targetProjectName, [ ...visited, sourceProjectName, ]); }).length > 0 ); } export function getSourceFilePath(sourceFileName: string, projectPath: string) { const normalizedProjectPath = normalizePath(projectPath); const normalizedSourceFileName = normalizePath(sourceFileName); return normalizedSourceFileName.slice(normalizedProjectPath.length + 1); } /** * Find constraint (if any) that explicitly banns the given target npm project * @param externalProject * @param depConstraints * @returns */ function isConstraintBanningProject( externalProject: ProjectGraphExternalNode, constraint: DepConstraint, imp: string ): boolean { const { allowedExternalImports, bannedExternalImports } = constraint; const { packageName } = externalProject.data; if (imp !== packageName && !imp.startsWith(`${packageName}/`)) { return false; } /* Check if import is banned... */ if ( bannedExternalImports?.some((importDefinition) => mapGlobToRegExp(importDefinition).test(imp) ) ) { return true; } /* ... then check if there is a whitelist and if there is a match in the whitelist. */ return allowedExternalImports?.every( (importDefinition) => !imp.startsWith(packageName) || !mapGlobToRegExp(importDefinition).test(imp) ); } export function hasBannedImport( source: ProjectGraphProjectNode, target: ProjectGraphExternalNode, depConstraints: DepConstraint[], imp: string ): DepConstraint | undefined { // return those constraints that match source project depConstraints = depConstraints.filter((c) => { let tags = []; if (isComboDepConstraint(c)) { tags = c.allSourceTags; } else { tags = [c.sourceTag]; } return tags.every((t) => hasTag(source, t)); }); return depConstraints.find((constraint) => isConstraintBanningProject(target, constraint, imp) ); } /** * Find all unique (transitive) external dependencies of given project * @param graph * @param source * @returns */ export function findTransitiveExternalDependencies( graph: ProjectGraph, source: ProjectGraphProjectNode ): ProjectGraphDependency[] { if (!graph.externalNodes) { return []; } const allReachableProjects = []; const allProjects = Object.keys(graph.nodes); for (let i = 0; i < allProjects.length; i++) { if (pathExists(graph, source.name, allProjects[i])) { allReachableProjects.push(allProjects[i]); } } const externalDependencies = []; for (let i = 0; i < allReachableProjects.length; i++) { const dependencies = graph.dependencies[allReachableProjects[i]]; if (dependencies) { for (let d = 0; d < dependencies.length; d++) { const dependency = dependencies[d]; if (graph.externalNodes[dependency.target]) { externalDependencies.push(dependency); } } } } return externalDependencies; } /** * Check if * @param externalDependencies * @param graph * @param depConstraint * @returns */ export function hasBannedDependencies( externalDependencies: ProjectGraphDependency[], graph: ProjectGraph, depConstraint: DepConstraint, imp: string ): | Array<[ProjectGraphExternalNode, ProjectGraphProjectNode, DepConstraint]> | undefined { return externalDependencies .filter((dependency) => isConstraintBanningProject( graph.externalNodes[dependency.target], depConstraint, imp ) ) .map((dep) => [ graph.externalNodes[dep.target], graph.nodes[dep.source], depConstraint, ]); } export function isDirectDependency( source: ProjectGraphProjectNode, target: ProjectGraphExternalNode ): boolean { return ( packageExistsInPackageJson(target.data.packageName, '.') || packageExistsInPackageJson(target.data.packageName, source.data.root) ); } function packageExistsInPackageJson( packageName: string, projectRoot: string ): boolean { const content = readFileIfExisting( join(workspaceRoot, projectRoot, 'package.json') ); if (content) { const { dependencies, devDependencies, peerDependencies } = parseJson(content); if (dependencies && dependencies[packageName]) { return true; } if (peerDependencies && peerDependencies[packageName]) { return true; } if (devDependencies && devDependencies[packageName]) { return true; } } return false; } /** * Maps import with wildcards to regex pattern * @param importDefinition * @returns */ function mapGlobToRegExp(importDefinition: string): RegExp { // we replace all instances of `*`, `**..*` and `.*` with `.*` const mappedWildcards = importDefinition.split(/(?:\.\*)|\*+/).join('.*'); return new RegExp(`^${new RegExp(mappedWildcards).source}$`); } /** * Verifies whether the given node has a builder target * @param projectGraph the node to verify * @param buildTargets the list of targets to check for */ export function hasBuildExecutor( projectGraph: ProjectGraphProjectNode, buildTargets = ['build'] ): boolean { return ( projectGraph.data.targets && buildTargets.some( (target) => projectGraph.data.targets[target] && projectGraph.data.targets[target].executor !== '' ) ); } const ESLINT_REGEX = /node_modules.*[\/\\]eslint(?:\.js)?$/; const JEST_REGEX = /node_modules\/.bin\/jest$/; // when we run unit tests in jest const NRWL_CLI_REGEX = /nx[\/\\]bin[\/\\]run-executor\.js$/; export function isTerminalRun(): boolean { return ( process.argv.length > 1 && (!!process.argv[1].match(NRWL_CLI_REGEX) || !!process.argv[1].match(JEST_REGEX) || !!process.argv[1].match(ESLINT_REGEX) || !!process.argv[1].endsWith('/bin/jest.js')) ); } /** * Takes an array of imports and tries to group them, so rather than having * `import { A } from './some-location'` and `import { B } from './some-location'` you get * `import { A, B } from './some-location'` * @param importsToRemap * @returns */ export function groupImports( importsToRemap: { member: string; importPath: string }[] ): string { const importsToRemapGrouped = importsToRemap.reduce((acc, curr) => { const existing = acc.find( (i) => i.importPath === curr.importPath && i.member !== curr.member ); if (existing) { if (existing.member) { existing.member += `, ${curr.member}`; } } else { acc.push({ importPath: curr.importPath, member: curr.member, }); } return acc; }, []); return importsToRemapGrouped .map((entry) => `import { ${entry.member} } from '${entry.importPath}';`) .join('\n'); } /** * Checks if source file belongs to a secondary entry point different than the import one */ export function belongsToDifferentNgEntryPoint( importExpr: string, filePath: string, projectRoot: string ): boolean { const resolvedImportFile = resolveModuleByImport( importExpr, filePath, // not strictly necessary, but speeds up resolution join(workspaceRoot, getRootTsConfigFileName()) ); if (!resolvedImportFile) { return false; } const importEntryPoint = getAngularEntryPoint( resolvedImportFile, projectRoot ); const srcEntryPoint = getAngularEntryPoint(filePath, projectRoot); // check if the entry point of import expression is different than the source file's entry point return importEntryPoint !== srcEntryPoint; } function getAngularEntryPoint(file: string, projectRoot: string): string { let parent = joinPathFragments(file, '../'); while (parent !== `${projectRoot}/`) { // we need to find closest existing ng-package.json // in order to determine if the file matches the secondary entry point const ngPackageContent = readFileIfExisting( joinPathFragments(workspaceRoot, parent, 'ng-package.json') ); if (ngPackageContent) { // https://github.com/ng-packagr/ng-packagr/blob/23c718d04eea85e015b4c261310b7bd0c39e5311/src/ng-package.schema.json#L54 const entryFile = parseJson(ngPackageContent)?.lib?.entryFile; return joinPathFragments(parent, entryFile); } parent = joinPathFragments(parent, '../'); } return undefined; } /** * Returns true if the given project contains MFE config with "exposes:" section */ export function appIsMFERemote(project: ProjectGraphProjectNode): boolean { const mfeConfig = readFileIfExisting( joinPathFragments( workspaceRoot, project.data.root, 'module-federation.config.js' ) ) || readFileIfExisting( joinPathFragments( workspaceRoot, project.data.root, 'module-federation.config.ts' ) ); if (mfeConfig) { return !!mfeConfig.match(/('|")?exposes('|")?:/); } return false; }