Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
4ecb633
feat: Offer configPath property to workspace configs
maxreichmann Oct 2, 2026
077ba7c
feat(project): Add logic for configPath parameter
maxreichmann Oct 8, 2026
a1fbf58
docs: Adjust Workspace.md page with configPath property
maxreichmann Oct 8, 2026
8e8cb78
refactor: Improve error handling when `path` is absolute
maxreichmann Oct 9, 2026
50b1429
refactor: Adjust workspace config to fix tests
maxreichmann Oct 9, 2026
2d3bb7e
test(project): Adjust resolutions path to absolute
maxreichmann Oct 12, 2026
31f1619
refactor: Remove "else" block in schema
maxreichmann Oct 13, 2026
a730f2f
refactor: Fix AJV strict mode warnings
maxreichmann Oct 13, 2026
bac2f97
docs: Remove "v5.0.0+" and only keep "v4.1.0+"
maxreichmann Oct 13, 2026
fc81fb0
docs: Apply @d3xter666 suggestion
maxreichmann Oct 14, 2026
c34fdb8
refactor: Add temp test matrix (DELETE LATER)
maxreichmann Oct 15, 2026
42fcc9b
test(project): Fix schema tests
maxreichmann Oct 16, 2026
eabc305
test: Add scenario for configPath in collections
maxreichmann Oct 16, 2026
67ecce9
test(project): Add first test case for collection scenarios
maxreichmann Oct 16, 2026
9b641d5
test(project): Add test cases
maxreichmann Oct 19, 2026
8ad07a2
test: Add important case isolating "last one wins" logic from current…
maxreichmann Oct 19, 2026
efadc9a
test: Add test matrix for "specific one wins" logic
maxreichmann Oct 20, 2026
560c97b
test: Add concept markdown for logic "specific one wins + error on du…
maxreichmann Oct 21, 2026
fafe13d
refactor: Add error handling for duplicate assignments
maxreichmann Oct 21, 2026
fd4f722
test(project): Fix stale tests
maxreichmann Oct 22, 2026
b9d5595
refactor: Only throw double resolutions if same absolute path
maxreichmann Oct 23, 2026
8c98055
test(project): Fix windows paths
maxreichmann Oct 23, 2026
133d63c
test(project): Cleanup
maxreichmann Oct 27, 2026
2d466cd
docs: Fix path resolution
maxreichmann Oct 27, 2026
0c0a472
test: Fix path for error message
maxreichmann Oct 27, 2026
236c5f5
fix(project): Forward workspace `configPath` to the project graph
maxreichmann Nov 5, 2026
c22ffd1
fix(project): Apply most specific workspace resolution configPath
devtomtom Nov 9, 2026
5eb9557
fix(project): Fix specificity of workspace config resolution
devtomtom Nov 9, 2026
2bbcfab
test(project): Add cases for overlapping workspace globs
maxreichmann Nov 9, 2026
5fe1f21
fix(project): Process workspace resolutions sequentially
maxreichmann Nov 10, 2026
d3b7fb4
fix(project): Normalize workspace paths with trailing slashes
maxreichmann Nov 10, 2026
75f8e5e
refactor: Rebase (+fix merge conflicts)
maxreichmann Nov 10, 2026
d80a8ab
docs: Add docu for "Most specific wins" logic
maxreichmann Nov 10, 2026
5de6920
docs: Refactor to "tip" element
maxreichmann Nov 11, 2026
88fc20b
test(project): Add two cases for dynamic workspace globs
maxreichmann Nov 11, 2026
7e3727f
refactor: Apply @codeworrior suggestion (make helper variable fully l…
maxreichmann Nov 11, 2026
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
38 changes: 31 additions & 7 deletions internal/documentation/docs/pages/Workspace.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

::: details
```yaml title="ui5-workspace.yaml"
specVersion: workspace/1.0
specVersion: workspace/1.1
metadata:
name: default
dependencyManagement:
Expand All @@ -23,14 +23,14 @@ Workspaces are typically configured in a `ui5-workspace.yaml` file, located next

::: details Example
```yaml title="ui5-workspace.yaml"
specVersion: workspace/1.0
specVersion: workspace/1.1
metadata:
name: default
dependencyManagement:
resolutions:
- path: ../heavy.library
---
specVersion: workspace/1.0
specVersion: workspace/1.1
metadata:
name: extended
dependencyManagement:
Expand All @@ -52,14 +52,14 @@ This concept has been discussed in an RFC: [RFC 0006 Local Dependency Resolution
A UI5 Workspace configuration must define a specification version (`specVersion`) compatible with its configuration. For more information, see [Workspace Specification Versions](#workspace-specification-versions).

```yaml
specVersion: "workspace/1.0"
specVersion: "workspace/1.1"
```

### Metadata

::: details Example
```yaml
specVersion: workspace/1.0
specVersion: workspace/1.1
metadata:
name: dolphin
```
Expand Down Expand Up @@ -91,7 +91,7 @@ UI5 Workspace configurations allow to influence the dependency resolution when w

::: details
```yaml
specVersion: workspace/1.0
specVersion: workspace/1.1
metadata:
name: dolphin
dependencyManagement:
Expand Down Expand Up @@ -119,11 +119,28 @@ Paths must be written in POSIX (i.e. using only forward slashes `/` as path segm

Note that this configuration only affects the resolution of dependencies which have already been found during the regular dependency resolution process of a project. For example, if a workspace resolution path resolves to a project that would otherwise not be part of the dependency tree of the current root project, it will not be added to the dependency tree. Also, transitive dependencies of resolved projects are not being followed.

::: tip Overlapping resolutions
If multiple resolutions find the same module, the entry pointing to the deepest directory wins, regardless of entry order. For example, `../libraries/my.lib` takes precedence over `../libraries`.
:::

#### configPath

*Available since `workspace/1.1`*

```yaml
dependencyManagement:
resolutions:
- path: ../test.library
configPath: custom-config.yaml
```

By default, UI5 CLI looks for a `ui5.yaml` at the root of the resolved module. The optional `configPath` property allows pointing to a different configuration file within the module directory. The path must be written in POSIX notation and must be **relative to the root of the resolved module**.

## Workspace Specification Versions
A workspace configuration must define a Specification Version by setting the `specVersion` property. UI5 CLI uses this information to detect whether the currently installed version is compatible with a workspace's configuration.

```yaml
specVersion: "workspace/1.0"
specVersion: "workspace/1.1"
[...]
```

Expand All @@ -142,8 +159,15 @@ Unless otherwise noted in the table below, UI5 CLI modules are backward-compatib

Version | UI5 CLI Release
--- | ---
**`workspace/1.1`** | v4.1.0+
**`workspace/1.0`** | v3.0.0+

### Specification Version `workspace/1.1`

Added [`configPath`](#configpath) property to resolution entries.

Specification Version `workspace/1.1` configurations are compatible with [UI5 CLI](https://github.com/UI5/cli) v4.1.0 and above.

### Specification Version `workspace/1.0`

Initial version.
Expand Down
9 changes: 7 additions & 2 deletions packages/project/lib/graph/Module.js
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,10 @@ class Module {
return this._modulePath;
}

getConfigPath() {
return this._configPath;
}

/**
* Specifications found in the module
*
Expand Down Expand Up @@ -309,8 +313,9 @@ class Module {
}
if (!configResource) {
if (configPath !== DEFAULT_CONFIG_PATH) {
throw new Error("Failed to read configuration for module " +
`${this.getId()}: Could not find configuration file in module at path '${configPath}'`);
throw new Error(`Failed to read configuration for module ` +
`${this.getId()}: Could not find configuration file in module at path `+
`'${this._configPath}'`);
}
return null;
}
Expand Down
100 changes: 71 additions & 29 deletions packages/project/lib/graph/Workspace.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ const log = getLogger("graph:Workspace");
* @alias @ui5/project/graph/Workspace
*/
class Workspace {
#visitedNodePaths = new Set();
#resolvedModules = new Map();
#configValidated = false;
#configuration;
#cwd;
Expand Down Expand Up @@ -139,27 +139,62 @@ class Workspace {
};
}

let resolvedModules = await Promise.all(resolutions.map(async (resolutionConfig) => {
// Detect duplicate resolutions targeting the same path
// (same exact absolute paths should throw):
const seenAbsolutePaths = [];
for (const {path: resolutionPath} of resolutions) {
if (!resolutionPath) {
continue;
}
const absolutePath = path.resolve(this.#cwd, resolutionPath);
if (seenAbsolutePaths.includes(absolutePath)) {
throw new Error(
`Multiple resolutions for the same dependency '${path.basename(absolutePath)}' ` +

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[suggestion only] I find the term dependency misleading / confusing. A resolution path is not necessarily the same as a dependency in the sense of the ui5.yaml. It might e.g. point to the openui5 repository as a whole, covering multiple dependencies.

Suggestion: "Two resolutions in workspace '${this.getName()}' point to the same folder: ${resolutionPath} and ${earlierResolutionPath}"

To be able to name the earlier resolution path, the bookkeeping seenAbsolutePaths would have to be changed maybe to a map (key: absolute path; value: original resolutionPath).

But again, only a suggestion :-)

@maxreichmann maxreichmann Nov 11, 2026 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

but what about more than two duplicate resolution paths? I would suggest:

"Two or more resolutions for '${path.basename(absolutePath)}' point to the same folder '${absolutePath}' in workspace '${this.getName()}'"

I guess, you also want to provide the full resolutionPath (what is written in the yaml)? Then we'd need to do some bookkeeping

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Your text proposal is acceptable. And yes, I thought that naming the original resolution path(s) would be helpful to the developer.

`(resolved to '${absolutePath}') in workspace '${this.getName()}' exist.`);
}
seenAbsolutePaths.push(absolutePath);
}

for (const resolutionConfig of resolutions) {
if (!resolutionConfig.path) {
throw new Error(
`Missing property 'path' in dependency resolution configuration of workspace ${this.getName()}`);
}
return await this._getModulesFromPath(
this.#cwd, resolutionConfig.path);
}));
if (path.isAbsolute(resolutionConfig.path)) {
throw new Error(
`Absolute paths are not allowed in workspace configurations of dependencies. ` +
`Found '${resolutionConfig.path}' in workspace '${this.getName()}'`);
}
if (resolutionConfig.configPath && path.isAbsolute(resolutionConfig.configPath)) {
throw new Error(
`Absolute config paths are not allowed in workspace configurations of dependencies. ` +
`Found '${resolutionConfig.configPath}' in workspace '${this.getName()}'`);
}

// Specificity = number of path segments in the absolute path:
const absolutePath = path.resolve(this.#cwd, resolutionConfig.path);
const segments = absolutePath.split(path.sep);
const specificity = segments.length;

// Flatten array since package-workspaces might have resolved to multiple modules for a single resolution
resolvedModules = Array.prototype.concat.apply([], resolvedModules);
await this._getModulesFromPath(
this.#cwd,
resolutionConfig.path,
resolutionConfig.configPath,
specificity);
}

const projectNameMap = new Map();
const moduleIdMap = new Map();
await Promise.all(resolvedModules.map(async (module) => {

await Promise.all(Array.from(this.#resolvedModules.values(), async ({id, version, modulePath, configPath}) => {
const module = new Module({id, version, modulePath, configPath});

const {project, extensions} = await module.getSpecifications();
if (project || extensions.length) {
moduleIdMap.set(module.getId(), module);
} else {
log.warn(
`Failed to create a project or extensions from module ${module.getId()} at ${module.getPath()}`);
log.warn(`Failed to create a project or extensions from module ${module.getId()} `+
`at ${module.getPath()}`);
}
if (project) {
projectNameMap.set(project.getName(), module);
Expand All @@ -176,13 +211,20 @@ class Workspace {
};
}

async _getModulesFromPath(cwd, relPath, failOnMissingFiles = true) {
const nodePath = path.join(cwd, relPath);
if (this.#visitedNodePaths.has(nodePath)) {
log.verbose(`Module located at ${nodePath} has already been visited`);
return [];
async _getModulesFromPath(
cwd,
relPath,
configPath,
specificity,
failOnMissingFiles = true,
visitedNodesPerResolution = new Set()) {
const nodePath = path.resolve(cwd, relPath);
// Skip this node when already visited in the current resolution context
// (e.g. in recursive workspace scenarios)
if (visitedNodesPerResolution.has(nodePath)) {
return;
}
this.#visitedNodePaths.add(nodePath);
visitedNodesPerResolution.add(nodePath);
let pkg;
try {
pkg = await this._readPackageJson(nodePath);
Expand All @@ -195,7 +237,7 @@ class Workspace {
// When resolving a dynamic workspace pattern (not a static path), ignore modules that
// are missing a package.json (this might simply indicate an empty directory)
log.verbose(`Ignoring module at path ${nodePath}: Directory does not contain a package.json`);
return [];
return;
}
throw new Error(
`Failed to resolve workspace dependency resolution path ${relPath} to ${nodePath}: ${err.message}`);
Expand Down Expand Up @@ -234,23 +276,23 @@ class Workspace {
}
searchPaths.push(...staticPatterns);

const resolvedModules = new Map();
await Promise.all(searchPaths.map(async (pkgPath) => {
const modules = await this._getModulesFromPath(nodePath, pkgPath, staticPatterns.includes(pkgPath));
modules.forEach((module) => {
const id = module.getId();
if (!resolvedModules.get(id)) {
resolvedModules.set(id, module);
}
});
await this._getModulesFromPath(nodePath, pkgPath, configPath, specificity,
staticPatterns.includes(pkgPath), visitedNodesPerResolution);
}));
return Array.from(resolvedModules.values());
} else {
return [new Module({
if (this.#resolvedModules.get(nodePath) &&
this.#resolvedModules.get(nodePath).specificity >= specificity) {
log.verbose(`Skipping module ${pkg.name} at ${nodePath} due to lower specificity`);
return;
}
this.#resolvedModules.set(nodePath, {
id: pkg.name,
version: pkg.version,
modulePath: nodePath
})];
modulePath: nodePath,
configPath: configPath ? path.resolve(nodePath, configPath) : undefined,
specificity
});
}
}

Expand Down
18 changes: 13 additions & 5 deletions packages/project/lib/graph/providers/NodePackageDependencies.js
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,8 @@ class NodePackageDependencies {
return [];
}
return Promise.all(node._dependencies.map(async ({name, optional}) => {
const modulePath = await this._resolveModulePath(node.path, name, workspace);
return this._getNode(modulePath, optional, name);
const {modulePath, configPath} = await this._resolveModulePath(node.path, name, workspace);
return this._getNode(modulePath, configPath, optional, name);
}));
}

Expand All @@ -87,7 +87,10 @@ class NodePackageDependencies {
log.info(`Resolved module ${moduleName} via ${workspace.getName()} workspace ` +
`to version ${workspaceNode.getVersion()}`);
log.verbose(` Resolved module ${moduleName} to path ${workspaceNode.getPath()}`);
return workspaceNode.getPath();
return {
modulePath: workspaceNode.getPath(),
configPath: workspaceNode.getConfigPath()
};
}
}

Expand All @@ -100,7 +103,10 @@ class NodePackageDependencies {

const modulePath = path.dirname(packageJsonPath);
log.verbose(`Resolved module ${moduleName} to path ${modulePath}`);
return modulePath;
return {
modulePath,
configPath: undefined
};
} catch (err) {
throw new Error(
`Unable to locate module ${moduleName} via resolve logic: ${err.message}`);
Expand All @@ -111,13 +117,14 @@ class NodePackageDependencies {
* Resolves a Node module by reading its package.json
*
* @param {string} modulePath Path to the module.
* @param {string} configPath Path to the module's configuration file.
* @param {boolean} optional Whether this dependency is optional.
* @param {string} [nameAlias] The name of the module. It's usually the same as the name definfed
* in package.json and could easily be skipped. Useful when defining dependency as an alias:
* {@link https://github.com/npm/rfcs/blob/main/implemented/0001-package-aliases.md}
* @returns {Promise<object>}
*/
async _getNode(modulePath, optional, nameAlias) {
async _getNode(modulePath, configPath, optional, nameAlias) {
log.verbose(`Reading package.json in directory ${modulePath}...`);
const packageJson = await readPackage({
cwd: modulePath,
Expand All @@ -128,6 +135,7 @@ class NodePackageDependencies {
id: nameAlias || packageJson.name,
version: packageJson.version,
path: modulePath,
configPath,
optional,
_dependencies: await this._getDependencies(modulePath, packageJson)
};
Expand Down
33 changes: 30 additions & 3 deletions packages/project/lib/validation/schema/ui5-workspace.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@
"additionalProperties": false,
"properties": {
"specVersion": {
"enum": ["workspace/1.0"],
"errorMessage": "Unsupported \"specVersion\"\nYour UI5 CLI installation might be outdated.\nSupported specification versions: \"workspace/1.0\"\nFor details, see: https://ui5.github.io/cli/v5/pages/Workspace#workspace-specification-versions"
"enum": ["workspace/1.1", "workspace/1.0"],
"errorMessage": "Unsupported \"specVersion\"\nYour UI5 CLI installation might be outdated.\nSupported specification versions: \"workspace/1.0\", \"workspace/1.1\"\nFor details, see: https://ui5.github.io/cli/v5/pages/Workspace#workspace-specification-versions"
},
"metadata": {
"$ref": "#/definitions/metadata"
Expand All @@ -19,6 +19,31 @@
"$ref": "#/definitions/dependencyManagement"
}
},
"if": {
"properties": {"specVersion": {"const": "workspace/1.0"}}
},
"then": {
"properties": {
"dependencyManagement": {
"type": "object",
"properties": {
"resolutions": {
"type": "array",
"items": {
"if": {"type": "object"},
"then": {
"not": {
"type": "object",
"required": ["configPath"]
},
"errorMessage": "\"configPath\" is only supported for specVersion \"workspace/1.1\" and higher"
}
}
}
}
}
}
},
"definitions": {
"metadata": {
"type": "object",
Expand All @@ -42,13 +67,15 @@
"properties": {
"resolutions": {
"type": "array",

"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"path": {
"type": "string"
},
"configPath": {
"type": "string"
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "library.c",
"version": "1.0.0",
"description": "Simple SAPUI5 based library"
}
Loading
Loading