Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[FEATURE] Add 'UI5 Workspace' Support #494

Merged
merged 26 commits into from
Feb 2, 2023
Merged
Show file tree
Hide file tree
Changes from 22 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
0f02ea0
[FEATURE] Add 'UI5 Workspace' Support
RandomByte Nov 9, 2022
40764f4
[INTERNAL] graphFromPackageDependencies: Create 'UI5 Workspace'
RandomByte Nov 9, 2022
9149d75
[INTERNAL] NodePackageDependencies: Resolve using 'UI5 Workspace' if …
RandomByte Nov 9, 2022
9859c43
[INTERNAL] ui5Framework: Use Workspace to override paths of single nodes
RandomByte Nov 9, 2022
941a010
[INTERNAL] ui5Framework: Resolve dependencies of projects resolved vi…
RandomByte Nov 9, 2022
9b5ea68
[INTERNAL] Workspace: Match framework projects based on name instead …
RandomByte Nov 16, 2022
65fee12
[INTERNAL] Workspace: Resolve package.json workspaces
RandomByte Nov 16, 2022
cdffdea
[INTERNAL] NodePackageDependencies: Add info log when resolving nodes…
RandomByte Nov 16, 2022
4bdd7cd
[INTERNAL] Workspace: Fix package workspace resolution, update tests
RandomByte Nov 16, 2022
26f2350
[INTERNAL] Workspace: Enhance test coverage
RandomByte Nov 16, 2022
41c2e5f
[INTERNAL] helpers/ui5Framework: Enhance test coverage
RandomByte Nov 16, 2022
717309e
[INTERNAL] Workspace: Adapt to new @ui5/logger API
RandomByte Jan 25, 2023
065b61a
[INTERNAL] graph#utils: Add workspace schema validation
RandomByte Jan 18, 2023
2c8d663
[INTERNAL] Workspace: Use config file location as CWD
RandomByte Jan 26, 2023
4b5ce09
[INTERNAL] Module: Add test for empty and corrupt YAML files
RandomByte Jan 27, 2023
0165e46
[INTERNAL] Workspace: Resolve nested workspaces
RandomByte Jan 27, 2023
34e102c
[INTERNAL] graph: Fix tests on Windows
RandomByte Jan 27, 2023
d003244
[INTERNAL] graph: Refactor Workspace creation into helper module
RandomByte Jan 27, 2023
caf2bc5
[INTERNAL] ui5Framework: Add detection for cyclic dependencies
RandomByte Jan 29, 2023
649107e
[INTERNAL] ProjectGraph: Improve comments, error handling, tests
RandomByte Jan 29, 2023
8a5c1b8
[INTERNAL] Workspace: Log resolved versions
RandomByte Jan 30, 2023
e006d52
[INTERNAL] Apply suggestions from code review
RandomByte Feb 1, 2023
c326827
[INTERNAL] Workspace: Also use modules containing extensions only
RandomByte Feb 1, 2023
dd7281a
[INTERNAL] Workspace: Add JSDoc and validation
RandomByte Feb 1, 2023
f16d3f2
[INTERNAL] Workspace: Cleanup based on code review
RandomByte Feb 1, 2023
a807d70
[INTERNAL] Workspace: Apply suggestions from code review
RandomByte Feb 1, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion lib/graph/Module.js
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ class Module {
}

return {
project: projects[0],
project: projects[0] || null,
extensions
};
}
Expand Down
56 changes: 27 additions & 29 deletions lib/graph/ProjectGraph.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import {getLogger} from "@ui5/logger";
const log = getLogger("graph:ProjectGraph");

