project/lib/graph/projectGraphBuilder.js

import path from "node:path";
import Module from "./Module.js";
import ProjectGraph from "./ProjectGraph.js";
import ShimCollection from "./ShimCollection.js";
import {getLogger} from "@ui5/logger";
const log = getLogger("graph:projectGraphBuilder");

function _handleExtensions(graph, shimCollection, extensions) {
	extensions.forEach((extension) => {
		const type = extension.getType();
		switch (type) {
		case "project-shim":
			shimCollection.addProjectShim(extension);
			break;
		case "task":
		case "server-middleware":
			graph.addExtension(extension);
			break;
		default:
			throw new Error(
				`Encountered unexpected extension of type ${type} ` +
				`Supported types are 'project-shim', 'task' and 'middleware'`);
		}
	});
}

function validateNode(node) {
	if (node.specVersion) {
		throw new Error(
			`Provided node with ID ${node.id} contains a top-level 'specVersion' property. ` +
			`With UI5 Tooling 3.0, project configuration needs to be provided in a dedicated ` +
			`'configuration' object`);
	}
	if (node.metadata) {
		throw new Error(
			`Provided node with ID ${node.id} contains a top-level 'metadata' property. ` +
			`With UI5 Tooling 3.0, project configuration needs to be provided in a dedicated ` +
			`'configuration' object`);
	}
}

/**
 * @public
 * @module @ui5/project/graph/ProjectGraphBuilder
 */

/**
 * Dependency graph node representing a module
 *
 * @public
 * @typedef {object} @ui5/project/graph/ProjectGraphBuilder~Node
 * @property {string} node.id Unique ID for the project
 * @property {string} node.version Version of the project
 * @property {string} node.path File System path to access the projects resources
 * @property {object|object[]} [node.configuration]
 *	Configuration object or array of objects to use instead of reading from a configuration file
 * @property {string} [node.configPath] Configuration file to use instead the default ui5.yaml
 * @property {boolean} [node.optional]
 *					Whether the node is an optional dependency of the parent it has been requested for
 * @property {*} * Additional attributes are allowed but ignored.
 *					These can be used to pass information internally in the provider.
 */

/**
 * Node Provider interface
 *
 * @public
 * @interface @ui5/project/graph/ProjectGraphBuilder~NodeProvider
 */

/**
 * Retrieve information on the root module
 *
 * @public
 * @function
 * @name @ui5/project/graph/ProjectGraphBuilder~NodeProvider#getRootNode
 * @returns {Node} The root node of the dependency graph
 */

/**
 * Retrieve information on given a nodes dependencies
 *
 * @public
 * @function
 * @name @ui5/project/graph/ProjectGraphBuilder~NodeProvider#getDependencies
 * @param {Node} node The root node of the dependency graph
 * @param {@ui5/project/graph/Workspace} [workspace] workspace instance to use for overriding node resolution
 * @returns {Node[]} Array of nodes which are direct dependencies of the given node
 */

/**
 * Generic helper module to create a [@ui5/project/graph/ProjectGraph]{@link @ui5/project/graph/ProjectGraph}.
 * For example from a dependency tree as returned by the legacy "translators".
 *
 * @public
 * @function default
 * @static
 * @param {@ui5/project/graph/ProjectGraphBuilder~NodeProvider} nodeProvider
 * 	Node provider instance to use for building the graph
 * @param {@ui5/project/graph/Workspace} [workspace]
 * 	Optional workspace instance to use for overriding project resolutions
 * @returns {@ui5/project/graph/ProjectGraph} A new project graph instance
 */