/**
* A rooted, directed graph representing a UI5 project, its dependencies and available extensions
* A rooted, directed graph representing a UI5 project, its dependencies and available extensions.
* <br><br>
* While it allows defining cyclic dependencies, both traversal functions will throw an error if they encounter cycles.
*
* @public
* @class
Expand All @@ -20,9 +22,9 @@ class ProjectGraph {
}
this._rootProjectName = rootProjectName;

this._projects = new Map(); // maps project name to instance
this._adjList = new Map(); // maps project name to edges/dependencies
this._optAdjList = new Map(); // maps project name to optional dependencies
this._projects = new Map(); // maps project name to instance (= nodes)
this._adjList = new Map(); // maps project name to dependencies (= edges)
this._optAdjList = new Map(); // maps project name to optional dependencies (= edges)

this._extensions = new Map(); // maps extension name to instance

Expand Down Expand Up @@ -175,10 +177,6 @@ class ProjectGraph {
declareDependency(fromProjectName, toProjectName) {
this._checkSealed();
try {
// if (this._optAdjList[fromProjectName] && this._optAdjList[fromProjectName][toProjectName]) {
// // TODO: Do we even care?
// throw new Error(`Dependency has already been declared as optional`);
// }
log.verbose(`Declaring dependency: ${fromProjectName} depends on ${toProjectName}`);
this._declareDependency(this._adjList, fromProjectName, toProjectName);
} catch (err) {
Expand All @@ -199,10 +197,6 @@ class ProjectGraph {
declareOptionalDependency(fromProjectName, toProjectName) {
this._checkSealed();
try {
// if (this._adjList[fromProjectName] && this._adjList[fromProjectName][toProjectName]) {
// // TODO: Do we even care?
// throw new Error(`Dependency has already been declared as non-optional`);
// }
log.verbose(`Declaring optional dependency: ${fromProjectName} depends on ${toProjectName}`);
this._declareDependency(this._optAdjList, fromProjectName, toProjectName);
this._hasUnresolvedOptionalDependencies = true;
Expand Down Expand Up @@ -267,6 +261,11 @@ class ProjectGraph {
*/
getTransitiveDependencies(projectName) {
const dependencies = new Set();
if (!this._projects.has(projectName)) {
throw new Error(
`Failed to get transitive dependencies for project ${projectName}: ` +
`Unable to find project in project graph`);
}

const processDependency = (depName) => {
const adjacencies = this._adjList.get(depName);
Expand Down Expand Up @@ -375,7 +374,6 @@ class ProjectGraph {
* graph traversal will wait and only continue once the promise has resolved.
*/

// TODO: Use generator functions instead?
/**
* Visit every project in the graph that can be reached by the given entry project exactly once.
* The entry project defaults to the root project.
Expand All @@ -398,28 +396,28 @@ class ProjectGraph {

const queue = [{
projectNames: [startName],
predecessors: []
ancestors: []
}];

const visited = Object.create(null);

while (queue.length) {
const {projectNames, predecessors} = queue.shift(); // Get and remove first entry from queue
const {projectNames, ancestors} = queue.shift(); // Get and remove first entry from queue

await Promise.all(projectNames.map(async (projectName) => {
this._checkCycle(predecessors, projectName);
this._checkCycle(ancestors, projectName);

if (visited[projectName]) {
return visited[projectName];
}

return visited[projectName] = (async () => {
const newPredecessors = [...predecessors, projectName];
const newAncestors = [...ancestors, projectName];
const dependencies = this.getDependencies(projectName);

queue.push({
projectNames: dependencies,
predecessors: newPredecessors
ancestors: newAncestors
});

await callback({
Expand Down Expand Up @@ -453,17 +451,17 @@ class ProjectGraph {
return this._traverseDepthFirst(startName, Object.create(null), [], callback);
}

async _traverseDepthFirst(projectName, visited, predecessors, callback) {
this._checkCycle(predecessors, projectName);
async _traverseDepthFirst(projectName, visited, ancestors, callback) {
this._checkCycle(ancestors, projectName);

if (visited[projectName]) {
return visited[projectName];
}
return visited[projectName] = (async () => {
const newPredecessors = [...predecessors, projectName];
const newAncestors = [...ancestors, projectName];
const dependencies = this.getDependencies(projectName);
await Promise.all(dependencies.map((depName) => {
return this._traverseDepthFirst(depName, visited, newPredecessors, callback);
return this._traverseDepthFirst(depName, visited, newAncestors, callback);
}));

await callback({
Expand Down Expand Up @@ -609,13 +607,13 @@ class ProjectGraph {
}
}

_checkCycle(predecessors, projectName) {
if (predecessors.includes(projectName)) {
// We start to run in circles. That's neither expected nor something we can deal with

// Mark first and last occurrence in chain with an asterisk
predecessors[predecessors.indexOf(projectName)] = `${projectName}*`;
throw new Error(`Detected cyclic dependency chain: ${predecessors.join(" -> ")} -> ${projectName}*`);
_checkCycle(ancestors, projectName) {
if (ancestors.includes(projectName)) {
// "Back-edge" detected. Neither BFS nor DFS searches should continue
// Mark first and last occurrence in chain with an asterisk and throw an error detailing the
// problematic dependency chain
ancestors[ancestors.indexOf(projectName)] = `*${projectName}*`;
flovogt marked this conversation as resolved.
Show resolved Hide resolved
throw new Error(`Detected cyclic dependency chain: ${ancestors.join(" -> ")} -> *${projectName}*`);
}
}

Expand Down
194 changes: 194 additions & 0 deletions lib/graph/Workspace.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
import fs from "graceful-fs";
import {globby, isDynamicPattern} from "globby";
import path from "node:path";
import {promisify} from "node:util";
import {getLogger} from "@ui5/logger";
import Module from "./Module.js";

const readFile = promisify(fs.readFile);
const log = getLogger("graph:Workspace");


/**
* Dependency graph node representing a module
*
* @public
* @typedef {object} @ui5/project/graph/Workspace~configuration
* @property {string} node.specVersion
* @property {object} node.metadata Version of the project
flovogt marked this conversation as resolved.
Show resolved Hide resolved
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be something like "Metadata of the configuration`, right?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! Correction in #568

* @property {object} node.dependencyManagement
flovogt marked this conversation as resolved.
Show resolved Hide resolved
*/

/**
* Workspace representation
*
* @public
flovogt marked this conversation as resolved.
Show resolved Hide resolved
* @class
* @alias @ui5/project/graph/Workspace
*/
class Workspace {
#visitedNodePaths = new Set();

/**
* @param {object} options
* @param {object} options.cwd Path to use for resolving all paths of the workspace configuration from.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cwd is of type string, isn't?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct!

* This should contain platform-specific path separator
* @param {@ui5/project/graph/Workspace~configuration} options.configuration
* Workspace configuration
*/
constructor({cwd, configuration}) {
if (!cwd || !configuration) {
throw new Error("[Workspace] One or more mandatory parameters not provided");
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

true but might lead to unhappy developers asking for which parameter is missing

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, added YAML validation to this class (wanted to do that anyways but disliked the idea of needing an async init function - now solved with a dedicated validate function which needs to be called from the code)

}

this._cwd = cwd;
this._name = configuration.metadata.name;
this._dependencyManagement = configuration.dependencyManagement;
}

getName() {
return this._name;
}

async getModuleByProjectName(name) {
const {projectNameMap} = await this._getResolvedModules();
return projectNameMap.get(name);
}

async getModuleByNodeId(id) {
const {moduleIdMap} = await this._getResolvedModules();
return moduleIdMap.get(id);
}

_getResolvedModules() {
if (this._pResolvedModules) {
return this._pResolvedModules;
}

return this._pResolvedModules = this._resolveModules();
}

async _resolveModules() {
if (!this._dependencyManagement?.resolutions?.length) {
return {
projectNameMap: new Map(),
moduleIdMap: new Map()
};
}

let resolvedModules = await Promise.all(this._dependencyManagement.resolutions.map(async (resolutionConfig) => {
if (!resolutionConfig.path) {
throw new Error(
`Missing property 'path' in dependency resolution configuration of workspace ${this._name}`);
}
return await this._getModulesFromPath(
this._cwd, resolutionConfig.path);
}));

// Flatten array since package-workspaces might have resolved to multiple modules for a single resolution
resolvedModules = Array.prototype.concat.apply([], resolvedModules);

const projectNameMap = new Map();
const moduleIdMap = new Map();
await Promise.all(resolvedModules.map(async (module) => {
const {project} = await module.getSpecifications();
if (project) {
log.verbose(`Module ${module.getId()} contains project ${project.getName()}`);
projectNameMap.set(project.getName(), module);
moduleIdMap.set(module.getId(), module);
} else {
log.warn(`Failed to create a project from module ${module.getId()} at ${module.getPath()}`);
}
}));
return {
projectNameMap,
moduleIdMap
};
}

async _getModulesFromPath(cwd, relPath) {
const nodePath = path.join(cwd, relPath);
if (this.#visitedNodePaths.has(nodePath)) {
log.verbose(`Module located at ${nodePath} has already been visited`);
return [];
}
this.#visitedNodePaths.add(nodePath);
let pkg;
try {
pkg = await this._readPackageJson(nodePath);
if (!pkg?.name || !pkg?.version) {
throw new Error(
`package.json must contain fields 'name' and 'version'`);
flovogt marked this conversation as resolved.
Show resolved Hide resolved
}
} catch (err) {
throw new Error(
`Failed to resolve workspace dependency resolution path ${relPath} to ${nodePath}: ${err.message}`);
}

// If the package.json defines an npm "workspaces", or an equivalent "ui5.workspaces" configuration,
// resolve the workspace and only use the resulting modules. The root package is ignored.
const packageWorkspaceConfig = pkg.ui5?.workspaces || pkg.workspaces;
if (packageWorkspaceConfig?.length) {
log.verbose(`Module ${pkg.name} provides a package.json workspaces configuration. ` +
`Ignoring the module and resolving workspaces instead...`);
const staticPatterns = [];
// Split provided patterns into dynamic and static patterns
// This is necessary, since fast-glob currently behaves different from
// "glob" (used by @npmcli/map-workspaces) in that it does not match the
// base directory in case it is equal to the pattern (https://github.com/mrmlnc/fast-glob/issues/47)
// For example a pattern "package-a" would not match a directory called
// "package-a" in the root directory of the project.
// We therefore detect the static pattern and resolve it directly
const dynamicPatterns = packageWorkspaceConfig.filter((pattern) => {
if (isDynamicPattern(pattern)) {
return true;
} else {
staticPatterns.push(pattern);
return false;
}
});

let searchPaths = [];
if (dynamicPatterns.length) {
searchPaths = await globby(dynamicPatterns, {
cwd: nodePath,
followSymbolicLinks: false,
onlyDirectories: true,
});
}
searchPaths.push(...staticPatterns);

const resolvedModules = new Map();
await Promise.all(searchPaths.map(async (pkgPath) => {
const modules = await this._getModulesFromPath(nodePath, pkgPath);
modules.forEach((module) => {
const id = module.getId();
if (!resolvedModules.get(id)) {
resolvedModules.set(id, module);
}
});
}));
return Array.from(resolvedModules.values());
} else {
return [new Module({
id: pkg.name,
version: pkg.version,
modulePath: nodePath
})];
}
}

/**
* Reads the package.json file and returns its content
*
* @private
* @param {string} modulePath Path to the module containing the package.json
* @returns {object} Package json content
*/
async _readPackageJson(modulePath) {
const content = await readFile(path.join(modulePath, "package.json"), "utf8");
return JSON.parse(content);
}
}

export default Workspace;
Loading