async function projectGraphBuilder(nodeProvider, workspace) {
	const shimCollection = new ShimCollection();
	const moduleCollection = Object.create(null);
	const handledExtensions = new Set(); // Set containing the IDs of modules which' extensions have been handled

	const rootNode = await nodeProvider.getRootNode();
	validateNode(rootNode);
	const rootModule = new Module({
		id: rootNode.id,
		version: rootNode.version,
		modulePath: rootNode.path,
		configPath: rootNode.configPath,
		configuration: rootNode.configuration
	});
	const {project: rootProject, extensions: rootExtensions} = await rootModule.getSpecifications();
	if (!rootProject) {
		throw new Error(
			`Failed to create a UI5 project from module ${rootNode.id} at ${rootNode.path}. ` +
			`Make sure the path is correct and a project configuration is present or supplied.`);
	}

	moduleCollection[rootNode.id] = rootModule;

	const rootProjectName = rootProject.getName();

	let qualifiedApplicationProject = null;
	if (rootProject.getType() === "application") {
		log.verbose(`Root project ${rootProjectName} qualified as application project for project graph`);
		qualifiedApplicationProject = rootProject;
	}


	const projectGraph = new ProjectGraph({
		rootProjectName: rootProjectName
	});
	projectGraph.addProject(rootProject);

	function handleExtensions(extensions) {
		return _handleExtensions(projectGraph, shimCollection, extensions);
	}

	handleExtensions(rootExtensions);
	handledExtensions.add(rootNode.id);

	const queue = [];

	const rootDependencies = await nodeProvider.getDependencies(rootNode, workspace);

	if (rootDependencies && rootDependencies.length) {
		queue.push({
			nodes: rootDependencies,
			parentProject: rootProject
		});
	}

	// Breadth-first search
	while (queue.length) {
		const {nodes, parentProject} = queue.shift(); // Get and remove first entry from queue
		const res = await Promise.all(nodes.map(async (node) => {
			let ui5Module = moduleCollection[node.id];

			if (ui5Module) {
				log.silly(
					`Re-visiting module ${node.id} as a dependency of ${parentProject.getName()}`);

				const {project, extensions} = await ui5Module.getSpecifications();
				if (!project && !extensions.length) {
					// Invalidate cache if the cached module is visited through another parent project and did not
					// resolve to a project or extension(s) before.
					// The module being visited now might be a different version containing for example
					// UI5 Tooling configuration, or one of the parent projects could have defined a
					// relevant configuration shim meanwhile
					log.silly(
						`Cached module ${node.id} did not resolve to any projects or extensions. ` +
						`Recreating module as a dependency of ${parentProject.getName()}...`);
					ui5Module = null;
				}
			}

			if (!ui5Module) {
				log.silly(`Visiting Module ${node.id} as a dependency of ${parentProject.getName()}`);
				log.verbose(`Creating module ${node.id}...`);
				validateNode(node);
				ui5Module = moduleCollection[node.id] = new Module({
					id: node.id,
					version: node.version,
					modulePath: node.path,
					configPath: node.configPath,
					configuration: node.configuration,
					shimCollection
				});
			} else if (ui5Module.getPath() !== node.path) {
				log.verbose(
					`Warning - Dependency ${node.id} is available at multiple paths:` +
					`\n  Location of the already processed module (this one will be used): ${ui5Module.getPath()}` +
					`\n  Additional location (this one will be ignored): ${node.path}`);
			}

			const {project, extensions} = await ui5Module.getSpecifications();
			return {
				node,
				project,
				extensions
			};
		}));

		// Keep this out of the async map function to ensure
		// all projects and extensions are applied in a deterministic order
		for (let i = 0; i < res.length; i++) {
			const {
				node, // Tree "raw" dependency tree node
				project, // The project found for this node, if any
				extensions // Any extensions found for this node
			} = res[i];

			if (extensions.length && (!node.optional || parentProject === rootProject)) {
				// Only handle extensions in non-optional dependencies and any dependencies of the root project
				if (handledExtensions.has(node.id)) {
					// Do not handle extensions of the same module twice
					log.verbose(`Extensions contained in module ${node.id} have already been handled`);
				} else {
					log.verbose(`Handling extensions for module ${node.id}...`);
					// If a different module contains the same extension, we expect an error to be thrown by the graph
					handleExtensions(extensions);
					handledExtensions.add(node.id);
				}
			}

			// Check for collection shims
			const collectionShims = shimCollection.getCollectionShims(node.id);
			if (collectionShims && collectionShims.length) {
				log.verbose(
					`One or more module collection shims have been defined for module ${node.id}. ` +
					`Therefore the module itself will not be resolved.`);

				const shimmedNodes = collectionShims.map(({name, shim}) => {
					log.verbose(`Applying module collection shim ${name} for module ${node.id}:`);
					return Object.entries(shim.modules).map(([shimModuleId, shimModuleRelPath]) => {
						const shimModulePath = path.join(node.path, shimModuleRelPath);
						log.verbose(`  Injecting module ${shimModuleId} with path ${shimModulePath}`);
						return {
							id: shimModuleId,
							version: node.version,
							path: shimModulePath
						};
					});
				});

				queue.push({
					nodes: Array.prototype.concat.apply([], shimmedNodes),
					parentProject,
				});
				// Skip collection node
				continue;
			}

			let skipDependencies = false;
			if (project) {
				const projectName = project.getName();
				if (project.getType() === "application") {
					// Special handling of application projects of which there must be exactly *one*
					// in the graph. Others shall be ignored.
					if (!qualifiedApplicationProject) {
						log.verbose(`Project ${projectName} qualified as application project for project graph`);
						qualifiedApplicationProject = project;
					} else if (qualifiedApplicationProject.getName() !== projectName) {
						// Project is not a duplicate of an already qualified project (which should
						// still be processed below), but a unique, additional application project

						// TODO: Should this rather be a verbose logging?
						//	projectPreprocessor handled this like any project that got ignored and did a
						//	(in this case misleading) general verbose logging:
						//	"Ignoring project with missing configuration"
						log.info(
							`Excluding additional application project ${projectName} from graph. `+
							`The project graph can only feature a single project of type application. ` +
							`Project ${qualifiedApplicationProject.getName()} has already qualified for that role.`);
						continue;
					}
				}
				if (projectGraph.getProject(projectName)) {
					// Opposing to extensions, we are generally fine with the same project being contained in different
					// modules. We simply ignore all but the first occurrence.
					// This can happen for example if the same project is packaged in different ways/modules
					// (e.g. one module containing the source and one containing the pre-built resources)
					log.verbose(
						`Project ${projectName} has already been added to the graph. ` +
						`Skipping dependency resolution...`);
					skipDependencies = true;
				} else {
					projectGraph.addProject(project);
				}

				if (parentProject) {
					if (node.optional) {
						projectGraph.declareOptionalDependency(parentProject.getName(), projectName);
					} else {
						projectGraph.declareDependency(parentProject.getName(), projectName);
					}

					if (project.isDeprecated() && parentProject === rootProject &&
							parentProject.getName() !== "testsuite") {
						// Only warn for direct dependencies of the root project
						// No warning for testsuite projects
						log.warn(
							`Dependency ${project.getName()} is deprecated and should not be used for new projects!`);
					}

					if (project.isSapInternal() && parentProject === rootProject &&
						!parentProject.getAllowSapInternal()) {
						// Only warn for direct dependencies of the root project, except it defines "allowSapInternal"
						log.warn(
							`Dependency ${project.getName()} is restricted for use by SAP internal projects only! ` +
							`If the project ${parentProject.getName()} is an SAP internal project, add the attribute ` +
							`"allowSapInternal: true" to its metadata configuration`);
					}
				}
			}

			if (!project && !extensions.length) {
				// Module provides neither a project nor an extension
				// => Do not follow its dependencies
				log.verbose(
					`Module ${node.id} neither provides a project nor an extension. Skipping dependency resolution`);
				skipDependencies = true;
			}

			if (skipDependencies) {
				continue;
			}

			const nodeDependencies = await nodeProvider.getDependencies(node);
			if (nodeDependencies && nodeDependencies.length) {
				queue.push({
					// copy array, so that the queue is stable while ignored project dependencies are removed
					nodes: [...nodeDependencies],
					parentProject: project ? project : parentProject,
				});
			}
		}
	}

	// Apply dependency shims
	for (const [shimmedModuleId, moduleDepShims] of Object.entries(shimCollection.getAllDependencyShims())) {
		const sourceModule = moduleCollection[shimmedModuleId];

		for (let j = 0; j < moduleDepShims.length; j++) {
			const depShim = moduleDepShims[j];
			if (!sourceModule) {
				log.warn(`Could not apply dependency shim ${depShim.name} for ${shimmedModuleId}: ` +
					`Module ${shimmedModuleId} is unknown`);
				continue;
			}
			const {project: sourceProject} = await sourceModule.getSpecifications();
			if (!sourceProject) {
				log.warn(`Could not apply dependency shim ${depShim.name} for ${shimmedModuleId}: ` +
					`Source module ${shimmedModuleId} does not contain a project`);
				continue;
			}
			for (let k = 0; k < depShim.shim.length; k++) {
				const targetModuleId = depShim.shim[k];
				const targetModule = moduleCollection[targetModuleId];
				if (!targetModule) {
					log.warn(`Could not apply dependency shim ${depShim.name} for ${shimmedModuleId}: ` +
						`Target module $${depShim} is unknown`);
					continue;
				}
				const {project: targetProject} = await targetModule.getSpecifications();
				if (!targetProject) {
					log.warn(`Could not apply dependency shim ${depShim.name} for ${shimmedModuleId}: ` +
						`Target module ${targetModuleId} does not contain a project`);
					continue;
				}
				projectGraph.declareDependency(sourceProject.getName(), targetProject.getName());
			}
		}
	}
	await projectGraph.resolveOptionalDependencies();

	return projectGraph;
}

export default projectGraphBuilder;